Skip to content

Commit f94c78e

Browse files
Add completion install command for zsh, bash, and fish
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8ad7d87 commit f94c78e

2 files changed

Lines changed: 247 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,15 @@ cipi-cli update --force Reinstall even if already up to date
166166

167167
Downloads the matching binary from the latest [GitHub Release](https://github.com/cipi-sh/cli/releases), verifies its SHA-256 checksum, and replaces the running binary in place. If it is installed in a system path (e.g. `/usr/local/bin`), run it with `sudo cipi-cli update`.
168168

169+
### Shell completion
170+
171+
```
172+
cipi-cli completion install Auto-detect shell and install completion
173+
cipi-cli completion install --shell zsh Install for a specific shell (zsh|bash|fish)
174+
```
175+
176+
Writes the script under `~/.cipi/completions/` (or fish’s completions dir) and, for zsh/bash, appends a source line to your rc file. Reload the shell afterwards.
177+
169178
### Other
170179

171180
```

‎cmd/completion.go‎

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
"strings"
10+
11+
"github.com/cipi-sh/cli/internal/config"
12+
"github.com/cipi-sh/cli/internal/output"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var completionInstallCmd = &cobra.Command{
17+
Use: "install",
18+
Short: "Install shell completion for the current shell",
19+
Long: `Detect your shell, generate the completion script, and install it.
20+
21+
Supported shells: zsh, bash, fish.
22+
23+
For zsh/bash, the script is written under ~/.cipi/completions/ and a
24+
source line is added to your shell rc file (idempotent).
25+
26+
For fish, the script is written to ~/.config/fish/completions/ (auto-loaded).
27+
28+
Restart your shell (or source the rc file) after installing.`,
29+
Example: ` cipi-cli completion install
30+
cipi-cli completion install --shell zsh
31+
cipi-cli completion install --shell bash`,
32+
Args: cobra.NoArgs,
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
shell, _ := cmd.Flags().GetString("shell")
35+
if shell == "" {
36+
shell = detectShell()
37+
}
38+
shell = strings.ToLower(strings.TrimSpace(shell))
39+
40+
switch shell {
41+
case "zsh", "bash", "fish":
42+
default:
43+
output.Error("Unsupported shell %q — use zsh, bash, or fish", shell)
44+
output.Info("Override with: cipi-cli completion install --shell zsh")
45+
return fmt.Errorf("unsupported shell: %s", shell)
46+
}
47+
48+
fmt.Println()
49+
output.Info("Installing %s completion...", shell)
50+
51+
path, err := installCompletion(shell)
52+
if err != nil {
53+
output.Error("%s", err)
54+
return err
55+
}
56+
57+
output.Success("Completion installed → %s", path)
58+
fmt.Println()
59+
switch shell {
60+
case "fish":
61+
output.Info("Fish loads completions automatically. Open a new shell, then try:")
62+
default:
63+
output.Info("Reload your shell, then try:")
64+
output.Dim.Printf(" source %s\n", shellRCPath(shell))
65+
}
66+
output.Dim.Println(" cipi-cli <TAB>")
67+
fmt.Println()
68+
return nil
69+
},
70+
}
71+
72+
func detectShell() string {
73+
shell := os.Getenv("SHELL")
74+
if shell == "" {
75+
return ""
76+
}
77+
base := strings.ToLower(filepath.Base(shell))
78+
switch base {
79+
case "zsh", "bash", "fish":
80+
return base
81+
default:
82+
return base
83+
}
84+
}
85+
86+
func installCompletion(shell string) (string, error) {
87+
var buf bytes.Buffer
88+
switch shell {
89+
case "zsh":
90+
if err := rootCmd.GenZshCompletion(&buf); err != nil {
91+
return "", fmt.Errorf("generating zsh completion: %w", err)
92+
}
93+
case "bash":
94+
if err := rootCmd.GenBashCompletionV2(&buf, true); err != nil {
95+
return "", fmt.Errorf("generating bash completion: %w", err)
96+
}
97+
case "fish":
98+
if err := rootCmd.GenFishCompletion(&buf, true); err != nil {
99+
return "", fmt.Errorf("generating fish completion: %w", err)
100+
}
101+
}
102+
103+
if shell == "fish" {
104+
home, err := os.UserHomeDir()
105+
if err != nil {
106+
return "", err
107+
}
108+
dir := filepath.Join(home, ".config", "fish", "completions")
109+
if err := os.MkdirAll(dir, 0755); err != nil {
110+
return "", fmt.Errorf("creating fish completions dir: %w", err)
111+
}
112+
path := filepath.Join(dir, "cipi-cli.fish")
113+
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
114+
return "", fmt.Errorf("writing completion file: %w", err)
115+
}
116+
return path, nil
117+
}
118+
119+
dir := filepath.Join(config.Dir(), "completions")
120+
if err := os.MkdirAll(dir, 0700); err != nil {
121+
return "", fmt.Errorf("creating completions dir: %w", err)
122+
}
123+
124+
filename := "cipi-cli." + shell
125+
path := filepath.Join(dir, filename)
126+
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
127+
return "", fmt.Errorf("writing completion file: %w", err)
128+
}
129+
130+
rc := shellRCPath(shell)
131+
marker := "# cipi-cli shell completion"
132+
sourceLine := completionSourceLine(shell, path)
133+
added, err := ensureRCSnippet(rc, marker, sourceLine)
134+
if err != nil {
135+
output.Warn("Could not update %s: %s", rc, err)
136+
output.Info("Add this line manually:")
137+
output.Dim.Printf(" %s\n", sourceLine)
138+
return path, nil
139+
}
140+
if added {
141+
output.Info("Hooked into %s", rc)
142+
} else {
143+
output.Info("Already hooked in %s", rc)
144+
}
145+
146+
return path, nil
147+
}
148+
149+
func shellRCPath(shell string) string {
150+
home, err := os.UserHomeDir()
151+
if err != nil {
152+
return ""
153+
}
154+
switch shell {
155+
case "zsh":
156+
return filepath.Join(home, ".zshrc")
157+
case "bash":
158+
bashrc := filepath.Join(home, ".bashrc")
159+
if runtime.GOOS == "darwin" {
160+
bashProfile := filepath.Join(home, ".bash_profile")
161+
if _, err := os.Stat(bashrc); err != nil {
162+
return bashProfile
163+
}
164+
// Prefer .bash_profile on macOS when it already exists (login shells).
165+
if _, err := os.Stat(bashProfile); err == nil {
166+
return bashProfile
167+
}
168+
}
169+
return bashrc
170+
default:
171+
return ""
172+
}
173+
}
174+
175+
func completionSourceLine(shell, path string) string {
176+
switch shell {
177+
case "zsh":
178+
return fmt.Sprintf("source %q", path)
179+
case "bash":
180+
return fmt.Sprintf("source %q", path)
181+
default:
182+
return ""
183+
}
184+
}
185+
186+
func ensureRCSnippet(rcPath, marker, sourceLine string) (bool, error) {
187+
if rcPath == "" {
188+
return false, fmt.Errorf("could not determine shell rc path")
189+
}
190+
191+
data, err := os.ReadFile(rcPath)
192+
if err != nil && !os.IsNotExist(err) {
193+
return false, err
194+
}
195+
196+
content := string(data)
197+
if strings.Contains(content, marker) || strings.Contains(content, sourceLine) {
198+
return false, nil
199+
}
200+
201+
snippet := "\n" + marker + "\n" + sourceLine + "\n"
202+
if !strings.HasSuffix(content, "\n") && len(content) > 0 {
203+
snippet = "\n" + snippet
204+
}
205+
206+
f, err := os.OpenFile(rcPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
207+
if err != nil {
208+
return false, err
209+
}
210+
defer f.Close()
211+
212+
if _, err = f.WriteString(snippet); err != nil {
213+
return false, err
214+
}
215+
return true, nil
216+
}
217+
218+
func init() {
219+
rootCmd.InitDefaultCompletionCmd()
220+
completionInstallCmd.Flags().String("shell", "", "Shell to install for (zsh, bash, fish). Defaults to $SHELL")
221+
222+
for _, c := range rootCmd.Commands() {
223+
if c.Name() == "completion" {
224+
c.AddCommand(completionInstallCmd)
225+
c.Short = "Generate or install shell completion scripts"
226+
c.Long = `Generate or install shell autocompletion for cipi-cli.
227+
228+
Quick setup (recommended):
229+
cipi-cli completion install
230+
231+
Or print a script for a specific shell:
232+
cipi-cli completion zsh
233+
cipi-cli completion bash
234+
cipi-cli completion fish`
235+
break
236+
}
237+
}
238+
}

0 commit comments

Comments
 (0)