1use 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
33struct Agent {
38 name: &'static str,
39 always_write: bool,
43 detect: fn() -> bool,
44 skill_path: fn(home: &Path) -> Option<PathBuf>,
47 skill_post: Option<fn(skill_path: &Path) -> Result<()>>,
50 mcp_path: fn(home: &Path) -> Option<PathBuf>,
53 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 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 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 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
146pub 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) => {} 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
224fn 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
259fn 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
302fn 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 skip_until_next_header = false;
329 if line.trim() == header {
330 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
395fn 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}