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 EXPECT_JUST_INSERTED_OBJECT: &str = "just-inserted object";
49const EXPECT_JUST_INSERTED_ARRAY: &str = "just-inserted array";
50
51const MARKER_START_KEY: &str = "// ai-memory:managed-block:start";
55const MARKER_END_KEY: &str = "// ai-memory:managed-block:end";
56
57const MARKER_PAYLOAD: &str = "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487";
61
62const MANAGED_KEYS_PROPERTY: &str = "// ai-memory:managed-keys";
67
68const AGENT_TARGET_CLAUDE_CODE: &str = "claude-code";
73
74#[cfg(any(target_os = "macos", target_os = "windows"))]
78const CLAUDE_DESKTOP_CONFIG_FILENAME: &str = "claude_desktop_config.json";
79
80pub(crate) const KEY_MCP_SERVERS: &str = "mcpServers";
85
86const KEY_EXPERIMENTAL: &str = "experimental";
89
90const KEY_MODEL_CONTEXT_PROTOCOL_SERVERS: &str = "modelContextProtocolServers";
92
93const HOOK_EVENT_SESSION_START: &str = "SessionStart";
95
96const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse";
98
99#[derive(Args, Debug)]
101pub struct InstallArgs {
102 #[command(subcommand)]
104 pub target: TargetCmd,
105}
106
107#[derive(Subcommand, Debug)]
112pub enum TargetCmd {
113 ClaudeCode(TargetArgs),
115 Openclaw(TargetArgs),
119 Cursor(TargetArgs),
121 Cline(TargetArgs),
124 Continue(TargetArgs),
126 Windsurf(TargetArgs),
129
130 ClaudeDesktop(TargetArgs),
135 Codex(TargetArgs),
139 GrokCli(TargetArgs),
143 GeminiCli(TargetArgs),
147}
148
149#[derive(Args, Debug, Default, Clone)]
152pub struct TargetArgs {
153 #[arg(long, value_name = "PATH")]
157 pub config: Option<PathBuf>,
158
159 #[arg(long, default_value_t = false, conflicts_with = "dry_run")]
164 pub apply: bool,
165
166 #[arg(long, default_value_t = false)]
170 pub dry_run: bool,
171
172 #[arg(long, default_value_t = false)]
175 pub uninstall: bool,
176
177 #[arg(long, value_name = "PATH")]
182 pub binary: Option<PathBuf>,
183
184 #[arg(long, value_name = "KIND")]
193 pub hook: Option<HookKind>,
194
195 #[arg(long, default_value_t = false)]
201 pub force: bool,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
208pub enum HookKind {
209 Pretool,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
220pub enum Target {
221 ClaudeCode,
222 Openclaw,
223 Cursor,
224 Cline,
225 Continue,
226 Windsurf,
227 ClaudeDesktop,
229 Codex,
230 GrokCli,
231 GeminiCli,
232}
233
234impl Target {
235 fn name(self) -> &'static str {
237 match self {
238 Self::ClaudeCode => AGENT_TARGET_CLAUDE_CODE,
239 Self::Openclaw => "openclaw",
240 Self::Cursor => "cursor",
241 Self::Cline => "cline",
242 Self::Continue => "continue",
243 Self::Windsurf => "windsurf",
244 Self::ClaudeDesktop => "claude-desktop",
245 Self::Codex => "codex",
246 Self::GrokCli => "grok-cli",
247 Self::GeminiCli => "gemini-cli",
248 }
249 }
250}
251
252impl TargetCmd {
253 fn target(&self) -> Target {
254 match self {
255 Self::ClaudeCode(_) => Target::ClaudeCode,
256 Self::Openclaw(_) => Target::Openclaw,
257 Self::Cursor(_) => Target::Cursor,
258 Self::Cline(_) => Target::Cline,
259 Self::Continue(_) => Target::Continue,
260 Self::Windsurf(_) => Target::Windsurf,
261 Self::ClaudeDesktop(_) => Target::ClaudeDesktop,
262 Self::Codex(_) => Target::Codex,
263 Self::GrokCli(_) => Target::GrokCli,
264 Self::GeminiCli(_) => Target::GeminiCli,
265 }
266 }
267
268 fn args(&self) -> &TargetArgs {
269 match self {
270 Self::ClaudeCode(a)
271 | Self::Openclaw(a)
272 | Self::Cursor(a)
273 | Self::Cline(a)
274 | Self::Continue(a)
275 | Self::Windsurf(a)
276 | Self::ClaudeDesktop(a)
277 | Self::Codex(a)
278 | Self::GrokCli(a)
279 | Self::GeminiCli(a) => a,
280 }
281 }
282}
283
284pub fn run(args: &InstallArgs, out: &mut CliOutput<'_>) -> Result<()> {
293 let target = args.target.target();
294 let t_args = args.target.args();
295
296 if t_args.hook.is_some() && target != Target::ClaudeCode {
299 bail!(
300 "--hook {kind:?} is only supported for `claude-code` today; \
301 other harnesses do not expose a PreToolUse-equivalent hook surface.",
302 kind = t_args.hook.unwrap(),
303 );
304 }
305
306 let config_path = resolve_config_path(target, t_args)?;
307 let binary = resolve_binary(t_args.binary.as_deref());
308
309 let (before_text, before_value) = read_config_or_empty(&config_path)?;
313
314 let config_format = ConfigFormat::detect(&config_path);
320
321 let after_value = if let Some(hook_kind) = t_args.hook {
323 if t_args.uninstall {
324 remove_hook_block(target, hook_kind, before_value.clone())?
325 } else {
326 apply_hook_block(target, hook_kind, before_value.clone(), t_args.force, out)?
327 }
328 } else if t_args.uninstall {
329 remove_managed_block(target, before_value.clone(), config_format)?
330 } else {
331 apply_managed_block(target, before_value.clone(), &binary, config_format)?
332 };
333
334 let config_format = ConfigFormat::detect(&config_path);
339 let after_text = match config_format {
340 ConfigFormat::Json => serde_json::to_string_pretty(&after_value)? + "\n",
341 ConfigFormat::Toml => {
342 let toml_value: toml::Value = toml::Value::try_from(&after_value).map_err(|e| {
346 anyhow!("internal error: cannot convert JSON Value into toml::Value ({e})")
347 })?;
348 toml::to_string_pretty(&toml_value)
349 .map_err(|e| anyhow!("internal error: cannot serialize TOML Value: {e}"))?
350 }
351 };
352
353 match config_format {
356 ConfigFormat::Json => {
357 let _: Value = serde_json::from_str(&after_text).context(
358 "internal error: serialised config did not round-trip through JSON parser",
359 )?;
360 }
361 ConfigFormat::Toml => {
362 let _: toml::Value = toml::from_str(&after_text).context(
363 "internal error: serialised config did not round-trip through TOML parser",
364 )?;
365 }
366 }
367
368 let action_label = if t_args.uninstall {
369 "uninstall"
370 } else {
371 "install"
372 };
373
374 if before_text.trim() == after_text.trim() {
375 writeln!(
376 out.stdout,
377 "ai-memory install: {target} {action} is a no-op (managed block already in desired state)",
378 target = target.name(),
379 action = action_label,
380 )?;
381 return Ok(());
382 }
383
384 if !t_args.apply {
385 writeln!(
388 out.stdout,
389 "ai-memory install: dry-run for {target} {action} at {path}",
390 target = target.name(),
391 action = action_label,
392 path = config_path.display(),
393 )?;
394 writeln!(out.stdout, "--- before")?;
395 writeln!(out.stdout, "+++ after")?;
396 emit_diff(out, &before_text, &after_text)?;
397 writeln!(
398 out.stdout,
399 "ai-memory install: re-run with --apply to write the changes"
400 )?;
401 return Ok(());
402 }
403
404 let backup_path = if config_path.exists() {
406 let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
407 let backup = config_path.with_extension(format!(
408 "{ext}bak.{ts}",
409 ext = match config_path.extension().and_then(|e| e.to_str()) {
410 Some(existing) => format!("{existing}."),
411 None => String::new(),
412 }
413 ));
414 std::fs::copy(&config_path, &backup).with_context(|| {
415 format!(
416 "backing up {} to {}",
417 config_path.display(),
418 backup.display()
419 )
420 })?;
421 Some(backup)
422 } else {
423 None
424 };
425
426 if let Some(parent) = config_path.parent()
427 && !parent.as_os_str().is_empty()
428 {
429 std::fs::create_dir_all(parent)
430 .with_context(|| format!("creating parent directory {}", parent.display()))?;
431 }
432
433 std::fs::write(&config_path, &after_text)
434 .with_context(|| crate::errors::msg::writing(config_path.display()))?;
435
436 writeln!(
437 out.stdout,
438 "ai-memory install: {action} applied to {path}",
439 action = action_label,
440 path = config_path.display(),
441 )?;
442
443 if let Some(hook_kind) = t_args.hook
447 && !t_args.uninstall
448 {
449 match hook_kind {
450 HookKind::Pretool => {
451 writeln!(
452 out.stdout,
453 "installed PreToolUse hook -> {}",
454 config_path.display(),
455 )?;
456 }
457 }
458 }
459 if let Some(b) = backup_path {
460 writeln!(out.stdout, "ai-memory install: backup at {}", b.display())?;
461 }
462
463 if !t_args.uninstall && t_args.hook.is_none() {
472 match write_system_prompt_snippet(target) {
473 Ok(snippet_path) => {
474 writeln!(
475 out.stderr,
476 "ai-memory install: wrote system-prompt snippet to {}. \
477 Paste into your {} system instructions.",
478 snippet_path.display(),
479 target.name(),
480 )?;
481 }
482 Err(e) => {
483 writeln!(
484 out.stderr,
485 "ai-memory install: warning — could not write system-prompt snippet: {e}"
486 )?;
487 }
488 }
489 }
490
491 Ok(())
492}
493
494fn snippet_body(target: Target) -> String {
522 let harness = target.name();
523 let harness_hint: &str = match target {
527 Target::ClaudeCode => {
528 "Claude Code supports deferred-tool registration via ToolSearch \
529 — new tools surface mid-session."
530 }
531 Target::Cursor => {
532 "Cursor eager-loads tools; call this in your first turn so its \
533 tools are visible."
534 }
535 Target::Cline | Target::Continue => {
536 "VS Code MCP clients eager-load tools; call this in your first turn."
537 }
538 Target::Codex => "Codex CLI eager-loads tools; call this in your first turn.",
539 Target::Windsurf | Target::GrokCli | Target::GeminiCli | Target::ClaudeDesktop => {
540 "This harness eager-loads tools; call this in your first turn."
541 }
542 Target::Openclaw => "OpenClaw eager-loads tools; call this in your first turn.",
543 };
544
545 format!(
546 "# ai-memory system prompt — {harness}\n\
547 \n\
548 You have persistent memory via the `ai-memory` MCP server.\n\
549 \n\
550 1. Call `memory_capabilities` first. It returns the live tool \
551 surface and a pre-computed `to_describe_to_user` summary. Trust \
552 it over any cached belief.\n\
553 2. Use `memory_load_family` to pre-load the context family you \
554 need (`core`, `lifecycle`, `graph`, `governance`). {harness_hint}\n\
555 3. Transcripts auto-extract via the R5 hook after each turn — \
556 do not call `memory_store` for chat history; extract only \
557 durable insights.\n\
558 4. Signed links carry `attest_level` \
559 (`unsigned`/`self_attested`/`peer_verified`). Treat anything \
560 below `peer_verified` as advisory.\n",
561 )
562}
563
564fn snippet_base_dir() -> Result<PathBuf> {
570 if let Ok(v) = std::env::var("AI_MEMORY_SYSTEM_PROMPT_DIR")
571 && !v.is_empty()
572 {
573 return Ok(PathBuf::from(v));
574 }
575 #[cfg(test)]
576 {
577 return Ok(test_default_snippet_dir());
578 }
579 #[cfg(not(test))]
580 {
581 let base = dirs::config_dir().ok_or_else(|| {
582 anyhow!(
583 "OS did not advertise a config directory; \
584 set AI_MEMORY_SYSTEM_PROMPT_DIR to choose where the snippet is written"
585 )
586 })?;
587 Ok(base.join("ai-memory"))
588 }
589}
590
591#[cfg(test)]
594fn test_default_snippet_dir() -> PathBuf {
595 use std::sync::OnceLock;
596 static DIR: OnceLock<PathBuf> = OnceLock::new();
597 DIR.get_or_init(|| {
598 let tmp = tempfile::tempdir().expect("tempdir for snippet test default");
599 let p = tmp.path().to_path_buf();
600 std::mem::forget(tmp);
604 p
605 })
606 .clone()
607}
608
609fn write_system_prompt_snippet_to(target: Target, dir: &std::path::Path) -> Result<PathBuf> {
619 std::fs::create_dir_all(dir)
620 .with_context(|| format!("creating snippet directory {}", dir.display()))?;
621 let path = dir.join(format!("system-prompt-{}.md", target.name()));
622 let body = snippet_body(target);
623 std::fs::write(&path, body)
624 .with_context(|| format!("writing snippet to {}", path.display()))?;
625 Ok(path)
626}
627
628fn write_system_prompt_snippet(target: Target) -> Result<PathBuf> {
629 let dir = snippet_base_dir()?;
630 write_system_prompt_snippet_to(target, &dir)
631}
632
633fn resolve_config_path(target: Target, args: &TargetArgs) -> Result<PathBuf> {
638 if let Some(ref p) = args.config {
639 return Ok(p.clone());
640 }
641 let home = dirs::home_dir()
642 .ok_or_else(|| anyhow!("could not resolve home directory; pass --config <path>"))?;
643 let p = match target {
644 Target::ClaudeCode => home.join(".claude").join("settings.json"),
645 Target::Openclaw => {
646 bail!(
653 "openclaw config path is not auto-discovered yet; pass --config <path>. \
654 See https://docs.openclaw.ai/cli/mcp for the canonical location."
655 );
656 }
657 Target::Cursor => home.join(".cursor").join("mcp.json"),
658 Target::Cline => {
659 bail!(
664 "cline config path varies by version; pass --config <path> \
665 (typically ~/.cline/mcp_settings.json or under the VS Code \
666 extension data dir)."
667 );
668 }
669 Target::Continue => home.join(".continue").join("config.json"),
670 Target::Windsurf => home
671 .join(".codeium")
672 .join("windsurf")
673 .join("mcp_config.json"),
674 Target::ClaudeDesktop => {
678 #[cfg(target_os = "macos")]
679 {
680 home.join("Library")
681 .join("Application Support")
682 .join("Claude")
683 .join(CLAUDE_DESKTOP_CONFIG_FILENAME)
684 }
685 #[cfg(target_os = "windows")]
686 {
687 std::env::var_os("APPDATA")
688 .map(|p| {
689 std::path::PathBuf::from(p)
690 .join("Claude")
691 .join(CLAUDE_DESKTOP_CONFIG_FILENAME)
692 })
693 .unwrap_or_else(|| {
694 home.join("AppData")
695 .join("Roaming")
696 .join("Claude")
697 .join(CLAUDE_DESKTOP_CONFIG_FILENAME)
698 })
699 }
700 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
701 {
702 bail!(
703 "claude-desktop config path is OS-specific and not auto-discovered \
704 on Linux; pass --config <path>. Common location: \
705 ~/.config/Claude/claude_desktop_config.json"
706 );
707 }
708 }
709 Target::Codex => {
713 bail!(
714 "codex config path varies by version; pass --config <path>. \
715 Common location: ~/.codex/config.json or ~/.config/codex/mcp.json"
716 );
717 }
718 Target::GrokCli => {
719 bail!(
720 "grok-cli config path varies by version; pass --config <path>. \
721 Common location: ~/.grok/mcp.json"
722 );
723 }
724 Target::GeminiCli => {
725 bail!(
726 "gemini-cli config path varies by version; pass --config <path>. \
727 Common location: ~/.gemini/mcp.json"
728 );
729 }
730 };
731 Ok(p)
732}
733
734fn resolve_binary(override_path: Option<&Path>) -> String {
743 if let Some(p) = override_path {
744 return p.display().to_string();
745 }
746 if which_ai_memory().is_some() {
747 return "ai-memory".to_string();
748 }
749 if let Ok(exe) = std::env::current_exe() {
750 return exe.display().to_string();
751 }
752 "ai-memory".to_string()
753}
754
755fn which_ai_memory() -> Option<PathBuf> {
756 let path_var = std::env::var_os("PATH")?;
757 for dir in std::env::split_paths(&path_var) {
758 let candidate = dir.join("ai-memory");
759 if candidate.is_file() {
760 return Some(candidate);
761 }
762 let candidate_exe = dir.join("ai-memory.exe");
763 if candidate_exe.is_file() {
764 return Some(candidate_exe);
765 }
766 }
767 None
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
781pub(super) enum ConfigFormat {
782 Json,
783 Toml,
784}
785
786impl ConfigFormat {
787 fn detect(path: &Path) -> Self {
791 if path
792 .extension()
793 .and_then(|e| e.to_str())
794 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
795 {
796 Self::Toml
797 } else {
798 Self::Json
799 }
800 }
801}
802
803fn read_config_or_empty(path: &Path) -> Result<(String, Value)> {
814 if !path.exists() {
815 return Ok((String::new(), Value::Object(Map::new())));
816 }
817 let text = std::fs::read_to_string(path)
818 .with_context(|| crate::errors::msg::reading(path.display()))?;
819 if text.trim().is_empty() {
820 return Ok((text, Value::Object(Map::new())));
821 }
822 match ConfigFormat::detect(path) {
823 ConfigFormat::Json => {
824 let value: Value = serde_json::from_str(&text).map_err(|e| {
825 anyhow!(
826 "existing config at {} is not valid JSON ({e}). \
827 Refusing to overwrite — fix the file by hand or remove it, \
828 then re-run `ai-memory install`.",
829 path.display()
830 )
831 })?;
832 Ok((text, value))
833 }
834 ConfigFormat::Toml => {
835 let toml_value: toml::Value = toml::from_str(&text).map_err(|e| {
841 anyhow!(
842 "existing config at {} is not valid TOML ({e}). \
843 Refusing to overwrite — fix the file by hand or remove it, \
844 then re-run `ai-memory install`.",
845 path.display()
846 )
847 })?;
848 let value: Value = serde_json::to_value(&toml_value).map_err(|e| {
849 anyhow!(
850 "existing TOML at {} contains a shape that cannot \
851 round-trip through JSON ({e}). Refusing to overwrite.",
852 path.display()
853 )
854 })?;
855 let value = if value.is_object() {
858 value
859 } else {
860 anyhow::bail!(
861 "existing TOML at {} top-level must be a table; \
862 got {value:?}",
863 path.display()
864 );
865 };
866 Ok((text, value))
867 }
868 }
869}
870
871fn apply_managed_block(
881 target: Target,
882 mut cfg: Value,
883 binary: &str,
884 format: ConfigFormat,
885) -> Result<Value> {
886 let obj = ensure_object(&mut cfg)?;
887 match target {
888 Target::ClaudeCode => apply_claude_code(obj, binary),
889 Target::Openclaw => apply_openclaw(obj, binary),
890 Target::Cursor => apply_cursor(obj, binary),
891 Target::Cline => apply_cline(obj, binary),
892 Target::Continue => apply_continue(obj, binary),
893 Target::Windsurf => apply_windsurf(obj, binary),
894 Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
897 apply_mcp_standard(obj, binary, mcp_servers_key(target, format));
898 }
899 }
900 Ok(cfg)
901}
902
903fn mcp_servers_key(target: Target, format: ConfigFormat) -> &'static str {
909 match (target, format) {
910 (Target::Codex, ConfigFormat::Toml) => "mcp_servers",
911 _ => KEY_MCP_SERVERS,
912 }
913}
914
915fn remove_managed_block(target: Target, mut cfg: Value, format: ConfigFormat) -> Result<Value> {
921 let obj = match cfg.as_object_mut() {
922 Some(o) => o,
923 None => return Ok(cfg),
924 };
925 match target {
926 Target::ClaudeCode => remove_claude_code(obj),
927 Target::Openclaw => remove_openclaw(obj),
928 Target::Cursor => remove_cursor(obj),
929 Target::Cline => remove_cline(obj),
930 Target::Continue => remove_continue(obj),
931 Target::Windsurf => remove_windsurf(obj),
932 Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
935 remove_mcp_standard(obj, mcp_servers_key(target, format));
936 }
937 }
938 Ok(cfg)
939}
940
941fn apply_mcp_standard(obj: &mut Map<String, Value>, binary: &str, mcp_key: &str) {
952 let mcp_servers = obj
953 .entry(mcp_key.to_string())
954 .or_insert_with(|| Value::Object(Map::new()));
955 if !mcp_servers.is_object() {
956 *mcp_servers = Value::Object(Map::new());
957 }
958 let mcp_obj = mcp_servers
959 .as_object_mut()
960 .expect(EXPECT_JUST_INSERTED_OBJECT);
961 mcp_obj.insert(
962 "ai-memory".to_string(),
963 serde_json::json!({
964 MARKER_START_KEY: MARKER_PAYLOAD,
965 MANAGED_KEYS_PROPERTY: ["command", "args", "env"],
966 "command": binary,
967 "args": ["mcp", "--profile", "core"],
972 "env": {},
973 MARKER_END_KEY: MARKER_PAYLOAD,
974 }),
975 );
976}
977
978fn remove_mcp_standard(obj: &mut Map<String, Value>, mcp_key: &str) {
979 if let Some(mcp_servers) = obj.get_mut(mcp_key).and_then(|v| v.as_object_mut()) {
980 mcp_servers.remove("ai-memory");
981 if mcp_servers.is_empty() {
982 obj.remove(mcp_key);
983 }
984 }
985}
986
987fn ensure_object(v: &mut Value) -> Result<&mut Map<String, Value>> {
988 if !v.is_object() {
989 bail!("existing config root is not a JSON object; refusing to clobber");
990 }
991 Ok(v.as_object_mut().expect("checked is_object"))
992}
993
994fn claude_code_hook_command(binary: &str) -> String {
999 format!("{binary} boot --quiet --limit 10 --budget-tokens 4096")
1000}
1001
1002fn apply_claude_code(obj: &mut Map<String, Value>, binary: &str) {
1003 let cmd = claude_code_hook_command(binary);
1005 let entry = serde_json::json!({
1006 MARKER_START_KEY: MARKER_PAYLOAD,
1007 MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
1008 "matcher": "*",
1009 "hooks": [
1010 { "type": "command", "command": cmd }
1011 ],
1012 MARKER_END_KEY: MARKER_PAYLOAD,
1013 });
1014
1015 let hooks = obj
1018 .entry("hooks".to_string())
1019 .or_insert_with(|| Value::Object(Map::new()));
1020 if !hooks.is_object() {
1021 *hooks = Value::Object(Map::new());
1022 }
1023 let hooks_obj = hooks.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1024 let session_start = hooks_obj
1025 .entry(HOOK_EVENT_SESSION_START.to_string())
1026 .or_insert_with(|| Value::Array(Vec::new()));
1027 if !session_start.is_array() {
1028 *session_start = Value::Array(Vec::new());
1029 }
1030 let arr = session_start
1031 .as_array_mut()
1032 .expect(EXPECT_JUST_INSERTED_ARRAY);
1033 arr.retain(|v| !is_managed_value(v));
1034 arr.insert(0, entry);
1035}
1036
1037fn remove_claude_code(obj: &mut Map<String, Value>) {
1038 if let Some(hooks) = obj.get_mut("hooks").and_then(|h| h.as_object_mut())
1039 && let Some(arr) = hooks
1040 .get_mut(HOOK_EVENT_SESSION_START)
1041 .and_then(|s| s.as_array_mut())
1042 {
1043 arr.retain(|v| !is_managed_value(v));
1044 if arr.is_empty() {
1045 hooks.remove(HOOK_EVENT_SESSION_START);
1046 }
1047 }
1048 if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
1051 && hooks.is_empty()
1052 {
1053 obj.remove("hooks");
1054 }
1055}
1056
1057const PRETOOL_HOOK_TOOL_NAME: &str = crate::mcp::registry::tool_names::MEMORY_CHECK_AGENT_ACTION;
1088
1089const PRETOOL_HOOK_MATCHER: &str = "Bash|Edit|Write";
1106
1107fn claude_code_pretool_entry() -> Value {
1114 serde_json::json!({
1115 MARKER_START_KEY: MARKER_PAYLOAD,
1116 MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
1117 "matcher": PRETOOL_HOOK_MATCHER,
1118 "hooks": [
1119 { "type": "mcp_tool", "tool": PRETOOL_HOOK_TOOL_NAME }
1120 ],
1121 MARKER_END_KEY: MARKER_PAYLOAD,
1122 })
1123}
1124
1125fn pretool_conflict_matcher(v: &Value) -> Option<String> {
1129 let obj = v.as_object()?;
1130 if obj.contains_key(MARKER_START_KEY) {
1131 return None;
1132 }
1133 let matcher = obj.get("matcher").and_then(Value::as_str)?;
1134 let hooks = obj.get("hooks").and_then(Value::as_array)?;
1135 for h in hooks {
1136 let h_obj = h.as_object()?;
1137 if h_obj.get("type").and_then(Value::as_str) == Some("mcp_tool")
1138 && h_obj.get("tool").and_then(Value::as_str) == Some(PRETOOL_HOOK_TOOL_NAME)
1139 {
1140 return Some(matcher.to_string());
1141 }
1142 }
1143 None
1144}
1145
1146fn apply_claude_code_pretool(
1150 obj: &mut Map<String, Value>,
1151 force: bool,
1152 out: &mut CliOutput<'_>,
1153) -> Result<()> {
1154 let entry = claude_code_pretool_entry();
1155
1156 let hooks = obj
1157 .entry("hooks".to_string())
1158 .or_insert_with(|| Value::Object(Map::new()));
1159 if !hooks.is_object() {
1160 *hooks = Value::Object(Map::new());
1161 }
1162 let hooks_obj = hooks.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1163 let pretool = hooks_obj
1164 .entry(HOOK_EVENT_PRE_TOOL_USE.to_string())
1165 .or_insert_with(|| Value::Array(Vec::new()));
1166 if !pretool.is_array() {
1167 *pretool = Value::Array(Vec::new());
1168 }
1169 let arr = pretool.as_array_mut().expect(EXPECT_JUST_INSERTED_ARRAY);
1170
1171 let conflicting: Vec<String> = arr
1175 .iter()
1176 .filter_map(pretool_conflict_matcher)
1177 .filter(|m| m != PRETOOL_HOOK_MATCHER)
1178 .collect();
1179 if !conflicting.is_empty() && !force {
1180 writeln!(
1181 out.stderr,
1182 "ai-memory install: warning — existing PreToolUse entry(s) already invoke \
1183 `{tool}` with matcher(s) {conflicts:?}. Pass --force to overwrite, or \
1184 remove the existing entries by hand if you want to keep your scoping.",
1185 tool = PRETOOL_HOOK_TOOL_NAME,
1186 conflicts = conflicting,
1187 )?;
1188 bail!(
1189 "refusing to overwrite a differing-but-similar PreToolUse hook \
1190 without --force; existing matcher(s): {conflicting:?}"
1191 );
1192 }
1193
1194 arr.retain(|v| {
1198 if is_managed_value(v) {
1199 return false;
1200 }
1201 if force && pretool_conflict_matcher(v).is_some() {
1202 return false;
1203 }
1204 true
1205 });
1206 arr.push(entry);
1207 Ok(())
1208}
1209
1210fn remove_claude_code_pretool(obj: &mut Map<String, Value>) {
1213 if let Some(hooks) = obj.get_mut("hooks").and_then(|h| h.as_object_mut())
1214 && let Some(arr) = hooks
1215 .get_mut(HOOK_EVENT_PRE_TOOL_USE)
1216 .and_then(|s| s.as_array_mut())
1217 {
1218 arr.retain(|v| !is_managed_value(v));
1219 if arr.is_empty() {
1220 hooks.remove(HOOK_EVENT_PRE_TOOL_USE);
1221 }
1222 }
1223 if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
1224 && hooks.is_empty()
1225 {
1226 obj.remove("hooks");
1227 }
1228}
1229
1230fn apply_hook_block(
1234 target: Target,
1235 kind: HookKind,
1236 mut cfg: Value,
1237 force: bool,
1238 out: &mut CliOutput<'_>,
1239) -> Result<Value> {
1240 let obj = ensure_object(&mut cfg)?;
1241 match (target, kind) {
1242 (Target::ClaudeCode, HookKind::Pretool) => {
1243 apply_claude_code_pretool(obj, force, out)?;
1244 }
1245 _ => bail!(
1249 "internal error: unsupported (target, hook) combination ({:?}, {:?})",
1250 target,
1251 kind
1252 ),
1253 }
1254 Ok(cfg)
1255}
1256
1257fn remove_hook_block(target: Target, kind: HookKind, mut cfg: Value) -> Result<Value> {
1260 let obj = match cfg.as_object_mut() {
1261 Some(o) => o,
1262 None => return Ok(cfg),
1263 };
1264 match (target, kind) {
1265 (Target::ClaudeCode, HookKind::Pretool) => {
1266 remove_claude_code_pretool(obj);
1267 }
1268 _ => {
1269 bail!(
1272 "internal error: unsupported (target, hook) combination ({:?}, {:?})",
1273 target,
1274 kind
1275 );
1276 }
1277 }
1278 Ok(cfg)
1279}
1280
1281fn ai_memory_server_value(binary: &str) -> Value {
1284 serde_json::json!({
1285 MARKER_START_KEY: MARKER_PAYLOAD,
1286 MANAGED_KEYS_PROPERTY: ["command", "args"],
1287 "command": binary,
1288 "args": ["mcp"],
1289 MARKER_END_KEY: MARKER_PAYLOAD,
1290 })
1291}
1292
1293fn apply_openclaw(obj: &mut Map<String, Value>, binary: &str) {
1294 let mcp = obj
1295 .entry("mcp".to_string())
1296 .or_insert_with(|| Value::Object(Map::new()));
1297 if !mcp.is_object() {
1298 *mcp = Value::Object(Map::new());
1299 }
1300 let mcp_obj = mcp.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1301 let servers = mcp_obj
1302 .entry("servers".to_string())
1303 .or_insert_with(|| Value::Object(Map::new()));
1304 if !servers.is_object() {
1305 *servers = Value::Object(Map::new());
1306 }
1307 let servers_obj = servers.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1308 servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
1309}
1310
1311fn remove_openclaw(obj: &mut Map<String, Value>) {
1312 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut())
1313 && let Some(servers) = mcp.get_mut("servers").and_then(|v| v.as_object_mut())
1314 {
1315 if let Some(v) = servers.get("ai-memory") {
1316 if is_managed_value(v) {
1317 servers.remove("ai-memory");
1318 }
1319 }
1320 if servers.is_empty() {
1321 mcp.remove("servers");
1322 }
1323 if mcp.is_empty() {
1324 obj.remove("mcp");
1325 }
1326 }
1327}
1328
1329fn apply_cursor(obj: &mut Map<String, Value>, binary: &str) {
1332 let servers = obj
1333 .entry(KEY_MCP_SERVERS.to_string())
1334 .or_insert_with(|| Value::Object(Map::new()));
1335 if !servers.is_object() {
1336 *servers = Value::Object(Map::new());
1337 }
1338 let servers_obj = servers.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1339 servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
1340}
1341
1342fn remove_cursor(obj: &mut Map<String, Value>) {
1343 if let Some(servers) = obj.get_mut(KEY_MCP_SERVERS).and_then(|v| v.as_object_mut()) {
1344 if let Some(v) = servers.get("ai-memory") {
1345 if is_managed_value(v) {
1346 servers.remove("ai-memory");
1347 }
1348 }
1349 if servers.is_empty() {
1350 obj.remove(KEY_MCP_SERVERS);
1351 }
1352 }
1353}
1354
1355fn apply_cline(obj: &mut Map<String, Value>, binary: &str) {
1358 apply_cursor(obj, binary);
1360}
1361
1362fn remove_cline(obj: &mut Map<String, Value>) {
1363 remove_cursor(obj);
1364}
1365
1366fn apply_continue(obj: &mut Map<String, Value>, binary: &str) {
1369 let exp = obj
1372 .entry(KEY_EXPERIMENTAL.to_string())
1373 .or_insert_with(|| Value::Object(Map::new()));
1374 if !exp.is_object() {
1375 *exp = Value::Object(Map::new());
1376 }
1377 let exp_obj = exp.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1378 let arr = exp_obj
1379 .entry(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS.to_string())
1380 .or_insert_with(|| Value::Array(Vec::new()));
1381 if !arr.is_array() {
1382 *arr = Value::Array(Vec::new());
1383 }
1384 let arr = arr.as_array_mut().expect(EXPECT_JUST_INSERTED_ARRAY);
1385 arr.retain(|v| !is_managed_value(v));
1386 let entry = serde_json::json!({
1387 MARKER_START_KEY: MARKER_PAYLOAD,
1388 MANAGED_KEYS_PROPERTY: ["transport"],
1389 "transport": {
1390 "type": "stdio",
1391 "command": binary,
1392 "args": ["mcp"],
1393 },
1394 MARKER_END_KEY: MARKER_PAYLOAD,
1395 });
1396 arr.insert(0, entry);
1397}
1398
1399fn remove_continue(obj: &mut Map<String, Value>) {
1400 if let Some(exp) = obj
1401 .get_mut(KEY_EXPERIMENTAL)
1402 .and_then(|v| v.as_object_mut())
1403 {
1404 if let Some(arr) = exp
1405 .get_mut(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS)
1406 .and_then(|v| v.as_array_mut())
1407 {
1408 arr.retain(|v| !is_managed_value(v));
1409 if arr.is_empty() {
1410 exp.remove(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS);
1411 }
1412 }
1413 if exp.is_empty() {
1414 obj.remove(KEY_EXPERIMENTAL);
1415 }
1416 }
1417}
1418
1419fn apply_windsurf(obj: &mut Map<String, Value>, binary: &str) {
1422 apply_cursor(obj, binary);
1423}
1424
1425fn remove_windsurf(obj: &mut Map<String, Value>) {
1426 remove_cursor(obj);
1427}
1428
1429fn is_managed_value(v: &Value) -> bool {
1437 v.as_object()
1438 .and_then(|o| o.get(MARKER_START_KEY))
1439 .is_some()
1440}
1441
1442fn emit_diff(out: &mut CliOutput<'_>, before: &str, after: &str) -> Result<()> {
1451 let before_lines: Vec<&str> = before.lines().collect();
1452 let after_lines: Vec<&str> = after.lines().collect();
1453 let max_len = before_lines.len().max(after_lines.len());
1454 for i in 0..max_len {
1455 let b = before_lines.get(i).copied();
1456 let a = after_lines.get(i).copied();
1457 match (b, a) {
1458 (Some(bl), Some(al)) if bl == al => writeln!(out.stdout, " {bl}")?,
1459 (Some(bl), Some(al)) => {
1460 writeln!(out.stdout, "-{bl}")?;
1461 writeln!(out.stdout, "+{al}")?;
1462 }
1463 (Some(bl), None) => writeln!(out.stdout, "-{bl}")?,
1464 (None, Some(al)) => writeln!(out.stdout, "+{al}")?,
1465 (None, None) => {}
1466 }
1467 }
1468 Ok(())
1469}
1470
1471#[cfg(test)]
1476mod tests {
1477 use super::*;
1478 use crate::cli::test_utils::TestEnv;
1479 use std::fs;
1480
1481 fn args_for(target: Target, config: PathBuf) -> InstallArgs {
1482 let t = TargetArgs {
1483 config: Some(config),
1484 apply: false,
1485 dry_run: false,
1486 uninstall: false,
1487 binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
1488 hook: None,
1489 force: false,
1490 };
1491 let target_cmd = match target {
1492 Target::ClaudeCode => TargetCmd::ClaudeCode(t),
1493 Target::Openclaw => TargetCmd::Openclaw(t),
1494 Target::Cursor => TargetCmd::Cursor(t),
1495 Target::Cline => TargetCmd::Cline(t),
1496 Target::Continue => TargetCmd::Continue(t),
1497 Target::Windsurf => TargetCmd::Windsurf(t),
1498 Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
1499 Target::Codex => TargetCmd::Codex(t),
1500 Target::GrokCli => TargetCmd::GrokCli(t),
1501 Target::GeminiCli => TargetCmd::GeminiCli(t),
1502 };
1503 InstallArgs { target: target_cmd }
1504 }
1505
1506 fn args_for_apply(target: Target, config: PathBuf) -> InstallArgs {
1507 let mut a = args_for(target, config);
1508 match &mut a.target {
1509 TargetCmd::ClaudeCode(t)
1510 | TargetCmd::Openclaw(t)
1511 | TargetCmd::Cursor(t)
1512 | TargetCmd::Cline(t)
1513 | TargetCmd::Continue(t)
1514 | TargetCmd::Windsurf(t)
1515 | TargetCmd::ClaudeDesktop(t)
1516 | TargetCmd::Codex(t)
1517 | TargetCmd::GrokCli(t)
1518 | TargetCmd::GeminiCli(t) => {
1519 t.apply = true;
1520 }
1521 }
1522 a
1523 }
1524
1525 fn args_for_uninstall_apply(target: Target, config: PathBuf) -> InstallArgs {
1526 let mut a = args_for(target, config);
1527 match &mut a.target {
1528 TargetCmd::ClaudeCode(t)
1529 | TargetCmd::Openclaw(t)
1530 | TargetCmd::Cursor(t)
1531 | TargetCmd::Cline(t)
1532 | TargetCmd::Continue(t)
1533 | TargetCmd::Windsurf(t)
1534 | TargetCmd::ClaudeDesktop(t)
1535 | TargetCmd::Codex(t)
1536 | TargetCmd::GrokCli(t)
1537 | TargetCmd::GeminiCli(t) => {
1538 t.uninstall = true;
1539 t.apply = true;
1540 }
1541 }
1542 a
1543 }
1544
1545 fn config_path(env: &TestEnv, name: &str) -> PathBuf {
1546 env.db_path.parent().unwrap().join(name)
1547 }
1548
1549 fn seed(path: &Path, contents: &str) {
1550 if let Some(parent) = path.parent() {
1551 fs::create_dir_all(parent).unwrap();
1552 }
1553 fs::write(path, contents).unwrap();
1554 }
1555
1556 #[test]
1561 fn claude_code_install_dry_run_emits_diff_no_writes() {
1562 let mut env = TestEnv::fresh();
1563 let path = config_path(&env, "settings.json");
1564 seed(&path, "{\n}\n");
1565 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1566 let args = args_for(Target::ClaudeCode, path.clone());
1567 let mut out = env.output();
1568 run(&args, &mut out).unwrap();
1569 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1570 assert!(stdout.contains("dry-run"));
1571 assert!(stdout.contains("SessionStart"));
1572 assert!(stdout.contains("ai-memory"));
1573 assert!(stdout.contains(MARKER_START_KEY));
1574 let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
1575 assert_eq!(mtime_before, mtime_after, "dry-run must not write");
1576 }
1577
1578 #[test]
1579 fn claude_code_install_apply_writes_marker_block() {
1580 let mut env = TestEnv::fresh();
1581 let path = config_path(&env, "settings.json");
1582 seed(&path, "{}\n");
1583 let args = args_for_apply(Target::ClaudeCode, path.clone());
1584 let mut out = env.output();
1585 run(&args, &mut out).unwrap();
1586 let written = fs::read_to_string(&path).unwrap();
1587 assert!(written.contains(MARKER_START_KEY));
1588 assert!(written.contains(MARKER_END_KEY));
1589 assert!(written.contains("SessionStart"));
1590 assert!(written.contains("ai-memory"));
1591 let _: Value = serde_json::from_str(&written).unwrap();
1593 }
1594
1595 #[test]
1596 fn claude_code_install_apply_preserves_user_keys() {
1597 let mut env = TestEnv::fresh();
1598 let path = config_path(&env, "settings.json");
1599 seed(
1600 &path,
1601 r#"{"theme":"dark","permissions":{"allow":["npm:*"]}}"#,
1602 );
1603 let args = args_for_apply(Target::ClaudeCode, path.clone());
1604 let mut out = env.output();
1605 run(&args, &mut out).unwrap();
1606 let written = fs::read_to_string(&path).unwrap();
1607 let parsed: Value = serde_json::from_str(&written).unwrap();
1608 assert_eq!(parsed["theme"], "dark");
1609 assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
1610 assert!(parsed["hooks"]["SessionStart"].is_array());
1611 }
1612
1613 #[test]
1614 fn claude_code_install_apply_is_idempotent() {
1615 let mut env = TestEnv::fresh();
1616 let path = config_path(&env, "settings.json");
1617 seed(&path, "{}\n");
1618 let args = args_for_apply(Target::ClaudeCode, path.clone());
1619 let mut out = env.output();
1620 run(&args, &mut out).unwrap();
1621 let after_first = fs::read_to_string(&path).unwrap();
1622 env.stdout.clear();
1624 let mut out2 = env.output();
1625 run(&args, &mut out2).unwrap();
1626 let after_second = fs::read_to_string(&path).unwrap();
1627 assert_eq!(after_first, after_second);
1628 let stdout2 = std::str::from_utf8(&env.stdout).unwrap();
1629 assert!(
1630 stdout2.contains("no-op"),
1631 "second install should be no-op: {stdout2}"
1632 );
1633 }
1634
1635 #[test]
1636 fn claude_code_uninstall_removes_marker_block_only() {
1637 let mut env = TestEnv::fresh();
1638 let path = config_path(&env, "settings.json");
1639 let original = "{\n \"theme\": \"dark\"\n}\n";
1640 seed(&path, original);
1641 run(
1643 &args_for_apply(Target::ClaudeCode, path.clone()),
1644 &mut env.output(),
1645 )
1646 .unwrap();
1647 let after_install = fs::read_to_string(&path).unwrap();
1648 assert!(after_install.contains(MARKER_START_KEY));
1649 run(
1650 &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
1651 &mut env.output(),
1652 )
1653 .unwrap();
1654 let after_uninstall = fs::read_to_string(&path).unwrap();
1655 let parsed: Value = serde_json::from_str(&after_uninstall).unwrap();
1656 assert_eq!(parsed["theme"], "dark");
1657 assert!(
1658 parsed.get("hooks").is_none(),
1659 "hooks should be gone after uninstall"
1660 );
1661 assert!(!after_uninstall.contains(MARKER_START_KEY));
1662 }
1663
1664 #[test]
1665 fn claude_code_install_refuses_malformed_config() {
1666 let mut env = TestEnv::fresh();
1667 let path = config_path(&env, "settings.json");
1668 seed(&path, "{not valid json");
1669 let args = args_for_apply(Target::ClaudeCode, path.clone());
1670 let mut out = env.output();
1671 let err = run(&args, &mut out).unwrap_err();
1672 let msg = format!("{err}");
1673 assert!(
1674 msg.contains("not valid JSON"),
1675 "error should explain malformed json: {msg}"
1676 );
1677 let still = fs::read_to_string(&path).unwrap();
1679 assert_eq!(still, "{not valid json");
1680 }
1681
1682 #[test]
1683 fn claude_code_install_writes_backup_file() {
1684 let mut env = TestEnv::fresh();
1685 let path = config_path(&env, "settings.json");
1686 seed(&path, "{}\n");
1687 let args = args_for_apply(Target::ClaudeCode, path.clone());
1688 let mut out = env.output();
1689 run(&args, &mut out).unwrap();
1690 let parent = path.parent().unwrap();
1692 let backups: Vec<_> = fs::read_dir(parent)
1693 .unwrap()
1694 .filter_map(|e| e.ok())
1695 .filter(|e| {
1696 e.file_name()
1697 .to_string_lossy()
1698 .starts_with("settings.json.bak.")
1699 || e.file_name().to_string_lossy().starts_with("settings.bak.")
1700 })
1701 .collect();
1702 assert!(
1703 !backups.is_empty(),
1704 "expected a settings.bak.<ts> backup beside the config; saw: {:?}",
1705 fs::read_dir(parent)
1706 .unwrap()
1707 .filter_map(|e| e.ok())
1708 .map(|e| e.file_name())
1709 .collect::<Vec<_>>()
1710 );
1711 }
1712
1713 #[test]
1718 fn cursor_install_dry_run_emits_diff_no_writes() {
1719 let mut env = TestEnv::fresh();
1720 let path = config_path(&env, "mcp.json");
1721 seed(&path, "{}\n");
1722 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1723 let args = args_for(Target::Cursor, path.clone());
1724 let mut out = env.output();
1725 run(&args, &mut out).unwrap();
1726 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1727 assert!(stdout.contains("dry-run"));
1728 assert!(stdout.contains("mcpServers"));
1729 let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
1730 assert_eq!(mtime_before, mtime_after);
1731 }
1732
1733 #[test]
1734 fn cursor_install_apply_writes_marker_block() {
1735 let mut env = TestEnv::fresh();
1736 let path = config_path(&env, "mcp.json");
1737 seed(&path, "{}\n");
1738 let args = args_for_apply(Target::Cursor, path.clone());
1739 run(&args, &mut env.output()).unwrap();
1740 let written = fs::read_to_string(&path).unwrap();
1741 let parsed: Value = serde_json::from_str(&written).unwrap();
1742 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1743 assert_eq!(
1744 parsed["mcpServers"]["ai-memory"]["command"],
1745 "/usr/local/bin/ai-memory"
1746 );
1747 }
1748
1749 #[test]
1750 fn cursor_install_apply_preserves_user_keys() {
1751 let mut env = TestEnv::fresh();
1752 let path = config_path(&env, "mcp.json");
1753 seed(
1754 &path,
1755 r#"{"mcpServers":{"my-other":{"command":"x"}},"telemetry":false}"#,
1756 );
1757 run(
1758 &args_for_apply(Target::Cursor, path.clone()),
1759 &mut env.output(),
1760 )
1761 .unwrap();
1762 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1763 assert_eq!(parsed["telemetry"], false);
1764 assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1765 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1766 }
1767
1768 #[test]
1769 fn cursor_install_apply_is_idempotent() {
1770 let mut env = TestEnv::fresh();
1771 let path = config_path(&env, "mcp.json");
1772 seed(&path, "{}\n");
1773 let args = args_for_apply(Target::Cursor, path.clone());
1774 run(&args, &mut env.output()).unwrap();
1775 let first = fs::read_to_string(&path).unwrap();
1776 run(&args, &mut env.output()).unwrap();
1777 let second = fs::read_to_string(&path).unwrap();
1778 assert_eq!(first, second);
1779 }
1780
1781 #[test]
1782 fn cursor_uninstall_removes_marker_block_only() {
1783 let mut env = TestEnv::fresh();
1784 let path = config_path(&env, "mcp.json");
1785 let original = r#"{"mcpServers":{"my-other":{"command":"x"}}}"#;
1786 seed(&path, original);
1787 run(
1788 &args_for_apply(Target::Cursor, path.clone()),
1789 &mut env.output(),
1790 )
1791 .unwrap();
1792 run(
1793 &args_for_uninstall_apply(Target::Cursor, path.clone()),
1794 &mut env.output(),
1795 )
1796 .unwrap();
1797 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1798 assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1799 assert!(
1800 parsed["mcpServers"]
1801 .as_object()
1802 .unwrap()
1803 .get("ai-memory")
1804 .is_none()
1805 );
1806 }
1807
1808 #[test]
1809 fn cursor_install_refuses_malformed_config() {
1810 let mut env = TestEnv::fresh();
1811 let path = config_path(&env, "mcp.json");
1812 seed(&path, "not json");
1813 let args = args_for_apply(Target::Cursor, path.clone());
1814 let err = run(&args, &mut env.output()).unwrap_err();
1815 assert!(format!("{err}").contains("not valid JSON"));
1816 }
1817
1818 #[test]
1819 fn cursor_install_writes_backup_file() {
1820 let mut env = TestEnv::fresh();
1821 let path = config_path(&env, "mcp.json");
1822 seed(&path, "{}\n");
1823 run(
1824 &args_for_apply(Target::Cursor, path.clone()),
1825 &mut env.output(),
1826 )
1827 .unwrap();
1828 let parent = path.parent().unwrap();
1829 let any_backup = fs::read_dir(parent)
1830 .unwrap()
1831 .filter_map(|e| e.ok())
1832 .any(|e| e.file_name().to_string_lossy().contains("bak."));
1833 assert!(any_backup);
1834 }
1835
1836 #[test]
1841 fn openclaw_install_dry_run_emits_diff_no_writes() {
1842 let mut env = TestEnv::fresh();
1843 let path = config_path(&env, "openclaw.json");
1844 seed(&path, "{}\n");
1845 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1846 run(&args_for(Target::Openclaw, path.clone()), &mut env.output()).unwrap();
1847 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1848 assert!(stdout.contains("dry-run"));
1849 assert!(stdout.contains("mcp"));
1850 assert_eq!(
1851 mtime_before,
1852 fs::metadata(&path).unwrap().modified().unwrap()
1853 );
1854 }
1855
1856 #[test]
1857 fn openclaw_install_apply_writes_marker_block() {
1858 let mut env = TestEnv::fresh();
1859 let path = config_path(&env, "openclaw.json");
1860 seed(&path, "{}\n");
1861 run(
1862 &args_for_apply(Target::Openclaw, path.clone()),
1863 &mut env.output(),
1864 )
1865 .unwrap();
1866 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1867 assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1868 }
1869
1870 #[test]
1871 fn openclaw_install_apply_preserves_user_keys() {
1872 let mut env = TestEnv::fresh();
1873 let path = config_path(&env, "openclaw.json");
1874 seed(
1875 &path,
1876 r#"{"mcp":{"servers":{"other":{"command":"y"}}},"editor":"vim"}"#,
1877 );
1878 run(
1879 &args_for_apply(Target::Openclaw, path.clone()),
1880 &mut env.output(),
1881 )
1882 .unwrap();
1883 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1884 assert_eq!(parsed["editor"], "vim");
1885 assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1886 assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1887 }
1888
1889 #[test]
1890 fn openclaw_install_apply_is_idempotent() {
1891 let mut env = TestEnv::fresh();
1892 let path = config_path(&env, "openclaw.json");
1893 seed(&path, "{}\n");
1894 let args = args_for_apply(Target::Openclaw, path.clone());
1895 run(&args, &mut env.output()).unwrap();
1896 let first = fs::read_to_string(&path).unwrap();
1897 run(&args, &mut env.output()).unwrap();
1898 let second = fs::read_to_string(&path).unwrap();
1899 assert_eq!(first, second);
1900 }
1901
1902 #[test]
1903 fn openclaw_uninstall_removes_marker_block_only() {
1904 let mut env = TestEnv::fresh();
1905 let path = config_path(&env, "openclaw.json");
1906 seed(&path, r#"{"mcp":{"servers":{"other":{"command":"y"}}}}"#);
1907 run(
1908 &args_for_apply(Target::Openclaw, path.clone()),
1909 &mut env.output(),
1910 )
1911 .unwrap();
1912 run(
1913 &args_for_uninstall_apply(Target::Openclaw, path.clone()),
1914 &mut env.output(),
1915 )
1916 .unwrap();
1917 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1918 assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1919 assert!(
1920 parsed["mcp"]["servers"]
1921 .as_object()
1922 .unwrap()
1923 .get("ai-memory")
1924 .is_none()
1925 );
1926 }
1927
1928 #[test]
1929 fn openclaw_install_refuses_malformed_config() {
1930 let mut env = TestEnv::fresh();
1931 let path = config_path(&env, "openclaw.json");
1932 seed(&path, "garbage");
1933 let err = run(
1934 &args_for_apply(Target::Openclaw, path.clone()),
1935 &mut env.output(),
1936 )
1937 .unwrap_err();
1938 assert!(format!("{err}").contains("not valid JSON"));
1939 }
1940
1941 #[test]
1942 fn openclaw_install_writes_backup_file() {
1943 let mut env = TestEnv::fresh();
1944 let path = config_path(&env, "openclaw.json");
1945 seed(&path, "{}\n");
1946 run(
1947 &args_for_apply(Target::Openclaw, path.clone()),
1948 &mut env.output(),
1949 )
1950 .unwrap();
1951 let parent = path.parent().unwrap();
1952 assert!(
1953 fs::read_dir(parent)
1954 .unwrap()
1955 .filter_map(|e| e.ok())
1956 .any(|e| e.file_name().to_string_lossy().contains("bak."))
1957 );
1958 }
1959
1960 #[test]
1965 fn cline_install_dry_run_emits_diff_no_writes() {
1966 let mut env = TestEnv::fresh();
1967 let path = config_path(&env, "cline.json");
1968 seed(&path, "{}\n");
1969 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1970 run(&args_for(Target::Cline, path.clone()), &mut env.output()).unwrap();
1971 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1972 assert!(stdout.contains("dry-run"));
1973 assert!(stdout.contains("mcpServers"));
1974 assert_eq!(
1975 mtime_before,
1976 fs::metadata(&path).unwrap().modified().unwrap()
1977 );
1978 }
1979
1980 #[test]
1981 fn cline_install_apply_writes_marker_block() {
1982 let mut env = TestEnv::fresh();
1983 let path = config_path(&env, "cline.json");
1984 seed(&path, "{}\n");
1985 run(
1986 &args_for_apply(Target::Cline, path.clone()),
1987 &mut env.output(),
1988 )
1989 .unwrap();
1990 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1991 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1992 }
1993
1994 #[test]
1995 fn cline_install_apply_preserves_user_keys() {
1996 let mut env = TestEnv::fresh();
1997 let path = config_path(&env, "cline.json");
1998 seed(&path, r#"{"mcpServers":{"x":{"command":"q"}},"foo":1}"#);
1999 run(
2000 &args_for_apply(Target::Cline, path.clone()),
2001 &mut env.output(),
2002 )
2003 .unwrap();
2004 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2005 assert_eq!(parsed["foo"], 1);
2006 assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
2007 }
2008
2009 #[test]
2010 fn cline_install_apply_is_idempotent() {
2011 let mut env = TestEnv::fresh();
2012 let path = config_path(&env, "cline.json");
2013 seed(&path, "{}\n");
2014 let args = args_for_apply(Target::Cline, path.clone());
2015 run(&args, &mut env.output()).unwrap();
2016 let first = fs::read_to_string(&path).unwrap();
2017 run(&args, &mut env.output()).unwrap();
2018 let second = fs::read_to_string(&path).unwrap();
2019 assert_eq!(first, second);
2020 }
2021
2022 #[test]
2023 fn cline_uninstall_removes_marker_block_only() {
2024 let mut env = TestEnv::fresh();
2025 let path = config_path(&env, "cline.json");
2026 seed(&path, r#"{"mcpServers":{"x":{"command":"q"}}}"#);
2027 run(
2028 &args_for_apply(Target::Cline, path.clone()),
2029 &mut env.output(),
2030 )
2031 .unwrap();
2032 run(
2033 &args_for_uninstall_apply(Target::Cline, path.clone()),
2034 &mut env.output(),
2035 )
2036 .unwrap();
2037 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2038 assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
2039 assert!(
2040 parsed["mcpServers"]
2041 .as_object()
2042 .unwrap()
2043 .get("ai-memory")
2044 .is_none()
2045 );
2046 }
2047
2048 #[test]
2049 fn cline_install_refuses_malformed_config() {
2050 let mut env = TestEnv::fresh();
2051 let path = config_path(&env, "cline.json");
2052 seed(&path, "totally not json");
2053 let err = run(
2054 &args_for_apply(Target::Cline, path.clone()),
2055 &mut env.output(),
2056 )
2057 .unwrap_err();
2058 assert!(format!("{err}").contains("not valid JSON"));
2059 }
2060
2061 #[test]
2062 fn cline_install_writes_backup_file() {
2063 let mut env = TestEnv::fresh();
2064 let path = config_path(&env, "cline.json");
2065 seed(&path, "{}\n");
2066 run(
2067 &args_for_apply(Target::Cline, path.clone()),
2068 &mut env.output(),
2069 )
2070 .unwrap();
2071 assert!(
2072 fs::read_dir(path.parent().unwrap())
2073 .unwrap()
2074 .filter_map(|e| e.ok())
2075 .any(|e| e.file_name().to_string_lossy().contains("bak."))
2076 );
2077 }
2078
2079 #[test]
2084 fn continue_install_dry_run_emits_diff_no_writes() {
2085 let mut env = TestEnv::fresh();
2086 let path = config_path(&env, "continue.json");
2087 seed(&path, "{}\n");
2088 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
2089 run(&args_for(Target::Continue, path.clone()), &mut env.output()).unwrap();
2090 let stdout = std::str::from_utf8(&env.stdout).unwrap();
2091 assert!(stdout.contains("dry-run"));
2092 assert!(stdout.contains("modelContextProtocolServers"));
2093 assert_eq!(
2094 mtime_before,
2095 fs::metadata(&path).unwrap().modified().unwrap()
2096 );
2097 }
2098
2099 #[test]
2100 fn continue_install_apply_writes_marker_block() {
2101 let mut env = TestEnv::fresh();
2102 let path = config_path(&env, "continue.json");
2103 seed(&path, "{}\n");
2104 run(
2105 &args_for_apply(Target::Continue, path.clone()),
2106 &mut env.output(),
2107 )
2108 .unwrap();
2109 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2110 let arr = parsed["experimental"]["modelContextProtocolServers"]
2111 .as_array()
2112 .unwrap();
2113 assert!(arr.iter().any(is_managed_value));
2114 }
2115
2116 #[test]
2117 fn continue_install_apply_preserves_user_keys() {
2118 let mut env = TestEnv::fresh();
2119 let path = config_path(&env, "continue.json");
2120 seed(
2121 &path,
2122 r#"{"models":[{"name":"x"}],"experimental":{"foo":true}}"#,
2123 );
2124 run(
2125 &args_for_apply(Target::Continue, path.clone()),
2126 &mut env.output(),
2127 )
2128 .unwrap();
2129 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2130 assert_eq!(parsed["models"][0]["name"], "x");
2131 assert_eq!(parsed["experimental"]["foo"], true);
2132 }
2133
2134 #[test]
2135 fn continue_install_apply_is_idempotent() {
2136 let mut env = TestEnv::fresh();
2137 let path = config_path(&env, "continue.json");
2138 seed(&path, "{}\n");
2139 let args = args_for_apply(Target::Continue, path.clone());
2140 run(&args, &mut env.output()).unwrap();
2141 let first = fs::read_to_string(&path).unwrap();
2142 run(&args, &mut env.output()).unwrap();
2143 let second = fs::read_to_string(&path).unwrap();
2144 assert_eq!(first, second);
2145 }
2146
2147 #[test]
2148 fn continue_uninstall_removes_marker_block_only() {
2149 let mut env = TestEnv::fresh();
2150 let path = config_path(&env, "continue.json");
2151 seed(&path, r#"{"models":[{"name":"x"}]}"#);
2152 run(
2153 &args_for_apply(Target::Continue, path.clone()),
2154 &mut env.output(),
2155 )
2156 .unwrap();
2157 run(
2158 &args_for_uninstall_apply(Target::Continue, path.clone()),
2159 &mut env.output(),
2160 )
2161 .unwrap();
2162 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2163 assert_eq!(parsed["models"][0]["name"], "x");
2164 assert!(parsed.get("experimental").is_none());
2165 }
2166
2167 #[test]
2168 fn continue_install_refuses_malformed_config() {
2169 let mut env = TestEnv::fresh();
2170 let path = config_path(&env, "continue.json");
2171 seed(&path, "[1,2,");
2172 let err = run(
2173 &args_for_apply(Target::Continue, path.clone()),
2174 &mut env.output(),
2175 )
2176 .unwrap_err();
2177 assert!(format!("{err}").contains("not valid JSON"));
2178 }
2179
2180 #[test]
2181 fn continue_install_writes_backup_file() {
2182 let mut env = TestEnv::fresh();
2183 let path = config_path(&env, "continue.json");
2184 seed(&path, "{}\n");
2185 run(
2186 &args_for_apply(Target::Continue, path.clone()),
2187 &mut env.output(),
2188 )
2189 .unwrap();
2190 assert!(
2191 fs::read_dir(path.parent().unwrap())
2192 .unwrap()
2193 .filter_map(|e| e.ok())
2194 .any(|e| e.file_name().to_string_lossy().contains("bak."))
2195 );
2196 }
2197
2198 #[test]
2203 fn windsurf_install_dry_run_emits_diff_no_writes() {
2204 let mut env = TestEnv::fresh();
2205 let path = config_path(&env, "mcp_config.json");
2206 seed(&path, "{}\n");
2207 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
2208 run(&args_for(Target::Windsurf, path.clone()), &mut env.output()).unwrap();
2209 let stdout = std::str::from_utf8(&env.stdout).unwrap();
2210 assert!(stdout.contains("dry-run"));
2211 assert!(stdout.contains("mcpServers"));
2212 assert_eq!(
2213 mtime_before,
2214 fs::metadata(&path).unwrap().modified().unwrap()
2215 );
2216 }
2217
2218 #[test]
2219 fn windsurf_install_apply_writes_marker_block() {
2220 let mut env = TestEnv::fresh();
2221 let path = config_path(&env, "mcp_config.json");
2222 seed(&path, "{}\n");
2223 run(
2224 &args_for_apply(Target::Windsurf, path.clone()),
2225 &mut env.output(),
2226 )
2227 .unwrap();
2228 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2229 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
2230 }
2231
2232 #[test]
2233 fn windsurf_install_apply_preserves_user_keys() {
2234 let mut env = TestEnv::fresh();
2235 let path = config_path(&env, "mcp_config.json");
2236 seed(&path, r#"{"mcpServers":{"k":{"command":"l"}},"a":42}"#);
2237 run(
2238 &args_for_apply(Target::Windsurf, path.clone()),
2239 &mut env.output(),
2240 )
2241 .unwrap();
2242 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2243 assert_eq!(parsed["a"], 42);
2244 assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
2245 }
2246
2247 #[test]
2248 fn windsurf_install_apply_is_idempotent() {
2249 let mut env = TestEnv::fresh();
2250 let path = config_path(&env, "mcp_config.json");
2251 seed(&path, "{}\n");
2252 let args = args_for_apply(Target::Windsurf, path.clone());
2253 run(&args, &mut env.output()).unwrap();
2254 let first = fs::read_to_string(&path).unwrap();
2255 run(&args, &mut env.output()).unwrap();
2256 let second = fs::read_to_string(&path).unwrap();
2257 assert_eq!(first, second);
2258 }
2259
2260 #[test]
2261 fn windsurf_uninstall_removes_marker_block_only() {
2262 let mut env = TestEnv::fresh();
2263 let path = config_path(&env, "mcp_config.json");
2264 seed(&path, r#"{"mcpServers":{"k":{"command":"l"}}}"#);
2265 run(
2266 &args_for_apply(Target::Windsurf, path.clone()),
2267 &mut env.output(),
2268 )
2269 .unwrap();
2270 run(
2271 &args_for_uninstall_apply(Target::Windsurf, path.clone()),
2272 &mut env.output(),
2273 )
2274 .unwrap();
2275 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2276 assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
2277 assert!(
2278 parsed["mcpServers"]
2279 .as_object()
2280 .unwrap()
2281 .get("ai-memory")
2282 .is_none()
2283 );
2284 }
2285
2286 #[test]
2287 fn windsurf_install_refuses_malformed_config() {
2288 let mut env = TestEnv::fresh();
2289 let path = config_path(&env, "mcp_config.json");
2290 seed(&path, "::");
2291 let err = run(
2292 &args_for_apply(Target::Windsurf, path.clone()),
2293 &mut env.output(),
2294 )
2295 .unwrap_err();
2296 assert!(format!("{err}").contains("not valid JSON"));
2297 }
2298
2299 #[test]
2300 fn windsurf_install_writes_backup_file() {
2301 let mut env = TestEnv::fresh();
2302 let path = config_path(&env, "mcp_config.json");
2303 seed(&path, "{}\n");
2304 run(
2305 &args_for_apply(Target::Windsurf, path.clone()),
2306 &mut env.output(),
2307 )
2308 .unwrap();
2309 assert!(
2310 fs::read_dir(path.parent().unwrap())
2311 .unwrap()
2312 .filter_map(|e| e.ok())
2313 .any(|e| e.file_name().to_string_lossy().contains("bak."))
2314 );
2315 }
2316
2317 #[test]
2322 fn install_creates_missing_config_file_under_apply() {
2323 let mut env = TestEnv::fresh();
2324 let path = config_path(&env, "fresh-config.json");
2325 assert!(!path.exists());
2326 run(
2327 &args_for_apply(Target::Cursor, path.clone()),
2328 &mut env.output(),
2329 )
2330 .unwrap();
2331 assert!(path.exists());
2332 let _: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2333 }
2334
2335 #[test]
2336 fn install_round_trip_install_then_uninstall_restores_original_for_empty_seed() {
2337 let mut env = TestEnv::fresh();
2340 let path = config_path(&env, "rt.json");
2341 seed(&path, "{}\n");
2342 run(
2343 &args_for_apply(Target::Cursor, path.clone()),
2344 &mut env.output(),
2345 )
2346 .unwrap();
2347 run(
2348 &args_for_uninstall_apply(Target::Cursor, path.clone()),
2349 &mut env.output(),
2350 )
2351 .unwrap();
2352 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2353 assert_eq!(parsed, serde_json::json!({}));
2354 }
2355
2356 #[test]
2357 fn resolve_binary_uses_override_when_provided() {
2358 let p = std::path::PathBuf::from("/custom/path/ai-memory");
2359 let resolved = resolve_binary(Some(&p));
2360 assert_eq!(resolved, "/custom/path/ai-memory");
2361 }
2362
2363 fn assert_mcp_standard_apply(target: Target, fname: &str) {
2371 let mut env = TestEnv::fresh();
2372 let path = config_path(&env, fname);
2373 seed(&path, "{}\n");
2374 run(&args_for_apply(target, path.clone()), &mut env.output()).unwrap();
2375 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2376 assert!(
2378 parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string(),
2379 "{} missing managed-block marker",
2380 target.name()
2381 );
2382 let args = parsed["mcpServers"]["ai-memory"]["args"]
2384 .as_array()
2385 .unwrap();
2386 let strs: Vec<&str> = args.iter().filter_map(Value::as_str).collect();
2387 assert_eq!(
2388 strs,
2389 vec!["mcp", "--profile", "core"],
2390 "{} should write `mcp --profile core` args",
2391 target.name()
2392 );
2393 let cmd = parsed["mcpServers"]["ai-memory"]["command"]
2394 .as_str()
2395 .unwrap();
2396 assert_eq!(cmd, "/usr/local/bin/ai-memory");
2397 }
2398
2399 #[test]
2400 fn claude_desktop_apply_writes_mcp_standard_with_profile_core() {
2401 assert_mcp_standard_apply(Target::ClaudeDesktop, "claude_desktop_config.json");
2402 }
2403
2404 #[test]
2405 fn codex_apply_writes_mcp_standard_with_profile_core() {
2406 assert_mcp_standard_apply(Target::Codex, "codex_config.json");
2407 }
2408
2409 #[test]
2410 fn grok_cli_apply_writes_mcp_standard_with_profile_core() {
2411 assert_mcp_standard_apply(Target::GrokCli, "grok_mcp.json");
2412 }
2413
2414 #[test]
2415 fn gemini_cli_apply_writes_mcp_standard_with_profile_core() {
2416 assert_mcp_standard_apply(Target::GeminiCli, "gemini_mcp.json");
2417 }
2418
2419 #[test]
2420 fn mcp_standard_uninstall_round_trip_restores_empty() {
2421 let mut env = TestEnv::fresh();
2422 let path = config_path(&env, "claude_desktop_config.json");
2423 seed(&path, "{}\n");
2424 run(
2425 &args_for_apply(Target::ClaudeDesktop, path.clone()),
2426 &mut env.output(),
2427 )
2428 .unwrap();
2429 run(
2430 &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
2431 &mut env.output(),
2432 )
2433 .unwrap();
2434 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2435 assert!(
2437 !parsed.as_object().unwrap().contains_key("mcpServers"),
2438 "uninstall should remove the empty mcpServers wrapper"
2439 );
2440 }
2441
2442 #[test]
2443 fn mcp_standard_apply_preserves_user_keys() {
2444 let mut env = TestEnv::fresh();
2445 let path = config_path(&env, "codex_config.json");
2446 seed(
2447 &path,
2448 r#"{"mcpServers":{"other-mcp":{"command":"x","args":[]}},"unrelated":42}"#,
2449 );
2450 run(
2451 &args_for_apply(Target::Codex, path.clone()),
2452 &mut env.output(),
2453 )
2454 .unwrap();
2455 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2456 assert_eq!(parsed["mcpServers"]["other-mcp"]["command"], "x");
2458 assert_eq!(parsed["unrelated"], 42);
2460 assert!(parsed["mcpServers"]["ai-memory"].is_object());
2462 }
2463
2464 #[test]
2467 fn config_format_detect_distinguishes_toml_and_json() {
2468 assert_eq!(
2469 ConfigFormat::detect(Path::new("/x/config.toml")),
2470 ConfigFormat::Toml
2471 );
2472 assert_eq!(
2473 ConfigFormat::detect(Path::new("/x/config.TOML")),
2474 ConfigFormat::Toml
2475 );
2476 assert_eq!(
2477 ConfigFormat::detect(Path::new("/x/config.json")),
2478 ConfigFormat::Json
2479 );
2480 assert_eq!(
2481 ConfigFormat::detect(Path::new("/x/noext")),
2482 ConfigFormat::Json
2483 );
2484 }
2485
2486 #[test]
2487 fn codex_apply_toml_roundtrips_and_preserves_user_keys() {
2488 let mut env = TestEnv::fresh();
2492 let path = config_path(&env, "config.toml");
2493 seed(
2494 &path,
2495 "unrelated = 42\n\n[mcp_servers.other-mcp]\ncommand = \"x\"\nargs = []\n",
2496 );
2497 run(
2498 &args_for_apply(Target::Codex, path.clone()),
2499 &mut env.output(),
2500 )
2501 .unwrap();
2502 let txt = fs::read_to_string(&path).unwrap();
2503 let tv: toml::Value = toml::from_str(&txt).expect("output must be valid TOML");
2504 let jv: Value = serde_json::to_value(&tv).unwrap();
2505 assert!(jv["mcp_servers"]["ai-memory"].is_object());
2507 assert_eq!(
2508 jv["mcp_servers"]["ai-memory"]["command"],
2509 "/usr/local/bin/ai-memory"
2510 );
2511 assert_eq!(jv["mcp_servers"]["other-mcp"]["command"], "x");
2513 assert_eq!(jv["unrelated"], 42);
2514 }
2515
2516 #[test]
2517 fn read_config_or_empty_rejects_invalid_toml() {
2518 let env = TestEnv::fresh();
2519 let path = config_path(&env, "broken.toml");
2520 seed(&path, "this is = = not valid toml\n");
2521 let err = read_config_or_empty(&path).unwrap_err();
2522 assert!(err.to_string().contains("is not valid TOML"), "got: {err}");
2523 }
2524
2525 fn snippet_env_lock() -> &'static std::sync::Mutex<()> {
2537 use std::sync::{Mutex, OnceLock};
2538 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2539 LOCK.get_or_init(|| Mutex::new(()))
2540 }
2541
2542 fn emit_snippet_isolated(target: Target) -> (PathBuf, String) {
2548 let tmp = tempfile::tempdir().expect("tempdir");
2555 let tmp_path = tmp.path().to_path_buf();
2556 let snippet_path =
2557 write_system_prompt_snippet_to(target, &tmp_path).expect("snippet write");
2558 let body = fs::read_to_string(&snippet_path).expect("read snippet");
2559 std::mem::forget(tmp); (snippet_path, body)
2561 }
2562
2563 fn assert_snippet_anchors(target: Target, body: &str) {
2566 let harness = target.name();
2567 assert!(
2568 body.contains(harness),
2569 "snippet for {harness} missing harness literal; body was:\n{body}",
2570 );
2571 for anchor in [
2572 "memory_capabilities",
2573 "memory_load_family",
2574 "attest_level",
2575 "R5 hook",
2577 ] {
2578 assert!(
2579 body.contains(anchor),
2580 "snippet for {harness} missing anchor `{anchor}`; body was:\n{body}",
2581 );
2582 }
2583 }
2584
2585 fn assert_snippet_token_budget(body: &str) {
2592 let approx_tokens = body.chars().count() / 4;
2593 assert!(
2594 approx_tokens <= 200,
2595 "snippet exceeds 200-token budget (≈{approx_tokens} tokens, {} chars)",
2596 body.chars().count(),
2597 );
2598 }
2599
2600 #[test]
2601 fn snippet_claude_code_has_anchors_and_under_budget() {
2602 let (path, body) = emit_snippet_isolated(Target::ClaudeCode);
2603 assert!(path.ends_with("system-prompt-claude-code.md"));
2604 assert_snippet_anchors(Target::ClaudeCode, &body);
2605 assert_snippet_token_budget(&body);
2606 assert!(
2608 body.contains("ToolSearch"),
2609 "claude-code snippet should mention ToolSearch (deferred-tool registration)",
2610 );
2611 }
2612
2613 #[test]
2614 fn snippet_cursor_has_anchors_and_under_budget() {
2615 let (path, body) = emit_snippet_isolated(Target::Cursor);
2616 assert!(path.ends_with("system-prompt-cursor.md"));
2617 assert_snippet_anchors(Target::Cursor, &body);
2618 assert_snippet_token_budget(&body);
2619 }
2620
2621 #[test]
2622 fn snippet_codex_has_anchors_and_under_budget() {
2623 let (path, body) = emit_snippet_isolated(Target::Codex);
2624 assert!(path.ends_with("system-prompt-codex.md"));
2625 assert_snippet_anchors(Target::Codex, &body);
2626 assert_snippet_token_budget(&body);
2627 }
2628
2629 #[test]
2630 fn snippet_continue_has_anchors_and_under_budget() {
2631 let (path, body) = emit_snippet_isolated(Target::Continue);
2632 assert!(path.ends_with("system-prompt-continue.md"));
2633 assert_snippet_anchors(Target::Continue, &body);
2634 assert_snippet_token_budget(&body);
2635 }
2636
2637 #[test]
2638 fn snippet_every_target_emits_under_budget() {
2639 for target in [
2669 Target::ClaudeCode,
2670 Target::Openclaw,
2671 Target::Cursor,
2672 Target::Cline,
2673 Target::Continue,
2674 Target::Windsurf,
2675 Target::ClaudeDesktop,
2676 Target::Codex,
2677 Target::GrokCli,
2678 Target::GeminiCli,
2679 ] {
2680 let tmp = tempfile::tempdir().expect("tempdir");
2681 let tmp_path = tmp.path().to_path_buf();
2682 let snippet_path =
2683 write_system_prompt_snippet_to(target, &tmp_path).expect("snippet write");
2684 let body = fs::read_to_string(&snippet_path).expect("read snippet");
2685 std::mem::forget(tmp);
2686 assert!(
2687 snippet_path.exists(),
2688 "snippet file for {} not created",
2689 target.name(),
2690 );
2691 assert_snippet_anchors(target, &body);
2692 assert_snippet_token_budget(&body);
2693 }
2694 }
2695
2696 #[test]
2697 fn snippet_emitted_during_install_apply_via_env_override() {
2698 let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
2702 let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
2703 unsafe {
2706 std::env::set_var("AI_MEMORY_SYSTEM_PROMPT_DIR", snippet_dir.path());
2707 }
2708
2709 let mut env = TestEnv::fresh();
2710 let cfg = config_path(&env, "settings.json");
2711 seed(&cfg, "{}\n");
2712 run(
2713 &args_for_apply(Target::ClaudeCode, cfg.clone()),
2714 &mut env.output(),
2715 )
2716 .unwrap();
2717
2718 let stderr = env.stderr_str();
2719 assert!(
2720 stderr.contains("system-prompt snippet"),
2721 "stderr should announce snippet write; got:\n{stderr}",
2722 );
2723 assert!(
2724 stderr.contains("claude-code"),
2725 "stderr should mention the harness name; got:\n{stderr}",
2726 );
2727
2728 let snippet = snippet_dir.path().join("system-prompt-claude-code.md");
2729 assert!(
2730 snippet.exists(),
2731 "snippet should exist at {}",
2732 snippet.display(),
2733 );
2734 let body = fs::read_to_string(&snippet).unwrap();
2735 assert_snippet_anchors(Target::ClaudeCode, &body);
2736
2737 unsafe {
2738 std::env::remove_var("AI_MEMORY_SYSTEM_PROMPT_DIR");
2739 }
2740 drop(snippet_dir);
2741 }
2742
2743 #[test]
2744 fn snippet_not_emitted_on_uninstall() {
2745 let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
2748 let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
2749 unsafe {
2750 std::env::set_var("AI_MEMORY_SYSTEM_PROMPT_DIR", snippet_dir.path());
2751 }
2752
2753 let mut env = TestEnv::fresh();
2754 let cfg = config_path(&env, "settings.json");
2755 seed(&cfg, "{}\n");
2757 run(
2758 &args_for_apply(Target::ClaudeCode, cfg.clone()),
2759 &mut env.output(),
2760 )
2761 .unwrap();
2762 env.stderr.clear();
2764
2765 run(
2767 &args_for_uninstall_apply(Target::ClaudeCode, cfg.clone()),
2768 &mut env.output(),
2769 )
2770 .unwrap();
2771 let stderr = env.stderr_str();
2772 assert!(
2773 !stderr.contains("system-prompt snippet"),
2774 "uninstall must not announce a snippet write; got:\n{stderr}",
2775 );
2776
2777 unsafe {
2778 std::env::remove_var("AI_MEMORY_SYSTEM_PROMPT_DIR");
2779 }
2780 drop(snippet_dir);
2781 }
2782
2783 fn args_no_config(target: Target) -> InstallArgs {
2788 let t = TargetArgs {
2791 config: None,
2792 apply: false,
2793 dry_run: false,
2794 uninstall: false,
2795 binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
2796 hook: None,
2797 force: false,
2798 };
2799 let target_cmd = match target {
2800 Target::ClaudeCode => TargetCmd::ClaudeCode(t),
2801 Target::Openclaw => TargetCmd::Openclaw(t),
2802 Target::Cursor => TargetCmd::Cursor(t),
2803 Target::Cline => TargetCmd::Cline(t),
2804 Target::Continue => TargetCmd::Continue(t),
2805 Target::Windsurf => TargetCmd::Windsurf(t),
2806 Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
2807 Target::Codex => TargetCmd::Codex(t),
2808 Target::GrokCli => TargetCmd::GrokCli(t),
2809 Target::GeminiCli => TargetCmd::GeminiCli(t),
2810 };
2811 InstallArgs { target: target_cmd }
2812 }
2813
2814 #[test]
2815 fn resolve_config_path_openclaw_bails_without_config() {
2816 let r = resolve_config_path(
2817 Target::Openclaw,
2818 &TargetArgs {
2819 config: None,
2820 ..TargetArgs::default()
2821 },
2822 );
2823 let err = r.unwrap_err();
2824 assert!(format!("{err}").contains("openclaw config path"));
2825 }
2826
2827 #[test]
2828 fn resolve_config_path_cline_bails_without_config() {
2829 let r = resolve_config_path(
2830 Target::Cline,
2831 &TargetArgs {
2832 config: None,
2833 ..TargetArgs::default()
2834 },
2835 );
2836 let err = r.unwrap_err();
2837 assert!(format!("{err}").contains("cline config path"));
2838 }
2839
2840 #[test]
2841 fn resolve_config_path_codex_bails_without_config() {
2842 let r = resolve_config_path(
2843 Target::Codex,
2844 &TargetArgs {
2845 config: None,
2846 ..TargetArgs::default()
2847 },
2848 );
2849 let err = r.unwrap_err();
2850 assert!(format!("{err}").contains("codex config path"));
2851 }
2852
2853 #[test]
2854 fn resolve_config_path_grok_cli_bails_without_config() {
2855 let r = resolve_config_path(
2856 Target::GrokCli,
2857 &TargetArgs {
2858 config: None,
2859 ..TargetArgs::default()
2860 },
2861 );
2862 let err = r.unwrap_err();
2863 assert!(format!("{err}").contains("grok-cli config path"));
2864 }
2865
2866 #[test]
2867 fn resolve_config_path_gemini_cli_bails_without_config() {
2868 let r = resolve_config_path(
2869 Target::GeminiCli,
2870 &TargetArgs {
2871 config: None,
2872 ..TargetArgs::default()
2873 },
2874 );
2875 let err = r.unwrap_err();
2876 assert!(format!("{err}").contains("gemini-cli config path"));
2877 }
2878
2879 #[test]
2880 fn resolve_config_path_claude_code_default_under_home() {
2881 let r = resolve_config_path(
2885 Target::ClaudeCode,
2886 &TargetArgs {
2887 config: None,
2888 ..TargetArgs::default()
2889 },
2890 )
2891 .expect("home dir present on test host");
2892 let s = r.to_string_lossy().to_string();
2893 assert!(s.ends_with(".claude/settings.json") || s.ends_with(".claude\\settings.json"));
2894 }
2895
2896 #[test]
2897 fn resolve_config_path_cursor_default_under_home() {
2898 let r = resolve_config_path(
2899 Target::Cursor,
2900 &TargetArgs {
2901 config: None,
2902 ..TargetArgs::default()
2903 },
2904 )
2905 .expect("home dir");
2906 let s = r.to_string_lossy().to_string();
2907 assert!(s.ends_with(".cursor/mcp.json") || s.ends_with(".cursor\\mcp.json"));
2908 }
2909
2910 #[test]
2911 fn resolve_config_path_continue_default_under_home() {
2912 let r = resolve_config_path(
2913 Target::Continue,
2914 &TargetArgs {
2915 config: None,
2916 ..TargetArgs::default()
2917 },
2918 )
2919 .expect("home dir");
2920 let s = r.to_string_lossy().to_string();
2921 assert!(s.ends_with(".continue/config.json") || s.ends_with(".continue\\config.json"));
2922 }
2923
2924 #[test]
2925 fn resolve_config_path_windsurf_default_under_home() {
2926 let r = resolve_config_path(
2927 Target::Windsurf,
2928 &TargetArgs {
2929 config: None,
2930 ..TargetArgs::default()
2931 },
2932 )
2933 .expect("home dir");
2934 let s = r.to_string_lossy().to_string();
2935 assert!(s.ends_with("mcp_config.json"), "got: {s}");
2936 }
2937
2938 #[cfg(target_os = "macos")]
2939 #[test]
2940 fn resolve_config_path_claude_desktop_default_under_macos() {
2941 let r = resolve_config_path(
2942 Target::ClaudeDesktop,
2943 &TargetArgs {
2944 config: None,
2945 ..TargetArgs::default()
2946 },
2947 )
2948 .expect("home dir");
2949 let s = r.to_string_lossy().to_string();
2950 assert!(s.ends_with("claude_desktop_config.json"), "got: {s}");
2951 }
2952
2953 #[test]
2954 fn install_dispatches_through_run_with_default_config_on_unsupported_target() {
2955 let args = args_no_config(Target::Codex);
2959 let mut env = TestEnv::fresh();
2960 let err = run(&args, &mut env.output()).unwrap_err();
2961 assert!(format!("{err}").contains("codex config path"));
2962 }
2963
2964 #[test]
2965 fn read_config_or_empty_handles_whitespace_only_file() {
2966 let tmp = tempfile::tempdir().unwrap();
2968 let p = tmp.path().join("blank.json");
2969 std::fs::write(&p, " \n \n").unwrap();
2970 let (text, val) = read_config_or_empty(&p).unwrap();
2971 assert!(!text.is_empty()); assert!(val.is_object() && val.as_object().unwrap().is_empty());
2973 }
2974
2975 #[test]
2976 fn read_config_or_empty_handles_missing_file() {
2977 let tmp = tempfile::tempdir().unwrap();
2979 let p = tmp.path().join("nonexistent.json");
2980 let (text, val) = read_config_or_empty(&p).unwrap();
2981 assert!(text.is_empty());
2982 assert!(val.is_object() && val.as_object().unwrap().is_empty());
2983 }
2984
2985 #[test]
2986 fn install_apply_rejects_non_object_json_root() {
2987 let mut env = TestEnv::fresh();
2989 let path = config_path(&env, "array.json");
2990 seed(&path, "[]");
2991 let err = run(
2992 &args_for_apply(Target::Cursor, path.clone()),
2993 &mut env.output(),
2994 )
2995 .unwrap_err();
2996 assert!(format!("{err}").contains("not a JSON object"));
2997 }
2998
2999 #[test]
3000 fn install_dry_run_emits_unified_diff_with_minus_and_plus_lines() {
3001 let mut env = TestEnv::fresh();
3004 let path = config_path(&env, "diff-source.json");
3005 seed(&path, "{\n \"theme\": \"dark\"\n}\n");
3008 run(&args_for(Target::Cursor, path.clone()), &mut env.output()).unwrap();
3009 let stdout = env.stdout_str();
3010 assert!(
3013 stdout.lines().any(|l| l.starts_with('+')),
3014 "expected at least one added line, got:\n{stdout}"
3015 );
3016 }
3017
3018 #[test]
3019 fn remove_mcp_standard_no_op_on_clean_config() {
3020 let mut env = TestEnv::fresh();
3023 let path = config_path(&env, "clean.json");
3024 seed(&path, "{}\n");
3025 run(
3026 &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
3027 &mut env.output(),
3028 )
3029 .unwrap();
3030 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3031 assert!(parsed.as_object().unwrap().is_empty());
3032 }
3033
3034 #[test]
3035 fn remove_claude_code_no_op_when_user_has_empty_hooks() {
3036 let mut env = TestEnv::fresh();
3040 let path = config_path(&env, "settings.json");
3041 seed(&path, r#"{"hooks":{}}"#);
3042 run(
3044 &args_for_apply(Target::ClaudeCode, path.clone()),
3045 &mut env.output(),
3046 )
3047 .unwrap();
3048 run(
3049 &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
3050 &mut env.output(),
3051 )
3052 .unwrap();
3053 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3054 assert!(parsed.get("hooks").is_none());
3057 }
3058
3059 #[test]
3060 fn install_run_creates_missing_parent_directory() {
3061 let mut env = TestEnv::fresh();
3064 let dir = env.db_path.parent().unwrap().to_path_buf();
3065 let nested = dir.join("not").join("yet").join("here").join("mcp.json");
3066 assert!(!nested.parent().unwrap().exists());
3067 run(
3068 &args_for_apply(Target::Cursor, nested.clone()),
3069 &mut env.output(),
3070 )
3071 .unwrap();
3072 assert!(nested.exists());
3073 }
3074
3075 #[test]
3076 fn resolve_binary_falls_through_when_no_override() {
3077 let s = resolve_binary(None);
3081 assert!(!s.is_empty(), "resolved binary path should be non-empty");
3082 }
3083
3084 #[test]
3085 fn which_ai_memory_returns_some_when_path_has_binary() {
3086 use std::sync::Mutex;
3090 static PATH_LOCK: Mutex<()> = Mutex::new(());
3091 let _g = PATH_LOCK.lock().unwrap();
3092
3093 let tmp = tempfile::tempdir().unwrap();
3094 let bin = tmp.path().join("ai-memory");
3095 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
3096 #[cfg(unix)]
3098 {
3099 use std::os::unix::fs::PermissionsExt;
3100 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
3101 }
3102 let orig = std::env::var_os("PATH");
3103 unsafe {
3105 std::env::set_var("PATH", tmp.path());
3106 }
3107 let found = which_ai_memory();
3108 unsafe {
3110 if let Some(p) = orig {
3111 std::env::set_var("PATH", p);
3112 } else {
3113 std::env::remove_var("PATH");
3114 }
3115 }
3116 assert!(found.is_some(), "expected to find ai-memory under $PATH");
3117 }
3118
3119 fn args_for_pretool_apply(config: PathBuf) -> InstallArgs {
3146 let t = TargetArgs {
3147 config: Some(config),
3148 apply: true,
3149 dry_run: false,
3150 uninstall: false,
3151 binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
3152 hook: Some(HookKind::Pretool),
3153 force: false,
3154 };
3155 InstallArgs {
3156 target: TargetCmd::ClaudeCode(t),
3157 }
3158 }
3159
3160 fn args_for_pretool_dry_run(config: PathBuf) -> InstallArgs {
3161 let mut a = args_for_pretool_apply(config);
3162 match &mut a.target {
3163 TargetCmd::ClaudeCode(t) => t.apply = false,
3164 _ => unreachable!(),
3165 }
3166 a
3167 }
3168
3169 fn args_for_pretool_uninstall(config: PathBuf) -> InstallArgs {
3170 let mut a = args_for_pretool_apply(config);
3171 match &mut a.target {
3172 TargetCmd::ClaudeCode(t) => {
3173 t.uninstall = true;
3174 }
3175 _ => unreachable!(),
3176 }
3177 a
3178 }
3179
3180 fn args_for_pretool_apply_force(config: PathBuf) -> InstallArgs {
3181 let mut a = args_for_pretool_apply(config);
3182 match &mut a.target {
3183 TargetCmd::ClaudeCode(t) => t.force = true,
3184 _ => unreachable!(),
3185 }
3186 a
3187 }
3188
3189 #[test]
3190 fn pretool_entry_shape_matches_documented_form() {
3191 let v = claude_code_pretool_entry();
3192 assert_eq!(PRETOOL_HOOK_MATCHER, "Bash|Edit|Write");
3194 assert_eq!(v["matcher"], PRETOOL_HOOK_MATCHER);
3195 assert_eq!(v["hooks"][0]["type"], "mcp_tool");
3196 assert_eq!(v["hooks"][0]["tool"], PRETOOL_HOOK_TOOL_NAME);
3197 assert_eq!(v["hooks"][0]["tool"], "memory_check_agent_action");
3198 assert!(v[MARKER_START_KEY].is_string());
3199 assert!(v[MARKER_END_KEY].is_string());
3200 }
3201
3202 #[test]
3203 fn pretool_conflict_detector_recognises_same_tool() {
3204 let v = serde_json::json!({
3205 "matcher": "Bash",
3206 "hooks": [
3207 { "type": "mcp_tool", "tool": "memory_check_agent_action" }
3208 ]
3209 });
3210 assert_eq!(pretool_conflict_matcher(&v).as_deref(), Some("Bash"));
3211 }
3212
3213 #[test]
3214 fn pretool_conflict_detector_ignores_managed_blocks() {
3215 let v = claude_code_pretool_entry();
3216 assert!(pretool_conflict_matcher(&v).is_none());
3217 }
3218
3219 #[test]
3220 fn pretool_conflict_detector_ignores_other_tools() {
3221 let v = serde_json::json!({
3222 "matcher": "*",
3223 "hooks": [
3224 { "type": "command", "command": "echo hi" }
3225 ]
3226 });
3227 assert!(pretool_conflict_matcher(&v).is_none());
3228 }
3229
3230 #[test]
3231 fn pretool_install_apply_writes_documented_entry() {
3232 let mut env = TestEnv::fresh();
3233 let path = config_path(&env, "settings.json");
3234 seed(&path, "{}\n");
3235 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3236 let written = fs::read_to_string(&path).unwrap();
3237 let parsed: Value = serde_json::from_str(&written).unwrap();
3238 let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3239 assert_eq!(arr.len(), 1);
3241 let entry = &arr[0];
3242 assert_eq!(entry["matcher"], PRETOOL_HOOK_MATCHER);
3243 assert_eq!(entry["hooks"][0]["type"], "mcp_tool");
3244 assert_eq!(entry["hooks"][0]["tool"], "memory_check_agent_action");
3245 assert!(env.stdout_str().contains("installed PreToolUse hook ->"));
3246 }
3247
3248 #[test]
3249 fn pretool_install_preserves_existing_keys() {
3250 let mut env = TestEnv::fresh();
3251 let path = config_path(&env, "settings.json");
3252 seed(
3253 &path,
3254 r#"{"permissions":{"allow":["npm:*"]},"env":{"FOO":"bar"}}"#,
3255 );
3256 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3257 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3258 assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
3259 assert_eq!(parsed["env"]["FOO"], "bar");
3260 assert!(parsed["hooks"]["PreToolUse"].is_array());
3261 }
3262
3263 #[test]
3264 fn pretool_install_appends_to_existing_pretooluse_array() {
3265 let mut env = TestEnv::fresh();
3266 let path = config_path(&env, "settings.json");
3267 seed(
3269 &path,
3270 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}"#,
3271 );
3272 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3273 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3274 let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3275 assert_eq!(arr.len(), 2, "operator entry + our managed entry");
3276 assert_eq!(arr[0]["matcher"], "Bash");
3278 assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
3279 assert_eq!(arr[1]["matcher"], PRETOOL_HOOK_MATCHER);
3280 assert_eq!(arr[1]["hooks"][0]["tool"], "memory_check_agent_action");
3281 }
3282
3283 #[test]
3284 fn pretool_install_is_idempotent() {
3285 let mut env = TestEnv::fresh();
3286 let path = config_path(&env, "settings.json");
3287 seed(&path, "{}\n");
3288 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3289 let first = fs::read_to_string(&path).unwrap();
3290 env.stdout.clear();
3291 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3292 let second = fs::read_to_string(&path).unwrap();
3293 assert_eq!(first, second);
3294 assert!(env.stdout_str().contains("no-op"));
3295 }
3296
3297 #[test]
3298 fn pretool_install_refuses_overwrite_without_force() {
3299 let mut env = TestEnv::fresh();
3300 let path = config_path(&env, "settings.json");
3301 seed(
3305 &path,
3306 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","tool":"memory_check_agent_action"}]}]}}"#,
3307 );
3308 let err = run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap_err();
3309 let msg = format!("{err}");
3310 assert!(
3311 msg.contains("--force"),
3312 "error should mention --force: {msg}"
3313 );
3314 let still = serde_json::from_str::<Value>(&fs::read_to_string(&path).unwrap()).unwrap();
3316 let arr = still["hooks"]["PreToolUse"].as_array().unwrap();
3317 assert_eq!(arr.len(), 1, "no new entry appended on refusal");
3318 assert_eq!(arr[0]["matcher"], "Bash");
3319 assert!(
3321 env.stderr_str().contains("existing PreToolUse entry"),
3322 "stderr should contain conflict warning: {}",
3323 env.stderr_str()
3324 );
3325 }
3326
3327 #[test]
3328 fn pretool_install_overwrites_conflict_with_force() {
3329 let mut env = TestEnv::fresh();
3330 let path = config_path(&env, "settings.json");
3331 seed(
3332 &path,
3333 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","tool":"memory_check_agent_action"}]}]}}"#,
3334 );
3335 run(
3336 &args_for_pretool_apply_force(path.clone()),
3337 &mut env.output(),
3338 )
3339 .unwrap();
3340 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3341 let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3342 assert_eq!(arr.len(), 1);
3344 assert_eq!(arr[0]["matcher"], PRETOOL_HOOK_MATCHER);
3345 assert_eq!(arr[0]["hooks"][0]["tool"], "memory_check_agent_action");
3346 assert!(arr[0][MARKER_START_KEY].is_string());
3347 }
3348
3349 #[test]
3350 fn pretool_uninstall_removes_managed_block_only() {
3351 let mut env = TestEnv::fresh();
3352 let path = config_path(&env, "settings.json");
3353 seed(
3354 &path,
3355 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]},"theme":"dark"}"#,
3356 );
3357 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3359 run(&args_for_pretool_uninstall(path.clone()), &mut env.output()).unwrap();
3361 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3362 assert_eq!(parsed["theme"], "dark");
3363 let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3365 assert_eq!(arr.len(), 1);
3366 assert_eq!(arr[0]["matcher"], "Bash");
3367 assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
3368 }
3369
3370 #[test]
3371 fn pretool_uninstall_clean_config_is_safe_noop() {
3372 let mut env = TestEnv::fresh();
3373 let path = config_path(&env, "settings.json");
3374 seed(&path, "{}\n");
3375 run(&args_for_pretool_uninstall(path.clone()), &mut env.output()).unwrap();
3377 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3378 assert!(parsed.as_object().unwrap().is_empty());
3379 }
3380
3381 #[test]
3382 fn pretool_dry_run_does_not_write() {
3383 let mut env = TestEnv::fresh();
3384 let path = config_path(&env, "settings.json");
3385 seed(&path, "{\n}\n");
3386 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
3387 run(&args_for_pretool_dry_run(path.clone()), &mut env.output()).unwrap();
3388 let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
3389 assert_eq!(mtime_before, mtime_after, "dry-run must not write");
3390 let stdout = env.stdout_str();
3391 assert!(stdout.contains("dry-run"));
3392 assert!(stdout.contains("PreToolUse"));
3393 assert!(stdout.contains("memory_check_agent_action"));
3394 }
3395
3396 #[test]
3397 fn pretool_install_rejects_hook_flag_on_non_claude_code() {
3398 let mut env = TestEnv::fresh();
3399 let path = config_path(&env, "mcp.json");
3400 seed(&path, "{}\n");
3401 let mut a = args_for_pretool_apply(path.clone());
3402 let t_args = match a.target {
3404 TargetCmd::ClaudeCode(t) => t,
3405 _ => unreachable!(),
3406 };
3407 a.target = TargetCmd::Cursor(t_args);
3408 let err = run(&a, &mut env.output()).unwrap_err();
3409 assert!(
3410 format!("{err}").contains("only supported for `claude-code`"),
3411 "got: {err}"
3412 );
3413 }
3414
3415 #[test]
3416 fn pretool_install_does_not_emit_system_prompt_snippet() {
3417 let mut env = TestEnv::fresh();
3421 let path = config_path(&env, "settings.json");
3422 seed(&path, "{}\n");
3423 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3424 assert!(
3425 !env.stderr_str().contains("system-prompt snippet"),
3426 "stderr should NOT mention the system-prompt snippet under --hook pretool: {}",
3427 env.stderr_str()
3428 );
3429 }
3430}