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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Shell completion scripts are embedded verbatim into the ant binary and
# served via `ant completion <shell>`. 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
6 changes: 3 additions & 3 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
153 changes: 113 additions & 40 deletions internal/autocomplete/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <shell>` cli.Command tree for
// attachment under any urfave/cli root. Leaves are generated for every shell
// in SupportedShells; the parent's Action handles `<app> completion` (no
// shell) and `<app> completion <unknown>` 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 `<app> 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 <shell> — 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 {
Expand Down
120 changes: 120 additions & 0 deletions internal/autocomplete/command_test.go
Original file line number Diff line number Diff line change
@@ -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_<shell>` is
// the form `____APPNAME___<shell>` 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 <shell>")
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 <shell>")
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")
}
2 changes: 1 addition & 1 deletion internal/autocomplete/shellscripts/zsh_autocomplete.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "$@"
Expand Down
6 changes: 0 additions & 6 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,12 +432,6 @@ func init() {
HideHelpCommand: true,
Action: autocomplete.ExecuteShellCompletion,
},
{
Name: "@completion",
Hidden: true,
HideHelpCommand: true,
Action: autocomplete.OutputCompletionScript,
},
},
HideHelpCommand: true,
}
Expand Down
11 changes: 11 additions & 0 deletions pkg/cmd/cmd_completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package cmd

import "github.com/anthropics/anthropic-cli/internal/autocomplete"

// The completion command tree (`ant completion <shell>`) 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"))
}