Restructure CLI to use cobra subcommands with generated restic metadata

Amolith created

Change summary

cmd/completions.go                         | 223 --------------
cmd/completions_test.go                    | 340 +++++------------------
cmd/root.go                                | 266 ++++++------------
cmd/root_test.go                           | 259 +++++++++--------
cmd/subcommands.go                         | 136 +++++++++
examples/README.md                         |   2 
examples/keld/config_long.toml             |   2 
examples/systemd/user/keld-backup@.service |   2 
examples/systemd/user/keld-verify@.service |   2 
9 files changed, 459 insertions(+), 773 deletions(-)

Detailed changes

cmd/completions.go 🔗

@@ -1,116 +1,44 @@
 package cmd
 
 import (
-	"slices"
 	"strings"
 
 	"github.com/spf13/cobra"
 
 	"git.secluded.site/keld/internal/config"
-	"git.secluded.site/keld/internal/restic"
 )
 
-// keldFlags lists keld's own flags (the ones extracted in extractOwnFlags).
-// Cobra can't advertise these automatically because DisableFlagParsing is on.
-var keldFlags = []string{
-	"--config",
-	"--dry-run",
-	"--help",
-}
-
-func init() {
-	rootCmd.ValidArgsFunction = completeArgs
-}
-
-// completeArgs provides dynamic shell completions for keld.
-//
-// It examines how many positional (non-flag) arguments have already been
-// provided and offers:
-//
-//   - 0 positionals so far → prefixes, composite presets, bare suffixes,
-//     plain presets, and known commands
-//   - 1 positional so far  →
-//   - if the word being completed contains "@", offer matching
-//     suffix completions (e.g. "home@" → "home@cloud", "home@nas")
-//   - if it's a preset, offer commands
-//   - if it's already a command, no more positional completions
-//   - 2+ positionals       → no further positional completions
-//
-// When the current word starts with "-", keld's own flags are offered instead.
-func completeArgs(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
-	// When the user is typing a flag, offer restic flags for the
-	// identified command (plus global flags), or keld's own flags if
-	// no command is known yet.
-	if len(toComplete) > 0 && toComplete[0] == '-' {
-		if cmd := commandFromArgs(args); cmd != "" {
-			return completeResticFlags(cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
-		}
-		return keldFlags, cobra.ShellCompDirectiveNoFileComp
-	}
-
-	// Count how many positional args have already been accepted (i.e. are
-	// in args, not toComplete). We need to skip flag tokens and their
-	// values, just like parseArgs does.
-	positionals := countPositionals(args)
-
-	switch positionals {
-	case 0:
-		// If the user is typing something with "@", complete with
-		// matching composite presets (prefix + suffix combos).
-		if strings.Contains(toComplete, "@") {
-			return completeSuffix(toComplete), cobra.ShellCompDirectiveNoFileComp
-		}
-		// Nothing committed yet — offer presets and commands.
-		return presetsAndCommands(), cobra.ShellCompDirectiveNoFileComp
-	case 1:
-		// One positional committed. If it's a known command, there's
-		// nothing more to complete positionally (the rest is restic
-		// flags, which we don't complete).
-		if isKnownCommand(args) {
-			return nil, cobra.ShellCompDirectiveNoFileComp
-		}
-		// Otherwise the first positional was a preset; offer commands.
-		return knownCommands, cobra.ShellCompDirectiveNoFileComp
-	default:
-		return nil, cobra.ShellCompDirectiveNoFileComp
+// completePreset offers completions for the root --preset flag.
+func completePreset(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+	if strings.Contains(toComplete, "@") {
+		return completeSuffix(toComplete), cobra.ShellCompDirectiveNoFileComp
 	}
-}
 
-// presetsAndCommands merges config presets with the known command list,
-// deduplicating any overlap. Prefixes (e.g. "home@") come first so tab
-// completion can chain into suffix selection, followed by composite and
-// plain presets, bare suffixes, then commands.
-func presetsAndCommands() []string {
 	prefixes := config.Prefixes()
 	presets := config.Presets()
 
-	seen := make(map[string]struct{}, len(prefixes)+len(presets)+len(knownCommands))
-	out := make([]string, 0, len(prefixes)+len(presets)+len(knownCommands))
+	seen := make(map[string]struct{}, len(prefixes)+len(presets))
+	out := make([]string, 0, len(prefixes)+len(presets))
 
 	add := func(s string) {
-		if _, ok := seen[s]; !ok {
-			seen[s] = struct{}{}
-			out = append(out, s)
+		if !strings.HasPrefix(s, toComplete) {
+			return
+		}
+		if _, ok := seen[s]; ok {
+			return
 		}
+		seen[s] = struct{}{}
+		out = append(out, s)
 	}
 
-	// 1. Prefixes first (enable chained suffix completion).
 	for _, p := range prefixes {
 		add(p)
 	}
-
-	// 2. Full presets (plain, composites, bare suffixes — in the order
-	//    Presets() returns them).
 	for _, p := range presets {
 		add(p)
 	}
 
-	// 3. Commands last.
-	for _, c := range knownCommands {
-		add(c)
-	}
-
-	return out
+	return out, cobra.ShellCompDirectiveNoFileComp
 }
 
 // completeSuffix generates completions when the user has typed a prefix
@@ -135,126 +63,3 @@ func completeSuffix(typed string) []string {
 	}
 	return out
 }
-
-// countPositionals returns the number of positional (non-flag) tokens in args.
-func countPositionals(args []string) int {
-	n := 0
-	for i := 0; i < len(args); i++ {
-		arg := args[i]
-		switch {
-		case arg == "--":
-			// Everything after -- is positional.
-			n += len(args) - i - 1
-			return n
-		case isFlagToken(arg):
-			// Skip the flag's value if it has one.
-			if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" {
-				i++
-			}
-		default:
-			n++
-		}
-	}
-	return n
-}
-
-// isKnownCommand reports whether the first positional arg in the already-
-// accepted args is a known restic command.
-func isKnownCommand(args []string) bool {
-	for i := 0; i < len(args); i++ {
-		arg := args[i]
-		switch {
-		case arg == "--":
-			return false
-		case isFlagToken(arg):
-			if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" {
-				i++
-			}
-		default:
-			// First positional — check if it's a command.
-			return slices.Contains(knownCommands, arg)
-		}
-	}
-	return false
-}
-
-func hasEqualSign(arg string) bool {
-	for _, c := range arg {
-		if c == '=' {
-			return true
-		}
-	}
-	return false
-}
-
-// commandFromArgs extracts the restic command name from the already-accepted
-// args. It finds the first or second positional token (depending on whether
-// the first is a preset or a command) and returns it if it exists in
-// restic.Commands.
-func commandFromArgs(args []string) string {
-	var positionals []string
-loop:
-	for i := 0; i < len(args); i++ {
-		arg := args[i]
-		switch {
-		case arg == "--":
-			return ""
-		case isFlagToken(arg):
-			if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" {
-				i++
-			}
-		default:
-			positionals = append(positionals, arg)
-			if len(positionals) == 2 {
-				break loop
-			}
-		}
-	}
-
-	// With 1 positional, it could be a command.
-	// With 2 positionals, the second is the command.
-	for i := len(positionals) - 1; i >= 0; i-- {
-		if _, ok := restic.Commands[positionals[i]]; ok && positionals[i] != "global" {
-			return positionals[i]
-		}
-	}
-	return ""
-}
-
-// completeResticFlags returns flag completions for the given restic command,
-// including global flags. Flags are prefixed with "--" (long) and "-" (short
-// aliases). Results are filtered by the toComplete prefix.
-func completeResticFlags(command, toComplete string) []string {
-	seen := make(map[string]struct{})
-	var out []string
-
-	addOptions := func(opts []restic.Option) {
-		for _, opt := range opts {
-			long := "--" + opt.Name
-			if _, ok := seen[long]; !ok {
-				seen[long] = struct{}{}
-				if strings.HasPrefix(long, toComplete) {
-					out = append(out, long)
-				}
-			}
-			if opt.Alias != "" {
-				short := "-" + opt.Alias
-				if _, ok := seen[short]; !ok {
-					seen[short] = struct{}{}
-					if strings.HasPrefix(short, toComplete) {
-						out = append(out, short)
-					}
-				}
-			}
-		}
-	}
-
-	if cmd, ok := restic.Commands[command]; ok {
-		addOptions(cmd.Options)
-	}
-	if global, ok := restic.Commands["global"]; ok {
-		addOptions(global.Options)
-	}
-
-	return out
-}

