Skip to main content

cli/
install.rs

1//! `mushroomdb install` / `uninstall` — wire the /mushroom skill and MCP
2//! server into Claude Code and Cursor.
3//!
4//! # Design notes
5//!
6//! - No network: writes local config only; binary is already on disk.
7//! - Idempotent: running install twice is a no-op (exit 0).
8//! - Non-destructive: refuses to overwrite user files install didn't create.
9//! - Manifest-driven uninstall: tracks every file written; removes exactly
10//!   what install created.
11//!
12//! # User-scope MCP config location (verified 2026-09-02 by live inspection)
13//!
14//! Claude Code user-level MCP servers live in `~/.claude.json` under the
15//! top-level `"mcpServers"` key. This was verified empirically on a live
16//! Claude Code install: `~/.claude/settings.json` holds env/permissions/hooks
17//! but NO mcpServers key. Cursor uses `~/.cursor/mcp.json` (same format as
18//! project-level `.cursor/mcp.json`).
19
20use crate::CliError;
21use serde::{Deserialize, Serialize};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25// Template files embedded at compile time. Files live inside the crates/cli
26// package so `cargo package` includes them in the published tarball.
27// Path is relative to this source file (crates/cli/src/install.rs).
28const SKILL_TEMPLATE: &str = include_str!("../skills/mushroom/SKILL.md");
29const CURSOR_RULES_TEMPLATE: &str = include_str!("../skills/mushroom/cursor-rules.mdc");
30
31/// Placeholder string replaced with the real db path in embedded templates.
32const DB_PATH_PLACEHOLDER: &str = "{{DB_PATH}}";
33
34/// Placeholder string replaced with the command that invokes mushroomdb —
35/// the bare name when it is on PATH, else the absolute path of the stable copy.
36const BIN_PLACEHOLDER: &str = "{{BIN}}";
37
38/// The MCP server name we write. Must not be changed without a migration.
39const SERVER_NAME: &str = "mushroomdb";
40
41/// The binary name looked up on PATH and used as the bare MCP command.
42const BIN_NAME: &str = "mushroomdb";
43
44/// How the MCP server entry (and the skill's bootstrap commands) invoke
45/// mushroomdb.
46///
47/// The assistant host spawns the MCP server by `command`; a bare name only
48/// works if it resolves on the host's PATH. `npx mushroomdb install` and a
49/// local `target/release` build both run install from a binary that is NOT
50/// on PATH, so writing the bare name silently produces a server that never
51/// connects. In that case we copy the running executable to a stable,
52/// install-owned location and write its absolute path instead.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum BinaryLocation {
55    /// `mushroomdb` resolves on PATH: write the bare name (upgrade-safe).
56    OnPath,
57    /// Not on PATH: copy this executable to `<home>/.mushroomdb/bin/mushroomdb`
58    /// and write that absolute path.
59    CopyFrom(PathBuf),
60}
61
62/// Decide how the MCP entry should invoke mushroomdb, from the real
63/// environment: PATH lookup first, else the current executable.
64pub fn detect_binary_location() -> BinaryLocation {
65    if bin_on_path() {
66        return BinaryLocation::OnPath;
67    }
68    match std::env::current_exe() {
69        Ok(exe) => BinaryLocation::CopyFrom(exe),
70        // Cannot locate ourselves — fall back to the bare name rather than fail.
71        Err(_) => BinaryLocation::OnPath,
72    }
73}
74
75fn bin_on_path() -> bool {
76    let Some(path) = std::env::var_os("PATH") else {
77        return false;
78    };
79    std::env::split_paths(&path).any(|dir| dir.join(BIN_NAME).is_file())
80}
81
82/// Stable, install-owned location for the copied binary (user-level, so a
83/// project-scope install still yields a command that works from any cwd).
84fn stable_bin_path(home: &Path) -> PathBuf {
85    home.join(".mushroomdb").join("bin").join(BIN_NAME)
86}
87
88/// Which assistant platform(s) to wire up.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Platform {
91    ClaudeCode,
92    Cursor,
93    All,
94}
95
96impl Platform {
97    pub fn parse(s: &str) -> Result<Self, String> {
98        match s {
99            "claude-code" => Ok(Platform::ClaudeCode),
100            "cursor" => Ok(Platform::Cursor),
101            "all" => Ok(Platform::All),
102            other => Err(format!(
103                "--platform must be claude-code | cursor | all, got: {other}"
104            )),
105        }
106    }
107}
108
109/// Options parsed from `mushroomdb install [flags]` or `mushroomdb uninstall [flags]`.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct InstallOpts {
112    /// Which platform to wire up. `None` = auto-detect.
113    pub platform: Option<Platform>,
114    /// Project scope (`--project`). If false → user scope.
115    pub project: bool,
116    /// Database directory. `None` = use the scope default.
117    pub db: Option<PathBuf>,
118}
119
120impl InstallOpts {
121    pub fn default_db(&self, project_root: &Path, home: &Path) -> PathBuf {
122        if self.project {
123            project_root.join("mushroom-memory")
124        } else {
125            home.join(".mushroomdb").join("memory")
126        }
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Manifest — tracks everything install wrote so uninstall can undo it.
132// ---------------------------------------------------------------------------
133
134#[derive(Serialize, Deserialize, Default, Debug)]
135struct Manifest {
136    /// Files created by this install (absolute paths).
137    files: Vec<PathBuf>,
138    /// MCP JSON keys added by this install.
139    mcp_keys: Vec<ManagedMcpKey>,
140    /// Hook entries added to a settings.json by this install.
141    #[serde(default)]
142    hooks: Vec<ManagedHook>,
143}
144
145#[derive(Serialize, Deserialize, Debug, Clone)]
146struct ManagedMcpKey {
147    /// The JSON file the key was added to (absolute path).
148    file: PathBuf,
149    /// The key inside `mcpServers`.
150    server: String,
151}
152
153#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
154struct ManagedHook {
155    /// The settings.json file the hook was added to (absolute path).
156    file: PathBuf,
157    /// The hook event name (e.g. `UserPromptSubmit`).
158    event: String,
159    /// The exact command string that was added.
160    command: String,
161}
162
163/// Claude Code hook event this install wires: fires before each prompt is
164/// sent, so the recall digest lands as context ahead of the user's turn.
165const HOOK_EVENT: &str = "UserPromptSubmit";
166/// Kept short: the hook must never noticeably slow a prompt.
167const HOOK_TIMEOUT_SECS: u64 = 5;
168
169/// Single-quote `s` for embedding in a POSIX shell command line, escaping
170/// embedded single quotes as `'\''`. Claude Code runs a `type: "command"`
171/// hook through a shell, so an unquoted path containing whitespace or shell
172/// metacharacters is word-split and the hook silently receives the wrong
173/// arguments — quoting both interpolations keeps the command exact.
174fn sh_quote(s: &str) -> String {
175    format!("'{}'", s.replace('\'', r"'\''"))
176}
177
178/// The exact command string written into the hook entry.
179fn recall_hook_command(bin_cmd: &str, db_str: &str) -> String {
180    format!("{} recall {}", sh_quote(bin_cmd), sh_quote(db_str))
181}
182
183/// One `hooks.<event>` array entry in Claude Code's settings.json shape.
184fn hook_entry(command: &str) -> serde_json::Value {
185    serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
186}
187
188/// True if any hook group under `event` contains a command hook equal to `command`.
189fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
190    root["hooks"][event]
191        .as_array()
192        .map(|groups| {
193            groups.iter().any(|g| {
194                g["hooks"]
195                    .as_array()
196                    .map(|hs| hs.iter().any(|h| h["command"] == command))
197                    .unwrap_or(false)
198            })
199        })
200        .unwrap_or(false)
201}
202
203/// Add the recall hook to `settings_file` (created if absent). Idempotent:
204/// no-op if the command is already present under `HOOK_EVENT`. Every other
205/// key in the file — including other hook events and groups — is preserved.
206/// Errors out (no write) rather than overwriting if `hooks` or
207/// `hooks.<HOOK_EVENT>` already exists with an unexpected JSON type, or if
208/// the file's top level is not a JSON object.
209fn merge_hook_entry(
210    settings_file: &Path,
211    command: &str,
212    manifest: &mut Manifest,
213) -> Result<(), CliError> {
214    let mut root: serde_json::Value = if settings_file.exists() {
215        let raw = fs::read_to_string(settings_file)
216            .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
217        serde_json::from_str(&raw)
218            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
219    } else {
220        serde_json::json!({})
221    };
222
223    if !root.is_object() {
224        return Err(CliError(format!(
225            "{} is not a JSON object at its top level — refusing to add a hook",
226            settings_file.display()
227        )));
228    }
229
230    if settings_has_hook(&root, HOOK_EVENT, command) {
231        return Ok(());
232    }
233
234    // Validate the shapes we are about to write into before touching
235    // anything: a wrong-shaped `hooks` or `hooks.<event>` value belongs to
236    // the user (or another tool) and must never be silently overwritten.
237    match root.get("hooks") {
238        None => root["hooks"] = serde_json::json!({}),
239        Some(v) if v.is_object() => {}
240        Some(_) => {
241            return Err(CliError(format!(
242                "{}: \"hooks\" is not a JSON object — refusing to overwrite it",
243                settings_file.display()
244            )));
245        }
246    }
247    match root["hooks"].get(HOOK_EVENT) {
248        None => root["hooks"][HOOK_EVENT] = serde_json::json!([]),
249        Some(v) if v.is_array() => {}
250        Some(_) => {
251            return Err(CliError(format!(
252                "{}: \"hooks.{HOOK_EVENT}\" is not a JSON array — refusing to overwrite it",
253                settings_file.display()
254            )));
255        }
256    }
257    root["hooks"][HOOK_EVENT]
258        .as_array_mut()
259        .unwrap()
260        .push(hook_entry(command));
261
262    let parent = settings_file.parent().unwrap_or(Path::new("."));
263    fs::create_dir_all(parent)
264        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
265    let json = serde_json::to_string_pretty(&root)
266        .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
267    fs::write(settings_file, json)
268        .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
269
270    manifest.hooks.push(ManagedHook {
271        file: settings_file.to_path_buf(),
272        event: HOOK_EVENT.into(),
273        command: command.into(),
274    });
275    Ok(())
276}
277
278/// Remove exactly the hook groups whose only command is `command`; drop the
279/// command from mixed groups; leave everything else semantically unchanged
280/// (every key is re-serialized — comments are not supported since
281/// `serde_json` is strict JSON).
282///
283/// Reads `hooks.<event>` through immutable accessors first, so a settings
284/// file where the user removed the `hooks` key (or `<event>`, or shaped
285/// either as something other than an object/array) is left byte-for-byte
286/// untouched rather than having a stray `null` written back in.
287fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<(), CliError> {
288    if !settings_file.exists() {
289        return Ok(());
290    }
291    let raw = fs::read_to_string(settings_file)
292        .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
293    let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
294        CliError(format!(
295            "corrupt settings json at {}: {e}",
296            settings_file.display()
297        ))
298    })?;
299
300    let Some(mut groups) = root
301        .get("hooks")
302        .and_then(|h| h.get(event))
303        .and_then(|g| g.as_array())
304        .cloned()
305    else {
306        // No matching (or well-shaped) event array — nothing of ours to
307        // remove; leave the file exactly as it is, no write at all.
308        return Ok(());
309    };
310
311    for g in groups.iter_mut() {
312        if let Some(hs) = g["hooks"].as_array_mut() {
313            hs.retain(|h| h["command"] != command);
314        }
315    }
316    groups.retain(|g| {
317        g["hooks"]
318            .as_array()
319            .map(|hs| !hs.is_empty())
320            .unwrap_or(true)
321    });
322
323    let before = root.clone();
324    if groups.is_empty() {
325        root["hooks"].as_object_mut().unwrap().remove(event);
326    } else {
327        root["hooks"][event] = serde_json::Value::Array(groups);
328    }
329    if root == before {
330        // The event array held none of our commands, so there is nothing to
331        // remove. Writing anyway would re-serialize a file we do not own —
332        // `serde_json` is built without `preserve_order`, so the user's key
333        // order and indentation would be rewritten for no reason.
334        return Ok(());
335    }
336
337    let json = serde_json::to_string_pretty(&root)
338        .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
339    fs::write(settings_file, json)
340        .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
341    Ok(())
342}
343
344// ---------------------------------------------------------------------------
345// Public entry points
346// ---------------------------------------------------------------------------
347
348/// Install the /mushroom skill and MCP server entry for the resolved platforms.
349///
350/// `project_root` is the directory where project-scope config files live
351/// (`.mcp.json`, `.claude/`, `.cursor/`). `home` is the user HOME directory.
352/// Tests pass temp directories for both; main.rs passes real values.
353pub fn run_install(
354    project_root: &Path,
355    home: &Path,
356    opts: &InstallOpts,
357) -> Result<String, CliError> {
358    run_install_with(project_root, home, opts, &detect_binary_location())
359}
360
361/// Like [`run_install`], but with the binary location supplied by the caller
362/// instead of detected from PATH / `current_exe`. Tests use this to stay
363/// deterministic; `run_install` is the real-environment wrapper.
364pub fn run_install_with(
365    project_root: &Path,
366    home: &Path,
367    opts: &InstallOpts,
368    bin: &BinaryLocation,
369) -> Result<String, CliError> {
370    let db = opts
371        .db
372        .clone()
373        .unwrap_or_else(|| opts.default_db(project_root, home));
374    let db_str = db.to_string_lossy();
375
376    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
377    let platforms = expand_platform(&resolved);
378
379    // Check for any conflicts before writing anything (atomic from user's POV).
380    for plat in &platforms {
381        preflight_check(project_root, home, plat, opts.project, &db_str)?;
382    }
383
384    let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
385
386    // Load the existing manifest so we can union it with what this run writes.
387    // This covers partial-drift re-installs: if SKILL.md was edited but the MCP
388    // entry is still intact, only the file is re-written this run; unioning
389    // preserves the MCP key in the saved manifest so uninstall cleans it up too.
390    let existing = load_manifest(&manifest_path);
391
392    let mut manifest = Manifest::default();
393
394    // Resolve the command the MCP entry and skill templates will use. For the
395    // off-PATH case this copies the binary first so the path it names exists.
396    let bin_cmd = match bin {
397        BinaryLocation::OnPath => BIN_NAME.to_string(),
398        BinaryLocation::CopyFrom(src) => {
399            let dest = stable_bin_path(home);
400            copy_binary(src, &dest, &mut manifest)?;
401            dest.to_string_lossy().into_owned()
402        }
403    };
404
405    for plat in &platforms {
406        let step = install_platform(
407            project_root,
408            home,
409            plat,
410            opts.project,
411            &db_str,
412            &bin_cmd,
413            &mut manifest,
414        );
415        if let Err(e) = step {
416            // Persist whatever was already written (binary copy, earlier
417            // platform's files) so uninstall can still clean up after a
418            // partial failure. Best effort: the original error wins.
419            let anything_written = !manifest.files.is_empty()
420                || !manifest.mcp_keys.is_empty()
421                || !manifest.hooks.is_empty();
422            if anything_written {
423                let merged = union_manifests(load_manifest(&manifest_path), &manifest);
424                let _ = write_manifest(&manifest_path, &merged);
425            }
426            return Err(e);
427        }
428    }
429
430    let anything_written =
431        !manifest.files.is_empty() || !manifest.mcp_keys.is_empty() || !manifest.hooks.is_empty();
432
433    if anything_written {
434        // Union this-run entries with the existing manifest (dedup by path/key).
435        let merged = union_manifests(existing, &manifest);
436        write_manifest(&manifest_path, &merged)?;
437    }
438
439    let mut out = format!("mushroomdb installed ({} platform(s))\n", platforms.len());
440    for f in &manifest.files {
441        out.push_str(&format!("  wrote  {}\n", f.display()));
442    }
443    for k in &manifest.mcp_keys {
444        out.push_str(&format!(
445            "  added  mcpServers.{} in {}\n",
446            k.server,
447            k.file.display()
448        ));
449    }
450    for h in &manifest.hooks {
451        out.push_str(&format!(
452            "  added  {} hook in {}\n",
453            h.event,
454            h.file.display()
455        ));
456    }
457    if anything_written {
458        out.push_str(&format!("  manifest  {}\n", manifest_path.display()));
459        out.push_str(&format!(
460            "  mcp command  {bin_cmd}\n  restart your assistant to connect the MCP server\n"
461        ));
462    } else {
463        out.push_str("  (already installed — no changes)\n");
464    }
465    Ok(out)
466}
467
468/// Copy the running binary to its stable location. No-op if the bytes at
469/// `dest` already match `src` (idempotent re-install); overwrites when they
470/// differ (upgrade). Records `dest` in the manifest so uninstall removes it.
471fn copy_binary(src: &Path, dest: &Path, manifest: &mut Manifest) -> Result<(), CliError> {
472    let bytes = fs::read(src)
473        .map_err(|e| CliError(format!("cannot read binary {}: {e}", src.display())))?;
474    if fs::read(dest).map(|cur| cur == bytes).unwrap_or(false) {
475        return Ok(());
476    }
477    let parent = dest.parent().unwrap_or(Path::new("."));
478    fs::create_dir_all(parent)
479        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
480    // Write to a temp name and rename so a running MCP server holding the old
481    // inode keeps working and the swap is atomic.
482    let tmp = parent.join(format!(".{BIN_NAME}.tmp-{}", std::process::id()));
483    fs::write(&tmp, &bytes)
484        .map_err(|e| CliError(format!("cannot write {}: {e}", tmp.display())))?;
485    let finish = || -> Result<(), CliError> {
486        #[cfg(unix)]
487        {
488            use std::os::unix::fs::PermissionsExt;
489            fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))
490                .map_err(|e| CliError(format!("cannot chmod {}: {e}", tmp.display())))?;
491        }
492        fs::rename(&tmp, dest)
493            .map_err(|e| CliError(format!("cannot move binary into {}: {e}", dest.display())))
494    };
495    if let Err(e) = finish() {
496        let _ = fs::remove_file(&tmp); // never leave an untracked temp file behind
497        return Err(e);
498    }
499    manifest.files.push(dest.to_path_buf());
500    Ok(())
501}
502
503/// Uninstall: remove exactly what install wrote. Reads the manifest.
504pub fn run_uninstall(
505    project_root: &Path,
506    home: &Path,
507    opts: &InstallOpts,
508) -> Result<String, CliError> {
509    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
510    let platforms = expand_platform(&resolved);
511
512    let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
513    if !manifest_path.exists() {
514        return Err(CliError(format!(
515            "no install manifest found at {} — nothing to uninstall",
516            manifest_path.display()
517        )));
518    }
519
520    let raw = fs::read_to_string(&manifest_path)
521        .map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
522    let manifest: Manifest =
523        serde_json::from_str(&raw).map_err(|e| CliError(format!("corrupt manifest: {e}")))?;
524
525    let mut removed = Vec::new();
526
527    // Remove MCP keys first (before files, in case files include .mcp.json).
528    for key in &manifest.mcp_keys {
529        if key.file.exists() {
530            remove_mcp_key(&key.file, &key.server)?;
531            removed.push(format!(
532                "removed  mcpServers.{} from {}",
533                key.server,
534                key.file.display()
535            ));
536        }
537    }
538
539    // Remove hooks (before files, same reasoning as MCP keys).
540    for h in &manifest.hooks {
541        if h.file.exists() {
542            remove_hook_entry(&h.file, &h.event, &h.command)?;
543            removed.push(format!(
544                "removed  {} hook from {}",
545                h.event,
546                h.file.display()
547            ));
548        }
549    }
550
551    // Remove files.
552    for f in &manifest.files {
553        if f.exists() {
554            fs::remove_file(f)
555                .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
556            removed.push(format!("removed  {}", f.display()));
557        }
558    }
559
560    // Remove the manifest itself.
561    if manifest_path.exists() {
562        fs::remove_file(&manifest_path)
563            .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
564    }
565
566    let mut out = "mushroomdb uninstalled\n".to_string();
567    for line in &removed {
568        out.push_str(&format!("  {line}\n"));
569    }
570    Ok(out)
571}
572
573// ---------------------------------------------------------------------------
574// Platform resolution
575// ---------------------------------------------------------------------------
576
577fn resolve_platform(
578    project_root: &Path,
579    home: &Path,
580    requested: Option<&Platform>,
581) -> Result<Platform, CliError> {
582    if let Some(p) = requested {
583        return Ok(p.clone());
584    }
585
586    // Auto-detect.
587    let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
588    let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
589
590    match (has_claude, has_cursor) {
591        (true, true) => Ok(Platform::All),
592        (true, false) => Ok(Platform::ClaudeCode),
593        (false, true) => Ok(Platform::Cursor),
594        (false, false) => Err(CliError(
595            "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
596             Pass --platform claude-code, --platform cursor, or --platform all."
597                .to_string(),
598        )),
599    }
600}
601
602fn expand_platform(p: &Platform) -> Vec<Platform> {
603    match p {
604        Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
605        Platform::ClaudeCode => vec![Platform::ClaudeCode],
606        Platform::Cursor => vec![Platform::Cursor],
607    }
608}
609
610// ---------------------------------------------------------------------------
611// Pre-flight conflict check (no writes)
612// ---------------------------------------------------------------------------
613
614fn preflight_check(
615    project_root: &Path,
616    home: &Path,
617    platform: &Platform,
618    project_scope: bool,
619    db_str: &str,
620) -> Result<(), CliError> {
621    match platform {
622        Platform::ClaudeCode => {
623            let mcp_file = if project_scope {
624                project_root.join(".mcp.json")
625            } else {
626                // User-scope: verified empirically on a live Claude Code install.
627                // ~/.claude.json holds top-level mcpServers; ~/.claude/settings.json
628                // holds env/permissions/hooks but no mcpServers key.
629                home.join(".claude.json")
630            };
631            check_mcp_conflict(&mcp_file, db_str)?;
632        }
633        Platform::Cursor => {
634            let mcp_file = if project_scope {
635                project_root.join(".cursor").join("mcp.json")
636            } else {
637                home.join(".cursor").join("mcp.json")
638            };
639            check_mcp_conflict(&mcp_file, db_str)?;
640        }
641        Platform::All => unreachable!("expand_platform never produces All"),
642    }
643    Ok(())
644}
645
646/// Check if a MCP JSON file has a conflicting `mushroomdb` entry.
647///
648/// A conflict is: the file exists, has `mcpServers.mushroomdb`, and its
649/// `args[1]` (the db path) differs from what we'd write. An entry for the
650/// SAME db with a different `command` is ours to repair (e.g. a bare name
651/// that never resolved, or a stale absolute path after an upgrade), so it is
652/// not a conflict.
653fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
654    if !mcp_file.exists() {
655        return Ok(());
656    }
657    let raw = fs::read_to_string(mcp_file)
658        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
659    let v: serde_json::Value = serde_json::from_str(&raw)
660        .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
661
662    let existing = &v["mcpServers"][SERVER_NAME];
663    if existing.is_null() {
664        return Ok(()); // Key absent — no conflict.
665    }
666
667    let existing_db = existing["args"]
668        .get(1)
669        .and_then(|v| v.as_str())
670        .unwrap_or("");
671
672    if existing_db == db_str {
673        return Ok(()); // Same db — idempotent or repairable, no conflict.
674    }
675
676    Err(CliError(format!(
677        "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
678         To update it, run `mushroomdb uninstall` first, then re-install.\n\
679         Or manually edit {} and remove the existing mushroomdb entry.",
680        mcp_file.display(),
681        existing_db,
682        mcp_file.display()
683    )))
684}
685
686// ---------------------------------------------------------------------------
687// Per-platform installation
688// ---------------------------------------------------------------------------
689
690fn install_platform(
691    project_root: &Path,
692    home: &Path,
693    platform: &Platform,
694    project_scope: bool,
695    db_str: &str,
696    bin_cmd: &str,
697    manifest: &mut Manifest,
698) -> Result<(), CliError> {
699    match platform {
700        Platform::ClaudeCode => {
701            install_claude_code(project_root, home, project_scope, db_str, bin_cmd, manifest)
702        }
703        Platform::Cursor => {
704            install_cursor(project_root, home, project_scope, db_str, bin_cmd, manifest)
705        }
706        Platform::All => unreachable!("expand_platform never produces All"),
707    }
708}
709
710/// Substitute both template placeholders.
711fn render_template(template: &str, db_str: &str, bin_cmd: &str) -> String {
712    template
713        .replace(DB_PATH_PLACEHOLDER, db_str)
714        .replace(BIN_PLACEHOLDER, bin_cmd)
715}
716
717fn install_claude_code(
718    project_root: &Path,
719    home: &Path,
720    project_scope: bool,
721    db_str: &str,
722    bin_cmd: &str,
723    manifest: &mut Manifest,
724) -> Result<(), CliError> {
725    let skill_content = render_template(SKILL_TEMPLATE, db_str, bin_cmd);
726
727    let skill_dir = if project_scope {
728        project_root.join(".claude").join("skills").join("mushroom")
729    } else {
730        home.join(".claude").join("skills").join("mushroom")
731    };
732    let skill_file = skill_dir.join("SKILL.md");
733
734    // Idempotent: skip if the file already has the same content.
735    if !file_matches(&skill_file, &skill_content) {
736        fs::create_dir_all(&skill_dir)
737            .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
738        fs::write(&skill_file, &skill_content)
739            .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
740        manifest.files.push(skill_file);
741    }
742
743    // MCP JSON. User-scope writes to ~/.claude.json (top-level mcpServers),
744    // not ~/.claude/settings.json (which holds env/hooks, not mcpServers).
745    let mcp_file = if project_scope {
746        project_root.join(".mcp.json")
747    } else {
748        home.join(".claude.json")
749    };
750    merge_mcp_entry(&mcp_file, db_str, bin_cmd, manifest)?;
751
752    // Recall hook: settings.json in the same scope as the skill.
753    let settings_file = if project_scope {
754        project_root.join(".claude").join("settings.json")
755    } else {
756        home.join(".claude").join("settings.json")
757    };
758    merge_hook_entry(
759        &settings_file,
760        &recall_hook_command(bin_cmd, db_str),
761        manifest,
762    )?;
763
764    Ok(())
765}
766
767fn install_cursor(
768    project_root: &Path,
769    home: &Path,
770    project_scope: bool,
771    db_str: &str,
772    bin_cmd: &str,
773    manifest: &mut Manifest,
774) -> Result<(), CliError> {
775    let rules_content = render_template(CURSOR_RULES_TEMPLATE, db_str, bin_cmd);
776
777    let rules_dir = if project_scope {
778        project_root.join(".cursor").join("rules")
779    } else {
780        home.join(".cursor").join("rules")
781    };
782    let rules_file = rules_dir.join("mushroom.mdc");
783
784    if !file_matches(&rules_file, &rules_content) {
785        fs::create_dir_all(&rules_dir)
786            .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
787        fs::write(&rules_file, &rules_content)
788            .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
789        manifest.files.push(rules_file);
790    }
791
792    // Cursor MCP JSON.
793    let mcp_file = if project_scope {
794        project_root.join(".cursor").join("mcp.json")
795    } else {
796        home.join(".cursor").join("mcp.json")
797    };
798    merge_mcp_entry(&mcp_file, db_str, bin_cmd, manifest)?;
799
800    Ok(())
801}
802
803// ---------------------------------------------------------------------------
804// MCP JSON merge helpers
805// ---------------------------------------------------------------------------
806
807/// Add `mcpServers.mushroomdb` to a JSON config file. Creates the file if
808/// absent. No-op if the entry already matches (idempotent).
809fn merge_mcp_entry(
810    mcp_file: &Path,
811    db_str: &str,
812    bin_cmd: &str,
813    manifest: &mut Manifest,
814) -> Result<(), CliError> {
815    let mut root: serde_json::Value = if mcp_file.exists() {
816        let raw = fs::read_to_string(mcp_file)
817            .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
818        serde_json::from_str(&raw)
819            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
820    } else {
821        serde_json::json!({})
822    };
823
824    // Ensure `mcpServers` object exists.
825    if !root["mcpServers"].is_object() {
826        root["mcpServers"] = serde_json::json!({});
827    }
828
829    let desired = mcp_server_entry(db_str, bin_cmd);
830    let existing = &root["mcpServers"][SERVER_NAME];
831
832    if existing == &desired {
833        return Ok(()); // Exact match — idempotent.
834    }
835
836    // Write the entry.
837    root["mcpServers"][SERVER_NAME] = desired;
838
839    let parent = mcp_file.parent().unwrap_or(Path::new("."));
840    fs::create_dir_all(parent)
841        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
842
843    let json = serde_json::to_string_pretty(&root)
844        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
845    fs::write(mcp_file, json)
846        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
847
848    manifest.mcp_keys.push(ManagedMcpKey {
849        file: mcp_file.to_path_buf(),
850        server: SERVER_NAME.to_string(),
851    });
852
853    Ok(())
854}
855
856/// Remove `mcpServers.<server>` from a JSON config file. Leaves the file in
857/// place (with the key removed) unless `mcpServers` becomes empty, in which
858/// case we still leave the file (the user may have other keys).
859fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<(), CliError> {
860    if !mcp_file.exists() {
861        return Ok(());
862    }
863    let raw = fs::read_to_string(mcp_file)
864        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
865    let mut root: serde_json::Value = serde_json::from_str(&raw)
866        .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
867
868    if let Some(servers) = root["mcpServers"].as_object_mut() {
869        servers.remove(server);
870    }
871
872    let json = serde_json::to_string_pretty(&root)
873        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
874    fs::write(mcp_file, json)
875        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
876    Ok(())
877}
878
879fn mcp_server_entry(db_str: &str, bin_cmd: &str) -> serde_json::Value {
880    serde_json::json!({
881        "command": bin_cmd,
882        "args": ["mcp", db_str]
883    })
884}
885
886// ---------------------------------------------------------------------------
887// Manifest helpers
888// ---------------------------------------------------------------------------
889
890fn manifest_path(
891    project_root: &Path,
892    home: &Path,
893    project_scope: bool,
894    platforms: &[Platform],
895) -> PathBuf {
896    if !project_scope {
897        return home.join(".mushroomdb").join("install-manifest.json");
898    }
899    // Project scope: prefer the Claude Code location; fall back to Cursor.
900    if platforms.contains(&Platform::ClaudeCode) {
901        project_root
902            .join(".claude")
903            .join("skills")
904            .join("mushroom")
905            .join(".install-manifest.json")
906    } else {
907        project_root.join(".cursor").join(".install-manifest.json")
908    }
909}
910
911/// Load an existing manifest from `path`. Returns an empty manifest if absent or unparseable.
912fn load_manifest(path: &Path) -> Manifest {
913    let raw = match fs::read_to_string(path) {
914        Ok(s) => s,
915        Err(_) => return Manifest::default(),
916    };
917    serde_json::from_str(&raw).unwrap_or_default()
918}
919
920/// Union `existing` with `this_run`, deduplicating by path (files), by
921/// (file, server) pair (mcp_keys), and by full equality (hooks). Entries from
922/// `this_run` win on collision so the manifest always reflects the latest
923/// state.
924fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
925    for f in &this_run.files {
926        if !existing.files.contains(f) {
927            existing.files.push(f.clone());
928        }
929    }
930    for k in &this_run.mcp_keys {
931        let already = existing
932            .mcp_keys
933            .iter()
934            .any(|e| e.file == k.file && e.server == k.server);
935        if !already {
936            existing.mcp_keys.push(k.clone());
937        }
938    }
939    for h in &this_run.hooks {
940        if !existing.hooks.contains(h) {
941            existing.hooks.push(h.clone());
942        }
943    }
944    existing
945}
946
947fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
948    let parent = path.parent().unwrap_or(Path::new("."));
949    fs::create_dir_all(parent).map_err(|e| {
950        CliError(format!(
951            "cannot create manifest dir {}: {e}",
952            parent.display()
953        ))
954    })?;
955    let json = serde_json::to_string_pretty(manifest)
956        .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
957    fs::write(path, json)
958        .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
959    Ok(())
960}
961
962// ---------------------------------------------------------------------------
963// Utilities
964// ---------------------------------------------------------------------------
965
966/// True if the file exists and its content equals `expected`.
967fn file_matches(path: &Path, expected: &str) -> bool {
968    fs::read_to_string(path)
969        .map(|s| s == expected)
970        .unwrap_or(false)
971}