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 #[serde(default)]
142 hooks: Vec<ManagedHook>,
143}
144
145#[derive(Serialize, Deserialize, Debug, Clone)]
146struct ManagedMcpKey {
147 file: PathBuf,
149 server: String,
151}
152
153#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
154struct ManagedHook {
155 file: PathBuf,
157 event: String,
159 command: String,
161}
162
163const HOOK_EVENT: &str = "UserPromptSubmit";
166const HOOK_TIMEOUT_SECS: u64 = 5;
168
169fn sh_quote(s: &str) -> String {
175 format!("'{}'", s.replace('\'', r"'\''"))
176}
177
178fn recall_hook_command(bin_cmd: &str, db_str: &str) -> String {
180 format!("{} recall {}", sh_quote(bin_cmd), sh_quote(db_str))
181}
182
183fn hook_entry(command: &str) -> serde_json::Value {
185 serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
186}
187
188fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
190 root["hooks"][event]
191 .as_array()
192 .map(|groups| {
193 groups.iter().any(|g| {
194 g["hooks"]
195 .as_array()
196 .map(|hs| hs.iter().any(|h| h["command"] == command))
197 .unwrap_or(false)
198 })
199 })
200 .unwrap_or(false)
201}
202
203fn merge_hook_entry(
210 settings_file: &Path,
211 command: &str,
212 manifest: &mut Manifest,
213) -> Result<(), CliError> {
214 let mut root: serde_json::Value = if settings_file.exists() {
215 let raw = fs::read_to_string(settings_file)
216 .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
217 serde_json::from_str(&raw)
218 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
219 } else {
220 serde_json::json!({})
221 };
222
223 if !root.is_object() {
224 return Err(CliError(format!(
225 "{} is not a JSON object at its top level — refusing to add a hook",
226 settings_file.display()
227 )));
228 }
229
230 if settings_has_hook(&root, HOOK_EVENT, command) {
231 return Ok(());
232 }
233
234 match root.get("hooks") {
238 None => root["hooks"] = serde_json::json!({}),
239 Some(v) if v.is_object() => {}
240 Some(_) => {
241 return Err(CliError(format!(
242 "{}: \"hooks\" is not a JSON object — refusing to overwrite it",
243 settings_file.display()
244 )));
245 }
246 }
247 match root["hooks"].get(HOOK_EVENT) {
248 None => root["hooks"][HOOK_EVENT] = serde_json::json!([]),
249 Some(v) if v.is_array() => {}
250 Some(_) => {
251 return Err(CliError(format!(
252 "{}: \"hooks.{HOOK_EVENT}\" is not a JSON array — refusing to overwrite it",
253 settings_file.display()
254 )));
255 }
256 }
257 root["hooks"][HOOK_EVENT]
258 .as_array_mut()
259 .unwrap()
260 .push(hook_entry(command));
261
262 let parent = settings_file.parent().unwrap_or(Path::new("."));
263 fs::create_dir_all(parent)
264 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
265 let json = serde_json::to_string_pretty(&root)
266 .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
267 fs::write(settings_file, json)
268 .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
269
270 manifest.hooks.push(ManagedHook {
271 file: settings_file.to_path_buf(),
272 event: HOOK_EVENT.into(),
273 command: command.into(),
274 });
275 Ok(())
276}
277
278fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<(), CliError> {
288 if !settings_file.exists() {
289 return Ok(());
290 }
291 let raw = fs::read_to_string(settings_file)
292 .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
293 let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
294 CliError(format!(
295 "corrupt settings json at {}: {e}",
296 settings_file.display()
297 ))
298 })?;
299
300 let Some(mut groups) = root
301 .get("hooks")
302 .and_then(|h| h.get(event))
303 .and_then(|g| g.as_array())
304 .cloned()
305 else {
306 return Ok(());
309 };
310
311 for g in groups.iter_mut() {
312 if let Some(hs) = g["hooks"].as_array_mut() {
313 hs.retain(|h| h["command"] != command);
314 }
315 }
316 groups.retain(|g| {
317 g["hooks"]
318 .as_array()
319 .map(|hs| !hs.is_empty())
320 .unwrap_or(true)
321 });
322
323 let before = root.clone();
324 if groups.is_empty() {
325 root["hooks"].as_object_mut().unwrap().remove(event);
326 } else {
327 root["hooks"][event] = serde_json::Value::Array(groups);
328 }
329 if root == before {
330 return Ok(());
335 }
336
337 let json = serde_json::to_string_pretty(&root)
338 .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
339 fs::write(settings_file, json)
340 .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
341 Ok(())
342}
343
344pub fn run_install(
354 project_root: &Path,
355 home: &Path,
356 opts: &InstallOpts,
357) -> Result<String, CliError> {
358 run_install_with(project_root, home, opts, &detect_binary_location())
359}
360
361pub fn run_install_with(
365 project_root: &Path,
366 home: &Path,
367 opts: &InstallOpts,
368 bin: &BinaryLocation,
369) -> Result<String, CliError> {
370 let db = opts
371 .db
372 .clone()
373 .unwrap_or_else(|| opts.default_db(project_root, home));
374 let db_str = db.to_string_lossy();
375
376 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
377 let platforms = expand_platform(&resolved);
378
379 for plat in &platforms {
381 preflight_check(project_root, home, plat, opts.project, &db_str)?;
382 }
383
384 let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
385
386 let existing = load_manifest(&manifest_path);
391
392 let mut manifest = Manifest::default();
393
394 let bin_cmd = match bin {
397 BinaryLocation::OnPath => BIN_NAME.to_string(),
398 BinaryLocation::CopyFrom(src) => {
399 let dest = stable_bin_path(home);
400 copy_binary(src, &dest, &mut manifest)?;
401 dest.to_string_lossy().into_owned()
402 }
403 };
404
405 for plat in &platforms {
406 let step = install_platform(
407 project_root,
408 home,
409 plat,
410 opts.project,
411 &db_str,
412 &bin_cmd,
413 &mut manifest,
414 );
415 if let Err(e) = step {
416 let anything_written = !manifest.files.is_empty()
420 || !manifest.mcp_keys.is_empty()
421 || !manifest.hooks.is_empty();
422 if anything_written {
423 let merged = union_manifests(load_manifest(&manifest_path), &manifest);
424 let _ = write_manifest(&manifest_path, &merged);
425 }
426 return Err(e);
427 }
428 }
429
430 let anything_written =
431 !manifest.files.is_empty() || !manifest.mcp_keys.is_empty() || !manifest.hooks.is_empty();
432
433 if anything_written {
434 let merged = union_manifests(existing, &manifest);
436 write_manifest(&manifest_path, &merged)?;
437 }
438
439 let mut out = format!("mushroomdb installed ({} platform(s))\n", platforms.len());
440 for f in &manifest.files {
441 out.push_str(&format!(" wrote {}\n", f.display()));
442 }
443 for k in &manifest.mcp_keys {
444 out.push_str(&format!(
445 " added mcpServers.{} in {}\n",
446 k.server,
447 k.file.display()
448 ));
449 }
450 for h in &manifest.hooks {
451 out.push_str(&format!(
452 " added {} hook in {}\n",
453 h.event,
454 h.file.display()
455 ));
456 }
457 if anything_written {
458 out.push_str(&format!(" manifest {}\n", manifest_path.display()));
459 out.push_str(&format!(
460 " mcp command {bin_cmd}\n restart your assistant to connect the MCP server\n"
461 ));
462 } else {
463 out.push_str(" (already installed — no changes)\n");
464 }
465 Ok(out)
466}
467
468fn copy_binary(src: &Path, dest: &Path, manifest: &mut Manifest) -> Result<(), CliError> {
472 let bytes = fs::read(src)
473 .map_err(|e| CliError(format!("cannot read binary {}: {e}", src.display())))?;
474 if fs::read(dest).map(|cur| cur == bytes).unwrap_or(false) {
475 return Ok(());
476 }
477 let parent = dest.parent().unwrap_or(Path::new("."));
478 fs::create_dir_all(parent)
479 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
480 let tmp = parent.join(format!(".{BIN_NAME}.tmp-{}", std::process::id()));
483 fs::write(&tmp, &bytes)
484 .map_err(|e| CliError(format!("cannot write {}: {e}", tmp.display())))?;
485 let finish = || -> Result<(), CliError> {
486 #[cfg(unix)]
487 {
488 use std::os::unix::fs::PermissionsExt;
489 fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))
490 .map_err(|e| CliError(format!("cannot chmod {}: {e}", tmp.display())))?;
491 }
492 fs::rename(&tmp, dest)
493 .map_err(|e| CliError(format!("cannot move binary into {}: {e}", dest.display())))
494 };
495 if let Err(e) = finish() {
496 let _ = fs::remove_file(&tmp); return Err(e);
498 }
499 manifest.files.push(dest.to_path_buf());
500 Ok(())
501}
502
503pub fn run_uninstall(
505 project_root: &Path,
506 home: &Path,
507 opts: &InstallOpts,
508) -> Result<String, CliError> {
509 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
510 let platforms = expand_platform(&resolved);
511
512 let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
513 if !manifest_path.exists() {
514 return Err(CliError(format!(
515 "no install manifest found at {} — nothing to uninstall",
516 manifest_path.display()
517 )));
518 }
519
520 let raw = fs::read_to_string(&manifest_path)
521 .map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
522 let manifest: Manifest =
523 serde_json::from_str(&raw).map_err(|e| CliError(format!("corrupt manifest: {e}")))?;
524
525 let mut removed = Vec::new();
526
527 for key in &manifest.mcp_keys {
529 if key.file.exists() {
530 remove_mcp_key(&key.file, &key.server)?;
531 removed.push(format!(
532 "removed mcpServers.{} from {}",
533 key.server,
534 key.file.display()
535 ));
536 }
537 }
538
539 for h in &manifest.hooks {
541 if h.file.exists() {
542 remove_hook_entry(&h.file, &h.event, &h.command)?;
543 removed.push(format!(
544 "removed {} hook from {}",
545 h.event,
546 h.file.display()
547 ));
548 }
549 }
550
551 for f in &manifest.files {
553 if f.exists() {
554 fs::remove_file(f)
555 .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
556 removed.push(format!("removed {}", f.display()));
557 }
558 }
559
560 if manifest_path.exists() {
562 fs::remove_file(&manifest_path)
563 .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
564 }
565
566 let mut out = "mushroomdb uninstalled\n".to_string();
567 for line in &removed {
568 out.push_str(&format!(" {line}\n"));
569 }
570 Ok(out)
571}
572
573fn resolve_platform(
578 project_root: &Path,
579 home: &Path,
580 requested: Option<&Platform>,
581) -> Result<Platform, CliError> {
582 if let Some(p) = requested {
583 return Ok(p.clone());
584 }
585
586 let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
588 let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
589
590 match (has_claude, has_cursor) {
591 (true, true) => Ok(Platform::All),
592 (true, false) => Ok(Platform::ClaudeCode),
593 (false, true) => Ok(Platform::Cursor),
594 (false, false) => Err(CliError(
595 "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
596 Pass --platform claude-code, --platform cursor, or --platform all."
597 .to_string(),
598 )),
599 }
600}
601
602fn expand_platform(p: &Platform) -> Vec<Platform> {
603 match p {
604 Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
605 Platform::ClaudeCode => vec![Platform::ClaudeCode],
606 Platform::Cursor => vec![Platform::Cursor],
607 }
608}
609
610fn preflight_check(
615 project_root: &Path,
616 home: &Path,
617 platform: &Platform,
618 project_scope: bool,
619 db_str: &str,
620) -> Result<(), CliError> {
621 match platform {
622 Platform::ClaudeCode => {
623 let mcp_file = if project_scope {
624 project_root.join(".mcp.json")
625 } else {
626 home.join(".claude.json")
630 };
631 check_mcp_conflict(&mcp_file, db_str)?;
632 }
633 Platform::Cursor => {
634 let mcp_file = if project_scope {
635 project_root.join(".cursor").join("mcp.json")
636 } else {
637 home.join(".cursor").join("mcp.json")
638 };
639 check_mcp_conflict(&mcp_file, db_str)?;
640 }
641 Platform::All => unreachable!("expand_platform never produces All"),
642 }
643 Ok(())
644}
645
646fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
654 if !mcp_file.exists() {
655 return Ok(());
656 }
657 let raw = fs::read_to_string(mcp_file)
658 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
659 let v: serde_json::Value = serde_json::from_str(&raw)
660 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
661
662 let existing = &v["mcpServers"][SERVER_NAME];
663 if existing.is_null() {
664 return Ok(()); }
666
667 let existing_db = existing["args"]
668 .get(1)
669 .and_then(|v| v.as_str())
670 .unwrap_or("");
671
672 if existing_db == db_str {
673 return Ok(()); }
675
676 Err(CliError(format!(
677 "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
678 To update it, run `mushroomdb uninstall` first, then re-install.\n\
679 Or manually edit {} and remove the existing mushroomdb entry.",
680 mcp_file.display(),
681 existing_db,
682 mcp_file.display()
683 )))
684}
685
686fn install_platform(
691 project_root: &Path,
692 home: &Path,
693 platform: &Platform,
694 project_scope: bool,
695 db_str: &str,
696 bin_cmd: &str,
697 manifest: &mut Manifest,
698) -> Result<(), CliError> {
699 match platform {
700 Platform::ClaudeCode => {
701 install_claude_code(project_root, home, project_scope, db_str, bin_cmd, manifest)
702 }
703 Platform::Cursor => {
704 install_cursor(project_root, home, project_scope, db_str, bin_cmd, manifest)
705 }
706 Platform::All => unreachable!("expand_platform never produces All"),
707 }
708}
709
710fn render_template(template: &str, db_str: &str, bin_cmd: &str) -> String {
712 template
713 .replace(DB_PATH_PLACEHOLDER, db_str)
714 .replace(BIN_PLACEHOLDER, bin_cmd)
715}
716
717fn install_claude_code(
718 project_root: &Path,
719 home: &Path,
720 project_scope: bool,
721 db_str: &str,
722 bin_cmd: &str,
723 manifest: &mut Manifest,
724) -> Result<(), CliError> {
725 let skill_content = render_template(SKILL_TEMPLATE, db_str, bin_cmd);
726
727 let skill_dir = if project_scope {
728 project_root.join(".claude").join("skills").join("mushroom")
729 } else {
730 home.join(".claude").join("skills").join("mushroom")
731 };
732 let skill_file = skill_dir.join("SKILL.md");
733
734 if !file_matches(&skill_file, &skill_content) {
736 fs::create_dir_all(&skill_dir)
737 .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
738 fs::write(&skill_file, &skill_content)
739 .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
740 manifest.files.push(skill_file);
741 }
742
743 let mcp_file = if project_scope {
746 project_root.join(".mcp.json")
747 } else {
748 home.join(".claude.json")
749 };
750 merge_mcp_entry(&mcp_file, db_str, bin_cmd, manifest)?;
751
752 let settings_file = if project_scope {
754 project_root.join(".claude").join("settings.json")
755 } else {
756 home.join(".claude").join("settings.json")
757 };
758 merge_hook_entry(
759 &settings_file,
760 &recall_hook_command(bin_cmd, db_str),
761 manifest,
762 )?;
763
764 Ok(())
765}
766
767fn install_cursor(
768 project_root: &Path,
769 home: &Path,
770 project_scope: bool,
771 db_str: &str,
772 bin_cmd: &str,
773 manifest: &mut Manifest,
774) -> Result<(), CliError> {
775 let rules_content = render_template(CURSOR_RULES_TEMPLATE, db_str, bin_cmd);
776
777 let rules_dir = if project_scope {
778 project_root.join(".cursor").join("rules")
779 } else {
780 home.join(".cursor").join("rules")
781 };
782 let rules_file = rules_dir.join("mushroom.mdc");
783
784 if !file_matches(&rules_file, &rules_content) {
785 fs::create_dir_all(&rules_dir)
786 .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
787 fs::write(&rules_file, &rules_content)
788 .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
789 manifest.files.push(rules_file);
790 }
791
792 let mcp_file = if project_scope {
794 project_root.join(".cursor").join("mcp.json")
795 } else {
796 home.join(".cursor").join("mcp.json")
797 };
798 merge_mcp_entry(&mcp_file, db_str, bin_cmd, manifest)?;
799
800 Ok(())
801}
802
803fn merge_mcp_entry(
810 mcp_file: &Path,
811 db_str: &str,
812 bin_cmd: &str,
813 manifest: &mut Manifest,
814) -> Result<(), CliError> {
815 let mut root: serde_json::Value = if mcp_file.exists() {
816 let raw = fs::read_to_string(mcp_file)
817 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
818 serde_json::from_str(&raw)
819 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
820 } else {
821 serde_json::json!({})
822 };
823
824 if !root["mcpServers"].is_object() {
826 root["mcpServers"] = serde_json::json!({});
827 }
828
829 let desired = mcp_server_entry(db_str, bin_cmd);
830 let existing = &root["mcpServers"][SERVER_NAME];
831
832 if existing == &desired {
833 return Ok(()); }
835
836 root["mcpServers"][SERVER_NAME] = desired;
838
839 let parent = mcp_file.parent().unwrap_or(Path::new("."));
840 fs::create_dir_all(parent)
841 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
842
843 let json = serde_json::to_string_pretty(&root)
844 .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
845 fs::write(mcp_file, json)
846 .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
847
848 manifest.mcp_keys.push(ManagedMcpKey {
849 file: mcp_file.to_path_buf(),
850 server: SERVER_NAME.to_string(),
851 });
852
853 Ok(())
854}
855
856fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<(), CliError> {
860 if !mcp_file.exists() {
861 return Ok(());
862 }
863 let raw = fs::read_to_string(mcp_file)
864 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
865 let mut root: serde_json::Value = serde_json::from_str(&raw)
866 .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
867
868 if let Some(servers) = root["mcpServers"].as_object_mut() {
869 servers.remove(server);
870 }
871
872 let json = serde_json::to_string_pretty(&root)
873 .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
874 fs::write(mcp_file, json)
875 .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
876 Ok(())
877}
878
879fn mcp_server_entry(db_str: &str, bin_cmd: &str) -> serde_json::Value {
880 serde_json::json!({
881 "command": bin_cmd,
882 "args": ["mcp", db_str]
883 })
884}
885
886fn manifest_path(
891 project_root: &Path,
892 home: &Path,
893 project_scope: bool,
894 platforms: &[Platform],
895) -> PathBuf {
896 if !project_scope {
897 return home.join(".mushroomdb").join("install-manifest.json");
898 }
899 if platforms.contains(&Platform::ClaudeCode) {
901 project_root
902 .join(".claude")
903 .join("skills")
904 .join("mushroom")
905 .join(".install-manifest.json")
906 } else {
907 project_root.join(".cursor").join(".install-manifest.json")
908 }
909}
910
911fn load_manifest(path: &Path) -> Manifest {
913 let raw = match fs::read_to_string(path) {
914 Ok(s) => s,
915 Err(_) => return Manifest::default(),
916 };
917 serde_json::from_str(&raw).unwrap_or_default()
918}
919
920fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
925 for f in &this_run.files {
926 if !existing.files.contains(f) {
927 existing.files.push(f.clone());
928 }
929 }
930 for k in &this_run.mcp_keys {
931 let already = existing
932 .mcp_keys
933 .iter()
934 .any(|e| e.file == k.file && e.server == k.server);
935 if !already {
936 existing.mcp_keys.push(k.clone());
937 }
938 }
939 for h in &this_run.hooks {
940 if !existing.hooks.contains(h) {
941 existing.hooks.push(h.clone());
942 }
943 }
944 existing
945}
946
947fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
948 let parent = path.parent().unwrap_or(Path::new("."));
949 fs::create_dir_all(parent).map_err(|e| {
950 CliError(format!(
951 "cannot create manifest dir {}: {e}",
952 parent.display()
953 ))
954 })?;
955 let json = serde_json::to_string_pretty(manifest)
956 .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
957 fs::write(path, json)
958 .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
959 Ok(())
960}
961
962fn file_matches(path: &Path, expected: &str) -> bool {
968 fs::read_to_string(path)
969 .map(|s| s == expected)
970 .unwrap_or(false)
971}