11package cmd
22
33import (
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+
1424type completionCmd struct {
1525 cmd * cobra.Command
1626
1727 shell string
1828 writeToStdout bool
29+ install bool
30+ uninstall bool
1931}
2032
2133func 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.\n Script written to: %s\n Shell config updated: %s\n Restart 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.\n Script written to: %s\n Restart 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 ("\n Warning: 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 ("\n Warning: 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