Skip to content

Commit dd1fe2e

Browse files
committed
Added more user friendly automated install and update
1 parent dd6a964 commit dd1fe2e

8 files changed

Lines changed: 570 additions & 56 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,13 @@ webex config get client-id # View current value
192192

193193
Config is stored in `~/.webex-cli/config.json`.
194194

195-
## Claude Code Integration
195+
## Coding Agent Skill
196196

197-
A sample Claude Code skill is included in `skill/SKILL.md`. See the [docs](https://cloverhound.github.io/webex-cli/agent-skill/) for setup instructions.
197+
A skill file is included in `skill/SKILL.md` that enables AI coding agents (Claude Code, Claude Cowork, OpenAI Codex, Cursor) to query and manage your Webex environment.
198+
199+
The installer and `webex post-install` command will offer to install the skill automatically. Skills are also kept up to date when you run `webex update`.
200+
201+
See the [docs](https://cloverhound.github.io/webex-cli/agent-skill/) for manual setup instructions.
198202

199203
## Development
200204

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/webex-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", "webex-cli")},
30+
{coworkName, ""}, // Cowork requires ZIP upload via web UI
31+
{"OpenAI Codex", filepath.Join(".codex", "skills", "webex-cli")},
32+
{"Cursor", filepath.Join(".cursor", "skills", "webex-cli")},
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 webex binary was installed to %s, but your shell\n"+
106+
"can't find it yet. Adding it to %s will make the\n"+
107+
"\"webex\" 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 \"webex\" 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 Webex skill for AI coding agents?").
176+
Description(
177+
"The Webex skill lets AI coding agents (Claude Code, Codex, Cursor)\n"+
178+
"query and manage your Webex environment 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 Webex 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", "webex-cli-skill.zip")
209+
if err := buildSkillZip(zipPath, "webex-cli", 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)