cmd/completions_test.go 🔗

@@ -8,6 +8,8 @@ import (
 	"testing"
 
 	"github.com/spf13/cobra"
+
+	"git.secluded.site/keld/internal/restic"
 )
 
 // setupCompletionConfig writes a small TOML fixture and points KELD_CONFIG_FILE
@@ -40,16 +42,14 @@ json = true
 	t.Setenv("HOME", dir)
 }
 
-func TestCompleteArgsNoArgs(t *testing.T) {
+func TestCompletePresetOffersPrefixesAndPresets(t *testing.T) {
 	setupCompletionConfig(t)
 
-	completions, directive := completeArgs(nil, nil, "")
+	completions, directive := completePreset(nil, nil, "")
 	if directive != cobra.ShellCompDirectiveNoFileComp {
-		t.Fatalf("expected NoFileComp directive, got %v", directive)
+		t.Fatalf("expected NoFileComp, got %v", directive)
 	}
 
-	// Ordering: prefixes first, then plain presets, composite presets,
-	// bare suffixes, then commands.
 	has := make(map[string]bool)
 	for _, c := range completions {
 		has[c] = true
@@ -58,325 +58,145 @@ func TestCompleteArgsNoArgs(t *testing.T) {
 	for _, want := range []string{
 		"home@",      // prefix
 		"archive",    // plain preset
-		"home@cloud", // composite
-		"home@nas",   // composite
-		"@cloud",     // bare suffix
-		"@nas",       // bare suffix
-		"backup",     // command
-		"restore",    // command
-		"snapshots",  // command
+		"home@cloud", // composite preset
+		"home@nas",   // composite preset
+		"@cloud",     // suffix preset
+		"@nas",       // suffix preset
 	} {
 		if !has[want] {
 			t.Errorf("missing expected completion %q in %v", want, completions)
 		}
 	}
-
-	// "global" must not appear.
-	if has["global"] {
-		t.Errorf("completions should not include 'global', got %v", completions)
-	}
-
-	// Verify ordering: "home@" (prefix) should appear before "home@cloud"
-	// (composite) and before "backup" (command).
-	idxPrefix := indexOf(completions, "home@")
-	idxComposite := indexOf(completions, "home@cloud")
-	idxCommand := indexOf(completions, "backup")
-	if idxPrefix >= idxComposite {
-		t.Errorf("prefix 'home@' (idx %d) should come before composite 'home@cloud' (idx %d)", idxPrefix, idxComposite)
-	}
-	if idxComposite >= idxCommand {
-		t.Errorf("composite 'home@cloud' (idx %d) should come before command 'backup' (idx %d)", idxComposite, idxCommand)
-	}
 }
 
-func TestCompleteArgsAfterPreset(t *testing.T) {
+func TestCompletePresetSuffixCompletion(t *testing.T) {
 	setupCompletionConfig(t)
 
-	completions, directive := completeArgs(nil, []string{"home@cloud"}, "")
+	completions, directive := completePreset(nil, nil, "home@")
 	if directive != cobra.ShellCompDirectiveNoFileComp {
 		t.Fatalf("expected NoFileComp, got %v", directive)
 	}
+
 	sort.Strings(completions)
-	want := make([]string, len(knownCommands))
-	copy(want, knownCommands)
-	sort.Strings(want)
+	want := []string{"home@cloud", "home@nas"}
 	if !reflect.DeepEqual(completions, want) {
-		t.Fatalf("after preset, expected commands %v, got %v", want, completions)
-	}
-}
-
-func TestCompleteArgsAfterCommand(t *testing.T) {
-	setupCompletionConfig(t)
-
-	completions, directive := completeArgs(nil, []string{"backup"}, "")
-	if directive != cobra.ShellCompDirectiveNoFileComp {
-		t.Fatalf("expected NoFileComp, got %v", directive)
-	}
-	if len(completions) != 0 {
-		t.Fatalf("after command, expected no completions, got %v", completions)
+		t.Fatalf("suffix completions mismatch: got %v, want %v", completions, want)
 	}
 }
 
-func TestCompleteArgsAfterPresetAndCommand(t *testing.T) {
+func TestCompletePresetPartialSuffixCompletion(t *testing.T) {
 	setupCompletionConfig(t)
 
-	completions, directive := completeArgs(nil, []string{"home@cloud", "backup"}, "")
+	completions, directive := completePreset(nil, nil, "home@c")
 	if directive != cobra.ShellCompDirectiveNoFileComp {
 		t.Fatalf("expected NoFileComp, got %v", directive)
 	}
-	if len(completions) != 0 {
-		t.Fatalf("after preset+command, expected no completions, got %v", completions)
-	}
-}
-
-func TestCompleteArgsFlagPrefix(t *testing.T) {
-	t.Parallel()
 
-	// No config needed — flag completions are static.
-	completions, directive := completeArgs(nil, nil, "--")
-	if directive != cobra.ShellCompDirectiveNoFileComp {
-		t.Fatalf("expected NoFileComp, got %v", directive)
-	}
-	sort.Strings(completions)
-	want := []string{"--config", "--dry-run", "--help"}
+	want := []string{"home@cloud"}
 	if !reflect.DeepEqual(completions, want) {
-		t.Fatalf("flag completions mismatch: got %v, want %v", completions, want)
+		t.Fatalf("partial suffix completions mismatch: got %v, want %v", completions, want)
 	}
 }
 
-func TestCompleteArgsSkipsFlags(t *testing.T) {
+func TestCompletePresetPrefixFilter(t *testing.T) {
 	setupCompletionConfig(t)
 
-	// Simulate: keld --config ./keld.toml <tab>
-	// The flag and its value should not count as positionals, so we
-	// should still be at 0 positionals → presets + commands.
-	completions, directive := completeArgs(nil, []string{"--config", "./keld.toml"}, "")
+	completions, directive := completePreset(nil, nil, "arc")
 	if directive != cobra.ShellCompDirectiveNoFileComp {
 		t.Fatalf("expected NoFileComp, got %v", directive)
 	}
 
-	has := make(map[string]bool)
-	for _, c := range completions {
-		has[c] = true
-	}
-	if !has["backup"] {
-		t.Errorf("expected commands after flag+value, got %v", completions)
-	}
-	if !has["home@"] {
-		t.Errorf("expected prefix 'home@' after flag+value, got %v", completions)
+	want := []string{"archive"}
+	if !reflect.DeepEqual(completions, want) {
+		t.Fatalf("prefix-filtered completions mismatch: got %v, want %v", completions, want)
 	}
 }
 
-func TestCompleteArgsSuffixCompletion(t *testing.T) {
-	setupCompletionConfig(t)
+func TestSubcommandsRegisteredFromResticMetadata(t *testing.T) {
+	t.Parallel()
 
-	// User has typed "home@" and pressed tab — should get suffix combos.
-	completions, directive := completeArgs(nil, nil, "home@")
-	if directive != cobra.ShellCompDirectiveNoFileComp {
-		t.Fatalf("expected NoFileComp, got %v", directive)
+	subcommands := rootCmd.Commands()
+	if got, want := len(subcommands), len(restic.Commands)-1; got != want {
+		t.Fatalf("subcommand count mismatch: got %d, want %d", got, want)
 	}
 
-	sort.Strings(completions)
-	want := []string{"home@cloud", "home@nas"}
-	if !reflect.DeepEqual(completions, want) {
-		t.Fatalf("suffix completions mismatch: got %v, want %v", completions, want)
+	seen := make(map[string]*cobra.Command, len(subcommands))
+	for _, subcmd := range subcommands {
+		seen[subcmd.Name()] = subcmd
 	}
-}
-
-func TestCompleteArgsSuffixPartialCompletion(t *testing.T) {
-	setupCompletionConfig(t)
 
-	// User has typed "home@c" — only "@cloud" matches.
-	completions, directive := completeArgs(nil, nil, "home@c")
-	if directive != cobra.ShellCompDirectiveNoFileComp {
-		t.Fatalf("expected NoFileComp, got %v", directive)
+	if _, ok := seen["global"]; ok {
+		t.Fatal("global command should not be registered as a subcommand")
 	}
 
-	want := []string{"home@cloud"}
-	if !reflect.DeepEqual(completions, want) {
-		t.Fatalf("partial suffix completions mismatch: got %v, want %v", completions, want)
-	}
-}
-
-func TestCountPositionals(t *testing.T) {
-	t.Parallel()
+	for name := range restic.Commands {
+		name := name
+		if name == "global" {
+			continue
+		}
 
-	tests := []struct {
-		name string
-		args []string
-		want int
-	}{
-		{name: "empty", args: nil, want: 0},
-		{name: "one positional", args: []string{"backup"}, want: 1},
-		{name: "two positionals", args: []string{"home@cloud", "backup"}, want: 2},
-		{name: "flag with value", args: []string{"--repo", "/repo"}, want: 0},
-		{name: "flag=value", args: []string{"--repo=/repo"}, want: 0},
-		{name: "mixed", args: []string{"--repo", "/repo", "home@cloud", "backup"}, want: 2},
-		{name: "double dash", args: []string{"home@cloud", "--", "a", "b"}, want: 3},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-			got := countPositionals(tt.args)
-			if got != tt.want {
-				t.Fatalf("countPositionals(%#v) = %d, want %d", tt.args, got, tt.want)
-			}
-		})
+		subcmd, ok := seen[name]
+		if !ok {
+			t.Fatalf("missing subcommand %q", name)
+		}
+		if !subcmd.DisableFlagParsing {
+			t.Fatalf("subcommand %q should have DisableFlagParsing enabled", name)
+		}
 	}
 }
 
-func TestIsKnownCommand(t *testing.T) {
+func TestRegisteredRootAndSubcommandFlags(t *testing.T) {
 	t.Parallel()
 
-	tests := []struct {
-		name string
-		args []string
-		want bool
-	}{
-		{name: "empty", args: nil, want: false},
-		{name: "command first", args: []string{"backup"}, want: true},
-		{name: "preset first", args: []string{"home@cloud"}, want: false},
-		{name: "flag then command", args: []string{"--repo", "/repo", "backup"}, want: true},
-		{name: "flag then preset", args: []string{"--repo", "/repo", "home@cloud"}, want: false},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-			got := isKnownCommand(tt.args)
-			if got != tt.want {
-				t.Fatalf("isKnownCommand(%#v) = %v, want %v", tt.args, got, tt.want)
-			}
-		})
+	for _, flagName := range []string{"preset", "show-command", "config", "repo", "verbose"} {
+		if rootCmd.PersistentFlags().Lookup(flagName) == nil {
+			t.Fatalf("missing expected root persistent flag %q", flagName)
+		}
 	}
-}
-
-func TestCompleteArgsResticFlagsAfterCommand(t *testing.T) {
-	t.Parallel()
 
-	tests := []struct {
-		name       string
-		args       []string
-		toComplete string
-		wantFlags  []string // flags that must appear in completions
-		wantAbsent []string // flags that must not appear
-	}{
-		{
-			name:       "backup flags with -- prefix",
-			args:       []string{"backup"},
-			toComplete: "--",
-			wantFlags:  []string{"--dry-run", "--exclude", "--tag"},
-			wantAbsent: []string{"--target"}, // restore-only flag
-		},
-		{
-			name:       "backup short aliases",
-			args:       []string{"backup"},
-			toComplete: "-",
-			wantFlags:  []string{"-n", "-e", "--dry-run", "--repo"},
-		},
-		{
-			name:       "restore flags include target",
-			args:       []string{"restore"},
-			toComplete: "--t",
-			wantFlags:  []string{"--target", "--tag", "--tls-client-cert"},
-		},
-		{
-			name:       "global flags included with command",
-			args:       []string{"backup"},
-			toComplete: "--re",
-			wantFlags:  []string{"--repo", "--repository-file", "--retry-lock"},
-		},
-		{
-			name:       "preset then command",
-			args:       []string{"home@cloud", "backup"},
-			toComplete: "--dr",
-			wantFlags:  []string{"--dry-run"},
-		},
-		{
-			name:       "flag then command",
-			args:       []string{"--config", "./keld.toml", "backup"},
-			toComplete: "--",
-			wantFlags:  []string{"--dry-run", "--exclude"},
-		},
+	if rootCmd.PersistentFlags().Lookup("dry-run") != nil {
+		t.Fatal("root should not expose a --dry-run flag")
 	}
 
-	for _, tt := range tests {
-		tt := tt
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-
-			completions, directive := completeArgs(nil, tt.args, tt.toComplete)
-			if directive != cobra.ShellCompDirectiveNoFileComp {
-				t.Fatalf("expected NoFileComp, got %v", directive)
-			}
-
-			has := make(map[string]bool, len(completions))
-			for _, c := range completions {
-				has[c] = true
-			}
-
-			for _, want := range tt.wantFlags {
-				if !has[want] {
-					t.Errorf("missing expected flag %q in completions %v", want, completions)
-				}
-			}
-			for _, absent := range tt.wantAbsent {
-				if has[absent] {
-					t.Errorf("unexpected flag %q in completions %v", absent, completions)
-				}
-			}
-		})
+	if got := rootCmd.PersistentFlags().Lookup("verbose").Value.Type(); got != "count" {
+		t.Fatalf("expected --verbose to be a count flag, got type %q", got)
 	}
-}
 
-func TestCompleteArgsNoCommandStillReturnsKeldFlags(t *testing.T) {
-	t.Parallel()
+	backup := rootCmd.Commands()[0]
+	for _, subcmd := range rootCmd.Commands() {
+		if subcmd.Name() == "backup" {
+			backup = subcmd
+			break
+		}
+	}
 
-	completions, directive := completeArgs(nil, nil, "--")
-	if directive != cobra.ShellCompDirectiveNoFileComp {
-		t.Fatalf("expected NoFileComp, got %v", directive)
+	for _, flagName := range []string{"exclude", "dry-run"} {
+		if backup.Flags().Lookup(flagName) == nil {
+			t.Fatalf("backup subcommand missing expected command flag %q", flagName)
+		}
 	}
-	sort.Strings(completions)
-	want := []string{"--config", "--dry-run", "--help"}
-	if !reflect.DeepEqual(completions, want) {
-		t.Fatalf("without command, flag completions mismatch: got %v, want %v", completions, want)
+
+	if backup.InheritedFlags().Lookup("repo") == nil {
+		t.Fatal("backup subcommand missing inherited global --repo flag")
 	}
 }
 
-func TestCommandFromArgs(t *testing.T) {
-	t.Parallel()
+func TestPresetFlagCompletionRegistered(t *testing.T) {
+	setupCompletionConfig(t)
 
-	tests := []struct {
-		name string
-		args []string
-		want string
-	}{
-		{name: "empty", args: nil, want: ""},
-		{name: "command only", args: []string{"backup"}, want: "backup"},
-		{name: "preset then command", args: []string{"home@cloud", "backup"}, want: "backup"},
-		{name: "unknown preset alone", args: []string{"home@cloud"}, want: ""},
-		{name: "flag then command", args: []string{"--config", "./f", "backup"}, want: "backup"},
-		{name: "double dash", args: []string{"backup", "--"}, want: ""},
+	completionFn, ok := rootCmd.GetFlagCompletionFunc("preset")
+	if !ok {
+		t.Fatal("root --preset completion function is not registered")
 	}
 
-	for _, tt := range tests {
-		tt := tt
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-
-			got := commandFromArgs(tt.args)
-			if got != tt.want {
-				t.Fatalf("commandFromArgs(%#v) = %q, want %q", tt.args, got, tt.want)
-			}
-		})
+	completions, directive := completionFn(rootCmd, nil, "home@")
+	if directive != cobra.ShellCompDirectiveNoFileComp {
+		t.Fatalf("expected NoFileComp, got %v", directive)
 	}
-}
 
-// indexOf returns the position of s in slice, or -1 if not found.
-func indexOf(slice []string, s string) int {
-	for i, v := range slice {
-		if v == s {
-			return i
-		}
+	sort.Strings(completions)
+	want := []string{"home@cloud", "home@nas"}
+	if !reflect.DeepEqual(completions, want) {
+		t.Fatalf("completion function returned %v, want %v", completions, want)
 	}
-	return -1
 }

cmd/root.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"slices"
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
@@ -17,200 +18,132 @@ import (
 )
 
 var (
-	flagDryRun     bool
+	flagPreset     string
+	flagShowCmd    bool
 	flagConfigFile string
 )
 
 const overrideArgumentsKey = "_arguments"
 
 var rootCmd = &cobra.Command{
-	Use:   "keld [preset] <command> [restic flags...]",
+	Use:   "keld [keld flags] <command> [restic flags...]",
 	Short: "A friendly wrapper around restic",
 	Long:  "keld resolves layered TOML config presets and executes restic with the merged result.",
 	Example: `  keld backup
-  keld home backup
-  keld home@nas backup --tag daily --verbose
-  keld --config ./keld.toml home backup
-  keld --dry-run home backup
-  keld --help`,
-
-	// Accept arbitrary args — we parse preset/command ourselves.
-	Args:               cobra.ArbitraryArgs,
-	DisableFlagParsing: true,
-	SilenceUsage:       true,
-	SilenceErrors:      true,
-
-	RunE: func(cmd *cobra.Command, args []string) error {
-		// Re-parse our own flags out of the raw args since cobra flag
-		// parsing is disabled (we need to pass unknown flags through to
-		// restic).
-		args = extractOwnFlags(args)
-
-		if hasHelpFlag(args) {
-			return cmd.Help()
+	keld --preset home backup
+	keld --preset home@nas backup --tag daily /src
+	keld --show-command --preset home backup
+	keld --config ./keld.toml --preset home backup
+	keld backup --help
+	keld`,
+
+	SilenceUsage:     true,
+	SilenceErrors:    true,
+	TraverseChildren: true,
+
+	RunE: func(cmd *cobra.Command, _ []string) error {
+		selected, err := runMenu()
+		if err != nil {
+			return fmt.Errorf("menu: %w", err)
 		}
-
-		if flagConfigFile != "" {
-			if err := os.Setenv("KELD_CONFIG_FILE", flagConfigFile); err != nil {
-				return fmt.Errorf("setting KELD_CONFIG_FILE: %w", err)
-			}
+		if selected == "" || selected == "quit" {
+			return nil
 		}
-		if flagDryRun {
-			if err := os.Setenv("KELD_DRYRUN", "1"); err != nil {
-				return fmt.Errorf("setting KELD_DRYRUN: %w", err)
-			}
+		if !slices.Contains(knownCommands, selected) {
+			return fmt.Errorf("unknown menu command %q", selected)
 		}
 
-		preset, command, passthrough := parseArgs(args)
+		return runCommand(selected, cmd, nil)
+	},
+}
 
-		// No command → interactive menu.
-		interactive := command == ""
-		if interactive {
-			selected, err := runMenu()
-			if err != nil {
-				return fmt.Errorf("menu: %w", err)
-			}
-			if selected == "" || selected == "quit" {
-				return nil
-			}
-			command = selected
+func registerRootFlags() {
+	flags := rootCmd.PersistentFlags()
+	flags.StringVarP(&flagPreset, "preset", "P", "", "config preset to apply before running the command")
+	flags.BoolVar(&flagShowCmd, "show-command", false, "print the resolved restic command and exit")
+	flags.StringVarP(&flagConfigFile, "config", "C", "", "path to keld config file")
+}
+
+func runCommand(commandName string, cmd *cobra.Command, rawArgs []string) error {
+	if flagConfigFile != "" {
+		if err := os.Setenv("KELD_CONFIG_FILE", flagConfigFile); err != nil {
+			return fmt.Errorf("setting KELD_CONFIG_FILE: %w", err)
 		}
+	}
+	if flagShowCmd {
+		if err := os.Setenv("KELD_DRYRUN", "1"); err != nil {
+			return fmt.Errorf("setting KELD_DRYRUN: %w", err)
+		}
+	}
 
-		// Validate the preset early when running non-interactively so the
-		// user gets a clear error instead of a silent empty config.
-		if !interactive && preset != "" {
-			if err := validatePreset(preset); err != nil {
-				return err
-			}
+	preset := flagPreset
+	interactive := cmd == cmd.Root()
+
+	if preset != "" {
+		if err := validatePreset(preset); err != nil {
+			return err
 		}
+	}
 
-		overrides := parsePassthrough(passthrough)
+	overrides := parsePassthrough(rawArgs)
 
-		// When launched interactively, prompt for a preset and any
-		// command-specific inputs that restic would otherwise reject.
-		if interactive {
+	// In interactive mode, fill missing preset/command inputs via prompts.
+	if interactive {
+		if preset == "" {
 			p, err := promptPreset()
 			if err != nil {
 				return err
 			}
 			preset = p
-
-			// Resolve config before prompting so we can see what the
-			// preset already provides and skip unnecessary prompts.
-			peek, err := config.Resolve(preset, command, overrides)
-			if err != nil {
-				return err
-			}
-
-			cmdOverrides, err := promptForCommand(command, peek)
-			if err != nil {
-				return err
-			}
-			if overrides == nil && len(cmdOverrides) > 0 {
-				overrides = cmdOverrides
-			} else {
-				for k, v := range cmdOverrides {
-					overrides[k] = v
-				}
-			}
 		}
 
-		cfg, err := config.Resolve(preset, command, overrides)
+		// Resolve config before prompting so we can skip questions for values
+		// already provided by presets.
+		peek, err := config.Resolve(preset, commandName, overrides)
 		if err != nil {
 			return err
 		}
 
-		restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
-
-		if config.IsDryRun() {
-			fmt.Print(restic.DryRun(cfg))
-			return nil
+		cmdOverrides, err := promptForCommand(commandName, peek)
+		if err != nil {
+			return err
 		}
-
-		return restic.Run(cfg)
-	},
-}
-
-// Execute is the main entry point, called from main.go.
-func Execute() {
-	if err := fang.Execute(context.Background(), rootCmd); err != nil {
-		os.Exit(1)
+		overrides = mergeOverrides(overrides, cmdOverrides)
 	}
-}
 
-// extractOwnFlags pulls --dry-run and --config out of the raw arg list,
-// setting package-level flags, and returns the remaining args.
-func extractOwnFlags(args []string) []string {
-	var rest []string
-	for i := 0; i < len(args); i++ {
-		switch args[i] {
-		case "--dry-run":
-			flagDryRun = true
-		case "--config":
-			if i+1 < len(args) {
-				i++
-				flagConfigFile = args[i]
-			}
-		case "--help", "-h":
-			// Keep help flags in the raw list so RunE can dispatch help.
-			rest = append(rest, args[i])
-		default:
-			rest = append(rest, args[i])
-		}
+	cfg, err := config.Resolve(preset, commandName, overrides)
+	if err != nil {
+		return err
 	}
-	return rest
-}
 
-func hasHelpFlag(args []string) bool {
-	for _, arg := range args {
-		if arg == "--help" || arg == "-h" {
-			return true
-		}
+	restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
+
+	if config.IsDryRun() {
+		fmt.Print(restic.DryRun(cfg))
+		return nil
 	}
-	return false
+
+	return restic.Run(cfg)
 }
 
-// parseArgs splits args into (preset, command, remaining) using crestic-style
-// positional arity.
-//
-// Rules:
-//   - 0 positional args: no preset, no command.
-//   - 1 positional arg: command only.
-//   - 2+ positional args: first is preset, second is command.
-//
-// All unconsumed args are returned as passthrough for parsePassthrough.
-func parseArgs(args []string) (preset, command string, rest []string) {
-	positionals := positionalIndices(args)
-	if len(positionals) == 0 {
-		if len(args) == 0 {
-			return "", "", nil
-		}
-		return "", "", append([]string(nil), args...)
+func mergeOverrides(base, extra map[string][]string) map[string][]string {
+	if len(extra) == 0 {
+		return base
 	}
-
-	consumed := make(map[int]struct{}, 2)
-	if len(positionals) == 1 {
-		idx := positionals[0]
-		command = args[idx]
-		consumed[idx] = struct{}{}
-	} else {
-		presetIdx := positionals[0]
-		commandIdx := positionals[1]
-		preset = args[presetIdx]
-		command = args[commandIdx]
-		consumed[presetIdx] = struct{}{}
-		consumed[commandIdx] = struct{}{}
+	if base == nil {
+		base = make(map[string][]string, len(extra))
 	}
-
-	rest = make([]string, 0, len(args)-len(consumed))
-	for i, arg := range args {
-		if _, ok := consumed[i]; ok {
-			continue
-		}
-		rest = append(rest, arg)
+	for key, values := range extra {
+		base[key] = values
 	}
+	return base
+}
 
-	return preset, command, rest
+// Execute is the main entry point, called from main.go.
+func Execute() {
+	if err := fang.Execute(context.Background(), rootCmd); err != nil {
+		os.Exit(1)
+	}
 }
 
 // parsePassthrough converts restic-style CLI flags into a map suitable for
@@ -268,35 +201,6 @@ func parsePassthrough(args []string) map[string][]string {
 	return overrides
 }
 
-// positionalIndices returns indices of positional arguments in args. Flag
-// values are treated as part of their flag, not positional args.
-func positionalIndices(args []string) []int {
-	var idx []int
-
-	for i := 0; i < len(args); i++ {
-		arg := args[i]
-
-		switch {
-		case arg == "--":
-			for j := i + 1; j < len(args); j++ {
-				idx = append(idx, j)
-			}
-			return idx
-		case isFlagToken(arg):
-			if strings.Contains(arg, "=") {
-				continue
-			}
-			if i+1 < len(args) && args[i+1] != "--" && !isFlagToken(args[i+1]) {
-				i++
-			}
-		default:
-			idx = append(idx, i)
-		}
-	}
-
-	return idx
-}
-
 func isFlagToken(arg string) bool {
 	return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--"
 }

cmd/root_test.go 🔗

@@ -1,106 +1,16 @@
 package cmd
 
 import (
+	"bytes"
+	"io"
 	"os"
 	"path/filepath"
 	"reflect"
 	"strings"
 	"testing"
-)
-
-func TestParseArgs(t *testing.T) {
-	t.Parallel()
-
-	tests := []struct {
-		name        string
-		args        []string
-		wantPreset  string
-		wantCommand string
-		wantRest    []string
-	}{
-		{
-			name:        "no args",
-			args:        nil,
-			wantPreset:  "",
-			wantCommand: "",
-			wantRest:    nil,
-		},
-		{
-			name:        "single positional is command",
-			args:        []string{"backup"},
-			wantPreset:  "",
-			wantCommand: "backup",
-			wantRest:    nil,
-		},
-		{
-			name:        "single split-like token is still command",
-			args:        []string{"home@nas"},
-			wantPreset:  "",
-			wantCommand: "home@nas",
-			wantRest:    nil,
-		},
-		{
-			name:        "two positionals are preset and command",
-			args:        []string{"home", "backup"},
-			wantPreset:  "home",
-			wantCommand: "backup",
-			wantRest:    nil,
-		},
-		{
-			name:        "two positionals plus passthrough",
-			args:        []string{"home@nas", "backup", "--tag", "daily", "/src"},
-			wantPreset:  "home@nas",
-			wantCommand: "backup",
-			wantRest:    []string{"--tag", "daily", "/src"},
-		},
-		{
-			name:        "single command with flags",
-			args:        []string{"backup", "--repo", "/repo", "--json"},
-			wantPreset:  "",
-			wantCommand: "backup",
-			wantRest:    []string{"--repo", "/repo", "--json"},
-		},
-		{
-			name:        "flags before command",
-			args:        []string{"--repo", "/repo", "backup", "--json"},
-			wantPreset:  "",
-			wantCommand: "backup",
-			wantRest:    []string{"--repo", "/repo", "--json"},
-		},
-		{
-			name:        "no positional command when only flags provided",
-			args:        []string{"--repo", "/repo", "--json"},
-			wantPreset:  "",
-			wantCommand: "",
-			wantRest:    []string{"--repo", "/repo", "--json"},
-		},
-		{
-			name:        "double-dash keeps following literals in passthrough",
-			args:        []string{"home", "backup", "--", "--literal", "path"},
-			wantPreset:  "home",
-			wantCommand: "backup",
-			wantRest:    []string{"--", "--literal", "path"},
-		},
-	}
 
-	for _, tt := range tests {
-		tt := tt
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-
-			gotPreset, gotCommand, gotRest := parseArgs(tt.args)
-			if gotPreset != tt.wantPreset {
-				t.Fatalf("preset mismatch: got %q, want %q", gotPreset, tt.wantPreset)
-			}
-			if gotCommand != tt.wantCommand {
-				t.Fatalf("command mismatch: got %q, want %q", gotCommand, tt.wantCommand)
-			}
-			if !equalStringSlices(gotRest, tt.wantRest) {
-				t.Fatalf("rest mismatch: got %#v, want %#v", gotRest, tt.wantRest)
-			}
-		})
-	}
-}
+	"github.com/spf13/cobra"
+)
 
 func TestParsePassthrough(t *testing.T) {
 	t.Parallel()
@@ -190,39 +100,150 @@ func TestParsePassthrough(t *testing.T) {
 	}
 }
 
-func TestHasHelpFlag(t *testing.T) {
-	t.Parallel()
+func TestRunCommandAppliesRootFlagsAndPassthrough(t *testing.T) {
+	configFile := setupCommandConfig(t)
+	setRootFlagValuesForTest(t, "home@cloud", true, configFile)
+	t.Setenv("KELD_CONFIG_FILE", "")
+	t.Setenv("KELD_DRYRUN", "")
 
-	tests := []struct {
-		name string
-		args []string
-		want bool
-	}{
-		{name: "no args", args: nil, want: false},
-		{name: "no help token", args: []string{"backup", "--repo", "/repo"}, want: false},
-		{name: "short help", args: []string{"-h"}, want: true},
-		{name: "long help", args: []string{"--help"}, want: true},
-		{name: "help mixed with args", args: []string{"home", "backup", "--help"}, want: true},
+	backup := lookupSubcommand(t, "backup")
+	out, err := captureStdout(t, func() error {
+		return runCommand("backup", backup, []string{"--tag", "daily", "/src"})
+	})
+	if err != nil {
+		t.Fatalf("runCommand returned error: %v", err)
 	}
 
-	for _, tt := range tests {
-		tt := tt
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
+	if got := os.Getenv("KELD_CONFIG_FILE"); got != configFile {
+		t.Fatalf("KELD_CONFIG_FILE mismatch: got %q, want %q", got, configFile)
+	}
+	if got := os.Getenv("KELD_DRYRUN"); got != "1" {
+		t.Fatalf("KELD_DRYRUN mismatch: got %q, want %q", got, "1")
+	}
 
-			got := hasHelpFlag(tt.args)
-			if got != tt.want {
-				t.Fatalf("hasHelpFlag(%#v) = %v, want %v", tt.args, got, tt.want)
-			}
-		})
+	for _, want := range []string{"\"backup\"", "\"--tag\" \"daily\"", "\"/src\""} {
+		if !strings.Contains(out, want) {
+			t.Fatalf("dry-run output missing %q:\n%s", want, out)
+		}
+	}
+}
+
+func TestRunCommandRejectsUnknownPreset(t *testing.T) {
+	configFile := setupCommandConfig(t)
+	setRootFlagValuesForTest(t, "missing", true, configFile)
+
+	backup := lookupSubcommand(t, "backup")
+	err := runCommand("backup", backup, nil)
+	if err == nil {
+		t.Fatal("expected unknown preset error")
+	}
+	if !strings.Contains(err.Error(), "unknown preset") {
+		t.Fatalf("expected unknown preset error, got: %v", err)
+	}
+}
+
+func TestSubcommandHelpShowsCobraHelp(t *testing.T) {
+	backup := lookupSubcommand(t, "backup")
+	buf := &bytes.Buffer{}
+	backup.SetOut(buf)
+	backup.SetErr(buf)
+	t.Cleanup(func() {
+		backup.SetOut(os.Stdout)
+		backup.SetErr(os.Stderr)
+	})
+
+	err := backup.RunE(backup, []string{"--help"})
+	if err != nil {
+		t.Fatalf("backup --help returned error: %v", err)
+	}
+
+	helpText := buf.String()
+	if !strings.Contains(helpText, "Usage:") {
+		t.Fatalf("help output missing Usage section:\n%s", helpText)
+	}
+	if !strings.Contains(helpText, "--exclude") {
+		t.Fatalf("help output missing backup flag listing:\n%s", helpText)
+	}
+	if !strings.Contains(helpText, "--repo") {
+		t.Fatalf("help output missing inherited global flags:\n%s", helpText)
+	}
+}
+
+func setupCommandConfig(t *testing.T) string {
+	t.Helper()
+
+	dir := t.TempDir()
+	cfg := filepath.Join(dir, "config.toml")
+	err := os.WriteFile(cfg, []byte(`
+[global]
+repo = "/repos/default"
+
+["home@"]
+tag = "home"
+
+["@cloud"]
+repo = "/repos/cloud"
+
+[archive]
+json = true
+`), 0o600)
+	if err != nil {
+		t.Fatalf("writing fixture: %v", err)
+	}
+	t.Setenv("HOME", dir)
+
+	return cfg
+}
+
+func setRootFlagValuesForTest(t *testing.T, preset string, showCommand bool, configFile string) {
+	t.Helper()
+
+	prevPreset, prevShowCommand, prevConfigFile := flagPreset, flagShowCmd, flagConfigFile
+	flagPreset = preset
+	flagShowCmd = showCommand
+	flagConfigFile = configFile
+	t.Cleanup(func() {
+		flagPreset = prevPreset
+		flagShowCmd = prevShowCommand
+		flagConfigFile = prevConfigFile
+	})
+}
+
+func lookupSubcommand(t *testing.T, name string) *cobra.Command {
+	t.Helper()
+
+	for _, cmd := range rootCmd.Commands() {
+		if cmd.Name() == name {
+			return cmd
+		}
 	}
+	t.Fatalf("subcommand %q not found", name)
+	return nil
 }
 
-func equalStringSlices(a, b []string) bool {
-	if len(a) == 0 && len(b) == 0 {
-		return true
+func captureStdout(t *testing.T, run func() error) (string, error) {
+	t.Helper()
+
+	oldStdout := os.Stdout
+	reader, writer, err := os.Pipe()
+	if err != nil {
+		t.Fatalf("creating stdout pipe: %v", err)
+	}
+	os.Stdout = writer
+	t.Cleanup(func() {
+		os.Stdout = oldStdout
+	})
+
+	runErr := run()
+	_ = writer.Close()
+
+	out, readErr := io.ReadAll(reader)
+	if readErr != nil {
+		t.Fatalf("reading stdout: %v", readErr)
 	}
-	return reflect.DeepEqual(a, b)
+	_ = reader.Close()
+
+	return string(out), runErr
 }
 
 func TestValidatePreset(t *testing.T) {

cmd/subcommands.go 🔗

@@ -0,0 +1,136 @@
+package cmd
+
+import (
+	"sort"
+	"strconv"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+
+	"git.secluded.site/keld/internal/restic"
+)
+
+func init() {
+	registerRootFlags()
+	registerGlobalFlags()
+	registerSubcommands()
+
+	if err := rootCmd.RegisterFlagCompletionFunc("preset", completePreset); err != nil {
+		panic(err)
+	}
+}
+
+func registerSubcommands() {
+	names := make([]string, 0, len(restic.Commands))
+	for name := range restic.Commands {
+		if name == "global" {
+			continue
+		}
+		names = append(names, name)
+	}
+	sort.Strings(names)
+
+	for _, name := range names {
+		def := restic.Commands[name]
+		commandName := name
+
+		subCmd := &cobra.Command{
+			Use:                commandName + " [flags...] [args...]",
+			Short:              def.Description,
+			DisableFlagParsing: true,
+			SilenceUsage:       true,
+			SilenceErrors:      true,
+			RunE: func(cmd *cobra.Command, args []string) error {
+				if wantsCommandHelp(args) {
+					return cmd.Help()
+				}
+				return runCommand(commandName, cmd, args)
+			},
+		}
+
+		registerResticOptions(subCmd.Flags(), def.Options)
+		rootCmd.AddCommand(subCmd)
+	}
+}
+
+func registerGlobalFlags() {
+	global, ok := restic.Commands["global"]
+	if !ok {
+		panic("restic command metadata missing global command")
+	}
+
+	flags := rootCmd.PersistentFlags()
+	for _, opt := range global.Options {
+		switch opt.Name {
+		case "help":
+			continue
+		case "verbose":
+			registerCountOption(flags, opt)
+			continue
+		}
+
+		registerResticOption(flags, opt)
+	}
+}
+
+func registerResticOptions(flags *pflag.FlagSet, options []restic.Option) {
+	for _, opt := range options {
+		if opt.Name == "help" {
+			continue
+		}
+		registerResticOption(flags, opt)
+	}
+}
+
+func registerResticOption(flags *pflag.FlagSet, opt restic.Option) {
+	shorthand := sanitizeShorthand(opt.Alias)
+
+	switch {
+	case opt.IsBool:
+		flags.BoolP(opt.Name, shorthand, optionDefaultBool(opt.Default), opt.Description)
+	case opt.Repeatable:
+		flags.StringArrayP(opt.Name, shorthand, nil, opt.Description)
+	default:
+		flags.StringP(opt.Name, shorthand, opt.Default, opt.Description)
+	}
+}
+
+func registerCountOption(flags *pflag.FlagSet, opt restic.Option) {
+	shorthand := sanitizeShorthand(opt.Alias)
+	value := flags.CountP(opt.Name, shorthand, opt.Description)
+
+	level, err := strconv.Atoi(opt.Default)
+	if err != nil || level == 0 {
+		return
+	}
+
+	*value = level
+	_ = flags.Set(opt.Name, strconv.Itoa(level))
+}
+
+func sanitizeShorthand(alias string) string {
+	if len(alias) == 1 {
+		return alias
+	}
+	return ""
+}
+
+func optionDefaultBool(raw string) bool {
+	v, err := strconv.ParseBool(raw)
+	if err != nil {
+		return false
+	}
+	return v
+}
+
+func wantsCommandHelp(args []string) bool {
+	for _, arg := range args {
+		if arg == "--" {
+			return false
+		}
+		if arg == "--help" || arg == "-h" {
+			return true
+		}
+	}
+	return false
+}

examples/README.md 🔗

@@ -73,7 +73,7 @@ systemctl --user list-timers
 systemctl --user status keld-backup@media@hetzner_media.service
 
 # Test a dry run
-keld --dry-run media@hetzner_media backup
+keld --show-command --preset media@hetzner_media backup
 ```
 
 Repeat this to add backups later.

examples/keld/config_long.toml 🔗

@@ -16,7 +16,7 @@ exclude-file = "~/.config/restic/excludes.txt"
 exclude-if-present = ".nobackup"
 
 [home.backup]
-# Simple preset: `keld home backup`
+# Simple preset: `keld --preset home backup`
 _arguments = ["/home/amolith"]
 tag = ["home"]
 

examples/systemd/user/keld-backup@.service 🔗

@@ -6,4 +6,4 @@ Nice=19
 IOSchedulingClass=idle
 KillSignal=SIGINT
 EnvironmentFile=-%h/.config/keld/timers/%I.env
-ExecStart=%h/.local/bin/mise x github:bdd/runitor -- runitor -- mise x http:keld -- keld %I backup
+ExecStart=%h/.local/bin/mise x github:bdd/runitor -- runitor -- mise x http:keld -- keld --preset %I backup

examples/systemd/user/keld-verify@.service 🔗

@@ -6,4 +6,4 @@ Nice=19
 IOSchedulingClass=idle
 KillSignal=SIGINT
 EnvironmentFile=-%h/.config/keld/timers/%I-verify.env
-ExecStart=%h/.local/bin/mise x github:bdd/runitor -- runitor -- mise x http:keld -- keld %I verify
+ExecStart=%h/.local/bin/mise x github:bdd/runitor -- runitor -- mise x http:keld -- keld --preset %I check