diff --git a/cmd/root/completion.go b/cmd/root/completion.go index 2ed061ce6..67596e53a 100644 --- a/cmd/root/completion.go +++ b/cmd/root/completion.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/userconfig" ) @@ -97,6 +98,22 @@ func completeMessage(cmd *cobra.Command, args []string, toComplete string) ([]st return candidates, cobra.ShellCompDirectiveNoFileComp } +func completeTheme(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + refs, err := styles.ListThemeRefs() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var candidates []string + for _, ref := range refs { + if strings.HasPrefix(ref, toComplete) { + candidates = append(candidates, ref) + } + } + + return candidates, cobra.ShellCompDirectiveNoFileComp +} + func completeAgentFilename(toComplete string) ([]string, cobra.ShellCompDirective) { dirPrefix, base := filepath.Split(toComplete) diff --git a/cmd/root/completion_test.go b/cmd/root/completion_test.go index e0afbe523..194ef29ae 100644 --- a/cmd/root/completion_test.go +++ b/cmd/root/completion_test.go @@ -321,6 +321,52 @@ func TestCompleteRunExec(t *testing.T) { } } +func TestCompleteTheme(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + toComplete string + wantSome []string + wantNone []string + }{ + { + name: "empty prefix lists default and built-ins", + toComplete: "", + wantSome: []string{"default", "nord", "dracula"}, + }, + { + name: "prefix filters to matching themes", + toComplete: "gruvbox", + wantSome: []string{"gruvbox-dark", "gruvbox-light"}, + wantNone: []string{"default", "nord"}, + }, + { + name: "non-matching prefix yields no themes", + toComplete: "this-theme-does-not-exist", + wantNone: []string{"default", "nord"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + completions, directive := completeTheme(nil, nil, tt.toComplete) + + for _, want := range tt.wantSome { + assert.Contains(t, completions, want) + } + for _, notWant := range tt.wantNone { + assert.NotContains(t, completions, notWant) + } + + assert.NotEqual(t, cobra.ShellCompDirective(0), directive&cobra.ShellCompDirectiveNoFileComp, + "expected NoFileComp directive to be set") + }) + } +} + func writeFile(t *testing.T, dir, name string) { t.Helper() require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o644)) diff --git a/cmd/root/run.go b/cmd/root/run.go index ade051430..6a97f9aef 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "time" "github.com/mattn/go-isatty" @@ -69,6 +70,7 @@ type runExecFlags struct { listenAddr string onEventSpecs []string disabledCommands []string + theme string // globalPermissions holds the user-level global permission checker built // from user config settings. Nil when no global permissions are configured. @@ -144,6 +146,8 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().StringVar(&flags.appName, "app-name", "", "Application name shown in the TUI in place of \"docker agent\"") cmd.PersistentFlags().StringSliceVar(&flags.disabledCommands, "disable-commands", nil, "Comma-separated list of slash commands to hide and disable in the TUI (e.g. /cost,/eval,/model)") cmd.PersistentFlags().BoolVar(&flags.sidebar, "sidebar", true, "Show the sidebar in the TUI (set --sidebar=false to hide it)") + cmd.PersistentFlags().StringVar(&flags.theme, "theme", "", "Preselect a TUI theme by name (overrides the theme from user config; ignored outside the interactive TUI)") + _ = cmd.RegisterFlagCompletionFunc("theme", completeTheme) cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)") cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "docker/sandbox-templates:docker-agent", "Template image for the sandbox (passed to docker sandbox create -t)") cmd.PersistentFlags().BoolVar(&flags.sbx, "sbx", true, "Prefer the sbx CLI backend when available (set --sbx=false to force docker sandbox)") @@ -176,6 +180,15 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command }() } + // Validate an explicit --theme value early so a typo fails fast with a + // helpful message instead of silently falling back to the default theme + // once the TUI starts. + if f.theme != "" { + if err := validateTheme(f.theme); err != nil { + return err + } + } + // Resolve alias / runtime-declared sandbox opt-in before dispatch. // An explicit --sandbox= on the CLI always wins, so we only // consult the lower-priority sources when the flag wasn't set. @@ -321,7 +334,7 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s return err } - applyTheme() + applyTheme(f.theme) opts, err := f.buildAppOpts(args) if err != nil { return err @@ -637,13 +650,30 @@ func stopToolSets(t toolStopper) { } } -// applyTheme applies the theme from user config, or the built-in default. -func applyTheme() { - // Resolve theme from user config > built-in default +// validateTheme reports whether ref names a loadable theme. It is used to +// fail fast on an explicit --theme value, listing the available themes so the +// user can correct a typo. +func validateTheme(ref string) error { + if _, err := styles.LoadTheme(ref); err != nil { + if refs, listErr := styles.ListThemeRefs(); listErr == nil && len(refs) > 0 { + return fmt.Errorf("unknown theme %q; available themes: %s", ref, strings.Join(refs, ", ")) + } + return fmt.Errorf("unknown theme %q: %w", ref, err) + } + return nil +} + +// applyTheme applies the theme, resolving it from the --theme flag, then the +// user config, then the built-in default. +func applyTheme(themeOverride string) { + // Resolve theme from --theme flag > user config > built-in default themeRef := styles.DefaultThemeRef if userSettings := userconfig.Get(); userSettings.Theme != "" { themeRef = userSettings.Theme } + if themeOverride != "" { + themeRef = themeOverride + } theme, err := styles.LoadTheme(themeRef) if err != nil { diff --git a/cmd/root/run_theme_test.go b/cmd/root/run_theme_test.go new file mode 100644 index 000000000..897fcb744 --- /dev/null +++ b/cmd/root/run_theme_test.go @@ -0,0 +1,68 @@ +package root + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/paths" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +func TestValidateTheme(t *testing.T) { + t.Parallel() + + t.Run("accepts built-in theme", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateTheme("nord")) + }) + + t.Run("accepts default theme", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateTheme(styles.DefaultThemeRef)) + }) + + t.Run("rejects unknown theme with helpful message", func(t *testing.T) { + t.Parallel() + err := validateTheme("does-not-exist") + require.Error(t, err) + assert.Contains(t, err.Error(), "does-not-exist") + assert.Contains(t, err.Error(), "available themes") + }) + + t.Run("rejects path traversal", func(t *testing.T) { + t.Parallel() + require.Error(t, validateTheme("../../etc/passwd")) + }) +} + +func TestApplyThemePrecedence(t *testing.T) { + // Not parallel: mutates the process-global applied theme via ApplyTheme. + // Isolate config/data dirs so a developer's real user config (which may + // pin a theme) cannot influence the precedence assertions. + dir := t.TempDir() + paths.SetConfigDir(dir) + paths.SetDataDir(dir) + t.Cleanup(func() { + paths.SetConfigDir("") + paths.SetDataDir("") + }) + + t.Run("override takes precedence and is applied", func(t *testing.T) { + applyTheme("nord") + assert.Equal(t, "nord", styles.CurrentTheme().Ref) + }) + + t.Run("invalid override falls back to default theme", func(t *testing.T) { + // applyTheme tolerates an invalid ref (validateTheme guards the CLI + // entry point); it must never panic and should apply the default. + applyTheme("does-not-exist") + assert.Equal(t, styles.DefaultThemeRef, styles.CurrentTheme().Ref) + }) + + t.Run("empty override applies default when no user config theme", func(t *testing.T) { + applyTheme("") + assert.Equal(t, styles.DefaultThemeRef, styles.CurrentTheme().Ref) + }) +}