1use crate::CliError;
21use serde::{Deserialize, Serialize};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25const SKILL_TEMPLATE: &str = include_str!("../skills/mushroom/SKILL.md");
29const CURSOR_RULES_TEMPLATE: &str = include_str!("../skills/mushroom/cursor-rules.mdc");
30
31const DB_PATH_PLACEHOLDER: &str = "{{DB_PATH}}";
33
34const BIN_PLACEHOLDER: &str = "{{BIN}}";
37
38const SERVER_NAME: &str = "mushroomdb";
40
41const BIN_NAME: &str = "mushroomdb";
43
44#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum BinaryLocation {
55 OnPath,
57 CopyFrom(PathBuf),
60}
61
62pub 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 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
82fn stable_bin_path(home: &Path) -> PathBuf {
85 home.join(".mushroomdb").join("bin").join(BIN_NAME)
86}
87
88#[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#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct InstallOpts {
112 pub platform: Option<Platform>,
114 pub project: bool,
116 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#[derive(Serialize, Deserialize, Default, Debug)]
135struct Manifest {
136 files: Vec<PathBuf>,
138 mcp_keys: Vec<ManagedMcpKey>,
140}
141
142#[derive(Serialize, Deserialize, Debug, Clone)]
143struct ManagedMcpKey {
144 file: PathBuf,
146 server: String,
148}
149
150pub 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
167pub 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 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 let existing = load_manifest(&manifest_path);
197
198 let mut manifest = Manifest::default();
199
200 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 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 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
264fn 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 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); return Err(e);
294 }
295 manifest.files.push(dest.to_path_buf());
296 Ok(())
297}
298
299pub 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 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 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 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
357fn 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 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
394fn 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 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
430fn 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(()); }
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(()); }
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
470fn 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
494fn 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 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 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 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
575fn 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 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(()); }
607
608 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
628fn 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
658fn 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 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
683fn 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
692fn 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
728fn file_matches(path: &Path, expected: &str) -> bool {
734 fs::read_to_string(path)
735 .map(|s| s == expected)
736 .unwrap_or(false)
737}