Skip to content

Commit c069a7d

Browse files
committed
Added post-install automated skill installation
1 parent 58e77cf commit c069a7d

5 files changed

Lines changed: 459 additions & 31 deletions

File tree

README.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -297,37 +297,38 @@ prompt-tools completion fish > ~/.config/fish/completions/prompt-tools.fish
297297

298298
## Coding Agent Skill
299299

300-
A skill file is included at `skill/SKILL.md` that teaches AI coding agents how to use the CLI. Install it for your agent of choice:
300+
A skill file is included at `skill/SKILL.md` that teaches AI coding agents how to use the CLI.
301301

302-
**Claude Code (macOS/Linux):**
302+
### Automatic Setup
303+
304+
The installer and `prompt-tools post-install` command will offer to install the skill for detected agents (Claude Code, Claude Cowork, OpenAI Codex, Cursor) via an interactive menu. Skills are also kept up to date when you run `prompt-tools update`.
305+
306+
### Manual Setup
307+
308+
If you prefer to install manually:
309+
310+
**Claude Code:**
303311
```bash
304312
mkdir -p ~/.claude/skills/prompt-tools
305313
curl -fsSL https://raw.githubusercontent.com/Cloverhound/prompt-tools-cli/main/skill/SKILL.md \
306314
-o ~/.claude/skills/prompt-tools/SKILL.md
307315
```
308316

