Detailed changes
@@ -45,7 +45,7 @@ var rootCmd = &cobra.Command{
TraverseChildren: true,
RunE: func(cmd *cobra.Command, _ []string) error {
- commandName, preset, overrides, err := runInteractive()
+ commandName, preset, overrides, err := runInteractive("")
if err != nil {
return err
}
@@ -85,7 +85,11 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessio
}
preset := flagPreset
- interactive := cmd == cmd.Root()
+ // Interactive mode when launched from root command or when a
+ // TUI session ran (presetResolved). The session handles preset
+ // selection, but runCommand may still need to collect values
+ // for commands without dedicated TUI screens (e.g. backup paths).
+ interactive := cmd == cmd.Root() || presetResolved
if preset != "" {
if err := validatePreset(preset); err != nil {
@@ -219,13 +223,18 @@ func isFlagToken(arg string) bool {
// menu, preset selector (if needed), and command-specific screens
// (e.g. the restore flow). Returns the chosen command, preset, any
// collected overrides, or ("", "", nil, nil) if the user cancelled.
-func runInteractive() (command, preset string, overrides map[string][]string, err error) {
+func runInteractive(preselectedCommand string) (command, preset string, overrides map[string][]string, err error) {
styles := theme.New(true)
var screenList []ui.Screen
- menuScreen := screens.NewMenu(menuItems, &styles)
- screenList = append(screenList, menuScreen)
+ // When a command is pre-selected (e.g. "keld restore"), skip the
+ // menu entirely. Otherwise show the interactive command menu.
+ var menuScreen *screens.Menu
+ if preselectedCommand == "" {
+ menuScreen = screens.NewMenu(menuItems, &styles)
+ screenList = append(screenList, menuScreen)
+ }
// Build the preset screen only when needed:
// - --preset on CLI: skip (already resolved).
@@ -251,7 +260,10 @@ func runInteractive() (command, preset string, overrides map[string][]string, er
// screens. It resolves the config and dynamically builds the
// remaining screens via ExtendMsg.
resolveScreen := screens.NewResolve(func() []ui.Screen {
- cmd := menuScreen.Selection()
+ cmd := preselectedCommand
+ if menuScreen != nil {
+ cmd = menuScreen.Selection()
+ }
p := preset
if presetScreen != nil {
p = presetScreen.Value()
@@ -296,7 +308,10 @@ func runInteractive() (command, preset string, overrides map[string][]string, er
return "", "", nil, nil
}
- command = menuScreen.Selection()
+ command = preselectedCommand
+ if menuScreen != nil {
+ command = menuScreen.Selection()
+ }
if !slices.Contains(knownCommands, command) {
return "", "", nil, fmt.Errorf("unknown menu command %q", command)
}
@@ -199,13 +199,16 @@ func setRootFlagValuesForTest(t *testing.T, preset string, showCommand bool, con
t.Helper()
prevPreset, prevShowCommand, prevConfigFile := flagPreset, flagShowCmd, flagConfigFile
+ prevPresetResolved := presetResolved
flagPreset = preset
flagShowCmd = showCommand
flagConfigFile = configFile
+ presetResolved = false
t.Cleanup(func() {
flagPreset = prevPreset
flagShowCmd = prevShowCommand
flagConfigFile = prevConfigFile
+ presetResolved = prevPresetResolved
})
}
@@ -44,6 +44,24 @@ func registerSubcommands() {
if wantsCommandHelp(args) {
return cmd.Help()
}
+
+ // When invoked with no flags or arguments and the command
+ // is wrapped (has TUI screens), enter the interactive
+ // session with the command pre-selected, skipping the
+ // menu screen.
+ if len(args) == 0 && isWrappedCommand(commandName) {
+ cmdName, preset, overrides, err := runInteractive(commandName)
+ if err != nil {
+ return err
+ }
+ if cmdName == "" {
+ return nil
+ }
+ flagPreset = preset
+ presetResolved = true
+ return runCommand(cmdName, cmd, nil, overrides)
+ }
+
return runCommand(commandName, cmd, args, nil)
},
}
@@ -1,6 +1,10 @@
package cmd
-import "git.secluded.site/keld/internal/ui/screens"
+import (
+ "slices"
+
+ "git.secluded.site/keld/internal/ui/screens"
+)
// wrappedCommand describes a restic command that keld exposes in its
// interactive menu and shell completions.
@@ -39,6 +43,12 @@ var knownCommands = func() []string {
return names
}()
+// isWrappedCommand reports whether the given command name is one of
+// the commands keld actively wraps with TUI screens.
+func isWrappedCommand(name string) bool {
+ return slices.Contains(knownCommands, name)
+}
+
// flagContract describes a restic flag that keld semantically depends on
// (e.g. used in HasFlag checks or keyAliases).
type flagContract struct {
@@ -0,0 +1,59 @@
+package cmd
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestIsWrappedCommand(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ cmd string
+ want bool
+ }{
+ {name: "backup is wrapped", cmd: "backup", want: true},
+ {name: "restore is wrapped", cmd: "restore", want: true},
+ {name: "snapshots is wrapped", cmd: "snapshots", want: true},
+ {name: "cat is not wrapped", cmd: "cat", want: false},
+ {name: "ls is not wrapped", cmd: "ls", want: false},
+ {name: "empty string", cmd: "", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := isWrappedCommand(tt.cmd)
+ if got != tt.want {
+ t.Errorf("isWrappedCommand(%q) = %v, want %v", tt.cmd, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSubcommandWithArgsStaysNonInteractive(t *testing.T) {
+ // Not parallel: mutates package-level flag vars via
+ // setRootFlagValuesForTest and environment via t.Setenv.
+ configFile := setupCommandConfig(t)
+ setRootFlagValuesForTest(t, "home@cloud", true, configFile)
+ t.Setenv("KELD_CONFIG_FILE", "")
+ t.Setenv("KELD_DRYRUN", "")
+
+ backup := lookupSubcommand(t, "backup")
+ out, err := captureStdout(t, func() error {
+ return backup.RunE(backup, []string{"--tag", "daily", "/src"})
+ })
+ if err != nil {
+ t.Fatalf("subcommand with args returned error: %v", err)
+ }
+
+ // Should produce dry-run output containing the command.
+ if !strings.Contains(out, `"backup"`) {
+ t.Fatalf("expected dry-run output to contain backup command, got:\n%s", out)
+ }
+ if !strings.Contains(out, `"--tag" "daily"`) {
+ t.Fatalf("expected dry-run output to contain --tag daily, got:\n%s", out)
+ }
+}