1use crate::cli::CliOutput;
42use anyhow::{Context, Result, anyhow, bail};
43use clap::{Args, Subcommand, ValueEnum};
44use serde_json::{Map, Value};
45use std::path::{Path, PathBuf};
46
47const MARKER_START_KEY: &str = "// ai-memory:managed-block:start";
51const MARKER_END_KEY: &str = "// ai-memory:managed-block:end";
52
53const MARKER_PAYLOAD: &str = "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487";
57
58const MANAGED_KEYS_PROPERTY: &str = "// ai-memory:managed-keys";
63
64#[derive(Args, Debug)]
66pub struct InstallArgs {
67 #[command(subcommand)]
69 pub target: TargetCmd,
70}
71
72#[derive(Subcommand, Debug)]
77pub enum TargetCmd {
78 ClaudeCode(TargetArgs),
80 Openclaw(TargetArgs),
84 Cursor(TargetArgs),
86 Cline(TargetArgs),
89 Continue(TargetArgs),
91 Windsurf(TargetArgs),
94
95 ClaudeDesktop(TargetArgs),
100 Codex(TargetArgs),
104 GrokCli(TargetArgs),
108 GeminiCli(TargetArgs),
112}
113
114#[derive(Args, Debug, Default, Clone)]
117pub struct TargetArgs {
118 #[arg(long, value_name = "PATH")]
122 pub config: Option<PathBuf>,
123
124 #[arg(long, default_value_t = false, conflicts_with = "dry_run")]
129 pub apply: bool,
130
131 #[arg(long, default_value_t = false)]
135 pub dry_run: bool,
136
137 #[arg(long, default_value_t = false)]
140 pub uninstall: bool,
141
142 #[arg(long, value_name = "PATH")]
147 pub binary: Option<PathBuf>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
154pub enum Target {
155 ClaudeCode,
156 Openclaw,
157 Cursor,
158 Cline,
159 Continue,
160 Windsurf,
161 ClaudeDesktop,
163 Codex,
164 GrokCli,
165 GeminiCli,
166}
167
168impl Target {
169 fn name(self) -> &'static str {
171 match self {
172 Self::ClaudeCode => "claude-code",
173 Self::Openclaw => "openclaw",
174 Self::Cursor => "cursor",
175 Self::Cline => "cline",
176 Self::Continue => "continue",
177 Self::Windsurf => "windsurf",
178 Self::ClaudeDesktop => "claude-desktop",
179 Self::Codex => "codex",
180 Self::GrokCli => "grok-cli",
181 Self::GeminiCli => "gemini-cli",
182 }
183 }
184}
185
186impl TargetCmd {
187 fn target(&self) -> Target {
188 match self {
189 Self::ClaudeCode(_) => Target::ClaudeCode,
190 Self::Openclaw(_) => Target::Openclaw,
191 Self::Cursor(_) => Target::Cursor,
192 Self::Cline(_) => Target::Cline,
193 Self::Continue(_) => Target::Continue,
194 Self::Windsurf(_) => Target::Windsurf,
195 Self::ClaudeDesktop(_) => Target::ClaudeDesktop,
196 Self::Codex(_) => Target::Codex,
197 Self::GrokCli(_) => Target::GrokCli,
198 Self::GeminiCli(_) => Target::GeminiCli,
199 }
200 }
201
202 fn args(&self) -> &TargetArgs {
203 match self {
204 Self::ClaudeCode(a)
205 | Self::Openclaw(a)
206 | Self::Cursor(a)
207 | Self::Cline(a)
208 | Self::Continue(a)
209 | Self::Windsurf(a)
210 | Self::ClaudeDesktop(a)
211 | Self::Codex(a)
212 | Self::GrokCli(a)
213 | Self::GeminiCli(a) => a,
214 }
215 }
216}
217
218pub fn run(args: &InstallArgs, out: &mut CliOutput<'_>) -> Result<()> {
227 let target = args.target.target();
228 let t_args = args.target.args();
229
230 let config_path = resolve_config_path(target, t_args)?;
231 let binary = resolve_binary(t_args.binary.as_deref());
232
233 let (before_text, before_value) = read_config_or_empty(&config_path)?;
237
238 let after_value = if t_args.uninstall {
240 remove_managed_block(target, before_value.clone())?
241 } else {
242 apply_managed_block(target, before_value.clone(), &binary)?
243 };
244
245 let after_text = serde_json::to_string_pretty(&after_value)? + "\n";
247
248 let _: Value = serde_json::from_str(&after_text)
251 .context("internal error: serialised config did not round-trip through JSON parser")?;
252
253 let action_label = if t_args.uninstall {
254 "uninstall"
255 } else {
256 "install"
257 };
258
259 if before_text.trim() == after_text.trim() {
260 writeln!(
261 out.stdout,
262 "ai-memory install: {target} {action} is a no-op (managed block already in desired state)",
263 target = target.name(),
264 action = action_label,
265 )?;
266 return Ok(());
267 }
268
269 if !t_args.apply {
270 writeln!(
273 out.stdout,
274 "ai-memory install: dry-run for {target} {action} at {path}",
275 target = target.name(),
276 action = action_label,
277 path = config_path.display(),
278 )?;
279 writeln!(out.stdout, "--- before")?;
280 writeln!(out.stdout, "+++ after")?;
281 emit_diff(out, &before_text, &after_text)?;
282 writeln!(
283 out.stdout,
284 "ai-memory install: re-run with --apply to write the changes"
285 )?;
286 return Ok(());
287 }
288
289 let backup_path = if config_path.exists() {
291 let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
292 let backup = config_path.with_extension(format!(
293 "{ext}bak.{ts}",
294 ext = match config_path.extension().and_then(|e| e.to_str()) {
295 Some(existing) => format!("{existing}."),
296 None => String::new(),
297 }
298 ));
299 std::fs::copy(&config_path, &backup).with_context(|| {
300 format!(
301 "backing up {} to {}",
302 config_path.display(),
303 backup.display()
304 )
305 })?;
306 Some(backup)
307 } else {
308 None
309 };
310
311 if let Some(parent) = config_path.parent()
312 && !parent.as_os_str().is_empty()
313 {
314 std::fs::create_dir_all(parent)
315 .with_context(|| format!("creating parent directory {}", parent.display()))?;
316 }
317
318 std::fs::write(&config_path, &after_text)
319 .with_context(|| format!("writing {}", config_path.display()))?;
320
321 writeln!(
322 out.stdout,
323 "ai-memory install: {action} applied to {path}",
324 action = action_label,
325 path = config_path.display(),
326 )?;
327 if let Some(b) = backup_path {
328 writeln!(out.stdout, "ai-memory install: backup at {}", b.display())?;
329 }
330 Ok(())
331}
332
333fn resolve_config_path(target: Target, args: &TargetArgs) -> Result<PathBuf> {
338 if let Some(ref p) = args.config {
339 return Ok(p.clone());
340 }
341 let home = dirs::home_dir()
342 .ok_or_else(|| anyhow!("could not resolve home directory; pass --config <path>"))?;
343 let p = match target {
344 Target::ClaudeCode => home.join(".claude").join("settings.json"),
345 Target::Openclaw => {
346 bail!(
353 "openclaw config path is not auto-discovered yet; pass --config <path>. \
354 See https://docs.openclaw.ai/cli/mcp for the canonical location."
355 );
356 }
357 Target::Cursor => home.join(".cursor").join("mcp.json"),
358 Target::Cline => {
359 bail!(
364 "cline config path varies by version; pass --config <path> \
365 (typically ~/.cline/mcp_settings.json or under the VS Code \
366 extension data dir)."
367 );
368 }
369 Target::Continue => home.join(".continue").join("config.json"),
370 Target::Windsurf => home
371 .join(".codeium")
372 .join("windsurf")
373 .join("mcp_config.json"),
374 Target::ClaudeDesktop => {
378 #[cfg(target_os = "macos")]
379 {
380 home.join("Library")
381 .join("Application Support")
382 .join("Claude")
383 .join("claude_desktop_config.json")
384 }
385 #[cfg(target_os = "windows")]
386 {
387 std::env::var_os("APPDATA")
388 .map(|p| {
389 std::path::PathBuf::from(p)
390 .join("Claude")
391 .join("claude_desktop_config.json")
392 })
393 .unwrap_or_else(|| {
394 home.join("AppData")
395 .join("Roaming")
396 .join("Claude")
397 .join("claude_desktop_config.json")
398 })
399 }
400 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
401 {
402 bail!(
403 "claude-desktop config path is OS-specific and not auto-discovered \
404 on Linux; pass --config <path>. Common location: \
405 ~/.config/Claude/claude_desktop_config.json"
406 );
407 }
408 }
409 Target::Codex => {
413 bail!(
414 "codex config path varies by version; pass --config <path>. \
415 Common location: ~/.codex/config.json or ~/.config/codex/mcp.json"
416 );
417 }
418 Target::GrokCli => {
419 bail!(
420 "grok-cli config path varies by version; pass --config <path>. \
421 Common location: ~/.grok/mcp.json"
422 );
423 }
424 Target::GeminiCli => {
425 bail!(
426 "gemini-cli config path varies by version; pass --config <path>. \
427 Common location: ~/.gemini/mcp.json"
428 );
429 }
430 };
431 Ok(p)
432}
433
434fn resolve_binary(override_path: Option<&Path>) -> String {
443 if let Some(p) = override_path {
444 return p.display().to_string();
445 }
446 if which_ai_memory().is_some() {
447 return "ai-memory".to_string();
448 }
449 if let Ok(exe) = std::env::current_exe() {
450 return exe.display().to_string();
451 }
452 "ai-memory".to_string()
453}
454
455fn which_ai_memory() -> Option<PathBuf> {
456 let path_var = std::env::var_os("PATH")?;
457 for dir in std::env::split_paths(&path_var) {
458 let candidate = dir.join("ai-memory");
459 if candidate.is_file() {
460 return Some(candidate);
461 }
462 let candidate_exe = dir.join("ai-memory.exe");
463 if candidate_exe.is_file() {
464 return Some(candidate_exe);
465 }
466 }
467 None
468}
469
470fn read_config_or_empty(path: &Path) -> Result<(String, Value)> {
478 if !path.exists() {
479 return Ok((String::new(), Value::Object(Map::new())));
480 }
481 let text =
482 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
483 if text.trim().is_empty() {
484 return Ok((text, Value::Object(Map::new())));
485 }
486 let value: Value = serde_json::from_str(&text).map_err(|e| {
487 anyhow!(
488 "existing config at {} is not valid JSON ({e}). \
489 Refusing to overwrite — fix the file by hand or remove it, \
490 then re-run `ai-memory install`.",
491 path.display()
492 )
493 })?;
494 Ok((text, value))
495}
496
497fn apply_managed_block(target: Target, mut cfg: Value, binary: &str) -> Result<Value> {
503 let obj = ensure_object(&mut cfg)?;
504 match target {
505 Target::ClaudeCode => apply_claude_code(obj, binary),
506 Target::Openclaw => apply_openclaw(obj, binary),
507 Target::Cursor => apply_cursor(obj, binary),
508 Target::Cline => apply_cline(obj, binary),
509 Target::Continue => apply_continue(obj, binary),
510 Target::Windsurf => apply_windsurf(obj, binary),
511 Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
514 apply_mcp_standard(obj, binary);
515 }
516 }
517 Ok(cfg)
518}
519
520fn remove_managed_block(target: Target, mut cfg: Value) -> Result<Value> {
522 let obj = match cfg.as_object_mut() {
523 Some(o) => o,
524 None => return Ok(cfg),
525 };
526 match target {
527 Target::ClaudeCode => remove_claude_code(obj),
528 Target::Openclaw => remove_openclaw(obj),
529 Target::Cursor => remove_cursor(obj),
530 Target::Cline => remove_cline(obj),
531 Target::Continue => remove_continue(obj),
532 Target::Windsurf => remove_windsurf(obj),
533 Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
536 remove_mcp_standard(obj);
537 }
538 }
539 Ok(cfg)
540}
541
542fn apply_mcp_standard(obj: &mut Map<String, Value>, binary: &str) {
553 let mcp_servers = obj
554 .entry("mcpServers".to_string())
555 .or_insert_with(|| Value::Object(Map::new()));
556 if !mcp_servers.is_object() {
557 *mcp_servers = Value::Object(Map::new());
558 }
559 let mcp_obj = mcp_servers.as_object_mut().expect("just-inserted object");
560 mcp_obj.insert(
561 "ai-memory".to_string(),
562 serde_json::json!({
563 MARKER_START_KEY: MARKER_PAYLOAD,
564 MANAGED_KEYS_PROPERTY: ["command", "args", "env"],
565 "command": binary,
566 "args": ["mcp", "--profile", "core"],
571 "env": {},
572 MARKER_END_KEY: MARKER_PAYLOAD,
573 }),
574 );
575}
576
577fn remove_mcp_standard(obj: &mut Map<String, Value>) {
578 if let Some(mcp_servers) = obj.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
579 mcp_servers.remove("ai-memory");
580 if mcp_servers.is_empty() {
581 obj.remove("mcpServers");
582 }
583 }
584}
585
586fn ensure_object(v: &mut Value) -> Result<&mut Map<String, Value>> {
587 if !v.is_object() {
588 bail!("existing config root is not a JSON object; refusing to clobber");
589 }
590 Ok(v.as_object_mut().expect("checked is_object"))
591}
592
593fn claude_code_hook_command(binary: &str) -> String {
598 format!("{binary} boot --quiet --limit 10 --budget-tokens 4096")
599}
600
601fn apply_claude_code(obj: &mut Map<String, Value>, binary: &str) {
602 let cmd = claude_code_hook_command(binary);
604 let entry = serde_json::json!({
605 MARKER_START_KEY: MARKER_PAYLOAD,
606 MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
607 "matcher": "*",
608 "hooks": [
609 { "type": "command", "command": cmd }
610 ],
611 MARKER_END_KEY: MARKER_PAYLOAD,
612 });
613
614 let hooks = obj
617 .entry("hooks".to_string())
618 .or_insert_with(|| Value::Object(Map::new()));
619 if !hooks.is_object() {
620 *hooks = Value::Object(Map::new());
621 }
622 let hooks_obj = hooks.as_object_mut().expect("just-inserted object");
623 let session_start = hooks_obj
624 .entry("SessionStart".to_string())
625 .or_insert_with(|| Value::Array(Vec::new()));
626 if !session_start.is_array() {
627 *session_start = Value::Array(Vec::new());
628 }
629 let arr = session_start.as_array_mut().expect("just-inserted array");
630 arr.retain(|v| !is_managed_value(v));
631 arr.insert(0, entry);
632}
633
634fn remove_claude_code(obj: &mut Map<String, Value>) {
635 if let Some(hooks) = obj.get_mut("hooks").and_then(|h| h.as_object_mut())
636 && let Some(arr) = hooks.get_mut("SessionStart").and_then(|s| s.as_array_mut())
637 {
638 arr.retain(|v| !is_managed_value(v));
639 if arr.is_empty() {
640 hooks.remove("SessionStart");
641 }
642 }
643 if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
646 && hooks.is_empty()
647 {
648 obj.remove("hooks");
649 }
650}
651
652fn ai_memory_server_value(binary: &str) -> Value {
655 serde_json::json!({
656 MARKER_START_KEY: MARKER_PAYLOAD,
657 MANAGED_KEYS_PROPERTY: ["command", "args"],
658 "command": binary,
659 "args": ["mcp"],
660 MARKER_END_KEY: MARKER_PAYLOAD,
661 })
662}
663
664fn apply_openclaw(obj: &mut Map<String, Value>, binary: &str) {
665 let mcp = obj
666 .entry("mcp".to_string())
667 .or_insert_with(|| Value::Object(Map::new()));
668 if !mcp.is_object() {
669 *mcp = Value::Object(Map::new());
670 }
671 let mcp_obj = mcp.as_object_mut().expect("just-inserted object");
672 let servers = mcp_obj
673 .entry("servers".to_string())
674 .or_insert_with(|| Value::Object(Map::new()));
675 if !servers.is_object() {
676 *servers = Value::Object(Map::new());
677 }
678 let servers_obj = servers.as_object_mut().expect("just-inserted object");
679 servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
680}
681
682fn remove_openclaw(obj: &mut Map<String, Value>) {
683 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut())
684 && let Some(servers) = mcp.get_mut("servers").and_then(|v| v.as_object_mut())
685 {
686 if let Some(v) = servers.get("ai-memory") {
687 if is_managed_value(v) {
688 servers.remove("ai-memory");
689 }
690 }
691 if servers.is_empty() {
692 mcp.remove("servers");
693 }
694 if mcp.is_empty() {
695 obj.remove("mcp");
696 }
697 }
698}
699
700fn apply_cursor(obj: &mut Map<String, Value>, binary: &str) {
703 let servers = obj
704 .entry("mcpServers".to_string())
705 .or_insert_with(|| Value::Object(Map::new()));
706 if !servers.is_object() {
707 *servers = Value::Object(Map::new());
708 }
709 let servers_obj = servers.as_object_mut().expect("just-inserted object");
710 servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
711}
712
713fn remove_cursor(obj: &mut Map<String, Value>) {
714 if let Some(servers) = obj.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
715 if let Some(v) = servers.get("ai-memory") {
716 if is_managed_value(v) {
717 servers.remove("ai-memory");
718 }
719 }
720 if servers.is_empty() {
721 obj.remove("mcpServers");
722 }
723 }
724}
725
726fn apply_cline(obj: &mut Map<String, Value>, binary: &str) {
729 apply_cursor(obj, binary);
731}
732
733fn remove_cline(obj: &mut Map<String, Value>) {
734 remove_cursor(obj);
735}
736
737fn apply_continue(obj: &mut Map<String, Value>, binary: &str) {
740 let exp = obj
743 .entry("experimental".to_string())
744 .or_insert_with(|| Value::Object(Map::new()));
745 if !exp.is_object() {
746 *exp = Value::Object(Map::new());
747 }
748 let exp_obj = exp.as_object_mut().expect("just-inserted object");
749 let arr = exp_obj
750 .entry("modelContextProtocolServers".to_string())
751 .or_insert_with(|| Value::Array(Vec::new()));
752 if !arr.is_array() {
753 *arr = Value::Array(Vec::new());
754 }
755 let arr = arr.as_array_mut().expect("just-inserted array");
756 arr.retain(|v| !is_managed_value(v));
757 let entry = serde_json::json!({
758 MARKER_START_KEY: MARKER_PAYLOAD,
759 MANAGED_KEYS_PROPERTY: ["transport"],
760 "transport": {
761 "type": "stdio",
762 "command": binary,
763 "args": ["mcp"],
764 },
765 MARKER_END_KEY: MARKER_PAYLOAD,
766 });
767 arr.insert(0, entry);
768}
769
770fn remove_continue(obj: &mut Map<String, Value>) {
771 if let Some(exp) = obj.get_mut("experimental").and_then(|v| v.as_object_mut()) {
772 if let Some(arr) = exp
773 .get_mut("modelContextProtocolServers")
774 .and_then(|v| v.as_array_mut())
775 {
776 arr.retain(|v| !is_managed_value(v));
777 if arr.is_empty() {
778 exp.remove("modelContextProtocolServers");
779 }
780 }
781 if exp.is_empty() {
782 obj.remove("experimental");
783 }
784 }
785}
786
787fn apply_windsurf(obj: &mut Map<String, Value>, binary: &str) {
790 apply_cursor(obj, binary);
791}
792
793fn remove_windsurf(obj: &mut Map<String, Value>) {
794 remove_cursor(obj);
795}
796
797fn is_managed_value(v: &Value) -> bool {
805 v.as_object()
806 .and_then(|o| o.get(MARKER_START_KEY))
807 .is_some()
808}
809
810fn emit_diff(out: &mut CliOutput<'_>, before: &str, after: &str) -> Result<()> {
819 let before_lines: Vec<&str> = before.lines().collect();
820 let after_lines: Vec<&str> = after.lines().collect();
821 let max_len = before_lines.len().max(after_lines.len());
822 for i in 0..max_len {
823 let b = before_lines.get(i).copied();
824 let a = after_lines.get(i).copied();
825 match (b, a) {
826 (Some(bl), Some(al)) if bl == al => writeln!(out.stdout, " {bl}")?,
827 (Some(bl), Some(al)) => {
828 writeln!(out.stdout, "-{bl}")?;
829 writeln!(out.stdout, "+{al}")?;
830 }
831 (Some(bl), None) => writeln!(out.stdout, "-{bl}")?,
832 (None, Some(al)) => writeln!(out.stdout, "+{al}")?,
833 (None, None) => {}
834 }
835 }
836 Ok(())
837}
838
839#[cfg(test)]
844mod tests {
845 use super::*;
846 use crate::cli::test_utils::TestEnv;
847 use std::fs;
848
849 fn args_for(target: Target, config: PathBuf) -> InstallArgs {
850 let t = TargetArgs {
851 config: Some(config),
852 apply: false,
853 dry_run: false,
854 uninstall: false,
855 binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
856 };
857 let target_cmd = match target {
858 Target::ClaudeCode => TargetCmd::ClaudeCode(t),
859 Target::Openclaw => TargetCmd::Openclaw(t),
860 Target::Cursor => TargetCmd::Cursor(t),
861 Target::Cline => TargetCmd::Cline(t),
862 Target::Continue => TargetCmd::Continue(t),
863 Target::Windsurf => TargetCmd::Windsurf(t),
864 Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
865 Target::Codex => TargetCmd::Codex(t),
866 Target::GrokCli => TargetCmd::GrokCli(t),
867 Target::GeminiCli => TargetCmd::GeminiCli(t),
868 };
869 InstallArgs { target: target_cmd }
870 }
871
872 fn args_for_apply(target: Target, config: PathBuf) -> InstallArgs {
873 let mut a = args_for(target, config);
874 match &mut a.target {
875 TargetCmd::ClaudeCode(t)
876 | TargetCmd::Openclaw(t)
877 | TargetCmd::Cursor(t)
878 | TargetCmd::Cline(t)
879 | TargetCmd::Continue(t)
880 | TargetCmd::Windsurf(t)
881 | TargetCmd::ClaudeDesktop(t)
882 | TargetCmd::Codex(t)
883 | TargetCmd::GrokCli(t)
884 | TargetCmd::GeminiCli(t) => {
885 t.apply = true;
886 }
887 }
888 a
889 }
890
891 fn args_for_uninstall_apply(target: Target, config: PathBuf) -> InstallArgs {
892 let mut a = args_for(target, config);
893 match &mut a.target {
894 TargetCmd::ClaudeCode(t)
895 | TargetCmd::Openclaw(t)
896 | TargetCmd::Cursor(t)
897 | TargetCmd::Cline(t)
898 | TargetCmd::Continue(t)
899 | TargetCmd::Windsurf(t)
900 | TargetCmd::ClaudeDesktop(t)
901 | TargetCmd::Codex(t)
902 | TargetCmd::GrokCli(t)
903 | TargetCmd::GeminiCli(t) => {
904 t.uninstall = true;
905 t.apply = true;
906 }
907 }
908 a
909 }
910
911 fn config_path(env: &TestEnv, name: &str) -> PathBuf {
912 env.db_path.parent().unwrap().join(name)
913 }
914
915 fn seed(path: &Path, contents: &str) {
916 if let Some(parent) = path.parent() {
917 fs::create_dir_all(parent).unwrap();
918 }
919 fs::write(path, contents).unwrap();
920 }
921
922 #[test]
927 fn claude_code_install_dry_run_emits_diff_no_writes() {
928 let mut env = TestEnv::fresh();
929 let path = config_path(&env, "settings.json");
930 seed(&path, "{\n}\n");
931 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
932 let args = args_for(Target::ClaudeCode, path.clone());
933 let mut out = env.output();
934 run(&args, &mut out).unwrap();
935 let stdout = std::str::from_utf8(&env.stdout).unwrap();
936 assert!(stdout.contains("dry-run"));
937 assert!(stdout.contains("SessionStart"));
938 assert!(stdout.contains("ai-memory"));
939 assert!(stdout.contains(MARKER_START_KEY));
940 let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
941 assert_eq!(mtime_before, mtime_after, "dry-run must not write");
942 }
943
944 #[test]
945 fn claude_code_install_apply_writes_marker_block() {
946 let mut env = TestEnv::fresh();
947 let path = config_path(&env, "settings.json");
948 seed(&path, "{}\n");
949 let args = args_for_apply(Target::ClaudeCode, path.clone());
950 let mut out = env.output();
951 run(&args, &mut out).unwrap();
952 let written = fs::read_to_string(&path).unwrap();
953 assert!(written.contains(MARKER_START_KEY));
954 assert!(written.contains(MARKER_END_KEY));
955 assert!(written.contains("SessionStart"));
956 assert!(written.contains("ai-memory"));
957 let _: Value = serde_json::from_str(&written).unwrap();
959 }
960
961 #[test]
962 fn claude_code_install_apply_preserves_user_keys() {
963 let mut env = TestEnv::fresh();
964 let path = config_path(&env, "settings.json");
965 seed(
966 &path,
967 r#"{"theme":"dark","permissions":{"allow":["npm:*"]}}"#,
968 );
969 let args = args_for_apply(Target::ClaudeCode, path.clone());
970 let mut out = env.output();
971 run(&args, &mut out).unwrap();
972 let written = fs::read_to_string(&path).unwrap();
973 let parsed: Value = serde_json::from_str(&written).unwrap();
974 assert_eq!(parsed["theme"], "dark");
975 assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
976 assert!(parsed["hooks"]["SessionStart"].is_array());
977 }
978
979 #[test]
980 fn claude_code_install_apply_is_idempotent() {
981 let mut env = TestEnv::fresh();
982 let path = config_path(&env, "settings.json");
983 seed(&path, "{}\n");
984 let args = args_for_apply(Target::ClaudeCode, path.clone());
985 let mut out = env.output();
986 run(&args, &mut out).unwrap();
987 let after_first = fs::read_to_string(&path).unwrap();
988 env.stdout.clear();
990 let mut out2 = env.output();
991 run(&args, &mut out2).unwrap();
992 let after_second = fs::read_to_string(&path).unwrap();
993 assert_eq!(after_first, after_second);
994 let stdout2 = std::str::from_utf8(&env.stdout).unwrap();
995 assert!(
996 stdout2.contains("no-op"),
997 "second install should be no-op: {stdout2}"
998 );
999 }
1000
1001 #[test]
1002 fn claude_code_uninstall_removes_marker_block_only() {
1003 let mut env = TestEnv::fresh();
1004 let path = config_path(&env, "settings.json");
1005 let original = "{\n \"theme\": \"dark\"\n}\n";
1006 seed(&path, original);
1007 run(
1009 &args_for_apply(Target::ClaudeCode, path.clone()),
1010 &mut env.output(),
1011 )
1012 .unwrap();
1013 let after_install = fs::read_to_string(&path).unwrap();
1014 assert!(after_install.contains(MARKER_START_KEY));
1015 run(
1016 &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
1017 &mut env.output(),
1018 )
1019 .unwrap();
1020 let after_uninstall = fs::read_to_string(&path).unwrap();
1021 let parsed: Value = serde_json::from_str(&after_uninstall).unwrap();
1022 assert_eq!(parsed["theme"], "dark");
1023 assert!(
1024 parsed.get("hooks").is_none(),
1025 "hooks should be gone after uninstall"
1026 );
1027 assert!(!after_uninstall.contains(MARKER_START_KEY));
1028 }
1029
1030 #[test]
1031 fn claude_code_install_refuses_malformed_config() {
1032 let mut env = TestEnv::fresh();
1033 let path = config_path(&env, "settings.json");
1034 seed(&path, "{not valid json");
1035 let args = args_for_apply(Target::ClaudeCode, path.clone());
1036 let mut out = env.output();
1037 let err = run(&args, &mut out).unwrap_err();
1038 let msg = format!("{err}");
1039 assert!(
1040 msg.contains("not valid JSON"),
1041 "error should explain malformed json: {msg}"
1042 );
1043 let still = fs::read_to_string(&path).unwrap();
1045 assert_eq!(still, "{not valid json");
1046 }
1047
1048 #[test]
1049 fn claude_code_install_writes_backup_file() {
1050 let mut env = TestEnv::fresh();
1051 let path = config_path(&env, "settings.json");
1052 seed(&path, "{}\n");
1053 let args = args_for_apply(Target::ClaudeCode, path.clone());
1054 let mut out = env.output();
1055 run(&args, &mut out).unwrap();
1056 let parent = path.parent().unwrap();
1058 let backups: Vec<_> = fs::read_dir(parent)
1059 .unwrap()
1060 .filter_map(|e| e.ok())
1061 .filter(|e| {
1062 e.file_name()
1063 .to_string_lossy()
1064 .starts_with("settings.json.bak.")
1065 || e.file_name().to_string_lossy().starts_with("settings.bak.")
1066 })
1067 .collect();
1068 assert!(
1069 !backups.is_empty(),
1070 "expected a settings.bak.<ts> backup beside the config; saw: {:?}",
1071 fs::read_dir(parent)
1072 .unwrap()
1073 .filter_map(|e| e.ok())
1074 .map(|e| e.file_name())
1075 .collect::<Vec<_>>()
1076 );
1077 }
1078
1079 #[test]
1084 fn cursor_install_dry_run_emits_diff_no_writes() {
1085 let mut env = TestEnv::fresh();
1086 let path = config_path(&env, "mcp.json");
1087 seed(&path, "{}\n");
1088 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1089 let args = args_for(Target::Cursor, path.clone());
1090 let mut out = env.output();
1091 run(&args, &mut out).unwrap();
1092 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1093 assert!(stdout.contains("dry-run"));
1094 assert!(stdout.contains("mcpServers"));
1095 let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
1096 assert_eq!(mtime_before, mtime_after);
1097 }
1098
1099 #[test]
1100 fn cursor_install_apply_writes_marker_block() {
1101 let mut env = TestEnv::fresh();
1102 let path = config_path(&env, "mcp.json");
1103 seed(&path, "{}\n");
1104 let args = args_for_apply(Target::Cursor, path.clone());
1105 run(&args, &mut env.output()).unwrap();
1106 let written = fs::read_to_string(&path).unwrap();
1107 let parsed: Value = serde_json::from_str(&written).unwrap();
1108 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1109 assert_eq!(
1110 parsed["mcpServers"]["ai-memory"]["command"],
1111 "/usr/local/bin/ai-memory"
1112 );
1113 }
1114
1115 #[test]
1116 fn cursor_install_apply_preserves_user_keys() {
1117 let mut env = TestEnv::fresh();
1118 let path = config_path(&env, "mcp.json");
1119 seed(
1120 &path,
1121 r#"{"mcpServers":{"my-other":{"command":"x"}},"telemetry":false}"#,
1122 );
1123 run(
1124 &args_for_apply(Target::Cursor, path.clone()),
1125 &mut env.output(),
1126 )
1127 .unwrap();
1128 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1129 assert_eq!(parsed["telemetry"], false);
1130 assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1131 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1132 }
1133
1134 #[test]
1135 fn cursor_install_apply_is_idempotent() {
1136 let mut env = TestEnv::fresh();
1137 let path = config_path(&env, "mcp.json");
1138 seed(&path, "{}\n");
1139 let args = args_for_apply(Target::Cursor, path.clone());
1140 run(&args, &mut env.output()).unwrap();
1141 let first = fs::read_to_string(&path).unwrap();
1142 run(&args, &mut env.output()).unwrap();
1143 let second = fs::read_to_string(&path).unwrap();
1144 assert_eq!(first, second);
1145 }
1146
1147 #[test]
1148 fn cursor_uninstall_removes_marker_block_only() {
1149 let mut env = TestEnv::fresh();
1150 let path = config_path(&env, "mcp.json");
1151 let original = r#"{"mcpServers":{"my-other":{"command":"x"}}}"#;
1152 seed(&path, original);
1153 run(
1154 &args_for_apply(Target::Cursor, path.clone()),
1155 &mut env.output(),
1156 )
1157 .unwrap();
1158 run(
1159 &args_for_uninstall_apply(Target::Cursor, path.clone()),
1160 &mut env.output(),
1161 )
1162 .unwrap();
1163 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1164 assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1165 assert!(
1166 parsed["mcpServers"]
1167 .as_object()
1168 .unwrap()
1169 .get("ai-memory")
1170 .is_none()
1171 );
1172 }
1173
1174 #[test]
1175 fn cursor_install_refuses_malformed_config() {
1176 let mut env = TestEnv::fresh();
1177 let path = config_path(&env, "mcp.json");
1178 seed(&path, "not json");
1179 let args = args_for_apply(Target::Cursor, path.clone());
1180 let err = run(&args, &mut env.output()).unwrap_err();
1181 assert!(format!("{err}").contains("not valid JSON"));
1182 }
1183
1184 #[test]
1185 fn cursor_install_writes_backup_file() {
1186 let mut env = TestEnv::fresh();
1187 let path = config_path(&env, "mcp.json");
1188 seed(&path, "{}\n");
1189 run(
1190 &args_for_apply(Target::Cursor, path.clone()),
1191 &mut env.output(),
1192 )
1193 .unwrap();
1194 let parent = path.parent().unwrap();
1195 let any_backup = fs::read_dir(parent)
1196 .unwrap()
1197 .filter_map(|e| e.ok())
1198 .any(|e| e.file_name().to_string_lossy().contains("bak."));
1199 assert!(any_backup);
1200 }
1201
1202 #[test]
1207 fn openclaw_install_dry_run_emits_diff_no_writes() {
1208 let mut env = TestEnv::fresh();
1209 let path = config_path(&env, "openclaw.json");
1210 seed(&path, "{}\n");
1211 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1212 run(&args_for(Target::Openclaw, path.clone()), &mut env.output()).unwrap();
1213 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1214 assert!(stdout.contains("dry-run"));
1215 assert!(stdout.contains("mcp"));
1216 assert_eq!(
1217 mtime_before,
1218 fs::metadata(&path).unwrap().modified().unwrap()
1219 );
1220 }
1221
1222 #[test]
1223 fn openclaw_install_apply_writes_marker_block() {
1224 let mut env = TestEnv::fresh();
1225 let path = config_path(&env, "openclaw.json");
1226 seed(&path, "{}\n");
1227 run(
1228 &args_for_apply(Target::Openclaw, path.clone()),
1229 &mut env.output(),
1230 )
1231 .unwrap();
1232 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1233 assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1234 }
1235
1236 #[test]
1237 fn openclaw_install_apply_preserves_user_keys() {
1238 let mut env = TestEnv::fresh();
1239 let path = config_path(&env, "openclaw.json");
1240 seed(
1241 &path,
1242 r#"{"mcp":{"servers":{"other":{"command":"y"}}},"editor":"vim"}"#,
1243 );
1244 run(
1245 &args_for_apply(Target::Openclaw, path.clone()),
1246 &mut env.output(),
1247 )
1248 .unwrap();
1249 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1250 assert_eq!(parsed["editor"], "vim");
1251 assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1252 assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1253 }
1254
1255 #[test]
1256 fn openclaw_install_apply_is_idempotent() {
1257 let mut env = TestEnv::fresh();
1258 let path = config_path(&env, "openclaw.json");
1259 seed(&path, "{}\n");
1260 let args = args_for_apply(Target::Openclaw, path.clone());
1261 run(&args, &mut env.output()).unwrap();
1262 let first = fs::read_to_string(&path).unwrap();
1263 run(&args, &mut env.output()).unwrap();
1264 let second = fs::read_to_string(&path).unwrap();
1265 assert_eq!(first, second);
1266 }
1267
1268 #[test]
1269 fn openclaw_uninstall_removes_marker_block_only() {
1270 let mut env = TestEnv::fresh();
1271 let path = config_path(&env, "openclaw.json");
1272 seed(&path, r#"{"mcp":{"servers":{"other":{"command":"y"}}}}"#);
1273 run(
1274 &args_for_apply(Target::Openclaw, path.clone()),
1275 &mut env.output(),
1276 )
1277 .unwrap();
1278 run(
1279 &args_for_uninstall_apply(Target::Openclaw, path.clone()),
1280 &mut env.output(),
1281 )
1282 .unwrap();
1283 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1284 assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1285 assert!(
1286 parsed["mcp"]["servers"]
1287 .as_object()
1288 .unwrap()
1289 .get("ai-memory")
1290 .is_none()
1291 );
1292 }
1293
1294 #[test]
1295 fn openclaw_install_refuses_malformed_config() {
1296 let mut env = TestEnv::fresh();
1297 let path = config_path(&env, "openclaw.json");
1298 seed(&path, "garbage");
1299 let err = run(
1300 &args_for_apply(Target::Openclaw, path.clone()),
1301 &mut env.output(),
1302 )
1303 .unwrap_err();
1304 assert!(format!("{err}").contains("not valid JSON"));
1305 }
1306
1307 #[test]
1308 fn openclaw_install_writes_backup_file() {
1309 let mut env = TestEnv::fresh();
1310 let path = config_path(&env, "openclaw.json");
1311 seed(&path, "{}\n");
1312 run(
1313 &args_for_apply(Target::Openclaw, path.clone()),
1314 &mut env.output(),
1315 )
1316 .unwrap();
1317 let parent = path.parent().unwrap();
1318 assert!(
1319 fs::read_dir(parent)
1320 .unwrap()
1321 .filter_map(|e| e.ok())
1322 .any(|e| e.file_name().to_string_lossy().contains("bak."))
1323 );
1324 }
1325
1326 #[test]
1331 fn cline_install_dry_run_emits_diff_no_writes() {
1332 let mut env = TestEnv::fresh();
1333 let path = config_path(&env, "cline.json");
1334 seed(&path, "{}\n");
1335 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1336 run(&args_for(Target::Cline, path.clone()), &mut env.output()).unwrap();
1337 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1338 assert!(stdout.contains("dry-run"));
1339 assert!(stdout.contains("mcpServers"));
1340 assert_eq!(
1341 mtime_before,
1342 fs::metadata(&path).unwrap().modified().unwrap()
1343 );
1344 }
1345
1346 #[test]
1347 fn cline_install_apply_writes_marker_block() {
1348 let mut env = TestEnv::fresh();
1349 let path = config_path(&env, "cline.json");
1350 seed(&path, "{}\n");
1351 run(
1352 &args_for_apply(Target::Cline, path.clone()),
1353 &mut env.output(),
1354 )
1355 .unwrap();
1356 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1357 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1358 }
1359
1360 #[test]
1361 fn cline_install_apply_preserves_user_keys() {
1362 let mut env = TestEnv::fresh();
1363 let path = config_path(&env, "cline.json");
1364 seed(&path, r#"{"mcpServers":{"x":{"command":"q"}},"foo":1}"#);
1365 run(
1366 &args_for_apply(Target::Cline, path.clone()),
1367 &mut env.output(),
1368 )
1369 .unwrap();
1370 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1371 assert_eq!(parsed["foo"], 1);
1372 assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
1373 }
1374
1375 #[test]
1376 fn cline_install_apply_is_idempotent() {
1377 let mut env = TestEnv::fresh();
1378 let path = config_path(&env, "cline.json");
1379 seed(&path, "{}\n");
1380 let args = args_for_apply(Target::Cline, path.clone());
1381 run(&args, &mut env.output()).unwrap();
1382 let first = fs::read_to_string(&path).unwrap();
1383 run(&args, &mut env.output()).unwrap();
1384 let second = fs::read_to_string(&path).unwrap();
1385 assert_eq!(first, second);
1386 }
1387
1388 #[test]
1389 fn cline_uninstall_removes_marker_block_only() {
1390 let mut env = TestEnv::fresh();
1391 let path = config_path(&env, "cline.json");
1392 seed(&path, r#"{"mcpServers":{"x":{"command":"q"}}}"#);
1393 run(
1394 &args_for_apply(Target::Cline, path.clone()),
1395 &mut env.output(),
1396 )
1397 .unwrap();
1398 run(
1399 &args_for_uninstall_apply(Target::Cline, path.clone()),
1400 &mut env.output(),
1401 )
1402 .unwrap();
1403 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1404 assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
1405 assert!(
1406 parsed["mcpServers"]
1407 .as_object()
1408 .unwrap()
1409 .get("ai-memory")
1410 .is_none()
1411 );
1412 }
1413
1414 #[test]
1415 fn cline_install_refuses_malformed_config() {
1416 let mut env = TestEnv::fresh();
1417 let path = config_path(&env, "cline.json");
1418 seed(&path, "totally not json");
1419 let err = run(
1420 &args_for_apply(Target::Cline, path.clone()),
1421 &mut env.output(),
1422 )
1423 .unwrap_err();
1424 assert!(format!("{err}").contains("not valid JSON"));
1425 }
1426
1427 #[test]
1428 fn cline_install_writes_backup_file() {
1429 let mut env = TestEnv::fresh();
1430 let path = config_path(&env, "cline.json");
1431 seed(&path, "{}\n");
1432 run(
1433 &args_for_apply(Target::Cline, path.clone()),
1434 &mut env.output(),
1435 )
1436 .unwrap();
1437 assert!(
1438 fs::read_dir(path.parent().unwrap())
1439 .unwrap()
1440 .filter_map(|e| e.ok())
1441 .any(|e| e.file_name().to_string_lossy().contains("bak."))
1442 );
1443 }
1444
1445 #[test]
1450 fn continue_install_dry_run_emits_diff_no_writes() {
1451 let mut env = TestEnv::fresh();
1452 let path = config_path(&env, "continue.json");
1453 seed(&path, "{}\n");
1454 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1455 run(&args_for(Target::Continue, path.clone()), &mut env.output()).unwrap();
1456 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1457 assert!(stdout.contains("dry-run"));
1458 assert!(stdout.contains("modelContextProtocolServers"));
1459 assert_eq!(
1460 mtime_before,
1461 fs::metadata(&path).unwrap().modified().unwrap()
1462 );
1463 }
1464
1465 #[test]
1466 fn continue_install_apply_writes_marker_block() {
1467 let mut env = TestEnv::fresh();
1468 let path = config_path(&env, "continue.json");
1469 seed(&path, "{}\n");
1470 run(
1471 &args_for_apply(Target::Continue, path.clone()),
1472 &mut env.output(),
1473 )
1474 .unwrap();
1475 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1476 let arr = parsed["experimental"]["modelContextProtocolServers"]
1477 .as_array()
1478 .unwrap();
1479 assert!(arr.iter().any(is_managed_value));
1480 }
1481
1482 #[test]
1483 fn continue_install_apply_preserves_user_keys() {
1484 let mut env = TestEnv::fresh();
1485 let path = config_path(&env, "continue.json");
1486 seed(
1487 &path,
1488 r#"{"models":[{"name":"x"}],"experimental":{"foo":true}}"#,
1489 );
1490 run(
1491 &args_for_apply(Target::Continue, path.clone()),
1492 &mut env.output(),
1493 )
1494 .unwrap();
1495 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1496 assert_eq!(parsed["models"][0]["name"], "x");
1497 assert_eq!(parsed["experimental"]["foo"], true);
1498 }
1499
1500 #[test]
1501 fn continue_install_apply_is_idempotent() {
1502 let mut env = TestEnv::fresh();
1503 let path = config_path(&env, "continue.json");
1504 seed(&path, "{}\n");
1505 let args = args_for_apply(Target::Continue, path.clone());
1506 run(&args, &mut env.output()).unwrap();
1507 let first = fs::read_to_string(&path).unwrap();
1508 run(&args, &mut env.output()).unwrap();
1509 let second = fs::read_to_string(&path).unwrap();
1510 assert_eq!(first, second);
1511 }
1512
1513 #[test]
1514 fn continue_uninstall_removes_marker_block_only() {
1515 let mut env = TestEnv::fresh();
1516 let path = config_path(&env, "continue.json");
1517 seed(&path, r#"{"models":[{"name":"x"}]}"#);
1518 run(
1519 &args_for_apply(Target::Continue, path.clone()),
1520 &mut env.output(),
1521 )
1522 .unwrap();
1523 run(
1524 &args_for_uninstall_apply(Target::Continue, path.clone()),
1525 &mut env.output(),
1526 )
1527 .unwrap();
1528 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1529 assert_eq!(parsed["models"][0]["name"], "x");
1530 assert!(parsed.get("experimental").is_none());
1531 }
1532
1533 #[test]
1534 fn continue_install_refuses_malformed_config() {
1535 let mut env = TestEnv::fresh();
1536 let path = config_path(&env, "continue.json");
1537 seed(&path, "[1,2,");
1538 let err = run(
1539 &args_for_apply(Target::Continue, path.clone()),
1540 &mut env.output(),
1541 )
1542 .unwrap_err();
1543 assert!(format!("{err}").contains("not valid JSON"));
1544 }
1545
1546 #[test]
1547 fn continue_install_writes_backup_file() {
1548 let mut env = TestEnv::fresh();
1549 let path = config_path(&env, "continue.json");
1550 seed(&path, "{}\n");
1551 run(
1552 &args_for_apply(Target::Continue, path.clone()),
1553 &mut env.output(),
1554 )
1555 .unwrap();
1556 assert!(
1557 fs::read_dir(path.parent().unwrap())
1558 .unwrap()
1559 .filter_map(|e| e.ok())
1560 .any(|e| e.file_name().to_string_lossy().contains("bak."))
1561 );
1562 }
1563
1564 #[test]
1569 fn windsurf_install_dry_run_emits_diff_no_writes() {
1570 let mut env = TestEnv::fresh();
1571 let path = config_path(&env, "mcp_config.json");
1572 seed(&path, "{}\n");
1573 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1574 run(&args_for(Target::Windsurf, path.clone()), &mut env.output()).unwrap();
1575 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1576 assert!(stdout.contains("dry-run"));
1577 assert!(stdout.contains("mcpServers"));
1578 assert_eq!(
1579 mtime_before,
1580 fs::metadata(&path).unwrap().modified().unwrap()
1581 );
1582 }
1583
1584 #[test]
1585 fn windsurf_install_apply_writes_marker_block() {
1586 let mut env = TestEnv::fresh();
1587 let path = config_path(&env, "mcp_config.json");
1588 seed(&path, "{}\n");
1589 run(
1590 &args_for_apply(Target::Windsurf, path.clone()),
1591 &mut env.output(),
1592 )
1593 .unwrap();
1594 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1595 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1596 }
1597
1598 #[test]
1599 fn windsurf_install_apply_preserves_user_keys() {
1600 let mut env = TestEnv::fresh();
1601 let path = config_path(&env, "mcp_config.json");
1602 seed(&path, r#"{"mcpServers":{"k":{"command":"l"}},"a":42}"#);
1603 run(
1604 &args_for_apply(Target::Windsurf, path.clone()),
1605 &mut env.output(),
1606 )
1607 .unwrap();
1608 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1609 assert_eq!(parsed["a"], 42);
1610 assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
1611 }
1612
1613 #[test]
1614 fn windsurf_install_apply_is_idempotent() {
1615 let mut env = TestEnv::fresh();
1616 let path = config_path(&env, "mcp_config.json");
1617 seed(&path, "{}\n");
1618 let args = args_for_apply(Target::Windsurf, path.clone());
1619 run(&args, &mut env.output()).unwrap();
1620 let first = fs::read_to_string(&path).unwrap();
1621 run(&args, &mut env.output()).unwrap();
1622 let second = fs::read_to_string(&path).unwrap();
1623 assert_eq!(first, second);
1624 }
1625
1626 #[test]
1627 fn windsurf_uninstall_removes_marker_block_only() {
1628 let mut env = TestEnv::fresh();
1629 let path = config_path(&env, "mcp_config.json");
1630 seed(&path, r#"{"mcpServers":{"k":{"command":"l"}}}"#);
1631 run(
1632 &args_for_apply(Target::Windsurf, path.clone()),
1633 &mut env.output(),
1634 )
1635 .unwrap();
1636 run(
1637 &args_for_uninstall_apply(Target::Windsurf, path.clone()),
1638 &mut env.output(),
1639 )
1640 .unwrap();
1641 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1642 assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
1643 assert!(
1644 parsed["mcpServers"]
1645 .as_object()
1646 .unwrap()
1647 .get("ai-memory")
1648 .is_none()
1649 );
1650 }
1651
1652 #[test]
1653 fn windsurf_install_refuses_malformed_config() {
1654 let mut env = TestEnv::fresh();
1655 let path = config_path(&env, "mcp_config.json");
1656 seed(&path, "::");
1657 let err = run(
1658 &args_for_apply(Target::Windsurf, path.clone()),
1659 &mut env.output(),
1660 )
1661 .unwrap_err();
1662 assert!(format!("{err}").contains("not valid JSON"));
1663 }
1664
1665 #[test]
1666 fn windsurf_install_writes_backup_file() {
1667 let mut env = TestEnv::fresh();
1668 let path = config_path(&env, "mcp_config.json");
1669 seed(&path, "{}\n");
1670 run(
1671 &args_for_apply(Target::Windsurf, path.clone()),
1672 &mut env.output(),
1673 )
1674 .unwrap();
1675 assert!(
1676 fs::read_dir(path.parent().unwrap())
1677 .unwrap()
1678 .filter_map(|e| e.ok())
1679 .any(|e| e.file_name().to_string_lossy().contains("bak."))
1680 );
1681 }
1682
1683 #[test]
1688 fn install_creates_missing_config_file_under_apply() {
1689 let mut env = TestEnv::fresh();
1690 let path = config_path(&env, "fresh-config.json");
1691 assert!(!path.exists());
1692 run(
1693 &args_for_apply(Target::Cursor, path.clone()),
1694 &mut env.output(),
1695 )
1696 .unwrap();
1697 assert!(path.exists());
1698 let _: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1699 }
1700
1701 #[test]
1702 fn install_round_trip_install_then_uninstall_restores_original_for_empty_seed() {
1703 let mut env = TestEnv::fresh();
1706 let path = config_path(&env, "rt.json");
1707 seed(&path, "{}\n");
1708 run(
1709 &args_for_apply(Target::Cursor, path.clone()),
1710 &mut env.output(),
1711 )
1712 .unwrap();
1713 run(
1714 &args_for_uninstall_apply(Target::Cursor, path.clone()),
1715 &mut env.output(),
1716 )
1717 .unwrap();
1718 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1719 assert_eq!(parsed, serde_json::json!({}));
1720 }
1721
1722 #[test]
1723 fn resolve_binary_uses_override_when_provided() {
1724 let p = std::path::PathBuf::from("/custom/path/ai-memory");
1725 let resolved = resolve_binary(Some(&p));
1726 assert_eq!(resolved, "/custom/path/ai-memory");
1727 }
1728
1729 fn assert_mcp_standard_apply(target: Target, fname: &str) {
1737 let mut env = TestEnv::fresh();
1738 let path = config_path(&env, fname);
1739 seed(&path, "{}\n");
1740 run(&args_for_apply(target, path.clone()), &mut env.output()).unwrap();
1741 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1742 assert!(
1744 parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string(),
1745 "{} missing managed-block marker",
1746 target.name()
1747 );
1748 let args = parsed["mcpServers"]["ai-memory"]["args"]
1750 .as_array()
1751 .unwrap();
1752 let strs: Vec<&str> = args.iter().filter_map(Value::as_str).collect();
1753 assert_eq!(
1754 strs,
1755 vec!["mcp", "--profile", "core"],
1756 "{} should write `mcp --profile core` args",
1757 target.name()
1758 );
1759 let cmd = parsed["mcpServers"]["ai-memory"]["command"]
1760 .as_str()
1761 .unwrap();
1762 assert_eq!(cmd, "/usr/local/bin/ai-memory");
1763 }
1764
1765 #[test]
1766 fn claude_desktop_apply_writes_mcp_standard_with_profile_core() {
1767 assert_mcp_standard_apply(Target::ClaudeDesktop, "claude_desktop_config.json");
1768 }
1769
1770 #[test]
1771 fn codex_apply_writes_mcp_standard_with_profile_core() {
1772 assert_mcp_standard_apply(Target::Codex, "codex_config.json");
1773 }
1774
1775 #[test]
1776 fn grok_cli_apply_writes_mcp_standard_with_profile_core() {
1777 assert_mcp_standard_apply(Target::GrokCli, "grok_mcp.json");
1778 }
1779
1780 #[test]
1781 fn gemini_cli_apply_writes_mcp_standard_with_profile_core() {
1782 assert_mcp_standard_apply(Target::GeminiCli, "gemini_mcp.json");
1783 }
1784
1785 #[test]
1786 fn mcp_standard_uninstall_round_trip_restores_empty() {
1787 let mut env = TestEnv::fresh();
1788 let path = config_path(&env, "claude_desktop_config.json");
1789 seed(&path, "{}\n");
1790 run(
1791 &args_for_apply(Target::ClaudeDesktop, path.clone()),
1792 &mut env.output(),
1793 )
1794 .unwrap();
1795 run(
1796 &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
1797 &mut env.output(),
1798 )
1799 .unwrap();
1800 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1801 assert!(
1803 !parsed.as_object().unwrap().contains_key("mcpServers"),
1804 "uninstall should remove the empty mcpServers wrapper"
1805 );
1806 }
1807
1808 #[test]
1809 fn mcp_standard_apply_preserves_user_keys() {
1810 let mut env = TestEnv::fresh();
1811 let path = config_path(&env, "codex_config.json");
1812 seed(
1813 &path,
1814 r#"{"mcpServers":{"other-mcp":{"command":"x","args":[]}},"unrelated":42}"#,
1815 );
1816 run(
1817 &args_for_apply(Target::Codex, path.clone()),
1818 &mut env.output(),
1819 )
1820 .unwrap();
1821 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1822 assert_eq!(parsed["mcpServers"]["other-mcp"]["command"], "x");
1824 assert_eq!(parsed["unrelated"], 42);
1826 assert!(parsed["mcpServers"]["ai-memory"].is_object());
1828 }
1829}