Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/root/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)

Expand Down
46 changes: 46 additions & 0 deletions cmd/root/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
38 changes: 34 additions & 4 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"time"

"github.com/mattn/go-isatty"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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 != "" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] --theme validation fires in --exec mode, contradicting "ignored outside TUI" docs

The flag's help text says the --theme flag is "ignored outside the interactive TUI", but validateTheme is called unconditionally from runRunCommand — the shared entry point for both interactive and --exec (non-TUI) modes.

This means:

docker agent run --exec --theme does-not-exist config.yaml

…will error out with unknown theme "does-not-exist"; available themes: … instead of silently ignoring the flag as the documentation promises.

Suggested fix: Gate the validation on whether the command is actually launching the interactive TUI (e.g. skip validation when --exec is set):

if f.theme != "" && !f.exec {
    if err := validateTheme(f.theme); err != nil {
        return err
    }
}

Or update the flag's help text to say the flag is validated even in exec mode (even if the theme is not applied).

if err := validateTheme(f.theme); err != nil {
return err
}
}

// Resolve alias / runtime-declared sandbox opt-in before dispatch.
// An explicit --sandbox=<bool> on the CLI always wins, so we only
// consult the lower-priority sources when the flag wasn't set.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions cmd/root/run_theme_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] TestApplyThemePrecedence missing test for user-config theme + --theme flag precedence

TestApplyThemePrecedence never sets a user config theme (userSettings.Theme != ""), so the critical precedence path — where both a user config theme and the --theme flag override are present — is never exercised.

The applyTheme logic has two layers:

  1. Apply user config theme if set (overrides default)
  2. Apply --theme flag override if set (overrides user config)

If this ordering were accidentally swapped in a future refactor (user config checked after flag instead of before), the existing tests would not catch the regression because they only test the flag override against an empty user config.

Suggested addition to TestApplyThemePrecedence:

t.Run("flag override wins over user config theme", func(t *testing.T) {
    // Write a user config that pins a different theme
    userConfigPath := filepath.Join(dir, "config.json")
    _ = os.WriteFile(userConfigPath, []byte(`{"theme":"dracula"}`), 0o600)
    
    applyTheme("nord") // --theme flag should override the config's "dracula"
    assert.Equal(t, "nord", styles.CurrentTheme().Ref)
})

// 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)
})
}
Loading