Skip to main content

voidcrawl_mcp/
install.rs

1//! `voidcrawl-mcp install` — wire the server into Claude Code, Codex, and
2//! opencode without hand-editing config.
3//!
4//! Hybrid strategy: delegate to a host's own `mcp add` CLI where one exists
5//! and is scriptable (Claude, Codex at user scope), write the host's config
6//! file directly where the CLI can't help (opencode's `mcp add` is
7//! interactive; Codex has no project scope), and when a host isn't installed
8//! at all, print the exact block to paste in once it is.
9//!
10//! Every wiring points at the absolute path of the running binary
11//! (`env::current_exe`), so it never depends on the host inheriting our
12//! `PATH` — the failure mode that makes hand-wired configs flaky.
13
14use 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
26/// Server key written into every host config.
27const SERVER_NAME: &str = "voidcrawl";
28
29/// Pool sizing the wired server launches with — mirrors the documented
30/// defaults. Single source of truth for both the CLI `--env` flags and the
31/// hand-edit blocks we print.
32const 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    /// Your personal config — works in every repo.
38    User,
39    /// Committed, in-repo config for this project.
40    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/// Flags shared by the `install` and `uninstall` subcommands.
55#[derive(Debug, Clone, Args)]
56pub struct InstallArgs {
57    /// Config scope to write.
58    #[arg(long, value_enum, default_value_t = Scope::User)]
59    pub scope:   Scope,
60    /// Host to target; repeat to pick several. Defaults to all three.
61    #[arg(long, value_enum)]
62    pub tool:    Vec<Host>,
63    /// Print what would change without writing anything.
64    #[arg(long)]
65    pub dry_run: bool,
66    /// Report where the server is already wired, instead of writing (install
67    /// only).
68    #[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/// Resolved request after folding the subcommand and flags together.
80#[derive(Debug, Clone)]
81struct Options {
82    action:  Action,
83    scope:   Scope,
84    hosts:   Vec<Host>,
85    dry_run: bool,
86}
87
88/// Entry point from `main`: `uninstall` picks the verb; `--status` (install
89/// only) reports state instead of writing. clap has already validated args.
90pub 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
127// ---- Claude Code -------------------------------------------------------
128
129fn 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
178/// `{ "command": <exe>, "env": { … } }` — the shape Claude Code and the
179/// hand-edit block share.
180fn server_entry_common(exe: &str) -> Value {
181    wrap_pair("command", Value::String(exe.to_owned()), "env", env_object())
182}
183
184// ---- Codex -------------------------------------------------------------
185
186fn codex(out: &mut impl io::Write, opts: &Options, exe: &str) -> Result<()> {
187    let label = "Codex";
188    // The Codex CLI only writes the global config, so a committed
189    // project-scoped server can only be done by hand.
190    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        // Writing to a String is infallible; the Result is only there to
238        // satisfy the `fmt::Write` signature.
239        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
248// ---- opencode ----------------------------------------------------------
249
250fn 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
289/// Merge the voidcrawl entry into an existing opencode config (or a fresh
290/// one), preserving every other key and MCP server.
291fn 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
327// ---- status ------------------------------------------------------------
328
329fn 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
369// ---- shared helpers ----------------------------------------------------
370
371/// Is `bin` on `PATH`? We search rather than spawn so detection has no side
372/// effects and stays fast.
373fn 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
406/// Lead line for the common case: the host's CLI isn't installed, so we hand
407/// the user the block to paste once it is.
408fn 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
450/// `$XDG_CONFIG_HOME` (if absolute) else `~/.config`. Mirrors how the core
451/// crate resolves Chrome's config root.
452fn 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    // Mirrors how `main` mounts these as subcommands, so the tests exercise
489    // clap exactly as the binary does.
490    #[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        // The run() builder fans an empty --tool out to every host.
540        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        // existing server survived
559        assert!(mcp.contains_key("other"));
560        // top-level key survived
561        assert_eq!(merged.get("lsp"), Some(&Value::Bool(true)));
562        // ours is wired with the absolute exe path and the right shape
563        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}