Skip to content

Commit e39678d

Browse files
committed
Add completion auto-install and plugin completion protocol
Adds --install and --uninstall flags to `stripe completion` that automatically write the completion script and configure the user's shell profile. Detects and warns about pre-existing manual completion references to avoid double-loading. Also adds the plugin completion protocol: when a plugin command receives tab completion requests, the host CLI invokes the plugin binary with Cobra's __complete protocol and returns the results. Committed-By-Agent: claude
1 parent e0e74b6 commit e39678d

2 files changed

Lines changed: 534 additions & 111 deletions

File tree

pkg/cmd/completion.go

Lines changed: 221 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"fmt"
56
"os"
7+
"path/filepath"
68
"runtime"
79
"strings"
810

@@ -11,11 +13,21 @@ import (
1113
"github.com/stripe/stripe-cli/pkg/validators"
1214
)
1315

16+
// sentinelBegin and sentinelEnd mark the completion configuration block
17+
// in shell config files (~/.zshrc, ~/.bashrc, ~/.bash_profile). This allows
18+
// safe idempotent install/uninstall without corrupting the user's existing config.
19+
const (
20+
sentinelBegin = "# begin stripe-completion"
21+
sentinelEnd = "# end stripe-completion"
22+
)
23+
1424
type completionCmd struct {
1525
cmd *cobra.Command
1626

1727
shell string
1828
writeToStdout bool
29+
install bool
30+
uninstall bool
1931
}
2032

2133
func newCompletionCmd() *completionCmd {
@@ -26,12 +38,30 @@ func newCompletionCmd() *completionCmd {
2638
Short: "Generate bash, zsh, and fish completion scripts",
2739
Args: validators.NoArgs,
2840
RunE: func(cmd *cobra.Command, args []string) error {
29-
return selectShell(cc.shell, cc.writeToStdout)
41+
shell := cc.shell
42+
if shell == "" {
43+
shell = detectShell()
44+
}
45+
46+
if cc.install || cc.uninstall {
47+
if shell == "" {
48+
return fmt.Errorf("could not automatically detect your shell. Please run the command with the `--shell` flag for bash, zsh, or fish")
49+
}
50+
if cc.install {
51+
return installCompletion(shell, os.UserHomeDir)
52+
}
53+
return uninstallCompletion(shell, os.UserHomeDir)
54+
}
55+
56+
return selectShell(shell, cc.writeToStdout)
3057
},
3158
}
3259

3360
cc.cmd.Flags().StringVar(&cc.shell, "shell", "", "The shell to generate completion commands for. Supports \"bash\", \"zsh\", or \"fish\"")
3461
cc.cmd.Flags().BoolVar(&cc.writeToStdout, "write-to-stdout", false, "Print completion script to stdout rather than creating a new file.")
62+
cc.cmd.Flags().BoolVar(&cc.install, "install", false, "Install completion script to ~/.stripe and configure your shell profile automatically")
63+
cc.cmd.Flags().BoolVar(&cc.uninstall, "uninstall", false, "Remove installed completion script and configuration from your shell profile")
64+
cc.cmd.MarkFlagsMutuallyExclusive("install", "uninstall")
3565

3666
return cc
3767
}
@@ -178,13 +208,175 @@ func detectShell() string {
178208
}
179209
}
180210

