Skip to content
Open
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
45 changes: 36 additions & 9 deletions cmd/root/completion.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package root

import (
"maps"
"os"
"path/filepath"
"slices"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

Expand Down
103 changes: 103 additions & 0 deletions cmd/root/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package root
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -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))
Expand Down
27 changes: 26 additions & 1 deletion cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <subcommand> <args...>
// 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 {
Expand Down Expand Up @@ -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 <subcommand> <args...>
//
// 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 <dataDir>/cagent.debug.log,
// or to the file specified by --log-file. Log files are rotated when they exceed 10MB,
Expand Down
59 changes: 59 additions & 0 deletions cmd/root/selfupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading