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