Skip to main content

vs_cli/
skill_install.rs

1//! `vs skill install` — install vibesurfer into every detected agent.
2//!
3//! Each agent is targeted on two surfaces (when supported):
4//!
5//!   1. **Skill** — SKILL.md (or GEMINI.md for Gemini) at the agent's
6//!      conventional skills location, plus any sibling manifest the
7//!      agent expects (Gemini's `gemini-extension.json`).
8//!   2. **MCP** — an `mcpServers.vibesurfer = {command: "vs",
9//!      args: ["mcp"]}` entry in the agent's MCP config file. Most
10//!      agents share a JSON shape; Codex stores `[mcp_servers.<name>]`
11//!      as TOML, hand-rolled here so we don't pull a TOML crate.
12//!
13//! Detection: an agent is considered installed when its config dir
14//! exists or its CLI is on PATH. The canonical `~/.agents/` target
15//! is always written. Per-agent failures don't abort — the run
16//! reports each result and exits non-zero only if no agent at all
17//! could be reached.
18//!
19//! Mirror of the `agented::internal::agents` shape. Keep this file
20//! the single source of truth for vibesurfer agent integrations:
21//! adding a new agent is one entry in `agents()`, not three patches
22//! across the binary.
23
24use std::path::{Path, PathBuf};
25use std::process::Command;
26
27use anyhow::{Context as _, Result};
28use serde_json::{json, Map, Value};
29
30const SERVER_NAME: &str = "vibesurfer";
31const SKILL_MD: &str = include_str!("../SKILL.md");
32
33// ============================================================================
34// Agent catalog
35// ============================================================================
36
37struct Agent {
38    name: &'static str,
39    /// True for the canonical `~/.agents/` target — always written
40    /// even when not "detected" (it's the cross-client convention,
41    /// not an installed binary).
42    always_write: bool,
43    detect: fn() -> bool,
44    /// Where the SKILL.md (or GEMINI.md) goes. None if the agent
45    /// has no skill surface.
46    skill_path: fn(home: &Path) -> Option<PathBuf>,
47    /// Optional sibling-manifest writer (Gemini extension manifest).
48    /// Called after the skill file is written.
49    skill_post: Option<fn(skill_path: &Path) -> Result<()>>,
50    /// Where the MCP config file lives. None if the agent has no MCP
51    /// surface.
52    mcp_path: fn(home: &Path) -> Option<PathBuf>,
53    /// Apply pattern: `Json` (mcpServers map) or `Toml` (Codex's
54    /// `[mcp_servers.<name>]` sections).
55    mcp_format: McpFormat,
56}
57
58#[derive(Copy, Clone)]
59enum McpFormat {
60    None,
61    Json,
62    Toml,
63}
64
65fn agents() -> Vec<Agent> {
66    vec![
67        // Canonical cross-client convention. Always write.
68        Agent {
69            name: "agents",
70            always_write: true,
71            detect: || false,
72            skill_path: |h| Some(h.join(".agents/skills/vibesurfer/SKILL.md")),
73            skill_post: None,
74            mcp_path: |_| None,
75            mcp_format: McpFormat::None,
76        },
77        Agent {
78            name: "claude",
79            always_write: false,
80            detect: || dir_exists(".claude") || file_exists(".claude.json") || on_path("claude"),
81            skill_path: |h| Some(h.join(".claude/skills/vibesurfer/SKILL.md")),
82            skill_post: None,
83            mcp_path: |h| Some(h.join(".claude.json")),
84            mcp_format: McpFormat::Json,
85        },
86        Agent {
87            name: "claude-desktop",
88            always_write: false,
89            detect: || claude_desktop_dir_exists(),
90            skill_path: |_| None,
91            skill_post: None,
92            mcp_path: |h| Some(claude_desktop_config_path(h)),
93            mcp_format: McpFormat::Json,
94        },
95        Agent {
96            name: "codex",
97            always_write: false,
98            detect: || dir_exists(".codex") || on_path("codex"),
99            skill_path: |h| Some(h.join(".codex/skills/vibesurfer/SKILL.md")),
100            skill_post: None,
101            mcp_path: |h| Some(h.join(".codex/config.toml")),
102            mcp_format: McpFormat::Toml,
103        },
104        Agent {
105            name: "cursor",
106            always_write: false,
107            detect: || project_dir_exists(".cursor") || on_path("cursor"),
108            // Cursor is project-scoped — write into ./.cursor of the
109            // current working dir, not $HOME.
110            skill_path: |_| {
111                std::env::current_dir()
112                    .ok()
113                    .map(|cwd| cwd.join(".cursor/skills/vibesurfer/SKILL.md"))
114            },
115            skill_post: None,
116            mcp_path: |_| {
117                std::env::current_dir()
118                    .ok()
119                    .map(|cwd| cwd.join(".cursor/mcp.json"))
120            },
121            mcp_format: McpFormat::Json,
122        },
123        Agent {
124            name: "gemini",
125            always_write: false,
126            detect: || dir_exists(".gemini") || on_path("gemini"),
127            // Gemini reads agent context from GEMINI.md inside an
128            // extension dir, not SKILL.md.
129            skill_path: |h| Some(h.join(".gemini/extensions/vibesurfer/GEMINI.md")),
130            skill_post: Some(write_gemini_manifest),
131            mcp_path: |h| Some(h.join(".gemini/settings.json")),
132            mcp_format: McpFormat::Json,
133        },
134        Agent {
135            name: "openclaw",
136            always_write: false,
137            detect: || dir_exists(".openclaw") || on_path("openclaw"),
138            skill_path: |h| Some(h.join(".openclaw/workspace/skills/vibesurfer/SKILL.md")),
139            skill_post: None,
140            mcp_path: |_| None,
141            mcp_format: McpFormat::None,
142        },
143    ]
144}
145
146// ============================================================================
147// Public entry point
148// ============================================================================
149
150pub fn run() -> Result<()> {
151    let home = home_dir().context("could not resolve $HOME")?;
152    let agents = agents();
153    let mut wrote_skill = 0usize;
154    let mut wrote_mcp = 0usize;
155    let mut detected = 0usize;
156    let mut failures = Vec::new();
157
158    for agent in &agents {
159        let active = agent.always_write || (agent.detect)();
160        if !active {
161            println!("  - {:<14}  skipped (not installed)", agent.name);
162            continue;
163        }
164        detected += 1;
165        let mut lines = Vec::new();
166
167        if let Some(path) = (agent.skill_path)(&home) {
168            match write_skill(&path) {
169                Ok(()) => {
170                    lines.push(format!("skill → {}", path.display()));
171                    if let Some(post) = agent.skill_post {
172                        if let Err(e) = post(&path) {
173                            failures.push(format!("{}: post-install: {e:#}", agent.name));
174                        }
175                    }
176                    wrote_skill += 1;
177                }
178                Err(e) => failures.push(format!("{}: skill: {e:#}", agent.name)),
179            }
180        }
181
182        if let Some(path) = (agent.mcp_path)(&home) {
183            let result = match agent.mcp_format {
184                McpFormat::None => Ok(false),
185                McpFormat::Json => apply_json(&path, SERVER_NAME, mcp_server_value()),
186                McpFormat::Toml => apply_toml(&path, SERVER_NAME, "vs", &["mcp"]),
187            };
188            match result {
189                Ok(true) => {
190                    lines.push(format!("mcp   → {}", path.display()));
191                    wrote_mcp += 1;
192                }
193                Ok(false) => {} // already up-to-date
194                Err(e) => failures.push(format!("{}: mcp: {e:#}", agent.name)),
195            }
196        }
197
198        if lines.is_empty() {
199            println!("  · {:<14}  (already up to date)", agent.name);
200        } else {
201            for (i, line) in lines.iter().enumerate() {
202                let mark = if i == 0 { "✓" } else { " " };
203                let label = if i == 0 { agent.name } else { "" };
204                println!("  {mark} {label:<14}  {line}");
205            }
206        }
207    }
208
209    println!(
210        "{wrote_skill} skill files, {wrote_mcp} MCP entries written across {detected} detected agents."
211    );
212    for f in &failures {
213        eprintln!("  ! {f}");
214    }
215    if detected == 0 {
216        anyhow::bail!("no agent surfaces found; install one (Claude, Codex, Cursor, Gemini, OpenClaw) and retry");
217    }
218    if !failures.is_empty() {
219        anyhow::bail!("{} target(s) failed; see above", failures.len());
220    }
221    Ok(())
222}
223
224// ============================================================================
225// Skill helpers
226// ============================================================================
227
228fn write_skill(path: &Path) -> Result<()> {
229    let dir = path
230        .parent()
231        .ok_or_else(|| anyhow::anyhow!("no parent for {}", path.display()))?;
232    std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?;
233    std::fs::write(path, SKILL_MD).with_context(|| format!("write {}", path.display()))?;
234    Ok(())
235}
236
237fn write_gemini_manifest(skill_path: &Path) -> Result<()> {
238    let dir = skill_path
239        .parent()
240        .ok_or_else(|| anyhow::anyhow!("no parent for {}", skill_path.display()))?;
241    let manifest = dir.join("gemini-extension.json");
242    let body = format!(
243        r#"{{
244  "name": "vibesurfer",
245  "version": "{ver}",
246  "contextFileName": "{ctx}"
247}}
248"#,
249        ver = env!("CARGO_PKG_VERSION"),
250        ctx = skill_path
251            .file_name()
252            .and_then(|n| n.to_str())
253            .unwrap_or("GEMINI.md"),
254    );
255    std::fs::write(&manifest, body).with_context(|| format!("write {}", manifest.display()))?;
256    Ok(())
257}
258
259// ============================================================================
260// MCP — JSON apply (Claude Code / Desktop, Cursor, Gemini)
261// ============================================================================
262
263fn mcp_server_value() -> Value {
264    json!({
265        "command": "vs",
266        "args": ["mcp"],
267    })
268}
269
270fn apply_json(path: &Path, name: &str, server: Value) -> Result<bool> {
271    if let Some(parent) = path.parent() {
272        std::fs::create_dir_all(parent)?;
273    }
274    let mut root: Value = if path.exists() {
275        let s = std::fs::read_to_string(path)?;
276        if s.trim().is_empty() {
277            json!({})
278        } else {
279            serde_json::from_str(&s).with_context(|| format!("parse {}", path.display()))?
280        }
281    } else {
282        json!({})
283    };
284    let root_obj = root
285        .as_object_mut()
286        .ok_or_else(|| anyhow::anyhow!("{} is not a JSON object at root", path.display()))?;
287    let mcp = root_obj
288        .entry("mcpServers".to_string())
289        .or_insert_with(|| Value::Object(Map::new()));
290    let mcp_obj = mcp
291        .as_object_mut()
292        .ok_or_else(|| anyhow::anyhow!("mcpServers in {} is not a JSON object", path.display()))?;
293    if mcp_obj.get(name) == Some(&server) {
294        return Ok(false);
295    }
296    mcp_obj.insert(name.to_string(), server);
297    let pretty = serde_json::to_string_pretty(&root)?;
298    std::fs::write(path, format!("{pretty}\n"))?;
299    Ok(true)
300}
301
302// ============================================================================
303// MCP — TOML apply (Codex)
304//
305// Hand-rolled section editor for `[mcp_servers.<name>]`. Codex's TOML
306// is shallow enough that we can do this without pulling a parser.
307// ============================================================================
308
309fn apply_toml(path: &Path, name: &str, command: &str, args: &[&str]) -> Result<bool> {
310    if let Some(parent) = path.parent() {
311        std::fs::create_dir_all(parent)?;
312    }
313    let body = if path.exists() {
314        std::fs::read_to_string(path)?
315    } else {
316        String::new()
317    };
318    let header = format!("[mcp_servers.{name}]");
319    let new_section = render_toml_section(&header, command, args);
320    let mut updated = String::new();
321    let mut replaced = false;
322    let mut skip_until_next_header = false;
323    for line in body.lines() {
324        let trimmed = line.trim_start();
325        if trimmed.starts_with('[') {
326            // New section starts; if we were skipping the old one,
327            // stop skipping now.
328            skip_until_next_header = false;
329            if line.trim() == header {
330                // Replace this section.
331                updated.push_str(&new_section);
332                replaced = true;
333                skip_until_next_header = true;
334                continue;
335            }
336        }
337        if skip_until_next_header {
338            continue;
339        }
340        updated.push_str(line);
341        updated.push('\n');
342    }
343    if !replaced {
344        if !updated.is_empty() && !updated.ends_with('\n') {
345            updated.push('\n');
346        }
347        if !updated.is_empty() {
348            updated.push('\n');
349        }
350        updated.push_str(&new_section);
351    }
352    if updated == body {
353        return Ok(false);
354    }
355    std::fs::write(path, updated)?;
356    Ok(true)
357}
358
359fn render_toml_section(header: &str, command: &str, args: &[&str]) -> String {
360    use std::fmt::Write as _;
361    let mut out = String::new();
362    out.push_str(header);
363    out.push('\n');
364    let _ = writeln!(out, "command = {}", toml_string(command));
365    if !args.is_empty() {
366        out.push_str("args = [");
367        for (i, a) in args.iter().enumerate() {
368            if i > 0 {
369                out.push_str(", ");
370            }
371            out.push_str(&toml_string(a));
372        }
373        out.push_str("]\n");
374    }
375    out
376}
377
378fn toml_string(s: &str) -> String {
379    let mut out = String::with_capacity(s.len() + 2);
380    out.push('"');
381    for ch in s.chars() {
382        match ch {
383            '"' => out.push_str(r#"\""#),
384            '\\' => out.push_str(r"\\"),
385            '\n' => out.push_str(r"\n"),
386            '\r' => out.push_str(r"\r"),
387            '\t' => out.push_str(r"\t"),
388            c => out.push(c),
389        }
390    }
391    out.push('"');
392    out
393}
394
395// ============================================================================
396// Detection
397// ============================================================================
398
399fn home_dir() -> Option<PathBuf> {
400    std::env::var_os("HOME").map(PathBuf::from)
401}
402
403fn dir_exists(rel: &str) -> bool {
404    home_dir().is_some_and(|h| h.join(rel).is_dir())
405}
406
407fn file_exists(rel: &str) -> bool {
408    home_dir().is_some_and(|h| h.join(rel).is_file())
409}
410
411fn project_dir_exists(rel: &str) -> bool {
412    std::env::current_dir()
413        .ok()
414        .is_some_and(|cwd| cwd.join(rel).is_dir())
415}
416
417fn on_path(bin: &str) -> bool {
418    Command::new(bin)
419        .arg("--version")
420        .stdout(std::process::Stdio::null())
421        .stderr(std::process::Stdio::null())
422        .status()
423        .is_ok()
424}
425
426#[cfg(target_os = "macos")]
427fn claude_desktop_config_path(home: &Path) -> PathBuf {
428    home.join("Library/Application Support/Claude/claude_desktop_config.json")
429}
430
431#[cfg(target_os = "linux")]
432fn claude_desktop_config_path(home: &Path) -> PathBuf {
433    home.join(".config/Claude/claude_desktop_config.json")
434}
435
436#[cfg(target_os = "windows")]
437fn claude_desktop_config_path(home: &Path) -> PathBuf {
438    let appdata = std::env::var_os("APPDATA").map(PathBuf::from);
439    if let Some(p) = appdata {
440        return p.join("Claude/claude_desktop_config.json");
441    }
442    home.join("AppData/Roaming/Claude/claude_desktop_config.json")
443}
444
445fn claude_desktop_dir_exists() -> bool {
446    home_dir().is_some_and(|h| {
447        claude_desktop_config_path(&h)
448            .parent()
449            .is_some_and(Path::is_dir)
450    })
451}