diff --git a/cmd/root/completion.go b/cmd/root/completion.go index e63aad66f..22153a6fb 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.Type().IsRegular() { + 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..1e0b4f33f 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,108 @@ 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 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)) diff --git a/cmd/root/root.go b/cmd/root/root.go index 969c3b5ac..43659df1f 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 { @@ -258,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 a12b70c10..2cd1d8c95 100644 --- a/cmd/root/selfupdate_test.go +++ b/cmd/root/selfupdate_test.go @@ -8,6 +8,65 @@ 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: "strips agent token with no trailing args (len==2)", + args: []string{cobra.ShellCompRequestCmd, "agent"}, + want: []string{cobra.ShellCompRequestCmd}, + }, + { + 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()