diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8fc865a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell completion scripts are embedded verbatim into the ant binary and +# served via `ant completion `. They must stay LF-terminated so the +# output is sourcable on Linux/macOS regardless of the platform the binary +# was built on. +internal/autocomplete/shellscripts/* text eol=lf diff --git a/.goreleaser.yml b/.goreleaser.yml index 5e6863f..74dd2e3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,9 +4,9 @@ version: 2 before: hooks: - mkdir -p completions - - sh -c "go run ./cmd/ant/main.go @completion bash > completions/ant.bash" - - sh -c "go run ./cmd/ant/main.go @completion zsh > completions/ant.zsh" - - sh -c "go run ./cmd/ant/main.go @completion fish > completions/ant.fish" + - sh -c "go run ./cmd/ant/main.go completion bash > completions/ant.bash" + - sh -c "go run ./cmd/ant/main.go completion zsh > completions/ant.zsh" + - sh -c "go run ./cmd/ant/main.go completion fish > completions/ant.fish" - sh -c "go run ./cmd/ant/main.go @manpages -o man" builds: diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go index 97fe1a8..2ef271c 100644 --- a/internal/autocomplete/autocomplete.go +++ b/internal/autocomplete/autocomplete.go @@ -20,59 +20,132 @@ const ( CompletionStyleFish CompletionStyle = "fish" ) -type renderCompletion func(cmd *cli.Command, appName string) (string, error) - -var ( - //go:embed shellscripts - autoCompleteFS embed.FS +//go:embed shellscripts +var autoCompleteFS embed.FS + +// shellScriptFiles maps each supported shell to its embedded script template. +// Templates contain the literal token `__APPNAME__`, replaced at render time. +var shellScriptFiles = map[CompletionStyle]string{ + CompletionStyleBash: "shellscripts/bash_autocomplete.bash", + CompletionStyleZsh: "shellscripts/zsh_autocomplete.zsh", + CompletionStyleFish: "shellscripts/fish_autocomplete.fish", + CompletionStylePowershell: "shellscripts/pwsh_autocomplete.ps1", +} - shellCompletions = map[CompletionStyle]renderCompletion{ - "bash": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/bash_autocomplete.bash") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "fish": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/fish_autocomplete.fish") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "pwsh": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/pwsh_autocomplete.ps1") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "zsh": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/zsh_autocomplete.zsh") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, +// SupportedShells returns the set of shells for which a completion script can +// be rendered, in a deterministic order suitable for help text and errors. +func SupportedShells() []CompletionStyle { + return []CompletionStyle{ + CompletionStyleBash, + CompletionStyleZsh, + CompletionStyleFish, + CompletionStylePowershell, } -) +} -func OutputCompletionScript(ctx context.Context, cmd *cli.Command) error { - shells := make([]CompletionStyle, 0, len(shellCompletions)) - for k := range shellCompletions { - shells = append(shells, k) +// RenderCompletionScript returns the shell completion script for the given +// shell, with the template's __APPNAME__ token replaced by appName. Returns +// an error for unsupported shells or embed read failures (the latter would +// indicate a build problem, not user error). +func RenderCompletionScript(shell CompletionStyle, appName string) (string, error) { + path, ok := shellScriptFiles[shell] + if !ok { + return "", unsupportedShellErr(string(shell)) + } + b, err := autoCompleteFS.ReadFile(path) + if err != nil { + return "", err } + return strings.ReplaceAll(string(b), "__APPNAME__", appName), nil +} - if cmd.Args().Len() == 0 { - return cli.Exit(fmt.Sprintf("no shell provided for completion command. available shells are %+v", shells), 1) +// BuildCompletionCommand returns a `completion ` cli.Command tree for +// attachment under any urfave/cli root. Leaves are generated for every shell +// in SupportedShells; the parent's Action handles ` completion` (no +// shell) and ` completion ` with a unified usage error. +// +// appName is interpolated into the install-hints rendered in the parent's +// Description. +func BuildCompletionCommand(appName string) *cli.Command { + shells := SupportedShells() + leaves := make([]*cli.Command, 0, len(shells)) + for _, shell := range shells { + leaves = append(leaves, &cli.Command{ + Name: string(shell), + Usage: fmt.Sprintf("Output a %s completion script for %s", shell, appName), + HideHelpCommand: true, + Action: renderShellAction(shell), + }) + } + return &cli.Command{ + Name: "completion", + Usage: "Generate shell completion scripts", + Description: completionDescription(appName, shells), + Suggest: true, + HideHelpCommand: true, + Commands: leaves, + Action: func(ctx context.Context, c *cli.Command) error { + return cli.Exit(usageError(appName, c.Args().First()), 1) + }, } - s := CompletionStyle(cmd.Args().First()) +} - renderCompletion, ok := shellCompletions[s] - if !ok { - return cli.Exit(fmt.Sprintf("unknown shell %s, available shells are %+v", s, shells), 1) +func renderShellAction(shell CompletionStyle) cli.ActionFunc { + return func(ctx context.Context, c *cli.Command) error { + script, err := RenderCompletionScript(shell, c.Root().Name) + if err != nil { + return cli.Exit(err, 1) + } + if _, err := c.Writer.Write([]byte(script)); err != nil { + return cli.Exit(err, 1) + } + return nil } +} - completionScript, err := renderCompletion(cmd, cmd.Root().Name) - if err != nil { - return cli.Exit(err, 1) +func shellList(shells []CompletionStyle) string { + parts := make([]string, len(shells)) + for i, s := range shells { + parts[i] = string(s) } + return strings.Join(parts, ", ") +} - _, err = cmd.Writer.Write([]byte(completionScript)) - if err != nil { - return cli.Exit(err, 1) +func unsupportedShellErr(shell string) error { + return fmt.Errorf("unsupported shell %q (supported: %s)", shell, shellList(SupportedShells())) +} + +// usageError returns the message for ` completion` invoked with no shell +// or with an unrecognized one. The two branches share the canonical shell +// list so the wording cannot drift. +func usageError(appName, shell string) string { + if shell == "" { + return fmt.Sprintf("%s completion — specify one of: %s", appName, shellList(SupportedShells())) } + return unsupportedShellErr(shell).Error() +} - return nil +func completionDescription(appName string, shells []CompletionStyle) string { + var b strings.Builder + fmt.Fprintf(&b, "Print a shell completion script for %s to stdout.\n\n", appName) + b.WriteString("Install it by writing the output to the location your shell loads completions\nfrom:\n\n") + for _, s := range shells { + switch s { + case CompletionStyleBash: + fmt.Fprintf(&b, " # bash (Linux)\n %s completion bash > ~/.local/share/bash-completion/completions/%s\n\n", appName, appName) + fmt.Fprintf(&b, " # bash (macOS, with Homebrew bash-completion@2)\n %s completion bash > \"$(brew --prefix)/etc/bash_completion.d/%s\"\n\n", appName, appName) + case CompletionStyleZsh: + fmt.Fprintf(&b, " # zsh — pick any directory that's already on $fpath\n %s completion zsh > \"${fpath[1]}/_%s\"\n\n", appName, appName) + case CompletionStyleFish: + fmt.Fprintf(&b, " # fish\n %s completion fish > ~/.config/fish/completions/%s.fish\n\n", appName, appName) + case CompletionStylePowershell: + fmt.Fprintf(&b, " # PowerShell\n %s completion pwsh >> \"$PROFILE.CurrentUserAllHosts\"\n\n", appName) + } + } + b.WriteString("Or source it directly in your shell rc for a one-shot install:\n\n") + fmt.Fprintf(&b, " echo 'source <(%s completion bash)' >> ~/.bashrc\n", appName) + fmt.Fprintf(&b, " echo 'source <(%s completion zsh)' >> ~/.zshrc", appName) + return b.String() } type ShellCompletion struct { diff --git a/internal/autocomplete/command_test.go b/internal/autocomplete/command_test.go new file mode 100644 index 0000000..c6b20ad --- /dev/null +++ b/internal/autocomplete/command_test.go @@ -0,0 +1,120 @@ +package autocomplete + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +// runCompletion drives the same factory production attaches in cmd.go's init, +// then runs argv against it. The Writer is wired down the whole tree because +// urfave/cli v3's setupDefaults only defaults Writer to os.Stdout on commands +// whose Writer is nil — and it runs per-subcommand, not via inheritance — so +// the buffer has to be set on every node we care about capturing output from. +func runCompletion(t *testing.T, argv ...string) (string, error) { + t.Helper() + buf := &bytes.Buffer{} + app := &cli.Command{ + Name: "ant", + Commands: []*cli.Command{BuildCompletionCommand("ant")}, + } + attachWriter(app, buf) + err := app.Run(context.Background(), append([]string{"ant"}, argv...)) + return buf.String(), err +} + +func attachWriter(c *cli.Command, w io.Writer) { + c.Writer = w + for _, sub := range c.Commands { + attachWriter(sub, w) + } +} + +func TestBuildCompletionCommand_RendersEveryShell(t *testing.T) { + // One case per shell. wantSubstrs are the function-name + dispatcher + // patterns that prove __APPNAME__ was substituted in every occurrence, + // not just the obvious top-level one. The substring `__ant_` is + // the form `____APPNAME___` collapses to after replacement — + // regressions that only substitute the first match would leave the + // inner occurrence intact and fail this assertion. + cases := []struct { + shell CompletionStyle + wantPrefix string + wantSubstrs []string + }{ + {CompletionStyleBash, "#!/bin/bash", []string{"__ant_bash_autocomplete", "complete -F __ant_bash_autocomplete ant"}}, + {CompletionStyleZsh, "#compdef ant", []string{"__ant_zsh_autocomplete", "compdef __ant_zsh_autocomplete ant"}}, + {CompletionStyleFish, "#!/usr/bin/env fish", []string{"__ant_fish_autocomplete", "complete -c ant"}}, + {CompletionStylePowershell, "", []string{"__ant_pwsh"}}, + } + for _, tc := range cases { + t.Run(string(tc.shell), func(t *testing.T) { + out, err := runCompletion(t, "completion", string(tc.shell)) + require.NoError(t, err) + require.NotEmpty(t, out) + assert.NotContains(t, out, "__APPNAME__", "template token must be substituted") + if tc.wantPrefix != "" { + assert.True(t, strings.HasPrefix(out, tc.wantPrefix), + "want prefix %q, got %q", tc.wantPrefix, out[:min(80, len(out))]) + } + for _, s := range tc.wantSubstrs { + assert.Contains(t, out, s) + } + }) + } +} + +func TestBuildCompletionCommand_BareReturnsUsageError(t *testing.T) { + out, err := runCompletion(t, "completion") + require.Error(t, err) + assert.Empty(t, out, "no script should be written when no shell is specified") + assert.Contains(t, err.Error(), "ant completion ") + for _, s := range SupportedShells() { + assert.Contains(t, err.Error(), string(s)) + } +} + +func TestBuildCompletionCommand_UnknownShellReturnsError(t *testing.T) { + out, err := runCompletion(t, "completion", "tcsh") + require.Error(t, err) + assert.Empty(t, out, "no script should be written for an unsupported shell") + assert.Contains(t, err.Error(), `unsupported shell "tcsh"`) + for _, s := range SupportedShells() { + assert.Contains(t, err.Error(), string(s)) + } +} + +func TestUsageError(t *testing.T) { + bare := usageError("ant", "") + assert.Contains(t, bare, "ant completion ") + assert.Contains(t, bare, "bash") + assert.Contains(t, bare, "zsh") + assert.Contains(t, bare, "fish") + assert.Contains(t, bare, "pwsh") + + unknown := usageError("ant", "tcsh") + assert.Contains(t, unknown, `"tcsh"`) + assert.Contains(t, unknown, "supported:") + assert.Contains(t, unknown, "bash") +} + +func TestRenderCompletionScript_UnsupportedShell(t *testing.T) { + _, err := RenderCompletionScript("tcsh", "ant") + require.Error(t, err) + assert.Contains(t, err.Error(), `unsupported shell "tcsh"`) +} + +func TestCompletionDescription_MentionsEveryShell(t *testing.T) { + desc := completionDescription("ant", SupportedShells()) + for _, s := range SupportedShells() { + assert.Contains(t, desc, string(s), "description should mention %s", s) + } + // install hints must use the app name we passed in + assert.Contains(t, desc, "ant completion bash") +} diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh index d937171..4919836 100644 --- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh +++ b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh @@ -47,7 +47,7 @@ ____APPNAME___zsh_autocomplete() { # When installed in fpath (e.g., via Homebrew's zsh_completion stanza), this file # is autoloaded as the function ___APPNAME__ and its body becomes that function's # body. Detect that case via funcstack and dispatch to the completion function. -# When sourced (e.g., `source <(__APPNAME__ @completion zsh)`), register the +# When sourced (e.g., `source <(__APPNAME__ completion zsh)`), register the # function with compdef instead. if [[ "${funcstack[1]}" = "___APPNAME__" ]]; then ____APPNAME___zsh_autocomplete "$@" diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 194deb3..2b835ec 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -432,12 +432,6 @@ func init() { HideHelpCommand: true, Action: autocomplete.ExecuteShellCompletion, }, - { - Name: "@completion", - Hidden: true, - HideHelpCommand: true, - Action: autocomplete.OutputCompletionScript, - }, }, HideHelpCommand: true, } diff --git a/pkg/cmd/cmd_completion.go b/pkg/cmd/cmd_completion.go new file mode 100644 index 0000000..77c592a --- /dev/null +++ b/pkg/cmd/cmd_completion.go @@ -0,0 +1,11 @@ +package cmd + +import "github.com/anthropics/anthropic-cli/internal/autocomplete" + +// The completion command tree (`ant completion `) is defined in the +// autocomplete package so the goreleaser-driven release pipeline and the +// user-facing CLI share one source of truth for which shells are exposed, +// what the install hints say, and how the renderer is wired. +func init() { + Command.Commands = append(Command.Commands, autocomplete.BuildCompletionCommand("ant")) +}