Skip to content

Commit 016d476

Browse files
Add fish shell completion support (#1484)
* Add fish shell completion support Cobra v1.9.1 already provides GenFishCompletion/GenFishCompletionFile, so this wires fish into the existing completion command alongside bash and zsh. Fish completions include inline descriptions during tab-complete. - Add genFish() following the genBash/genZsh pattern - Add fish detection in detectShell() and selectShell() - Update Homebrew formula to generate and install stripe.fish - Add completion_test.go (first tests for this file): detection, error paths, stdout generation, file creation, auto-detection - Add stripe.fish to .gitignore Closes #926 Committed-By-Agent: claude * Address Copilot review feedback on shell detection - Use filepath.Base() instead of strings.Contains() in detectShell() to prevent false positives when "fish" appears in a directory name (e.g. /home/shellfish/bin/csh no longer misdetects as fish) - Differentiate error messages: "Unsupported shell" for explicit --shell with invalid value vs "Could not automatically detect" for the auto-detection path - Add regression test for shellfish directory false-positive Committed-By-Agent: claude * fix: distinguish auto-detected vs explicit shell in completion output When the user passes --shell explicitly, print "Generating <shell> completion file" instead of "Detected <shell>". The "Detected" prefix is now reserved for the auto-detection path where $SHELL is inspected. Committed-By-Agent: claude * fix: address review feedback on fish completion - Add RegisterFlagCompletionFunc for --shell flag so tab-completion works on the completion command's own flag - Use idiomatic value switch instead of boolean expression switch - Simplify detectShell with multi-case return - Add file creation tests for bash and zsh (parity with fish test) - Extract runInTempDir test helper to reduce duplication Committed-By-Agent: claude * fix: remove unnecessary nolint directive on RegisterFlagCompletionFunc Use blank identifier assignment instead of suppressing the errcheck lint. The error can only occur if the flag name doesn't exist, which is guaranteed not to happen since we register "shell" immediately above. Committed-By-Agent: claude * fix: lowercase error strings per Go conventions Committed-By-Agent: claude
1 parent e1def8b commit 016d476

4 files changed

Lines changed: 234 additions & 19 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ default_cassette.yaml
99
.vscode/*.log
1010
stripe-completion.bash
1111
stripe-completion.zsh
12+
stripe.fish
1213

1314
# Claude Code local configuration (auto-added)
1415
.claude/*.local.*

.goreleaser/mac.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,13 @@ brews:
6060
description: Stripe CLI utility
6161
install: |
6262
bin.install "stripe"
63-
rm Dir["#{bin}/{stripe-completion.bash,stripe-completion.zsh}"]
63+
rm Dir["#{bin}/{stripe-completion.bash,stripe-completion.zsh,stripe.fish}"]
6464
system bin/"stripe", "completion", "--shell", "bash"
6565
system bin/"stripe", "completion", "--shell", "zsh"
66+
system bin/"stripe", "completion", "--shell", "fish"
6667
bash_completion.install "stripe-completion.bash"
6768
zsh_completion.install "stripe-completion.zsh"
69+
fish_completion.install "stripe.fish"
6870
(zsh_completion/"_stripe").write <<~EOS
6971
#compdef stripe
7072
_stripe () {

pkg/cmd/completion.go

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ package cmd
33
import (
44
"fmt"
55
"os"
6-
"strings"
6+
"path/filepath"
7+
"runtime"
78

89
"github.com/spf13/cobra"
910

10-
"runtime"
11-
1211
"github.com/stripe/stripe-cli/pkg/validators"
1312
)
1413

@@ -24,16 +23,20 @@ func newCompletionCmd() *completionCmd {
2423

2524
cc.cmd = &cobra.Command{
2625
Use: "completion",
27-
Short: "Generate bash and zsh completion scripts",
26+
Short: "Generate bash, zsh, and fish completion scripts",
2827
Args: validators.NoArgs,
2928
RunE: func(cmd *cobra.Command, args []string) error {
3029
return selectShell(cc.shell, cc.writeToStdout)
3130
},
3231
}
3332

34-
cc.cmd.Flags().StringVar(&cc.shell, "shell", "", "The shell to generate completion commands for. Supports \"bash\" or \"zsh\"")
33+
cc.cmd.Flags().StringVar(&cc.shell, "shell", "", "Shell to generate completions for: bash, zsh, or fish (auto-detected if omitted)")
3534
cc.cmd.Flags().BoolVar(&cc.writeToStdout, "write-to-stdout", false, "Print completion script to stdout rather than creating a new file.")
3635

36+
_ = cc.cmd.RegisterFlagCompletionFunc("shell", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
37+
return []string{"bash", "zsh", "fish"}, cobra.ShellCompDirectiveNoFileComp
38+
})
39+
3740
return cc
3841
}
3942

@@ -84,30 +87,50 @@ Set up Stripe autocompletion:
8487
8588
4. Either restart your terminal, or run the following command in your current session to enable immediately:
8689
source ~/.stripe/stripe-completion.bash`
90+
91+
fishCompletionInstructions = `
92+
1. Move ` + "`stripe.fish`" + ` to the fish completions directory:
93+
mkdir -p ~/.config/fish/completions
94+
mv stripe.fish ~/.config/fish/completions/stripe.fish
95+
96+
Fish automatically loads completions from this directory, so no additional
97+
configuration is needed. Open a new terminal session and completions will
98+
be available.`
8799
)
88100

89101
func selectShell(shell string, writeToStdout bool) error {
90102
selected := shell
103+
autoDetected := false
91104
if selected == "" {
92105
selected = detectShell()
106+
autoDetected = selected != ""
93107
}
94108

95109
switch selected {
96110
case "zsh":
97-
return genZsh(writeToStdout)
111+
return genZsh(writeToStdout, autoDetected)
98112
case "bash":
99-
return genBash(writeToStdout)
113+
return genBash(writeToStdout, autoDetected)
114+
case "fish":
115+
return genFish(writeToStdout, autoDetected)
100116
default:
101-
return fmt.Errorf("could not automatically detect your shell, please run the command with the `--shell` flag for either bash or zsh")
117+
if shell != "" {
118+
return fmt.Errorf("unsupported shell %q; supported shells are: bash, zsh, fish", shell)
119+
}
120+
return fmt.Errorf("could not automatically detect your shell; please run the command with the --shell flag for bash, zsh, or fish")
102121
}
103122
}
104123

105-
func genZsh(writeToStdout bool) error {
124+
func genZsh(writeToStdout bool, autoDetected bool) error {
106125
if writeToStdout {
107126
return rootCmd.GenZshCompletion(os.Stdout)
108127
}
109128

110-
fmt.Println("Detected `zsh`, generating zsh completion file: stripe-completion.zsh")
129+
if autoDetected {
130+
fmt.Println("Detected `zsh`, generating zsh completion file: stripe-completion.zsh")
131+
} else {
132+
fmt.Println("Generating zsh completion file: stripe-completion.zsh")
133+
}
111134

112135
err := rootCmd.GenZshCompletionFile("stripe-completion.zsh")
113136
if err == nil {
@@ -117,12 +140,16 @@ func genZsh(writeToStdout bool) error {
117140
return err
118141
}
119142

120-
func genBash(writeToStdout bool) error {
143+
func genBash(writeToStdout bool, autoDetected bool) error {
121144
if writeToStdout {
122145
return rootCmd.GenBashCompletion(os.Stdout)
123146
}
124147

125-
fmt.Println("Detected `bash`, generating bash completion file: stripe-completion.bash")
148+
if autoDetected {
149+
fmt.Println("Detected `bash`, generating bash completion file: stripe-completion.bash")
150+
} else {
151+
fmt.Println("Generating bash completion file: stripe-completion.bash")
152+
}
126153

127154
err := rootCmd.GenBashCompletionFile("stripe-completion.bash")
128155
if err == nil {
@@ -137,14 +164,33 @@ func genBash(writeToStdout bool) error {
137164
return err
138165
}
139166

167+
func genFish(writeToStdout bool, autoDetected bool) error {
168+
if writeToStdout {
169+
// true enables completion descriptions (fish displays them inline during tab-complete)
170+
return rootCmd.GenFishCompletion(os.Stdout, true)
171+
}
172+
173+
if autoDetected {
174+
fmt.Println("Detected `fish`, generating fish completion file: stripe.fish")
175+
} else {
176+
fmt.Println("Generating fish completion file: stripe.fish")
177+
}
178+
179+
// true enables completion descriptions (fish displays them inline during tab-complete)
180+
err := rootCmd.GenFishCompletionFile("stripe.fish", true)
181+
if err == nil {
182+
fmt.Printf("%s%s\n", instructionsHeader, fishCompletionInstructions)
183+
}
184+
185+
return err
186+
}
187+
140188
func detectShell() string {
141-
shell := os.Getenv("SHELL")
189+
shell := filepath.Base(os.Getenv("SHELL"))
142190

143-
switch {
144-
case strings.Contains(shell, "zsh"):
145-
return "zsh"
146-
case strings.Contains(shell, "bash"):
147-
return "bash"
191+
switch shell {
192+
case "zsh", "bash", "fish":
193+
return shell
148194
default:
149195
return ""
150196
}

pkg/cmd/completion_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestDetectShell(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
envValue string
15+
expected string
16+
}{
17+
{
18+
name: "detects bash from /bin/bash",
19+
envValue: "/bin/bash",
20+
expected: "bash",
21+
},
22+
{
23+
name: "detects zsh from /bin/zsh",
24+
envValue: "/bin/zsh",
25+
expected: "zsh",
26+
},
27+
{
28+
name: "detects fish from /usr/bin/fish",
29+
envValue: "/usr/bin/fish",
30+
expected: "fish",
31+
},
32+
{
33+
name: "detects fish from /opt/homebrew/bin/fish",
34+
envValue: "/opt/homebrew/bin/fish",
35+
expected: "fish",
36+
},
37+
{
38+
name: "detects bash even when path contains fish",
39+
envValue: "/home/fishing/bin/bash",
40+
expected: "bash",
41+
},
42+
{
43+
name: "detects zsh even when path contains fish",
44+
envValue: "/home/shellfish/bin/zsh",
45+
expected: "zsh",
46+
},
47+
{
48+
name: "does not false-positive on fish in directory name",
49+
envValue: "/home/shellfish/bin/csh",
50+
expected: "",
51+
},
52+
{
53+
name: "returns empty string for unknown shell",
54+
envValue: "/bin/csh",
55+
expected: "",
56+
},
57+
{
58+
name: "returns empty string when SHELL is empty",
59+
envValue: "",
60+
expected: "",
61+
},
62+
}
63+
64+
for _, tt := range tests {
65+
t.Run(tt.name, func(t *testing.T) {
66+
t.Setenv("SHELL", tt.envValue)
67+
result := detectShell()
68+
assert.Equal(t, tt.expected, result)
69+
})
70+
}
71+
}
72+
73+
func TestSelectShellErrors(t *testing.T) {
74+
t.Run("explicit unsupported shell produces unsupported error", func(t *testing.T) {
75+
err := selectShell("powershell", true)
76+
require.Error(t, err)
77+
assert.Contains(t, err.Error(), "unsupported shell")
78+
assert.Contains(t, err.Error(), "powershell")
79+
})
80+
81+
t.Run("empty shell with no SHELL env produces auto-detect error", func(t *testing.T) {
82+
t.Setenv("SHELL", "")
83+
err := selectShell("", true)
84+
require.Error(t, err)
85+
assert.Contains(t, err.Error(), "--shell")
86+
})
87+
}
88+
89+
func TestSelectShellWriteToStdout(t *testing.T) {
90+
// rootCmd must be initialized for Cobra's completion generation to work.
91+
// The init() function in root.go sets this up.
92+
shells := []string{"bash", "zsh", "fish"}
93+
94+
for _, shell := range shells {
95+
t.Run(shell, func(t *testing.T) {
96+
err := selectShell(shell, true)
97+
assert.NoError(t, err)
98+
})
99+
}
100+
}
101+
102+
func TestSelectShellAutoDetectsFish(t *testing.T) {
103+
t.Setenv("SHELL", "/usr/bin/fish")
104+
err := selectShell("", true)
105+
assert.NoError(t, err)
106+
}
107+
108+
// runInTempDir executes fn in a temporary directory, restoring the original
109+
// working directory on cleanup.
110+
func runInTempDir(t *testing.T, fn func()) {
111+
t.Helper()
112+
originalWd, err := os.Getwd()
113+
require.NoError(t, err)
114+
115+
tmpDir := t.TempDir()
116+
require.NoError(t, os.Chdir(tmpDir))
117+
t.Cleanup(func() {
118+
if err := os.Chdir(originalWd); err != nil {
119+
t.Errorf("failed to restore working directory: %v", err)
120+
}
121+
})
122+
123+
fn()
124+
}
125+
126+
func TestGenShellCreatesFile(t *testing.T) {
127+
tests := []struct {
128+
name string
129+
genFunc func(bool, bool) error
130+
filename string
131+
contentMatch string
132+
}{
133+
{
134+
name: "bash",
135+
genFunc: genBash,
136+
filename: "stripe-completion.bash",
137+
contentMatch: "bash completion",
138+
},
139+
{
140+
name: "zsh",
141+
genFunc: genZsh,
142+
filename: "stripe-completion.zsh",
143+
contentMatch: "zsh completion",
144+
},
145+
{
146+
name: "fish",
147+
genFunc: genFish,
148+
filename: "stripe.fish",
149+
contentMatch: "fish completion for stripe",
150+
},
151+
}
152+
153+
for _, tt := range tests {
154+
t.Run(tt.name, func(t *testing.T) {
155+
runInTempDir(t, func() {
156+
err := tt.genFunc(false, false)
157+
require.NoError(t, err)
158+
159+
content, err := os.ReadFile(tt.filename)
160+
require.NoError(t, err)
161+
assert.NotEmpty(t, content)
162+
assert.Contains(t, string(content), tt.contentMatch)
163+
})
164+
})
165+
}
166+
}

0 commit comments

Comments
 (0)