181-
// sentinelBegin and sentinelEnd mark the completion configuration block
182-
// in shell config files (~/.zshrc, ~/.bashrc, ~/.bash_profile). This allows
183-
// safe idempotent install/uninstall without corrupting the user's existing config.
184-
const (
185-
sentinelBegin = "# begin stripe-completion — managed by stripe cli, do not edit"
186-
sentinelEnd = "# end stripe-completion"
187-
)
211+
// ---------------------------------------------------------------------------
212+
// Auto-install/uninstall support
213+
// ---------------------------------------------------------------------------
214+
215+
// getCompletionScriptDir returns the directory where completion scripts are stored.
216+
func getCompletionScriptDir(homeDir string) string {
217+
return filepath.Join(homeDir, ".stripe")
218+
}
219+
220+
// getShellConfigFile returns the path to the shell's configuration file.
221+
// For fish, returns "" because fish auto-loads completions from a directory
222+
// (~/.config/fish/completions/) and does not require a config file entry.
223+
func getShellConfigFile(shell, homeDir string) string {
224+
switch shell {
225+
case "bash":
226+
if runtime.GOOS == "darwin" {
227+
return filepath.Join(homeDir, ".bash_profile")
228+
}
229+
return filepath.Join(homeDir, ".bashrc")
230+
case "zsh":
231+
return filepath.Join(homeDir, ".zshrc")
232+
default:
233+
return ""
234+
}
235+
}
236+
237+
// getFishCompletionsDir returns the directory where fish completions are stored.
238+
func getFishCompletionsDir(homeDir string) string {
239+
return filepath.Join(homeDir, ".config", "fish", "completions")
240+
}
241+
242+
// completionScriptFilename returns the filename for the completion script.
243+
// Fish uses "stripe.fish" (matching the command name) rather than
244+
// "stripe-completion.fish" because fish auto-loads completions from
245+
// ~/.config/fish/completions/ based on command name.
246+
func completionScriptFilename(shell string) string {
247+
switch shell {
248+
case "bash":
249+
return "stripe-completion.bash"
250+
case "zsh":
251+
return "stripe-completion.zsh"
252+
case "fish":
253+
return "stripe.fish"
254+
default:
255+
return ""
256+
}
257+
}
258+
259+
// generateCompletionScript writes the completion script for the given shell into buf.
260+
func generateCompletionScript(shell string, buf *bytes.Buffer) error {
261+
switch shell {
262+
case "bash":
263+
return rootCmd.GenBashCompletionV2(buf, true)
264+
case "zsh":
265+
return rootCmd.GenZshCompletion(buf)
266+
case "fish":
267+
return rootCmd.GenFishCompletion(buf, true)
268+
default:
269+
return fmt.Errorf("unsupported shell: %s", shell)
270+
}
271+
}
272+
273+
// sourceLine returns the shell-specific line that loads the completion script.
274+
func sourceLine(shell, scriptPath string) string {
275+
switch shell {
276+
case "bash", "zsh":
277+
return fmt.Sprintf("source %s", scriptPath)
278+
default:
279+
return ""
280+
}
281+
}
282+
283+
// homeDirFunc is a function type that returns the user's home directory.
284+
// Enables dependency injection during testing (see completion_test.go).
285+
type homeDirFunc func() (string, error)
286+
287+
func installCompletion(shell string, getHomeDir homeDirFunc) error {
288+
homeDir, err := getHomeDir()
289+
if err != nil {
290+
return fmt.Errorf("could not determine home directory: %w", err)
291+
}
292+
293+
// Determine script destination
294+
var scriptDir string
295+
if shell == "fish" {
296+
scriptDir = getFishCompletionsDir(homeDir)
297+
} else {
298+
scriptDir = getCompletionScriptDir(homeDir)
299+
}
300+
301+
// Create directory
302+
if err := os.MkdirAll(scriptDir, 0755); err != nil {
303+
return fmt.Errorf("could not create directory %s: %w", scriptDir, err)
304+
}
305+
306+
// Generate completion script
307+
var buf bytes.Buffer
308+
if err := generateCompletionScript(shell, &buf); err != nil {
309+
return fmt.Errorf("could not generate %s completion script: %w", shell, err)
310+
}
311+
312+
// Write script file
313+
scriptPath := filepath.Join(scriptDir, completionScriptFilename(shell))
314+
if err := os.WriteFile(scriptPath, buf.Bytes(), 0644); err != nil {
315+
return fmt.Errorf("could not write completion script to %s: %w", scriptPath, err)
316+
}
317+
318+
// For bash/zsh, add source line to shell config
319+
if shell != "fish" {
320+
configPath := getShellConfigFile(shell, homeDir)
321+
line := sourceLine(shell, scriptPath)
322+
if err := addSentinelBlock(configPath, line); err != nil {
323+
return fmt.Errorf("could not update %s: %w", configPath, err)
324+
}
325+
fmt.Printf("Completion installed for %s.\nScript written to: %s\nShell config updated: %s\nRestart your shell or run: %s\n", shell, scriptPath, configPath, line)
326+
327+
// Warn about manually-added lines outside our sentinel block
328+
remnants := findManualRemnants(configPath, completionScriptFilename(shell))
329+
warnManualRemnants(configPath, remnants)
330+
} else {
331+
fmt.Printf("Completion installed for fish.\nScript written to: %s\nRestart your shell or open a new terminal session.\n", scriptPath)
332+
}
333+
334+
return nil
335+
}
336+
337+
func uninstallCompletion(shell string, getHomeDir homeDirFunc) error {
338+
homeDir, err := getHomeDir()
339+
if err != nil {
340+
return fmt.Errorf("could not determine home directory: %w", err)
341+
}
342+
343+
// Determine script location
344+
var scriptPath string
345+
if shell == "fish" {
346+
scriptPath = filepath.Join(getFishCompletionsDir(homeDir), completionScriptFilename(shell))
347+
} else {
348+
scriptPath = filepath.Join(getCompletionScriptDir(homeDir), completionScriptFilename(shell))
349+
}
350+
351+
// Remove script file (ignore if doesn't exist)
352+
if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) {
353+
return fmt.Errorf("could not remove completion script %s: %w", scriptPath, err)
354+
}
355+
356+
// For bash/zsh, remove sentinel block from shell config
357+
if shell != "fish" {
358+
configPath := getShellConfigFile(shell, homeDir)
359+
if err := removeSentinelBlock(configPath); err != nil {
360+
return fmt.Errorf("could not update %s: %w", configPath, err)
361+
}
362+
363+
fmt.Printf("Completion uninstalled for %s.\n", shell)
364+
365+
// Warn about manually-added lines that survive uninstall
366+
remnants := findManualRemnants(configPath, completionScriptFilename(shell))
367+
if len(remnants) > 0 {
368+
fmt.Printf("\nWarning: your shell config file %s still references the completion script outside the managed block:\n", configPath)
369+
for _, r := range remnants {
370+
fmt.Printf(" line %d: %s\n", r.lineNumber, r.lineText)
371+
}
372+
fmt.Printf("Remove %s manually to fully disable shell completion.\n", pluralize(len(remnants), "this line", "these lines"))
373+
}
374+
} else {
375+
fmt.Printf("Completion uninstalled for %s.\n", shell)
376+
}
377+
378+
return nil
379+
}
188380

189381
// addSentinelBlock adds or replaces a sentinel-delimited block in the given
190382
// config file. If the file does not exist, it is created with mode 0644.
@@ -328,3 +520,24 @@ func findManualRemnants(configPath, scriptFilename string) []manualRemnant {
328520

329521
return remnants
330522
}
523+
524+
// warnManualRemnants prints a warning about manually-added completion lines
525+
// found outside the sentinel block. Does nothing if remnants is empty.
526+
func warnManualRemnants(configPath string, remnants []manualRemnant) {
527+
if len(remnants) == 0 {
528+
return
529+
}
530+
531+
fmt.Printf("\nWarning: found a manually-added completion reference outside the managed block in %s:\n", configPath)
532+
for _, r := range remnants {
533+
fmt.Printf(" line %d: %s\n", r.lineNumber, r.lineText)
534+
}
535+
fmt.Printf("You may want to remove %s manually to avoid loading completions twice.\n", pluralize(len(remnants), "this line", "these lines"))
536+
}
537+
538+
func pluralize(n int, singular, plural string) string {
539+
if n == 1 {
540+
return singular
541+
}
542+
return plural
543+
}

0 commit comments

Comments
 (0)