|
| 1 | +//go:build !windows |
| 2 | + |
| 3 | +package cli |
| 4 | + |
| 5 | +import ( |
| 6 | + "bytes" |
| 7 | + "fmt" |
| 8 | + "os" |
| 9 | + "os/exec" |
| 10 | + "path/filepath" |
| 11 | + "strings" |
| 12 | + |
| 13 | + "github.com/charmbracelet/huh" |
| 14 | + "github.com/spf13/cobra" |
| 15 | + |
| 16 | + "github.com/naoray/anvil/internal/ui" |
| 17 | +) |
| 18 | + |
| 19 | +// detectShell returns "zsh", "bash", or "fish" from $SHELL. |
| 20 | +// Defaults to "zsh" for unknown shells. |
| 21 | +func detectShell() string { |
| 22 | + shell := os.Getenv("SHELL") |
| 23 | + base := filepath.Base(shell) |
| 24 | + switch base { |
| 25 | + case "zsh": |
| 26 | + return "zsh" |
| 27 | + case "bash": |
| 28 | + return "bash" |
| 29 | + case "fish": |
| 30 | + return "fish" |
| 31 | + default: |
| 32 | + return "zsh" |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +// completionInstallPath returns the target file path for a given shell. |
| 37 | +// zsh → $(brew --prefix)/share/zsh/site-functions/_anvil, else ~/.zsh/completions/_anvil |
| 38 | +// bash → /etc/bash_completion.d/anvil if writable, else ~/.bash_completion.d/anvil |
| 39 | +// fish → $XDG_CONFIG_HOME/fish/completions/anvil.fish, else ~/.config/fish/completions/anvil.fish |
| 40 | +func completionInstallPath(shell string) (string, error) { |
| 41 | + home, err := os.UserHomeDir() |
| 42 | + if err != nil { |
| 43 | + return "", fmt.Errorf("getting home directory: %w", err) |
| 44 | + } |
| 45 | + |
| 46 | + switch shell { |
| 47 | + case "zsh": |
| 48 | + // Determine brew prefix: env var takes priority (empty string means "no brew") |
| 49 | + brewPrefix, envSet := os.LookupEnv("HOMEBREW_PREFIX") |
| 50 | + if !envSet { |
| 51 | + // Not set — try running brew --prefix |
| 52 | + if brewOut, err := exec.Command("brew", "--prefix").Output(); err == nil { |
| 53 | + brewPrefix = strings.TrimSpace(string(brewOut)) |
| 54 | + } |
| 55 | + } |
| 56 | + if brewPrefix != "" { |
| 57 | + brewPath := filepath.Join(brewPrefix, "share", "zsh", "site-functions", "_anvil") |
| 58 | + if isWritableDir(filepath.Dir(brewPath)) { |
| 59 | + return brewPath, nil |
| 60 | + } |
| 61 | + } |
| 62 | + return filepath.Join(home, ".zsh", "completions", "_anvil"), nil |
| 63 | + |
| 64 | + case "bash": |
| 65 | + systemPath := "/etc/bash_completion.d/anvil" |
| 66 | + if isWritableDir(filepath.Dir(systemPath)) { |
| 67 | + return systemPath, nil |
| 68 | + } |
| 69 | + return filepath.Join(home, ".bash_completion.d", "anvil"), nil |
| 70 | + |
| 71 | + case "fish": |
| 72 | + configBase := os.Getenv("XDG_CONFIG_HOME") |
| 73 | + if configBase == "" { |
| 74 | + configBase = filepath.Join(home, ".config") |
| 75 | + } |
| 76 | + return filepath.Join(configBase, "fish", "completions", "anvil.fish"), nil |
| 77 | + |
| 78 | + default: |
| 79 | + return "", fmt.Errorf("unsupported shell %q — supported: zsh, bash, fish", shell) |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +// isWritableDir checks if the directory exists and is writable. |
| 84 | +func isWritableDir(dir string) bool { |
| 85 | + info, err := os.Stat(dir) |
| 86 | + if err != nil || !info.IsDir() { |
| 87 | + return false |
| 88 | + } |
| 89 | + // Try to create a temp file to verify write access |
| 90 | + tmp, err := os.CreateTemp(dir, ".anvil-write-test-*") |
| 91 | + if err != nil { |
| 92 | + return false |
| 93 | + } |
| 94 | + name := tmp.Name() |
| 95 | + if err := tmp.Close(); err != nil { |
| 96 | + return false |
| 97 | + } |
| 98 | + if err := os.Remove(name); err != nil { |
| 99 | + return false |
| 100 | + } |
| 101 | + return true |
| 102 | +} |
| 103 | + |
| 104 | +// generateCompletionScript generates the completion script for the given shell via Cobra. |
| 105 | +// root is the root cobra command used to generate the script. |
| 106 | +func generateCompletionScript(root *cobra.Command, shell string) ([]byte, error) { |
| 107 | + var buf bytes.Buffer |
| 108 | + var genErr error |
| 109 | + |
| 110 | + switch shell { |
| 111 | + case "zsh": |
| 112 | + genErr = root.GenZshCompletion(&buf) |
| 113 | + case "bash": |
| 114 | + genErr = root.GenBashCompletionV2(&buf, true) |
| 115 | + case "fish": |
| 116 | + genErr = root.GenFishCompletion(&buf, true) |
| 117 | + default: |
| 118 | + return nil, fmt.Errorf("unsupported shell: %s", shell) |
| 119 | + } |
| 120 | + |
| 121 | + if genErr != nil { |
| 122 | + return nil, fmt.Errorf("generating %s completion: %w", shell, genErr) |
| 123 | + } |
| 124 | + |
| 125 | + return buf.Bytes(), nil |
| 126 | +} |
| 127 | + |
| 128 | +// installCompletionToPath writes the completion script to the given path, creating directories as needed. |
| 129 | +// root is the root cobra command used to generate the script. |
| 130 | +func installCompletionToPath(root *cobra.Command, shell, targetPath string) error { |
| 131 | + script, err := generateCompletionScript(root, shell) |
| 132 | + if err != nil { |
| 133 | + return err |
| 134 | + } |
| 135 | + |
| 136 | + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { |
| 137 | + return fmt.Errorf("creating completion directory: %w", err) |
| 138 | + } |
| 139 | + |
| 140 | + if err := os.WriteFile(targetPath, script, 0644); err != nil { |
| 141 | + return fmt.Errorf("writing completion file: %w", err) |
| 142 | + } |
| 143 | + |
| 144 | + return nil |
| 145 | +} |
| 146 | + |
| 147 | +// installCompletion generates the completion script and writes it to disk after confirming with the user. |
| 148 | +// Returns nil if the user declines (not an error). |
| 149 | +// root is the root cobra command; cmd is the command the wizard was invoked from (used to reach root). |
| 150 | +func installCompletion(cmd *cobra.Command, shell string) error { |
| 151 | + root := cmd.Root() |
| 152 | + |
| 153 | + targetPath, err := completionInstallPath(shell) |
| 154 | + if err != nil { |
| 155 | + return err |
| 156 | + } |
| 157 | + |
| 158 | + ui.PrintInfo(fmt.Sprintf("Will install %s completion to: %s", shell, targetPath)) |
| 159 | + |
| 160 | + var proceed bool |
| 161 | + form := huh.NewForm( |
| 162 | + huh.NewGroup( |
| 163 | + huh.NewConfirm(). |
| 164 | + Title("Install shell completion?"). |
| 165 | + Description(fmt.Sprintf("Write %s completion script to %s", shell, targetPath)). |
| 166 | + Value(&proceed), |
| 167 | + ), |
| 168 | + ).WithTheme(huh.ThemeCatppuccin()) |
| 169 | + |
| 170 | + if err := form.Run(); err != nil { |
| 171 | + return ui.NormalizeAbort(err) |
| 172 | + } |
| 173 | + |
| 174 | + if !proceed { |
| 175 | + ui.PrintInfo("Skipping shell completion installation") |
| 176 | + return nil |
| 177 | + } |
| 178 | + |
| 179 | + if err := installCompletionToPath(root, shell, targetPath); err != nil { |
| 180 | + return err |
| 181 | + } |
| 182 | + |
| 183 | + ui.PrintSuccess(fmt.Sprintf("Completion installed at %s", targetPath)) |
| 184 | + return nil |
| 185 | +} |
| 186 | + |
| 187 | +// overrideCompletionSubcommands replaces the RunE on each shell's completion subcommand |
| 188 | +// to install by default, with --print to print to stdout instead. |
| 189 | +func overrideCompletionSubcommands(rootCmd *cobra.Command) { |
| 190 | + rootCmd.InitDefaultCompletionCmd() |
| 191 | + |
| 192 | + var completionCmd *cobra.Command |
| 193 | + for _, cmd := range rootCmd.Commands() { |
| 194 | + if cmd.Name() == "completion" { |
| 195 | + completionCmd = cmd |
| 196 | + break |
| 197 | + } |
| 198 | + } |
| 199 | + if completionCmd == nil { |
| 200 | + return |
| 201 | + } |
| 202 | + |
| 203 | + shellNames := []string{"zsh", "bash", "fish"} |
| 204 | + |
| 205 | + for _, shellName := range shellNames { |
| 206 | + shell := shellName // capture |
| 207 | + for _, sub := range completionCmd.Commands() { |
| 208 | + if sub.Name() != shell { |
| 209 | + continue |
| 210 | + } |
| 211 | + |
| 212 | + original := sub.RunE |
| 213 | + sub.Flags().Bool("print", false, "Print completion script to stdout instead of installing") |
| 214 | + |
| 215 | + sub.RunE = func(cmd *cobra.Command, args []string) error { |
| 216 | + print, _ := cmd.Flags().GetBool("print") |
| 217 | + if print { |
| 218 | + if original != nil { |
| 219 | + return original(cmd, args) |
| 220 | + } |
| 221 | + return nil |
| 222 | + } |
| 223 | + return installCompletion(cmd, shell) |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | +} |
0 commit comments