1use std::{
15 env,
16 fmt::Write as _,
17 fs, io,
18 path::{Path, PathBuf},
19 process::Command,
20};
21
22use anyhow::{Context as _, Result};
23use clap::{Args, ValueEnum};
24use serde_json::{Map, Value};
25
26const SERVER_NAME: &str = "voidcrawl";
28
29const SERVER_ENV: &[(&str, &str)] =
33 &[("BROWSER_COUNT", "1"), ("TABS_PER_BROWSER", "5"), ("CHROME_HEADLESS", "1")];
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
36pub enum Scope {
37 User,
39 Project,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
44pub enum Host {
45 Claude,
46 Codex,
47 Opencode,
48}
49
50impl Host {
51 const ALL: [Host; 3] = [Host::Claude, Host::Codex, Host::Opencode];
52}
53
54#[derive(Debug, Clone, Args)]
56pub struct InstallArgs {
57 #[arg(long, value_enum, default_value_t = Scope::User)]
59 pub scope: Scope,
60 #[arg(long, value_enum)]
62 pub tool: Vec<Host>,
63 #[arg(long)]
65 pub dry_run: bool,
66 #[arg(long)]
69 pub status: bool,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum Action {
74 Install,
75 Uninstall,
76 Status,
77}
78
79#[derive(Debug, Clone)]
81struct Options {
82 action: Action,
83 scope: Scope,
84 hosts: Vec<Host>,
85 dry_run: bool,
86}
87
88pub fn run(uninstall: bool, args: &InstallArgs) -> Result<()> {
91 let action = if uninstall {
92 Action::Uninstall
93 } else if args.status {
94 Action::Status
95 } else {
96 Action::Install
97 };
98 let hosts = if args.tool.is_empty() { Host::ALL.to_vec() } else { args.tool.clone() };
99 dispatch(&Options { action, scope: args.scope, hosts, dry_run: args.dry_run })
100}
101
102fn dispatch(opts: &Options) -> Result<()> {
103 let exe = env::current_exe().context("resolving the voidcrawl-mcp binary path")?;
104 let exe = exe.to_string_lossy().into_owned();
105 let mut out = io::stdout().lock();
106
107 if opts.action == Action::Status {
108 return status(&mut out, opts);
109 }
110 for &host in &opts.hosts {
111 match host {
112 Host::Claude => claude(&mut out, opts, &exe)?,
113 Host::Codex => codex(&mut out, opts, &exe)?,
114 Host::Opencode => opencode(&mut out, opts, &exe)?,
115 }
116 }
117 Ok(())
118}
119
120fn scope_str(scope: Scope) -> &'static str {
121 match scope {
122 Scope::User => "user",
123 Scope::Project => "project",
124 }
125}
126
127fn claude(out: &mut impl io::Write, opts: &Options, exe: &str) -> Result<()> {
130 let label = "Claude Code";
131 if !on_path("claude") {
132 if opts.action == Action::Uninstall {
133 writeln!(out, "[{label}] CLI not found; nothing to remove")?;
134 return Ok(());
135 }
136 let hint = ".mcp.json (project) or ~/.claude.json (user)";
137 return print_manual(out, label, &missing_lead(hint), &claude_manual_json(exe)?);
138 }
139 let argv = match opts.action {
140 Action::Install => claude_add_argv(opts.scope, exe),
141 Action::Uninstall => vec![
142 "mcp".into(),
143 "remove".into(),
144 "--scope".into(),
145 scope_str(opts.scope).into(),
146 SERVER_NAME.into(),
147 ],
148 Action::Status => return Ok(()),
149 };
150 run_cli(out, label, opts.dry_run, "claude", &argv)
151}
152
153fn claude_add_argv(scope: Scope, exe: &str) -> Vec<String> {
154 let mut argv = vec![
155 "mcp".into(),
156 "add".into(),
157 "--scope".into(),
158 scope_str(scope).into(),
159 "--transport".into(),
160 "stdio".into(),
161 ];
162 for (k, v) in SERVER_ENV {
163 argv.push("--env".into());
164 argv.push(format!("{k}={v}"));
165 }
166 argv.push(SERVER_NAME.into());
167 argv.push("--".into());
168 argv.push(exe.into());
169 argv
170}
171
172fn claude_manual_json(exe: &str) -> Result<String> {
173 let entry = server_entry_common(exe);
174 let block = wrap("mcpServers", wrap(SERVER_NAME, entry));
175 Ok(serde_json::to_string_pretty(&block)?)
176}
177
178fn server_entry_common(exe: &str) -> Value {
181 wrap_pair("command", Value::String(exe.to_owned()), "env", env_object())
182}
183
184fn codex(out: &mut impl io::Write, opts: &Options, exe: &str) -> Result<()> {
187 let label = "Codex";
188 if opts.scope == Scope::Project {
191 if opts.action == Action::Uninstall {
192 writeln!(
193 out,
194 "[{label}] remove the [mcp_servers.{SERVER_NAME}] block from ./.codex/config.toml"
195 )?;
196 } else {
197 let lead = "the Codex CLI only writes global config — add this to \
198 ./.codex/config.toml for a project-scoped server:";
199 print_manual(out, label, lead, &codex_manual_toml(exe))?;
200 }
201 return Ok(());
202 }
203 if !on_path("codex") {
204 if opts.action == Action::Uninstall {
205 writeln!(out, "[{label}] CLI not found; nothing to remove")?;
206 return Ok(());
207 }
208 return print_manual(
209 out,
210 label,
211 &missing_lead("~/.codex/config.toml"),
212 &codex_manual_toml(exe),
213 );
214 }
215 let argv = match opts.action {
216 Action::Install => codex_add_argv(exe),
217 Action::Uninstall => vec!["mcp".into(), "remove".into(), SERVER_NAME.into()],
218 Action::Status => return Ok(()),
219 };
220 run_cli(out, label, opts.dry_run, "codex", &argv)
221}
222
223fn codex_add_argv(exe: &str) -> Vec<String> {
224 let mut argv = vec!["mcp".into(), "add".into()];
225 for (k, v) in SERVER_ENV {
226 argv.push("--env".into());
227 argv.push(format!("{k}={v}"));
228 }
229 argv.push(SERVER_NAME.into());
230 argv.push("--".into());
231 argv.push(exe.into());
232 argv
233}
234
235fn codex_manual_toml(exe: &str) -> String {
236 let env_lines = SERVER_ENV.iter().fold(String::new(), |mut acc, (k, v)| {
237 let _ = writeln!(acc, "{k} = \"{v}\"");
240 acc
241 });
242 format!(
243 "[mcp_servers.{SERVER_NAME}]\ncommand = \"{exe}\"\nargs = []\n\n\
244 [mcp_servers.{SERVER_NAME}.env]\n{env_lines}"
245 )
246}
247
248fn opencode(out: &mut impl io::Write, opts: &Options, exe: &str) -> Result<()> {
251 let label = "opencode";
252 if !on_path("opencode") {
253 if opts.action == Action::Uninstall {
254 writeln!(out, "[{label}] not found; nothing to remove")?;
255 return Ok(());
256 }
257 let hint = "opencode.json (project) or ~/.config/opencode/opencode.json (user)";
258 return print_manual(out, label, &missing_lead(hint), &opencode_manual_json(exe)?);
259 }
260
261 let path = opencode_path(opts.scope)?;
262 let existing = read_opt(&path)?;
263 let value = match opts.action {
264 Action::Uninstall => {
265 let Some(text) = existing.as_deref() else {
266 writeln!(out, "[{label}] no {} to edit", path.display())?;
267 return Ok(());
268 };
269 opencode_remove(text)?
270 }
271 _ => opencode_merge(existing.as_deref(), exe)?,
272 };
273 let verb = if opts.action == Action::Uninstall { "updated" } else { "wired in" };
274 commit_json(out, opts.dry_run, label, &path, &value, existing.is_some(), verb)
275}
276
277fn opencode_path(scope: Scope) -> Result<PathBuf> {
278 match scope {
279 Scope::Project => {
280 Ok(env::current_dir().context("resolving the current directory")?.join("opencode.json"))
281 }
282 Scope::User => Ok(config_home()
283 .context("resolving XDG config dir / HOME for opencode")?
284 .join("opencode")
285 .join("opencode.json")),
286 }
287}
288
289fn opencode_merge(existing: Option<&str>, exe: &str) -> Result<Value> {
292 let mut root = match existing {
293 Some(s) if !s.trim().is_empty() => {
294 serde_json::from_str::<Value>(s).context("parsing existing opencode.json")?
295 }
296 _ => Value::Object(Map::new()),
297 };
298 let obj = root.as_object_mut().context("opencode.json root is not a JSON object")?;
299 let mcp = obj.entry("mcp").or_insert_with(|| Value::Object(Map::new()));
300 let mcp_obj = mcp.as_object_mut().context("opencode.json `mcp` is not a JSON object")?;
301 mcp_obj.insert(SERVER_NAME.to_owned(), opencode_entry(exe));
302 Ok(root)
303}
304
305fn opencode_remove(text: &str) -> Result<Value> {
306 let mut root: Value = serde_json::from_str(text).context("parsing opencode.json")?;
307 if let Some(mcp) = root.get_mut("mcp").and_then(Value::as_object_mut) {
308 mcp.remove(SERVER_NAME);
309 }
310 Ok(root)
311}
312
313fn opencode_entry(exe: &str) -> Value {
314 let mut m = Map::new();
315 m.insert("type".to_owned(), Value::String("local".to_owned()));
316 m.insert("command".to_owned(), Value::Array(vec![Value::String(exe.to_owned())]));
317 m.insert("enabled".to_owned(), Value::Bool(true));
318 m.insert("environment".to_owned(), env_object());
319 Value::Object(m)
320}
321
322fn opencode_manual_json(exe: &str) -> Result<String> {
323 let block = wrap("mcp", wrap(SERVER_NAME, opencode_entry(exe)));
324 Ok(serde_json::to_string_pretty(&block)?)
325}
326
327fn status(out: &mut impl io::Write, opts: &Options) -> Result<()> {
330 for &host in &opts.hosts {
331 match host {
332 Host::Claude => status_cli(out, "Claude Code", "claude")?,
333 Host::Codex => status_cli(out, "Codex", "codex")?,
334 Host::Opencode => status_opencode(out, opts.scope)?,
335 }
336 }
337 Ok(())
338}
339
340fn status_cli(out: &mut impl io::Write, label: &str, prog: &str) -> Result<()> {
341 if !on_path(prog) {
342 writeln!(out, "[{label}] CLI not found")?;
343 return Ok(());
344 }
345 let configured = Command::new(prog)
346 .args(["mcp", "get", SERVER_NAME])
347 .output()
348 .is_ok_and(|o| o.status.success());
349 writeln!(out, "[{label}] {}", if configured { "configured" } else { "not configured" })?;
350 Ok(())
351}
352
353fn status_opencode(out: &mut impl io::Write, scope: Scope) -> Result<()> {
354 let path = opencode_path(scope)?;
355 let configured = read_opt(&path)?
356 .as_deref()
357 .and_then(|t| serde_json::from_str::<Value>(t).ok())
358 .and_then(|v| v.get("mcp").and_then(|m| m.get(SERVER_NAME)).map(|_| true))
359 .unwrap_or(false);
360 writeln!(
361 out,
362 "[opencode] {} ({})",
363 if configured { "configured" } else { "not configured" },
364 path.display()
365 )?;
366 Ok(())
367}
368
369fn on_path(bin: &str) -> bool {
374 env::var_os("PATH")
375 .is_some_and(|paths| env::split_paths(&paths).any(|dir| dir.join(bin).is_file()))
376}
377
378fn run_cli(
379 out: &mut impl io::Write,
380 label: &str,
381 dry_run: bool,
382 prog: &str,
383 argv: &[String],
384) -> Result<()> {
385 if dry_run {
386 writeln!(out, "[{label}] would run: {prog} {}", argv.join(" "))?;
387 return Ok(());
388 }
389 let status = Command::new(prog)
390 .args(argv)
391 .status()
392 .with_context(|| format!("running `{prog}` for {label}"))?;
393 if status.success() {
394 writeln!(out, "[{label}] wired via `{prog} mcp`")?;
395 } else {
396 writeln!(out, "[{label}] `{prog} mcp` exited with {status}")?;
397 }
398 Ok(())
399}
400
401fn print_manual(out: &mut impl io::Write, label: &str, lead: &str, block: &str) -> Result<()> {
402 writeln!(out, "[{label}] {lead}\n\n{block}\n")?;
403 Ok(())
404}
405
406fn missing_lead(hint: &str) -> String {
409 format!("CLI not found on PATH — add this to {hint} once it's installed:")
410}
411
412fn commit_json(
413 out: &mut impl io::Write,
414 dry_run: bool,
415 label: &str,
416 path: &Path,
417 value: &Value,
418 had_existing: bool,
419 verb: &str,
420) -> Result<()> {
421 let text = format!("{}\n", serde_json::to_string_pretty(value)?);
422 if dry_run {
423 writeln!(out, "[{label}] would write {}:\n{text}", path.display())?;
424 return Ok(());
425 }
426 if let Some(parent) = path.parent() {
427 if !parent.as_os_str().is_empty() {
428 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
429 }
430 }
431 if had_existing {
432 let bak = PathBuf::from(format!("{}.bak", path.display()));
433 if let Err(e) = fs::copy(path, &bak) {
434 writeln!(out, "[{label}] warning: backup to {} failed: {e}", bak.display())?;
435 }
436 }
437 fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
438 writeln!(out, "[{label}] {verb} {}", path.display())?;
439 Ok(())
440}
441
442fn read_opt(path: &Path) -> Result<Option<String>> {
443 match fs::read_to_string(path) {
444 Ok(s) => Ok(Some(s)),
445 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
446 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
447 }
448}
449
450fn config_home() -> Option<PathBuf> {
453 env::var_os("XDG_CONFIG_HOME")
454 .map(PathBuf::from)
455 .filter(|p| p.is_absolute())
456 .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
457}
458
459fn env_object() -> Value {
460 let mut m = Map::new();
461 for (k, v) in SERVER_ENV {
462 m.insert((*k).to_owned(), Value::String((*v).to_owned()));
463 }
464 Value::Object(m)
465}
466
467fn wrap(key: &str, val: Value) -> Value {
468 let mut m = Map::new();
469 m.insert(key.to_owned(), val);
470 Value::Object(m)
471}
472
473fn wrap_pair(k1: &str, v1: Value, k2: &str, v2: Value) -> Value {
474 let mut m = Map::new();
475 m.insert(k1.to_owned(), v1);
476 m.insert(k2.to_owned(), v2);
477 Value::Object(m)
478}
479
480#[cfg(test)]
481mod tests {
482 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, reason = "test harness")]
483
484 use clap::Parser;
485
486 use super::*;
487
488 #[derive(Parser)]
491 struct TestCli {
492 #[command(subcommand)]
493 cmd: TestCmd,
494 }
495
496 #[derive(clap::Subcommand)]
497 enum TestCmd {
498 Install(InstallArgs),
499 Uninstall(InstallArgs),
500 }
501
502 fn args_of(argv: &[&str]) -> InstallArgs {
503 match TestCli::parse_from(argv).cmd {
504 TestCmd::Install(a) | TestCmd::Uninstall(a) => a,
505 }
506 }
507
508 #[test]
509 fn defaults_user_scope_no_tools() {
510 let a = args_of(&["voidcrawl-mcp", "install"]);
511 assert_eq!(a.scope, Scope::User);
512 assert!(a.tool.is_empty());
513 assert!(!a.dry_run);
514 assert!(!a.status);
515 }
516
517 #[test]
518 fn parses_scope_repeated_tool_and_flags() {
519 let a = args_of(&[
520 "voidcrawl-mcp",
521 "install",
522 "--scope",
523 "project",
524 "--tool",
525 "codex",
526 "--tool",
527 "opencode",
528 "--dry-run",
529 "--status",
530 ]);
531 assert_eq!(a.scope, Scope::Project);
532 assert_eq!(a.tool, vec![Host::Codex, Host::Opencode]);
533 assert!(a.dry_run);
534 assert!(a.status);
535 }
536
537 #[test]
538 fn empty_tool_resolves_to_all_hosts() {
539 let a = args_of(&["voidcrawl-mcp", "install"]);
541 let hosts = if a.tool.is_empty() { Host::ALL.to_vec() } else { a.tool.clone() };
542 assert_eq!(hosts, Host::ALL.to_vec());
543 }
544
545 #[test]
546 fn rejects_bad_enum_value() {
547 assert!(
548 TestCli::try_parse_from(["voidcrawl-mcp", "install", "--scope", "global"]).is_err()
549 );
550 assert!(TestCli::try_parse_from(["voidcrawl-mcp", "install", "--tool", "vim"]).is_err());
551 }
552
553 #[test]
554 fn opencode_merge_preserves_other_servers() {
555 let existing = r#"{ "lsp": true, "mcp": { "other": { "type": "local" } } }"#;
556 let merged = opencode_merge(Some(existing), "/abs/voidcrawl-mcp").unwrap();
557 let mcp = merged.get("mcp").unwrap().as_object().unwrap();
558 assert!(mcp.contains_key("other"));
560 assert_eq!(merged.get("lsp"), Some(&Value::Bool(true)));
562 let ours = mcp.get("voidcrawl").unwrap();
564 assert_eq!(ours.get("type").unwrap(), "local");
565 assert_eq!(ours.get("command").unwrap(), &Value::Array(vec!["/abs/voidcrawl-mcp".into()]));
566 assert_eq!(ours.get("enabled").unwrap(), &Value::Bool(true));
567 assert_eq!(ours.get("environment").unwrap().get("CHROME_HEADLESS").unwrap(), "1");
568 }
569
570 #[test]
571 fn opencode_merge_from_empty_makes_valid_root() {
572 let merged = opencode_merge(None, "/abs/voidcrawl-mcp").unwrap();
573 assert!(merged.get("mcp").unwrap().get("voidcrawl").is_some());
574 }
575
576 #[test]
577 fn opencode_remove_drops_only_ours() {
578 let existing = r#"{ "mcp": { "voidcrawl": {}, "other": {} } }"#;
579 let pruned = opencode_remove(existing).unwrap();
580 let mcp = pruned.get("mcp").unwrap().as_object().unwrap();
581 assert!(!mcp.contains_key("voidcrawl"));
582 assert!(mcp.contains_key("other"));
583 }
584
585 #[test]
586 fn manual_blocks_embed_absolute_exe_path() {
587 let exe = "/home/u/.cargo/bin/voidcrawl-mcp";
588 assert!(claude_manual_json(exe).unwrap().contains(exe));
589 assert!(opencode_manual_json(exe).unwrap().contains(exe));
590 let toml = codex_manual_toml(exe);
591 assert!(toml.contains(exe));
592 assert!(toml.contains("[mcp_servers.voidcrawl]"));
593 assert!(toml.contains("CHROME_HEADLESS = \"1\""));
594 }
595
596 #[test]
597 fn claude_add_argv_terminates_command_after_double_dash() {
598 let argv = claude_add_argv(Scope::User, "/abs/voidcrawl-mcp");
599 let dash = argv.iter().position(|a| a == "--").unwrap();
600 assert_eq!(argv.last().unwrap(), "/abs/voidcrawl-mcp");
601 assert!(argv[..dash].contains(&"--scope".to_string()));
602 assert!(argv[..dash].contains(&"user".to_string()));
603 }
604}