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}
141
142#[derive(Serialize, Deserialize, Debug, Clone)]
143struct ManagedMcpKey {
144    /// The JSON file the key was added to (absolute path).
145    file: PathBuf,
146    /// The key inside `mcpServers`.
147    server: String,
148}
149
150// ---------------------------------------------------------------------------
151// Public entry points
152// ---------------------------------------------------------------------------
153
154/// Install the /mushroom skill and MCP server entry for the resolved platforms.
155///
156/// `project_root` is the directory where project-scope config files live
157/// (`.mcp.json`, `.claude/`, `.cursor/`). `home` is the user HOME directory.
158/// Tests pass temp directories for both; main.rs passes real values.
159pub fn run_install(
160    project_root: &Path,
161    home: &Path,
162    opts: &InstallOpts,
163) -> Result<String, CliError> {
164    run_install_with(project_root, home, opts, &detect_binary_location())
165}
166
167/// Like [`run_install`], but with the binary location supplied by the caller
168/// instead of detected from PATH / `current_exe`. Tests use this to stay
169/// deterministic; `run_install` is the real-environment wrapper.
170pub fn run_install_with(
171    project_root: &Path,
172    home: &Path,
173    opts: &InstallOpts,
174    bin: &BinaryLocation,
175) -> Result<String, CliError> {
176    let db = opts
177        .db
178        .clone()
179        .unwrap_or_else(|| opts.default_db(project_root, home));
180    let db_str = db.to_string_lossy();
181
182    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
183    let platforms = expand_platform(&resolved);
184
185    // Check for any conflicts before writing anything (atomic from user's POV).
186    for plat in &platforms {
187        preflight_check(project_root, home, plat, opts.project, &db_str)?;
188    }
189
190    let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
191
192    // Load the existing manifest so we can union it with what this run writes.
193    // This covers partial-drift re-installs: if SKILL.md was edited but the MCP
194    // entry is still intact, only the file is re-written this run; unioning
195    // preserves the MCP key in the saved manifest so uninstall cleans it up too.
196    let existing = load_manifest(&manifest_path);
197
198    let mut manifest = Manifest::default();
199
200    // Resolve the command the MCP entry and skill templates will use. For the
201    // off-PATH case this copies the binary first so the path it names exists.
202    let bin_cmd = match bin {
203        BinaryLocation::OnPath => BIN_NAME.to_string(),
204        BinaryLocation::CopyFrom(src) => {
205            let dest = stable_bin_path(home);
206            copy_binary(src, &dest, &mut manifest)?;
207            dest.to_string_lossy().into_owned()
208        }
209    };
210
211    for plat in &platforms {
212        let step = install_platform(
213            project_root,
214            home,
215            plat,
216            opts.project,
217            &db_str,
218            &bin_cmd,
219            &mut manifest,
220        );
221        if let Err(e) = step {
222            // Persist whatever was already written (binary copy, earlier
223            // platform's files) so uninstall can still clean up after a
224            // partial failure. Best effort: the original error wins.
225            let anything_written = !manifest.files.is_empty() || !manifest.mcp_keys.is_empty();
226            if anything_written {
227                let merged = union_manifests(load_manifest(&manifest_path), &manifest);
228                let _ = write_manifest(&manifest_path, &merged);
229            }
230            return Err(e);
231        }
232    }
233
234    let anything_written = !manifest.files.is_empty() || !manifest.mcp_keys.is_empty();
235
236    if anything_written {
237        // Union this-run entries with the existing manifest (dedup by path/key).
238        let merged = union_manifests(existing, &manifest);
239        write_manifest(&manifest_path, &merged)?;
240    }
241
242    let mut out = format!("mushroomdb installed ({} platform(s))\n", platforms.len());
243    for f in &manifest.files {
244        out.push_str(&format!("  wrote  {}\n", f.display()));
245    }
246    for k in &manifest.mcp_keys {
247        out.push_str(&format!(
248            "  added  mcpServers.{} in {}\n",
249            k.server,
250            k.file.display()
251        ));
252    }
253    if anything_written {
254        out.push_str(&format!("  manifest  {}\n", manifest_path.display()));
255        out.push_str(&format!(
256            "  mcp command  {bin_cmd}\n  restart your assistant to connect the MCP server\n"
257        ));
258    } else {
259        out.push_str("  (already installed — no changes)\n");
260    }
261    Ok(out)
262}
263
264/// Copy the running binary to its stable location. No-op if the bytes at
265/// `dest` already match `src` (idempotent re-install); overwrites when they
266/// differ (upgrade). Records `dest` in the manifest so uninstall removes it.
267fn copy_binary(src: &Path, dest: &Path, manifest: &mut Manifest) -> Result<(), CliError> {
268    let bytes = fs::read(src)
269        .map_err(|e| CliError(format!("cannot read binary {}: {e}", src.display())))?;
270    if fs::read(dest).map(|cur| cur == bytes).unwrap_or(false) {
271        return Ok(());
272    }
273    let parent = dest.parent().unwrap_or(Path::new("."));
274    fs::create_dir_all(parent)
275        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
276    // Write to a temp name and rename so a running MCP server holding the old
277    // inode keeps working and the swap is atomic.
278    let tmp = parent.join(format!(".{BIN_NAME}.tmp-{}", std::process::id()));
279    fs::write(&tmp, &bytes)
280        .map_err(|e| CliError(format!("cannot write {}: {e}", tmp.display())))?;
281    let finish = || -> Result<(), CliError> {
282        #[cfg(unix)]
283        {
284            use std::os::unix::fs::PermissionsExt;
285            fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))
286                .map_err(|e| CliError(format!("cannot chmod {}: {e}", tmp.display())))?;
287        }
288        fs::rename(&tmp, dest)
289            .map_err(|e| CliError(format!("cannot move binary into {}: {e}", dest.display())))
290    };
291    if let Err(e) = finish() {
292        let _ = fs::remove_file(&tmp); // never leave an untracked temp file behind
293        return Err(e);
294    }
295    manifest.files.push(dest.to_path_buf());
296    Ok(())
297}
298
299/// Uninstall: remove exactly what install wrote. Reads the manifest.
300pub fn run_uninstall(
301    project_root: &Path,
302    home: &Path,
303    opts: &InstallOpts,
304) -> Result<String, CliError> {
305    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
306    let platforms = expand_platform(&resolved);
307
308    let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
309    if !manifest_path.exists() {
310        return Err(CliError(format!(
311            "no install manifest found at {} — nothing to uninstall",
312            manifest_path.display()
313        )));
314    }
315
316    let raw = fs::read_to_string(&manifest_path)
317        .map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
318    let manifest: Manifest =
319        serde_json::from_str(&raw).map_err(|e| CliError(format!("corrupt manifest: {e}")))?;
320
321    let mut removed = Vec::new();
322
323    // Remove MCP keys first (before files, in case files include .mcp.json).
324    for key in &manifest.mcp_keys {
325        if key.file.exists() {
326            remove_mcp_key(&key.file, &key.server)?;
327            removed.push(format!(
328                "removed  mcpServers.{} from {}",
329                key.server,
330                key.file.display()
331            ));
332        }
333    }
334
335    // Remove files.
336    for f in &manifest.files {
337        if f.exists() {
338            fs::remove_file(f)
339                .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
340            removed.push(format!("removed  {}", f.display()));
341        }
342    }
343
344    // Remove the manifest itself.
345    if manifest_path.exists() {
346        fs::remove_file(&manifest_path)
347            .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
348    }
349
350    let mut out = "mushroomdb uninstalled\n".to_string();
351    for line in &removed {
352        out.push_str(&format!("  {line}\n"));
353    }
354    Ok(out)
355}
356
357// ---------------------------------------------------------------------------
358// Platform resolution
359// ---------------------------------------------------------------------------
360
361fn resolve_platform(
362    project_root: &Path,
363    home: &Path,
364    requested: Option<&Platform>,
365) -> Result<Platform, CliError> {
366    if let Some(p) = requested {
367        return Ok(p.clone());
368    }
369
370    // Auto-detect.
371    let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
372    let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
373
374    match (has_claude, has_cursor) {
375        (true, true) => Ok(Platform::All),
376        (true, false) => Ok(Platform::ClaudeCode),
377        (false, true) => Ok(Platform::Cursor),
378        (false, false) => Err(CliError(
379            "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
380             Pass --platform claude-code, --platform cursor, or --platform all."
381                .to_string(),
382        )),
383    }
384}
385
386fn expand_platform(p: &Platform) -> Vec<Platform> {
387    match p {
388        Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
389        Platform::ClaudeCode => vec![Platform::ClaudeCode],
390        Platform::Cursor => vec![Platform::Cursor],
391    }
392}
393
394// ---------------------------------------------------------------------------
395// Pre-flight conflict check (no writes)
396// ---------------------------------------------------------------------------
397
398fn preflight_check(
399    project_root: &Path,
400    home: &Path,
401    platform: &Platform,
402    project_scope: bool,
403    db_str: &str,
404) -> Result<(), CliError> {
405    match platform {
406        Platform::ClaudeCode => {
407            let mcp_file = if project_scope {
408                project_root.join(".mcp.json")
409            } else {
410                // User-scope: verified empirically on a live Claude Code install.
411                // ~/.claude.json holds top-level mcpServers; ~/.claude/settings.json
412                // holds env/permissions/hooks but no mcpServers key.
413                home.join(".claude.json")
414            };
415            check_mcp_conflict(&mcp_file, db_str)?;
416        }
417        Platform::Cursor => {
418            let mcp_file = if project_scope {
419                project_root.join(".cursor").join("mcp.json")
420            } else {
421                home.join(".cursor").join("mcp.json")
422            };
423            check_mcp_conflict(&mcp_file, db_str)?;
424        }
425        Platform::All => unreachable!("expand_platform never produces All"),
426    }
427    Ok(())
428}
429
430/// Check if a MCP JSON file has a conflicting `mushroomdb` entry.
431///
432/// A conflict is: the file exists, has `mcpServers.mushroomdb`, and its
433/// `args[1]` (the db path) differs from what we'd write. An entry for the
434/// SAME db with a different `command` is ours to repair (e.g. a bare name
435/// that never resolved, or a stale absolute path after an upgrade), so it is
436/// not a conflict.
437fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
438    if !mcp_file.exists() {
439        return Ok(());
440    }
441    let raw = fs::read_to_string(mcp_file)
442        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
443    let v: serde_json::Value = serde_json::from_str(&raw)
444        .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
445
446    let existing = &v["mcpServers"][SERVER_NAME];
447    if existing.is_null() {
448        return Ok(()); // Key absent — no conflict.
449    }
450
451    let existing_db = existing["args"]
452        .get(1)
453        .and_then(|v| v.as_str())
454        .unwrap_or("");
455
456    if existing_db == db_str {
457        return Ok(()); // Same db — idempotent or repairable, no conflict.
458    }
459
460    Err(CliError(format!(
461        "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
462         To update it, run `mushroomdb uninstall` first, then re-install.\n\
463         Or manually edit {} and remove the existing mushroomdb entry.",
464        mcp_file.display(),
465        existing_db,
466        mcp_file.display()
467    )))
468}
469
470// ---------------------------------------------------------------------------
471// Per-platform installation
472// ---------------------------------------------------------------------------
473
474fn install_platform(
475    project_root: &Path,
476    home: &Path,
477    platform: &Platform,
478    project_scope: bool,
479    db_str: &str,
480    bin_cmd: &str,
481    manifest: &mut Manifest,
482) -> Result<(), CliError> {
483    match platform {
484        Platform::ClaudeCode => {
485            install_claude_code(project_root, home, project_scope, db_str, bin_cmd, manifest)
486        }
487        Platform::Cursor => {
488            install_cursor(project_root, home, project_scope, db_str, bin_cmd, manifest)
489        }
490        Platform::All => unreachable!("expand_platform never produces All"),
491    }
492}
493
494/// Substitute both template placeholders.
495fn render_template(template: &str, db_str: &str, bin_cmd: &str) -> String {
496    template
497        .replace(DB_PATH_PLACEHOLDER, db_str)
498        .replace(BIN_PLACEHOLDER, bin_cmd)
499}
500
501fn install_claude_code(
502    project_root: &Path,
503    home: &Path,
504    project_scope: bool,
505    db_str: &str,
506    bin_cmd: &str,
507    manifest: &mut Manifest,
508) -> Result<(), CliError> {
509    let skill_content = render_template(SKILL_TEMPLATE, db_str, bin_cmd);
510
511    let skill_dir = if project_scope {
512        project_root.join(".claude").join("skills").join("mushroom")
513    } else {
514        home.join(".claude").join("skills").join("mushroom")
515    };
516    let skill_file = skill_dir.join("SKILL.md");
517
518    // Idempotent: skip if the file already has the same content.
519    if !file_matches(&skill_file, &skill_content) {
520        fs::create_dir_all(&skill_dir)
521            .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
522        fs::write(&skill_file, &skill_content)
523            .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
524        manifest.files.push(skill_file);
525    }
526
527    // MCP JSON. User-scope writes to ~/.claude.json (top-level mcpServers),
528    // not ~/.claude/settings.json (which holds env/hooks, not mcpServers).
529    let mcp_file = if project_scope {
530        project_root.join(".mcp.json")
531    } else {
532        home.join(".claude.json")
533    };
534    merge_mcp_entry(&mcp_file, db_str, bin_cmd, manifest)?;
535
536    Ok(())
537}
538
539fn install_cursor(
540    project_root: &Path,
541    home: &Path,
542    project_scope: bool,
543    db_str: &str,
544    bin_cmd: &str,
545    manifest: &mut Manifest,
546) -> Result<(), CliError> {
547    let rules_content = render_template(CURSOR_RULES_TEMPLATE, db_str, bin_cmd);
548
549    let rules_dir = if project_scope {
550        project_root.join(".cursor").join("rules")
551    } else {
552        home.join(".cursor").join("rules")
553    };
554    let rules_file = rules_dir.join("mushroom.mdc");
555
556    if !file_matches(&rules_file, &rules_content) {
557        fs::create_dir_all(&rules_dir)
558            .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
559        fs::write(&rules_file, &rules_content)
560            .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
561        manifest.files.push(rules_file);
562    }
563
564    // Cursor MCP JSON.
565    let mcp_file = if project_scope {
566        project_root.join(".cursor").join("mcp.json")
567    } else {
568        home.join(".cursor").join("mcp.json")
569    };
570    merge_mcp_entry(&mcp_file, db_str, bin_cmd, manifest)?;
571
572    Ok(())
573}
574
575// ---------------------------------------------------------------------------
576// MCP JSON merge helpers
577// ---------------------------------------------------------------------------
578
579/// Add `mcpServers.mushroomdb` to a JSON config file. Creates the file if
580/// absent. No-op if the entry already matches (idempotent).
581fn merge_mcp_entry(
582    mcp_file: &Path,
583    db_str: &str,
584    bin_cmd: &str,
585    manifest: &mut Manifest,
586) -> Result<(), CliError> {
587    let mut root: serde_json::Value = if mcp_file.exists() {
588        let raw = fs::read_to_string(mcp_file)
589            .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
590        serde_json::from_str(&raw)
591            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
592    } else {
593        serde_json::json!({})
594    };
595
596    // Ensure `mcpServers` object exists.
597    if !root["mcpServers"].is_object() {
598        root["mcpServers"] = serde_json::json!({});
599    }
600
601    let desired = mcp_server_entry(db_str, bin_cmd);
602    let existing = &root["mcpServers"][SERVER_NAME];
603
604    if existing == &desired {
605        return Ok(()); // Exact match — idempotent.
606    }
607
608    // Write the entry.
609    root["mcpServers"][SERVER_NAME] = desired;
610
611    let parent = mcp_file.parent().unwrap_or(Path::new("."));
612    fs::create_dir_all(parent)
613        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
614
615    let json = serde_json::to_string_pretty(&root)
616        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
617    fs::write(mcp_file, json)
618        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
619
620    manifest.mcp_keys.push(ManagedMcpKey {
621        file: mcp_file.to_path_buf(),
622        server: SERVER_NAME.to_string(),
623    });
624
625    Ok(())
626}
627
628/// Remove `mcpServers.<server>` from a JSON config file. Leaves the file in
629/// place (with the key removed) unless `mcpServers` becomes empty, in which
630/// case we still leave the file (the user may have other keys).
631fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<(), CliError> {
632    if !mcp_file.exists() {
633        return Ok(());
634    }
635    let raw = fs::read_to_string(mcp_file)
636        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
637    let mut root: serde_json::Value = serde_json::from_str(&raw)
638        .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
639
640    if let Some(servers) = root["mcpServers"].as_object_mut() {
641        servers.remove(server);
642    }
643
644    let json = serde_json::to_string_pretty(&root)
645        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
646    fs::write(mcp_file, json)
647        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
648    Ok(())
649}
650
651fn mcp_server_entry(db_str: &str, bin_cmd: &str) -> serde_json::Value {
652    serde_json::json!({
653        "command": bin_cmd,
654        "args": ["mcp", db_str]
655    })
656}
657
658// ---------------------------------------------------------------------------
659// Manifest helpers
660// ---------------------------------------------------------------------------
661
662fn manifest_path(
663    project_root: &Path,
664    home: &Path,
665    project_scope: bool,
666    platforms: &[Platform],
667) -> PathBuf {
668    if !project_scope {
669        return home.join(".mushroomdb").join("install-manifest.json");
670    }
671    // Project scope: prefer the Claude Code location; fall back to Cursor.
672    if platforms.contains(&Platform::ClaudeCode) {
673        project_root
674            .join(".claude")
675            .join("skills")
676            .join("mushroom")
677            .join(".install-manifest.json")
678    } else {
679        project_root.join(".cursor").join(".install-manifest.json")
680    }
681}
682
683/// Load an existing manifest from `path`. Returns an empty manifest if absent or unparseable.
684fn load_manifest(path: &Path) -> Manifest {
685    let raw = match fs::read_to_string(path) {
686        Ok(s) => s,
687        Err(_) => return Manifest::default(),
688    };
689    serde_json::from_str(&raw).unwrap_or_default()
690}
691
692/// Union `existing` with `this_run`, deduplicating by path (files) and by
693/// (file, server) pair (mcp_keys). Entries from `this_run` win on collision
694/// so the manifest always reflects the latest state.
695fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
696    for f in &this_run.files {
697        if !existing.files.contains(f) {
698            existing.files.push(f.clone());
699        }
700    }
701    for k in &this_run.mcp_keys {
702        let already = existing
703            .mcp_keys
704            .iter()
705            .any(|e| e.file == k.file && e.server == k.server);
706        if !already {
707            existing.mcp_keys.push(k.clone());
708        }
709    }
710    existing
711}
712
713fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
714    let parent = path.parent().unwrap_or(Path::new("."));
715    fs::create_dir_all(parent).map_err(|e| {
716        CliError(format!(
717            "cannot create manifest dir {}: {e}",
718            parent.display()
719        ))
720    })?;
721    let json = serde_json::to_string_pretty(manifest)
722        .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
723    fs::write(path, json)
724        .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
725    Ok(())
726}
727
728// ---------------------------------------------------------------------------
729// Utilities
730// ---------------------------------------------------------------------------
731
732/// True if the file exists and its content equals `expected`.
733fn file_matches(path: &Path, expected: &str) -> bool {
734    fs::read_to_string(path)
735        .map(|s| s == expected)
736        .unwrap_or(false)
737}