From b5c5ef595c565ef03001b8179189ea5d9a2ef207 Mon Sep 17 00:00:00 2001 From: hjeddad Date: Fri, 5 Jun 2026 13:15:07 +0000 Subject: [PATCH 1/5] fix: filter completion candidates to aliases and agent YAMLs only - Replace completeAgentFilename fallthrough in completeAlias with a new completeAgentYAMLInCwd helper that only returns non-dotfile *.yaml/yml files from the current directory (no directories, no dotfiles) - Sort alias names alphabetically using slices.Sorted(maps.Keys(...)) - Add tests: no directories in plain-prefix completion, no dotfile YAMLs, path drill-down still works with ./ prefix, aliases are sorted --- cmd/root/completion.go | 45 ++++++++++++++++---- cmd/root/completion_test.go | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/cmd/root/completion.go b/cmd/root/completion.go index e63aad66f..0c83d37e2 100644 --- a/cmd/root/completion.go +++ b/cmd/root/completion.go @@ -1,8 +1,10 @@ package root import ( + "maps" "os" "path/filepath" + "slices" "strings" "github.com/spf13/cobra" @@ -30,26 +32,22 @@ func completeAlias(toComplete string) ([]string, cobra.ShellCompDirective) { var candidates []string - // Add matching built-in agent names for _, name := range config.BuiltinAgentNames() { if strings.HasPrefix(name, toComplete) { candidates = append(candidates, name+"\tbuilt-in agent") } } - // Add matching aliases - cfg, err := userconfig.Load() - if err == nil { - for k, v := range cfg.Aliases { + if cfg, err := userconfig.Load(); err == nil { + names := slices.Sorted(maps.Keys(cfg.Aliases)) + for _, k := range names { if strings.HasPrefix(k, toComplete) { - candidates = append(candidates, k+"\t"+v.Path) + candidates = append(candidates, k+"\t"+cfg.Aliases[k].Path) } } } - // Also add matching YAML files from the current directory - fileCandidates, _ := completeAgentFilename(toComplete) - candidates = append(candidates, fileCandidates...) + candidates = append(candidates, completeAgentYAMLInCwd(toComplete)...) return candidates, cobra.ShellCompDirectiveNoFileComp } @@ -113,6 +111,35 @@ func completeTheme(_ *cobra.Command, _ []string, toComplete string) ([]string, c return candidates, cobra.ShellCompDirectiveNoFileComp } +// completeAgentYAMLInCwd returns *.yaml / *.yml files in the current directory +// whose name starts with the prefix. Directories and dotfiles are excluded; +// this is the "no path typed yet" case where suggesting the full filesystem +// tree would be noise. +func completeAgentYAMLInCwd(prefix string) []string { + entries, err := os.ReadDir(".") + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + if !strings.HasPrefix(name, prefix) { + continue + } + ext := strings.ToLower(filepath.Ext(name)) + if ext == ".yaml" || ext == ".yml" { + out = append(out, name) + } + } + return out +} + 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 194ef29ae..64c23f7a8 100644 --- a/cmd/root/completion_test.go +++ b/cmd/root/completion_test.go @@ -3,6 +3,7 @@ package root import ( "os" "path/filepath" + "strings" "testing" "github.com/spf13/cobra" @@ -367,6 +368,90 @@ func TestCompleteTheme(t *testing.T) { } } +func TestCompleteAlias_NoDirectoriesForPlainPrefix(t *testing.T) { + // This test changes the working directory so it cannot run in parallel + + tmpDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)) + writeFile(t, tmpDir, "agent.yaml") + + t.Chdir(tmpDir) + + completions, directive := completeAlias("") + + assert.NotContains(t, completions, "subdir/", "directories should not appear in plain-prefix completion") + assert.Contains(t, completions, "agent.yaml") + assert.NotEqual(t, cobra.ShellCompDirective(0), directive&cobra.ShellCompDirectiveNoFileComp) +} + +func TestCompleteAlias_NoDotfileYAML(t *testing.T) { + // This test changes the working directory so it cannot run in parallel + + tmpDir := t.TempDir() + writeFile(t, tmpDir, ".golangci.yml") + writeFile(t, tmpDir, "agent.yaml") + + t.Chdir(tmpDir) + + completions, directive := completeAlias("") + + assert.NotContains(t, completions, ".golangci.yml", "dotfile YAMLs should not appear in completion") + assert.Contains(t, completions, "agent.yaml") + assert.NotEqual(t, cobra.ShellCompDirective(0), directive&cobra.ShellCompDirectiveNoFileComp) +} + +func TestCompleteAlias_PathPrefixStillDrillsDown(t *testing.T) { + // This test changes the working directory so it cannot run in parallel + + tmpDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)) + + t.Chdir(tmpDir) + + completions, directive := completeAlias("./") + + assert.Contains(t, completions, "./subdir/", "path drill-down with './' prefix must still include directories") + assert.NotEqual(t, cobra.ShellCompDirective(0), directive&cobra.ShellCompDirectiveNoFileComp) +} + +func TestCompleteAlias_SortedAliases(t *testing.T) { + // This test changes the working directory so it cannot run in parallel + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Write a userconfig with aliases out of alphabetical order. + // Set HOME so paths.GetConfigDir resolves to our temp directory. + t.Setenv("HOME", tmpDir) + cfgDir := filepath.Join(tmpDir, ".config", "cagent") + require.NoError(t, os.MkdirAll(cfgDir, 0o755)) + cfgContent := `aliases: + zeta: + path: /z.yaml + alpha: + path: /a.yaml + mid: + path: /m.yaml +` + require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(cfgContent), 0o644)) + + completions, directive := completeAlias("") + + // Extract only the alias keys (strip tab-separated description) + var aliasNames []string + for _, c := range completions { + parts := strings.SplitN(c, "\t", 2) + name := parts[0] + // Only include aliases we created (alpha, mid, zeta) + if name == "alpha" || name == "mid" || name == "zeta" { + aliasNames = append(aliasNames, name) + } + } + + assert.Equal(t, []string{"alpha", "mid", "zeta"}, aliasNames, "aliases should appear in alphabetical order") + assert.NotEqual(t, cobra.ShellCompDirective(0), directive&cobra.ShellCompDirectiveNoFileComp) +} + func writeFile(t *testing.T, dir, name string) { t.Helper() require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o644)) From 1dc61e53c8dae85499bf1e820ff15fa80e54dab9 Mon Sep 17 00:00:00 2001 From: hjeddad Date: Fri, 5 Jun 2026 13:19:30 +0000 Subject: [PATCH 2/5] fix: exclude non-regular files via IsRegular check in completeAgentYAMLInCwd Replace e.IsDir() with !e.Type().IsRegular() so that symlinks-to-directories and other non-regular entries (pipes, devices) are also excluded. --- cmd/root/completion.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root/completion.go b/cmd/root/completion.go index 0c83d37e2..22153a6fb 100644 --- a/cmd/root/completion.go +++ b/cmd/root/completion.go @@ -122,7 +122,7 @@ func completeAgentYAMLInCwd(prefix string) []string { } var out []string for _, e := range entries { - if e.IsDir() { + if !e.Type().IsRegular() { continue } name := e.Name() From 8db45ae1c00734fb5d6804146b3cd592c2845779 Mon Sep 17 00:00:00 2001 From: hjeddad Date: Fri, 5 Jun 2026 13:22:44 +0000 Subject: [PATCH 3/5] test: document that non-dotfile YAMLs like Taskfile.yml still appear in completion Add TestCompleteAlias_PlainNonAgentYAMLStillAppears to make the current contract explicit: regular non-dotfile .yml/.yaml files (e.g. Taskfile.yml) are included in plain-prefix completions. Filtering those is a known follow-up, not covered by this PR. --- cmd/root/completion_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmd/root/completion_test.go b/cmd/root/completion_test.go index 64c23f7a8..1e0b4f33f 100644 --- a/cmd/root/completion_test.go +++ b/cmd/root/completion_test.go @@ -452,6 +452,24 @@ func TestCompleteAlias_SortedAliases(t *testing.T) { assert.NotEqual(t, cobra.ShellCompDirective(0), directive&cobra.ShellCompDirectiveNoFileComp) } +func TestCompleteAlias_PlainNonAgentYAMLStillAppears(t *testing.T) { + // This test changes the working directory so it cannot run in parallel + + tmpDir := t.TempDir() + writeFile(t, tmpDir, "Taskfile.yml") + writeFile(t, tmpDir, "agent.yaml") + + t.Chdir(tmpDir) + + completions, directive := completeAlias("") + + // Both files are regular, non-dotfile YAMLs — both appear by design. + // Filtering non-agent YAMLs like Taskfile.yml is a known follow-up. + assert.Contains(t, completions, "Taskfile.yml") + assert.Contains(t, completions, "agent.yaml") + assert.NotEqual(t, cobra.ShellCompDirective(0), directive&cobra.ShellCompDirectiveNoFileComp) +} + func writeFile(t *testing.T, dir, name string) { t.Helper() require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o644)) From 028c91353b1d99e37d5be093c678f6f116c0b8b3 Mon Sep 17 00:00:00 2001 From: hjeddad Date: Fri, 5 Jun 2026 13:29:58 +0000 Subject: [PATCH 4/5] fix: strip plugin-name token from completion args when invoked by Docker CLI When Docker CLI delegates tab-completion to the plugin it calls: docker-agent __complete agent RunningStandalone() returns true here (os.Args[1] is '__complete', not the metadata subcommand), so the cobra root command received 'agent' as the first argument to __complete and could not resolve the subcommand tree. The result was zsh falling back to default filesystem completion, showing every file and directory in cwd. Fix: strip the redundant 'agent' token in stripPluginNameFromCompletionArgs() before passing args to cobra, so 'docker agent run ' correctly delegates to completeRunExec and returns only aliases and agent YAMLs. --- cmd/root/root.go | 27 ++++++++++++++++++- cmd/root/selfupdate_test.go | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/cmd/root/root.go b/cmd/root/root.go index 969c3b5ac..5243e3128 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -183,10 +183,19 @@ func Execute(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, arg rootCmd.SetIn(stdin) rootCmd.SetOut(stdout) rootCmd.SetErr(stderr) - rootCmd.SetArgs(args) runningStandalone := plugin.RunningStandalone() + // When the Docker CLI invokes the plugin for shell completion it calls: + // docker-agent __complete agent + // RunningStandalone() returns true here (os.Args[1] is "__complete", not + // the metadata subcommand), so cobra would receive "agent" as the first + // arg to __complete and fail to resolve the subcommand tree. Strip the + // redundant plugin-name token so cobra sees the correct args. + args = stripPluginNameFromCompletionArgs(args) + + rootCmd.SetArgs(args) + visitAll(rootCmd, func(cmd *cobra.Command) { cmd.SetContext(ctx) if !runningStandalone { @@ -240,6 +249,22 @@ func visitAll(cmd *cobra.Command, fn func(*cobra.Command)) { // // Help and version are detected anywhere in args, not just at args[0], so that // per-subcommand help (e.g. "run --help") is also skipped. +// stripPluginNameFromCompletionArgs removes the redundant "agent" token that +// the Docker CLI inserts when delegating shell completion to the plugin: +// +// docker-agent __complete agent +// +// Without the strip, cobra receives "agent" as the first argument to +// __complete and cannot resolve the subcommand tree. +func stripPluginNameFromCompletionArgs(args []string) []string { + if len(args) >= 2 && + (args[0] == cobra.ShellCompRequestCmd || args[0] == cobra.ShellCompNoDescRequestCmd) && + args[1] == "agent" { + return append(args[:1:1], args[2:]...) + } + return args +} + func isManagementInvocation(args []string) bool { if len(args) == 0 { return false diff --git a/cmd/root/selfupdate_test.go b/cmd/root/selfupdate_test.go index a12b70c10..444956a0e 100644 --- a/cmd/root/selfupdate_test.go +++ b/cmd/root/selfupdate_test.go @@ -8,6 +8,60 @@ import ( "github.com/stretchr/testify/assert" ) +func TestStripPluginNameFromCompletionArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "strips agent token from __complete invocation", + args: []string{cobra.ShellCompRequestCmd, "agent", "run", ""}, + want: []string{cobra.ShellCompRequestCmd, "run", ""}, + }, + { + name: "strips agent token from __completeNoDesc invocation", + args: []string{cobra.ShellCompNoDescRequestCmd, "agent", "run", ""}, + want: []string{cobra.ShellCompNoDescRequestCmd, "run", ""}, + }, + { + name: "leaves normal run args unchanged", + args: []string{"run", "agent.yaml"}, + want: []string{"run", "agent.yaml"}, + }, + { + name: "leaves standalone complete args unchanged", + args: []string{cobra.ShellCompRequestCmd, "run", ""}, + want: []string{cobra.ShellCompRequestCmd, "run", ""}, + }, + { + name: "does not strip non-agent second token", + args: []string{cobra.ShellCompRequestCmd, "other", "run", ""}, + want: []string{cobra.ShellCompRequestCmd, "other", "run", ""}, + }, + { + name: "handles empty args", + args: []string{}, + want: []string{}, + }, + { + name: "handles single arg", + args: []string{cobra.ShellCompRequestCmd}, + want: []string{cobra.ShellCompRequestCmd}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := stripPluginNameFromCompletionArgs(tt.args) + assert.Equal(t, tt.want, got) + }) + } +} + func TestIsManagementInvocation(t *testing.T) { t.Parallel() From c98aac2b4140285b2aaf5c52d4ceb639dd6f4d59 Mon Sep 17 00:00:00 2001 From: hjeddad Date: Fri, 5 Jun 2026 13:34:58 +0000 Subject: [PATCH 5/5] fix: restore godoc separation and add edge-case test - Reorder isManagementInvocation and stripPluginNameFromCompletionArgs so each function has its own doc comment (previously the merged block was attributed entirely to stripPluginNameFromCompletionArgs) - Add test case for stripPluginNameFromCompletionArgs with len==2 input ([__complete, agent]) to document the no-trailing-args edge case --- cmd/root/root.go | 32 ++++++++++++++++---------------- cmd/root/selfupdate_test.go | 5 +++++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/cmd/root/root.go b/cmd/root/root.go index 5243e3128..43659df1f 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -249,22 +249,6 @@ func visitAll(cmd *cobra.Command, fn func(*cobra.Command)) { // // Help and version are detected anywhere in args, not just at args[0], so that // per-subcommand help (e.g. "run --help") is also skipped. -// stripPluginNameFromCompletionArgs removes the redundant "agent" token that -// the Docker CLI inserts when delegating shell completion to the plugin: -// -// docker-agent __complete agent -// -// Without the strip, cobra receives "agent" as the first argument to -// __complete and cannot resolve the subcommand tree. -func stripPluginNameFromCompletionArgs(args []string) []string { - if len(args) >= 2 && - (args[0] == cobra.ShellCompRequestCmd || args[0] == cobra.ShellCompNoDescRequestCmd) && - args[1] == "agent" { - return append(args[:1:1], args[2:]...) - } - return args -} - func isManagementInvocation(args []string) bool { if len(args) == 0 { return false @@ -283,6 +267,22 @@ func isManagementInvocation(args []string) bool { return false } +// stripPluginNameFromCompletionArgs removes the redundant "agent" token that +// the Docker CLI inserts when delegating shell completion to the plugin: +// +// docker-agent __complete agent +// +// Without the strip, cobra receives "agent" as the first argument to +// __complete and cannot resolve the subcommand tree. +func stripPluginNameFromCompletionArgs(args []string) []string { + if len(args) >= 2 && + (args[0] == cobra.ShellCompRequestCmd || args[0] == cobra.ShellCompNoDescRequestCmd) && + args[1] == "agent" { + return append(args[:1:1], args[2:]...) + } + return args +} + // setupLogging configures slog logging behavior. // When --debug is enabled, logs are written to a rotating file /cagent.debug.log, // or to the file specified by --log-file. Log files are rotated when they exceed 10MB, diff --git a/cmd/root/selfupdate_test.go b/cmd/root/selfupdate_test.go index 444956a0e..2cd1d8c95 100644 --- a/cmd/root/selfupdate_test.go +++ b/cmd/root/selfupdate_test.go @@ -41,6 +41,11 @@ func TestStripPluginNameFromCompletionArgs(t *testing.T) { args: []string{cobra.ShellCompRequestCmd, "other", "run", ""}, want: []string{cobra.ShellCompRequestCmd, "other", "run", ""}, }, + { + name: "strips agent token with no trailing args (len==2)", + args: []string{cobra.ShellCompRequestCmd, "agent"}, + want: []string{cobra.ShellCompRequestCmd}, + }, { name: "handles empty args", args: []string{},