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/// The MCP server name we write. Must not be changed without a migration.
35const SERVER_NAME: &str = "mushroomdb";
36
37/// Which assistant platform(s) to wire up.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Platform {
40    ClaudeCode,
41    Cursor,
42    All,
43}
44
45impl Platform {
46    pub fn parse(s: &str) -> Result<Self, String> {
47        match s {
48            "claude-code" => Ok(Platform::ClaudeCode),
49            "cursor" => Ok(Platform::Cursor),
50            "all" => Ok(Platform::All),
51            other => Err(format!(
52                "--platform must be claude-code | cursor | all, got: {other}"
53            )),
54        }
55    }
56}
57
58/// Options parsed from `mushroomdb install [flags]` or `mushroomdb uninstall [flags]`.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct InstallOpts {
61    /// Which platform to wire up. `None` = auto-detect.
62    pub platform: Option<Platform>,
63    /// Project scope (`--project`). If false → user scope.
64    pub project: bool,
65    /// Database directory. `None` = use the scope default.
66    pub db: Option<PathBuf>,
67}
68
69impl InstallOpts {
70    pub fn default_db(&self, project_root: &Path, home: &Path) -> PathBuf {
71        if self.project {
72            project_root.join("mushroom-memory")
73        } else {
74            home.join(".mushroomdb").join("memory")
75        }
76    }
77}
78
79// ---------------------------------------------------------------------------
80// Manifest — tracks everything install wrote so uninstall can undo it.
81// ---------------------------------------------------------------------------
82
83#[derive(Serialize, Deserialize, Default, Debug)]
84struct Manifest {
85    /// Files created by this install (absolute paths).
86    files: Vec<PathBuf>,
87    /// MCP JSON keys added by this install.
88    mcp_keys: Vec<ManagedMcpKey>,
89}
90
91#[derive(Serialize, Deserialize, Debug, Clone)]
92struct ManagedMcpKey {
93    /// The JSON file the key was added to (absolute path).
94    file: PathBuf,
95    /// The key inside `mcpServers`.
96    server: String,
97}
98
99// ---------------------------------------------------------------------------
100// Public entry points
101// ---------------------------------------------------------------------------
102
103/// Install the /mushroom skill and MCP server entry for the resolved platforms.
104///
105/// `project_root` is the directory where project-scope config files live
106/// (`.mcp.json`, `.claude/`, `.cursor/`). `home` is the user HOME directory.
107/// Tests pass temp directories for both; main.rs passes real values.
108pub fn run_install(
109    project_root: &Path,
110    home: &Path,
111    opts: &InstallOpts,
112) -> Result<String, CliError> {
113    let db = opts
114        .db
115        .clone()
116        .unwrap_or_else(|| opts.default_db(project_root, home));
117    let db_str = db.to_string_lossy();
118
119    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
120    let platforms = expand_platform(&resolved);
121
122    // Check for any conflicts before writing anything (atomic from user's POV).
123    for plat in &platforms {
124        preflight_check(project_root, home, plat, opts.project, &db_str)?;
125    }
126
127    let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
128
129    // Load the existing manifest so we can union it with what this run writes.
130    // This covers partial-drift re-installs: if SKILL.md was edited but the MCP
131    // entry is still intact, only the file is re-written this run; unioning
132    // preserves the MCP key in the saved manifest so uninstall cleans it up too.
133    let existing = load_manifest(&manifest_path);
134
135    let mut manifest = Manifest::default();
136
137    for plat in &platforms {
138        install_platform(
139            project_root,
140            home,
141            plat,
142            opts.project,
143            &db_str,
144            &mut manifest,
145        )?;
146    }
147
148    let anything_written = !manifest.files.is_empty() || !manifest.mcp_keys.is_empty();
149
150    if anything_written {
151        // Union this-run entries with the existing manifest (dedup by path/key).
152        let merged = union_manifests(existing, &manifest);
153        write_manifest(&manifest_path, &merged)?;
154    }
155
156    let mut out = format!("mushroomdb installed ({} platform(s))\n", platforms.len());
157    for f in &manifest.files {
158        out.push_str(&format!("  wrote  {}\n", f.display()));
159    }
160    for k in &manifest.mcp_keys {
161        out.push_str(&format!(
162            "  added  mcpServers.{} in {}\n",
163            k.server,
164            k.file.display()
165        ));
166    }
167    if anything_written {
168        out.push_str(&format!("  manifest  {}\n", manifest_path.display()));
169    } else {
170        out.push_str("  (already installed — no changes)\n");
171    }
172    Ok(out)
173}
174
175/// Uninstall: remove exactly what install wrote. Reads the manifest.
176pub fn run_uninstall(
177    project_root: &Path,
178    home: &Path,
179    opts: &InstallOpts,
180) -> Result<String, CliError> {
181    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
182    let platforms = expand_platform(&resolved);
183
184    let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
185    if !manifest_path.exists() {
186        return Err(CliError(format!(
187            "no install manifest found at {} — nothing to uninstall",
188            manifest_path.display()
189        )));
190    }
191
192    let raw = fs::read_to_string(&manifest_path)
193        .map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
194    let manifest: Manifest =
195        serde_json::from_str(&raw).map_err(|e| CliError(format!("corrupt manifest: {e}")))?;
196
197    let mut removed = Vec::new();
198
199    // Remove MCP keys first (before files, in case files include .mcp.json).
200    for key in &manifest.mcp_keys {
201        if key.file.exists() {
202            remove_mcp_key(&key.file, &key.server)?;
203            removed.push(format!(
204                "removed  mcpServers.{} from {}",
205                key.server,
206                key.file.display()
207            ));
208        }
209    }
210
211    // Remove files.
212    for f in &manifest.files {
213        if f.exists() {
214            fs::remove_file(f)
215                .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
216            removed.push(format!("removed  {}", f.display()));
217        }
218    }
219
220    // Remove the manifest itself.
221    if manifest_path.exists() {
222        fs::remove_file(&manifest_path)
223            .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
224    }
225
226    let mut out = "mushroomdb uninstalled\n".to_string();
227    for line in &removed {
228        out.push_str(&format!("  {line}\n"));
229    }
230    Ok(out)
231}
232
233// ---------------------------------------------------------------------------
234// Platform resolution
235// ---------------------------------------------------------------------------
236
237fn resolve_platform(
238    project_root: &Path,
239    home: &Path,
240    requested: Option<&Platform>,
241) -> Result<Platform, CliError> {
242    if let Some(p) = requested {
243        return Ok(p.clone());
244    }
245
246    // Auto-detect.
247    let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
248    let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
249
250    match (has_claude, has_cursor) {
251        (true, true) => Ok(Platform::All),
252        (true, false) => Ok(Platform::ClaudeCode),
253        (false, true) => Ok(Platform::Cursor),
254        (false, false) => Err(CliError(
255            "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
256             Pass --platform claude-code, --platform cursor, or --platform all."
257                .to_string(),
258        )),
259    }
260}
261
262fn expand_platform(p: &Platform) -> Vec<Platform> {
263    match p {
264        Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
265        Platform::ClaudeCode => vec![Platform::ClaudeCode],
266        Platform::Cursor => vec![Platform::Cursor],
267    }
268}
269
270// ---------------------------------------------------------------------------
271// Pre-flight conflict check (no writes)
272// ---------------------------------------------------------------------------
273
274fn preflight_check(
275    project_root: &Path,
276    home: &Path,
277    platform: &Platform,
278    project_scope: bool,
279    db_str: &str,
280) -> Result<(), CliError> {
281    match platform {
282        Platform::ClaudeCode => {
283            let mcp_file = if project_scope {
284                project_root.join(".mcp.json")
285            } else {
286                // User-scope: verified empirically on a live Claude Code install.
287                // ~/.claude.json holds top-level mcpServers; ~/.claude/settings.json
288                // holds env/permissions/hooks but no mcpServers key.
289                home.join(".claude.json")
290            };
291            check_mcp_conflict(&mcp_file, db_str)?;
292        }
293        Platform::Cursor => {
294            let mcp_file = if project_scope {
295                project_root.join(".cursor").join("mcp.json")
296            } else {
297                home.join(".cursor").join("mcp.json")
298            };
299            check_mcp_conflict(&mcp_file, db_str)?;
300        }
301        Platform::All => unreachable!("expand_platform never produces All"),
302    }
303    Ok(())
304}
305
306/// Check if a MCP JSON file has a conflicting `mushroomdb` entry.
307///
308/// A conflict is: the file exists, has `mcpServers.mushroomdb`, and its
309/// `args[1]` (the db path) differs from what we'd write.
310fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
311    if !mcp_file.exists() {
312        return Ok(());
313    }
314    let raw = fs::read_to_string(mcp_file)
315        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
316    let v: serde_json::Value = serde_json::from_str(&raw)
317        .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
318
319    let existing = &v["mcpServers"][SERVER_NAME];
320    if existing.is_null() {
321        return Ok(()); // Key absent — no conflict.
322    }
323
324    // Key present — check if it matches what we'd write.
325    let existing_cmd = existing["command"].as_str().unwrap_or("");
326    let existing_db = existing["args"]
327        .get(1)
328        .and_then(|v| v.as_str())
329        .unwrap_or("");
330
331    if existing_cmd == "mushroomdb" && existing_db == db_str {
332        return Ok(()); // Exact match — idempotent, no conflict.
333    }
334
335    Err(CliError(format!(
336        "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
337         To update it, run `mushroomdb uninstall` first, then re-install.\n\
338         Or manually edit {} and remove the existing mushroomdb entry.",
339        mcp_file.display(),
340        existing_db,
341        mcp_file.display()
342    )))
343}
344
345// ---------------------------------------------------------------------------
346// Per-platform installation
347// ---------------------------------------------------------------------------
348
349fn install_platform(
350    project_root: &Path,
351    home: &Path,
352    platform: &Platform,
353    project_scope: bool,
354    db_str: &str,
355    manifest: &mut Manifest,
356) -> Result<(), CliError> {
357    match platform {
358        Platform::ClaudeCode => {
359            install_claude_code(project_root, home, project_scope, db_str, manifest)
360        }
361        Platform::Cursor => install_cursor(project_root, home, project_scope, db_str, manifest),
362        Platform::All => unreachable!("expand_platform never produces All"),
363    }
364}
365
366fn install_claude_code(
367    project_root: &Path,
368    home: &Path,
369    project_scope: bool,
370    db_str: &str,
371    manifest: &mut Manifest,
372) -> Result<(), CliError> {
373    let skill_content = SKILL_TEMPLATE.replace(DB_PATH_PLACEHOLDER, db_str);
374
375    let skill_dir = if project_scope {
376        project_root.join(".claude").join("skills").join("mushroom")
377    } else {
378        home.join(".claude").join("skills").join("mushroom")
379    };
380    let skill_file = skill_dir.join("SKILL.md");
381
382    // Idempotent: skip if the file already has the same content.
383    if !file_matches(&skill_file, &skill_content) {
384        fs::create_dir_all(&skill_dir)
385            .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
386        fs::write(&skill_file, &skill_content)
387            .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
388        manifest.files.push(skill_file);
389    }
390
391    // MCP JSON. User-scope writes to ~/.claude.json (top-level mcpServers),
392    // not ~/.claude/settings.json (which holds env/hooks, not mcpServers).
393    let mcp_file = if project_scope {
394        project_root.join(".mcp.json")
395    } else {
396        home.join(".claude.json")
397    };
398    merge_mcp_entry(&mcp_file, db_str, manifest)?;
399
400    Ok(())
401}
402
403fn install_cursor(
404    project_root: &Path,
405    home: &Path,
406    project_scope: bool,
407    db_str: &str,
408    manifest: &mut Manifest,
409) -> Result<(), CliError> {
410    let rules_content = CURSOR_RULES_TEMPLATE.replace(DB_PATH_PLACEHOLDER, db_str);
411
412    let rules_dir = if project_scope {
413        project_root.join(".cursor").join("rules")
414    } else {
415        home.join(".cursor").join("rules")
416    };
417    let rules_file = rules_dir.join("mushroom.mdc");
418
419    if !file_matches(&rules_file, &rules_content) {
420        fs::create_dir_all(&rules_dir)
421            .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
422        fs::write(&rules_file, &rules_content)
423            .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
424        manifest.files.push(rules_file);
425    }
426
427    // Cursor MCP JSON.
428    let mcp_file = if project_scope {
429        project_root.join(".cursor").join("mcp.json")
430    } else {
431        home.join(".cursor").join("mcp.json")
432    };
433    merge_mcp_entry(&mcp_file, db_str, manifest)?;
434
435    Ok(())
436}
437
438// ---------------------------------------------------------------------------
439// MCP JSON merge helpers
440// ---------------------------------------------------------------------------
441
442/// Add `mcpServers.mushroomdb` to a JSON config file. Creates the file if
443/// absent. No-op if the entry already matches (idempotent).
444fn merge_mcp_entry(mcp_file: &Path, db_str: &str, manifest: &mut Manifest) -> Result<(), CliError> {
445    let mut root: serde_json::Value = if mcp_file.exists() {
446        let raw = fs::read_to_string(mcp_file)
447            .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
448        serde_json::from_str(&raw)
449            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
450    } else {
451        serde_json::json!({})
452    };
453
454    // Ensure `mcpServers` object exists.
455    if !root["mcpServers"].is_object() {
456        root["mcpServers"] = serde_json::json!({});
457    }
458
459    let desired = mcp_server_entry(db_str);
460    let existing = &root["mcpServers"][SERVER_NAME];
461
462    if existing == &desired {
463        return Ok(()); // Exact match — idempotent.
464    }
465
466    // Write the entry.
467    root["mcpServers"][SERVER_NAME] = desired;
468
469    let parent = mcp_file.parent().unwrap_or(Path::new("."));
470    fs::create_dir_all(parent)
471        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
472
473    let json = serde_json::to_string_pretty(&root)
474        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
475    fs::write(mcp_file, json)
476        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
477
478    manifest.mcp_keys.push(ManagedMcpKey {
479        file: mcp_file.to_path_buf(),
480        server: SERVER_NAME.to_string(),
481    });
482
483    Ok(())
484}
485
486/// Remove `mcpServers.<server>` from a JSON config file. Leaves the file in
487/// place (with the key removed) unless `mcpServers` becomes empty, in which
488/// case we still leave the file (the user may have other keys).
489fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<(), CliError> {
490    if !mcp_file.exists() {
491        return Ok(());
492    }
493    let raw = fs::read_to_string(mcp_file)
494        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
495    let mut root: serde_json::Value = serde_json::from_str(&raw)
496        .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
497
498    if let Some(servers) = root["mcpServers"].as_object_mut() {
499        servers.remove(server);
500    }
501
502    let json = serde_json::to_string_pretty(&root)
503        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
504    fs::write(mcp_file, json)
505        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
506    Ok(())
507}
508
509fn mcp_server_entry(db_str: &str) -> serde_json::Value {
510    serde_json::json!({
511        "command": "mushroomdb",
512        "args": ["mcp", db_str]
513    })
514}
515
516// ---------------------------------------------------------------------------
517// Manifest helpers
518// ---------------------------------------------------------------------------
519
520fn manifest_path(
521    project_root: &Path,
522    home: &Path,
523    project_scope: bool,
524    platforms: &[Platform],
525) -> PathBuf {
526    if !project_scope {
527        return home.join(".mushroomdb").join("install-manifest.json");
528    }
529    // Project scope: prefer the Claude Code location; fall back to Cursor.
530    if platforms.contains(&Platform::ClaudeCode) {
531        project_root
532            .join(".claude")
533            .join("skills")
534            .join("mushroom")
535            .join(".install-manifest.json")
536    } else {
537        project_root.join(".cursor").join(".install-manifest.json")
538    }
539}
540
541/// Load an existing manifest from `path`. Returns an empty manifest if absent or unparseable.
542fn load_manifest(path: &Path) -> Manifest {
543    let raw = match fs::read_to_string(path) {
544        Ok(s) => s,
545        Err(_) => return Manifest::default(),
546    };
547    serde_json::from_str(&raw).unwrap_or_default()
548}
549
550/// Union `existing` with `this_run`, deduplicating by path (files) and by
551/// (file, server) pair (mcp_keys). Entries from `this_run` win on collision
552/// so the manifest always reflects the latest state.
553fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
554    for f in &this_run.files {
555        if !existing.files.contains(f) {
556            existing.files.push(f.clone());
557        }
558    }
559    for k in &this_run.mcp_keys {
560        let already = existing
561            .mcp_keys
562            .iter()
563            .any(|e| e.file == k.file && e.server == k.server);
564        if !already {
565            existing.mcp_keys.push(k.clone());
566        }
567    }
568    existing
569}
570
571fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
572    let parent = path.parent().unwrap_or(Path::new("."));
573    fs::create_dir_all(parent).map_err(|e| {
574        CliError(format!(
575            "cannot create manifest dir {}: {e}",
576            parent.display()
577        ))
578    })?;
579    let json = serde_json::to_string_pretty(manifest)
580        .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
581    fs::write(path, json)
582        .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
583    Ok(())
584}
585
586// ---------------------------------------------------------------------------
587// Utilities
588// ---------------------------------------------------------------------------
589
590/// True if the file exists and its content equals `expected`.
591fn file_matches(path: &Path, expected: &str) -> bool {
592    fs::read_to_string(path)
593        .map(|s| s == expected)
594        .unwrap_or(false)
595}