package cmd import ( "bytes" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "github.com/spf13/cobra" ) var completionInstallCmd = &cobra.Command{ Use: "install [shell]", Short: "Install shell completions to a standard location", Long: "Install shell completions for fgj to a shell-appropriate location. If [shell] is omitted, it is detected from $SHELL. Supported: bash, zsh, fish.", Args: cobra.MaximumNArgs(1), ValidArgs: []string{"bash", "zsh", "fish"}, RunE: runCompletionInstall, } func init() { completionCmd.AddCommand(completionInstallCmd) completionInstallCmd.Flags().Bool("system", false, "Install system-wide (bash only; prints required sudo command)") completionInstallCmd.Flags().Bool("dry-run", false, "Print the target path and exit without writing") } func runCompletionInstall(cmd *cobra.Command, args []string) error { shell := "" if len(args) == 1 { shell = args[0] } else { detected, err := detectShell() if err != nil { return err } shell = detected } switch shell { case "bash", "zsh", "fish": // supported case "powershell": return fmt.Errorf("powershell auto-install is not supported; run `fgj completion powershell` and save the output manually") default: return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell) } system, _ := cmd.Flags().GetBool("system") dryRun, _ := cmd.Flags().GetBool("dry-run") // Generate completion script into a buffer. var buf bytes.Buffer switch shell { case "bash": if err := rootCmd.GenBashCompletion(&buf); err != nil { return fmt.Errorf("failed to generate bash completions: %w", err) } case "zsh": if err := rootCmd.GenZshCompletion(&buf); err != nil { return fmt.Errorf("failed to generate zsh completions: %w", err) } case "fish": if err := rootCmd.GenFishCompletion(&buf, true); err != nil { return fmt.Errorf("failed to generate fish completions: %w", err) } } // Resolve the target path. targetPath, sudoHint, err := completionTargetPath(shell, system) if err != nil { return err } if sudoHint != "" { // System-wide bash path requires root. Print the command and exit. fmt.Fprintf(ios.Out, "System-wide install requires root. Run:\n %s\n", sudoHint) return nil } if dryRun { fmt.Fprintln(ios.Out, targetPath) return nil } // Create parent directory. if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return fmt.Errorf("failed to create %s: %w", filepath.Dir(targetPath), err) } // Idempotent write: if the file already exists and matches, skip. if existing, err := os.ReadFile(targetPath); err == nil { if bytes.Equal(existing, buf.Bytes()) { fmt.Fprintf(ios.Out, "Completion already installed at %s (up to date)\n", targetPath) return nil } } if err := os.WriteFile(targetPath, buf.Bytes(), 0o644); err != nil { return fmt.Errorf("failed to write %s: %w", targetPath, err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Installed %s completions to %s\n", cs.SuccessIcon(), shell, targetPath) if shell == "zsh" { fmt.Fprintln(ios.Out, "") fmt.Fprintln(ios.Out, "If you haven't already, add the following to your ~/.zshrc:") fmt.Fprintln(ios.Out, " fpath=(~/.zsh/completions $fpath)") fmt.Fprintln(ios.Out, " autoload -U compinit && compinit") } return nil } // completionTargetPath resolves the target install path for the given shell. // For bash system-wide installs, it returns an empty path and a sudo hint command // that the caller should print instead of writing. func completionTargetPath(shell string, system bool) (string, string, error) { home, err := os.UserHomeDir() if err != nil { return "", "", fmt.Errorf("failed to resolve home directory: %w", err) } switch shell { case "bash": if system { if _, err := os.Stat("/etc/bash_completion.d"); err == nil { exe, exeErr := os.Executable() if exeErr != nil || exe == "" { exe = "fgj" } sudo := fmt.Sprintf("sudo sh -c '%s completion bash > /etc/bash_completion.d/fgj'", exe) return "", sudo, nil } return "", "", fmt.Errorf("--system requested but /etc/bash_completion.d does not exist") } if runtime.GOOS == "darwin" { if prefix, ok := brewPrefix(); ok { return filepath.Join(prefix, "etc", "bash_completion.d", "fgj"), "", nil } } xdg := os.Getenv("XDG_DATA_HOME") if xdg == "" { xdg = filepath.Join(home, ".local", "share") } return filepath.Join(xdg, "bash-completion", "completions", "fgj"), "", nil case "zsh": zdot := os.Getenv("ZDOTDIR") if zdot == "" { zdot = home } return filepath.Join(zdot, ".zsh", "completions", "_fgj"), "", nil case "fish": return filepath.Join(home, ".config", "fish", "completions", "fgj.fish"), "", nil } return "", "", fmt.Errorf("unsupported shell: %s", shell) } // detectShell reads $SHELL and returns its basename. func detectShell() (string, error) { s := os.Getenv("SHELL") if s == "" { return "", fmt.Errorf("cannot detect shell: $SHELL is empty (pass shell name explicitly)") } return filepath.Base(s), nil } // brewPrefix returns the Homebrew prefix if brew is available on PATH. func brewPrefix() (string, bool) { if _, err := exec.LookPath("brew"); err != nil { return "", false } out, err := exec.Command("brew", "--prefix").Output() if err != nil { return "", false } prefix := strings.TrimSpace(string(out)) if prefix == "" { return "", false } return prefix, true }