Skip to content

Commit 4e85652

Browse files
authored
Merge pull request #10 from Naoray/feat/setup-wizard-completion
feat: setup wizard, first-run detection, and completion install
2 parents 52d6fe4 + abd9310 commit 4e85652

10 files changed

Lines changed: 882 additions & 54 deletions

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,43 @@ DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
6363
go build -ldflags "-X main.Version=$VERSION -X main.Commit=$COMMIT -X main.BuildDate=$DATE" -o anvil ./cmd/anvil
6464
```
6565

66+
## Setup
67+
68+
After installation, run the setup wizard to configure anvil:
69+
70+
```bash
71+
anvil install
72+
```
73+
74+
The wizard will:
75+
1. Check your PATH
76+
2. Detect Herd/Valet
77+
3. Install shell completions
78+
4. Set a default projects root
79+
5. Optionally install AI CLI skills (Claude Code, etc.)
80+
81+
### Shell Completions
82+
83+
Shell completions are installed automatically by the wizard. To install or reinstall manually:
84+
85+
```bash
86+
# Install completions for your current shell
87+
anvil completion zsh # installs to zsh site-functions
88+
anvil completion bash # installs to bash_completion.d
89+
anvil completion fish # installs to fish completions dir
90+
91+
# Print completion script to stdout (for manual setup)
92+
anvil completion zsh --print
93+
```
94+
95+
For **zsh** with Homebrew, the completion file is written to `$(brew --prefix)/share/zsh/site-functions/_anvil`. For user-local installs, it goes to `~/.zsh/completions/_anvil`. Add the directory to your `fpath` if needed:
96+
97+
```zsh
98+
# ~/.zshrc
99+
fpath=(~/.zsh/completions $fpath)
100+
autoload -Uz compinit && compinit
101+
```
102+
66103
## Quick Start
67104

68105
```bash

internal/cli/completion_install.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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

Comments
 (0)