309-
**Claude Code (Windows PowerShell):**
310-
```powershell
311-
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.claude\skills\prompt-tools"
312-
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Cloverhound/prompt-tools-cli/main/skill/SKILL.md" `
313-
-OutFile "$env:USERPROFILE\.claude\skills\prompt-tools\SKILL.md"
314-
```
315-
316-
**OpenAI Codex (macOS/Linux):**
317+
**OpenAI Codex:**
317318
```bash
318-
mkdir -p ~/.agents/skills/prompt-tools
319+
mkdir -p ~/.codex/skills/prompt-tools
319320
curl -fsSL https://raw.githubusercontent.com/Cloverhound/prompt-tools-cli/main/skill/SKILL.md \
320-
-o ~/.agents/skills/prompt-tools/SKILL.md
321+
-o ~/.codex/skills/prompt-tools/SKILL.md
321322
```
322323

323-
**Cursor (macOS/Linux):**
324+
**Cursor:**
324325
```bash
325326
mkdir -p ~/.cursor/skills/prompt-tools
326327
curl -fsSL https://raw.githubusercontent.com/Cloverhound/prompt-tools-cli/main/skill/SKILL.md \
327328
-o ~/.cursor/skills/prompt-tools/SKILL.md
328329
```
329330

330-
If the `prompt-tools` binary isn't in your PATH, update the binary path inside the installed skill file.
331+
**Claude Cowork:** Run `prompt-tools post-install` to generate the ZIP, then upload at: Claude Desktop → Cowork tab → Customize → Skills → + → Upload a skill.
331332

332333
For project-specific installation, place the skill file in your project directory instead of the user-level folder.
333334

cmd/postinstall.go

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package cmd
2+
3+
import (
4+
"archive/zip"
5+
"bytes"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
13+
14+
"github.com/charmbracelet/huh"
15+
"github.com/charmbracelet/lipgloss"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
const skillURL = "https://raw.githubusercontent.com/Cloverhound/prompt-tools-cli/main/skill/SKILL.md"
20+
21+
const coworkName = "Claude Cowork"
22+
23+
type agentPlatform struct {
24+
Name string
25+
SkillDir string // relative to home dir
26+
}
27+
28+
var agentPlatforms = []agentPlatform{
29+
{"Claude Code", filepath.Join(".claude", "skills", "prompt-tools")},
30+
{coworkName, ""}, // Cowork requires ZIP upload via web UI
31+
{"OpenAI Codex", filepath.Join(".codex", "skills", "prompt-tools")},
32+
{"Cursor", filepath.Join(".cursor", "skills", "prompt-tools")},
33+
}
34+
35+
var postInstallCmd = &cobra.Command{
36+
Use: "post-install",
37+
Short: "Run post-installation setup (PATH, agent skills)",
38+
RunE: func(cmd *cobra.Command, args []string) error {
39+
execPath, err := os.Executable()
40+
if err != nil {
41+
return fmt.Errorf("detecting executable path: %w", err)
42+
}
43+
installDir := filepath.Dir(execPath)
44+
45+
// Step 1: PATH setup
46+
if !dirInPath(installDir) {
47+
if runtime.GOOS == "windows" {
48+
if err := setupWindowsPath(installDir); err != nil {
49+
return err
50+
}
51+
} else {
52+
if err := setupUnixPath(installDir); err != nil {
53+
return err
54+
}
55+
}
56+
fmt.Println()
57+
}
58+
59+
// Step 2: Agent skill installation
60+
if err := setupAgentSkills(); err != nil {
61+
return err
62+
}
63+
64+
return nil
65+
},
66+
}
67+
68+
func dirInPath(dir string) bool {
69+
for _, p := range filepath.SplitList(os.Getenv("PATH")) {
70+
if p == dir {
71+
return true
72+
}
73+
}
74+
return false
75+
}
76+
77+
func setupUnixPath(installDir string) error {
78+
shell := filepath.Base(os.Getenv("SHELL"))
79+
var rcFile string
80+
switch shell {
81+
case "zsh":
82+
rcFile = filepath.Join(os.Getenv("HOME"), ".zshrc")
83+
case "bash":
84+
rcFile = filepath.Join(os.Getenv("HOME"), ".bashrc")
85+
default:
86+
rcFile = filepath.Join(os.Getenv("HOME"), ".profile")
87+
}
88+
89+
exportLine := fmt.Sprintf(`export PATH="%s:$PATH"`, installDir)
90+
91+
if data, err := os.ReadFile(rcFile); err == nil {
92+
if strings.Contains(string(data), installDir) {
93+
fmt.Printf("%s already references %s — restart your terminal or run: source %s\n", rcFile, installDir, rcFile)
94+
return nil
95+
}
96+
}
97+
98+
var choice string
99+
form := huh.NewForm(
100+
huh.NewGroup(
101+
huh.NewSelect[string]().
102+
Title(fmt.Sprintf("%s is not in your PATH", installDir)).
103+
Description(
104+
fmt.Sprintf(
105+
"The prompt-tools binary was installed to %s, but your shell\n"+
106+
"can't find it yet. Adding it to %s will make the\n"+
107+
"\"prompt-tools\" command available in all new terminal sessions.",
108+
installDir, filepath.Base(rcFile),
109+
),
110+
).
111+
Options(
112+
huh.NewOption(fmt.Sprintf("Yes — add to %s", filepath.Base(rcFile)), "yes"),
113+
huh.NewOption("No — I'll do it myself", "no"),
114+
).
115+
Value(&choice),
116+
),
117+
)
118+
119+
if err := form.Run(); err != nil {
120+
return nil
121+
}
122+
123+
if choice == "yes" {
124+
f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
125+
if err != nil {
126+
return fmt.Errorf("opening %s: %w", rcFile, err)
127+
}
128+
defer f.Close()
129+
130+
if data, err := os.ReadFile(rcFile); err == nil && len(data) > 0 && data[len(data)-1] != '\n' {
131+
f.WriteString("\n")
132+
}
133+
f.WriteString(exportLine + "\n")
134+
135+
fmt.Printf("Added to %s. Restart your terminal or run: source %s\n", rcFile, rcFile)
136+
} else {
137+
fmt.Println()
138+
fmt.Println("To add it manually, run:")
139+
fmt.Println()
140+
fmt.Printf(" echo '%s' >> %s && source %s\n", exportLine, rcFile, rcFile)
141+
fmt.Println()
142+
}
143+
144+
return nil
145+
}
146+
147+
func setupWindowsPath(installDir string) error {
148+
fmt.Printf("Add %s to your system PATH to use the \"prompt-tools\" command.\n", installDir)
149+
return nil
150+
}
151+
152+
func setupAgentSkills() error {
153+
home, err := os.UserHomeDir()
154+
if err != nil {
155+
return fmt.Errorf("detecting home directory: %w", err)
156+
}
157+
158+
var options []huh.Option[string]
159+
for _, p := range agentPlatforms {
160+
label := p.Name
161+
if p.Name == coworkName {
162+
label += " (saves ZIP to ~/Downloads for manual upload)"
163+
}
164+
opt := huh.NewOption(label, p.Name)
165+
if agentDetected(home, p) {
166+
opt = opt.Selected(true)
167+
}
168+
options = append(options, opt)
169+
}
170+
171+
var selected []string
172+
form := huh.NewForm(
173+
huh.NewGroup(
174+
huh.NewMultiSelect[string]().
175+
Title("Install Prompt Tools skill for AI coding agents?").
176+
Description(
177+
"The Prompt Tools skill lets AI coding agents (Claude Code, Codex, Cursor)\n"+
178+
"generate audio prompts and transcribe recordings using natural language.\n"+
179+
"Detected agents are pre-selected.",
180+
).
181+
Options(options...).
182+
Value(&selected),
183+
),
184+
)
185+
186+
if err := form.Run(); err != nil {
187+
return nil
188+
}
189+
190+
if len(selected) == 0 {
191+
return nil
192+
}
193+
194+
fmt.Print("Downloading Prompt Tools skill...")
195+
skillContent, err := downloadSkill()
196+
if err != nil {
197+
fmt.Println(" failed")
198+
return fmt.Errorf("downloading skill: %w", err)
199+
}
200+
fmt.Println(" ok")
201+
202+
for _, name := range selected {
203+
for _, p := range agentPlatforms {
204+
if p.Name != name {
205+
continue
206+
}
207+
if p.Name == coworkName {
208+
zipPath := filepath.Join(home, "Downloads", "prompt-tools-skill.zip")
209+
if err := buildSkillZip(zipPath, "prompt-tools", skillContent); err != nil {
210+
fmt.Printf(" %s: failed (%v)\n", p.Name, err)
211+
} else {
212+
fmt.Printf(" %s: saved to %s\n", p.Name, zipPath)
213+
printCoworkInstructions(zipPath)
214+
}
215+
} else {
216+
dest := filepath.Join(home, p.SkillDir, "SKILL.md")
217+
if err := installSkill(dest, skillContent); err != nil {
218+
fmt.Printf(" %s: failed (%v)\n", p.Name, err)
219+
} else {
220+
fmt.Printf(" %s: installed to %s\n", p.Name, dest)
221+
}
222+
}
223+
}
224+
}
225+
226+
return nil
227+
}
228+
229+
func agentDetected(home string, p agentPlatform) bool {
230+
if p.Name == coworkName {
231+
switch runtime.GOOS {
232+
case "darwin":
233+
_, err := os.Stat("/Applications/Claude.app")
234+
return err == nil
235+
case "windows":
236+
_, err := os.Stat(filepath.Join(os.Getenv("LOCALAPPDATA"), "AnthropicClaude"))
237+
return err == nil
238+
}
239+
return false
240+
}
241+
topDir := filepath.Join(home, strings.SplitN(p.SkillDir, string(filepath.Separator), 2)[0])
242+
info, err := os.Stat(topDir)
243+
return err == nil && info.IsDir()
244+
}
245+
246+
func downloadSkill() ([]byte, error) {
247+
resp, err := http.Get(skillURL)
248+
if err != nil {
249+
return nil, err
250+
}
251+
defer resp.Body.Close()
252+
253+
if resp.StatusCode != http.StatusOK {
254+
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
255+
}
256+
257+
return io.ReadAll(resp.Body)
258+
}
259+
260+
func printCoworkInstructions(zipPath string) {
261+
box := lipgloss.NewStyle().
262+
Border(lipgloss.RoundedBorder()).
263+
BorderForeground(lipgloss.Color("212")).
264+
Padding(1, 2).
265+
MarginLeft(2)
266+
267+
heading := lipgloss.NewStyle().
268+
Bold(true).
269+
Foreground(lipgloss.Color("212"))
270+
271+
step := lipgloss.NewStyle().
272+
Foreground(lipgloss.Color("252"))
273+
274+
sub := lipgloss.NewStyle().
275+
Foreground(lipgloss.Color("245")).
276+
Italic(true)
277+
278+
content := heading.Render("⚠ Action required: Upload skill to Claude Cowork") + "\n" +
279+
sub.Render("Unlike Claude Code, Cowork skills must be manually uploaded to the Claude Desktop app.") + "\n\n" +
280+
step.Render("1. Open Claude Desktop and switch to the Cowork tab") + "\n" +
281+
step.Render("2. Click Customize (left sidebar) → Skills → + → Upload a skill") + "\n" +
282+
step.Render("3. Select: "+zipPath)
283+
284+
fmt.Println(box.Render(content))
285+
}
286+
287+
func buildSkillZip(dest, skillName string, skillContent []byte) error {
288+
var buf bytes.Buffer
289+
w := zip.NewWriter(&buf)
290+
291+
f, err := w.Create(skillName + "/SKILL.md")
292+
if err != nil {
293+
return fmt.Errorf("creating zip entry: %w", err)
294+
}
295+
if _, err := f.Write(skillContent); err != nil {
296+
return fmt.Errorf("writing zip entry: %w", err)
297+
}
298+
if err := w.Close(); err != nil {
299+
return fmt.Errorf("closing zip: %w", err)
300+
}
301+
302+
dir := filepath.Dir(dest)
303+
if err := os.MkdirAll(dir, 0755); err != nil {
304+
return fmt.Errorf("creating directory: %w", err)
305+
}
306+
return os.WriteFile(dest, buf.Bytes(), 0644)
307+
}
308+
309+
func installSkill(dest string, content []byte) error {
310+
dir := filepath.Dir(dest)
311+
if err := os.MkdirAll(dir, 0755); err != nil {
312+
return fmt.Errorf("creating directory: %w", err)
313+
}
314+
return os.WriteFile(dest, content, 0644)
315+
}
316+
317+
func init() {
318+
rootCmd.AddCommand(postInstallCmd)
319+
}

0 commit comments

Comments
 (0)