fj/cmd/completion_install.go

189 lines
5.4 KiB
Go
Raw Permalink Normal View History

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
}