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
1089fn claude_code_pretool_entry() -> Value {
1096 serde_json::json!({
1097 MARKER_START_KEY: MARKER_PAYLOAD,
1098 MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
1099 "matcher": "*",
1100 "hooks": [
1101 { "type": "mcp_tool", "tool": PRETOOL_HOOK_TOOL_NAME }
1102 ],
1103 MARKER_END_KEY: MARKER_PAYLOAD,
1104 })
1105}
1106
1107fn pretool_conflict_matcher(v: &Value) -> Option<String> {
1111 let obj = v.as_object()?;
1112 if obj.contains_key(MARKER_START_KEY) {
1113 return None;
1114 }
1115 let matcher = obj.get("matcher").and_then(Value::as_str)?;
1116 let hooks = obj.get("hooks").and_then(Value::as_array)?;
1117 for h in hooks {
1118 let h_obj = h.as_object()?;
1119 if h_obj.get("type").and_then(Value::as_str) == Some("mcp_tool")
1120 && h_obj.get("tool").and_then(Value::as_str) == Some(PRETOOL_HOOK_TOOL_NAME)
1121 {
1122 return Some(matcher.to_string());
1123 }
1124 }
1125 None
1126}
1127
1128fn apply_claude_code_pretool(
1132 obj: &mut Map<String, Value>,
1133 force: bool,
1134 out: &mut CliOutput<'_>,
1135) -> Result<()> {
1136 let entry = claude_code_pretool_entry();
1137
1138 let hooks = obj
1139 .entry("hooks".to_string())
1140 .or_insert_with(|| Value::Object(Map::new()));
1141 if !hooks.is_object() {
1142 *hooks = Value::Object(Map::new());
1143 }
1144 let hooks_obj = hooks.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1145 let pretool = hooks_obj
1146 .entry(HOOK_EVENT_PRE_TOOL_USE.to_string())
1147 .or_insert_with(|| Value::Array(Vec::new()));
1148 if !pretool.is_array() {
1149 *pretool = Value::Array(Vec::new());
1150 }
1151 let arr = pretool.as_array_mut().expect(EXPECT_JUST_INSERTED_ARRAY);
1152
1153 let conflicting: Vec<String> = arr
1157 .iter()
1158 .filter_map(pretool_conflict_matcher)
1159 .filter(|m| m != "*")
1160 .collect();
1161 if !conflicting.is_empty() && !force {
1162 writeln!(
1163 out.stderr,
1164 "ai-memory install: warning — existing PreToolUse entry(s) already invoke \
1165 `{tool}` with matcher(s) {conflicts:?}. Pass --force to overwrite, or \
1166 remove the existing entries by hand if you want to keep your scoping.",
1167 tool = PRETOOL_HOOK_TOOL_NAME,
1168 conflicts = conflicting,
1169 )?;
1170 bail!(
1171 "refusing to overwrite a differing-but-similar PreToolUse hook \
1172 without --force; existing matcher(s): {conflicting:?}"
1173 );
1174 }
1175
1176 arr.retain(|v| {
1180 if is_managed_value(v) {
1181 return false;
1182 }
1183 if force && pretool_conflict_matcher(v).is_some() {
1184 return false;
1185 }
1186 true
1187 });
1188 arr.push(entry);
1189 Ok(())
1190}
1191
1192fn remove_claude_code_pretool(obj: &mut Map<String, Value>) {
1195 if let Some(hooks) = obj.get_mut("hooks").and_then(|h| h.as_object_mut())
1196 && let Some(arr) = hooks
1197 .get_mut(HOOK_EVENT_PRE_TOOL_USE)
1198 .and_then(|s| s.as_array_mut())
1199 {
1200 arr.retain(|v| !is_managed_value(v));
1201 if arr.is_empty() {
1202 hooks.remove(HOOK_EVENT_PRE_TOOL_USE);
1203 }
1204 }
1205 if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
1206 && hooks.is_empty()
1207 {
1208 obj.remove("hooks");
1209 }
1210}
1211
1212fn apply_hook_block(
1216 target: Target,
1217 kind: HookKind,
1218 mut cfg: Value,
1219 force: bool,
1220 out: &mut CliOutput<'_>,
1221) -> Result<Value> {
1222 let obj = ensure_object(&mut cfg)?;
1223 match (target, kind) {
1224 (Target::ClaudeCode, HookKind::Pretool) => {
1225 apply_claude_code_pretool(obj, force, out)?;
1226 }
1227 _ => bail!(
1231 "internal error: unsupported (target, hook) combination ({:?}, {:?})",
1232 target,
1233 kind
1234 ),
1235 }
1236 Ok(cfg)
1237}
1238
1239fn remove_hook_block(target: Target, kind: HookKind, mut cfg: Value) -> Result<Value> {
1242 let obj = match cfg.as_object_mut() {
1243 Some(o) => o,
1244 None => return Ok(cfg),
1245 };
1246 match (target, kind) {
1247 (Target::ClaudeCode, HookKind::Pretool) => {
1248 remove_claude_code_pretool(obj);
1249 }
1250 _ => {
1251 bail!(
1254 "internal error: unsupported (target, hook) combination ({:?}, {:?})",
1255 target,
1256 kind
1257 );
1258 }
1259 }
1260 Ok(cfg)
1261}
1262
1263fn ai_memory_server_value(binary: &str) -> Value {
1266 serde_json::json!({
1267 MARKER_START_KEY: MARKER_PAYLOAD,
1268 MANAGED_KEYS_PROPERTY: ["command", "args"],
1269 "command": binary,
1270 "args": ["mcp"],
1271 MARKER_END_KEY: MARKER_PAYLOAD,
1272 })
1273}
1274
1275fn apply_openclaw(obj: &mut Map<String, Value>, binary: &str) {
1276 let mcp = obj
1277 .entry("mcp".to_string())
1278 .or_insert_with(|| Value::Object(Map::new()));
1279 if !mcp.is_object() {
1280 *mcp = Value::Object(Map::new());
1281 }
1282 let mcp_obj = mcp.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1283 let servers = mcp_obj
1284 .entry("servers".to_string())
1285 .or_insert_with(|| Value::Object(Map::new()));
1286 if !servers.is_object() {
1287 *servers = Value::Object(Map::new());
1288 }
1289 let servers_obj = servers.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1290 servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
1291}
1292
1293fn remove_openclaw(obj: &mut Map<String, Value>) {
1294 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut())
1295 && let Some(servers) = mcp.get_mut("servers").and_then(|v| v.as_object_mut())
1296 {
1297 if let Some(v) = servers.get("ai-memory") {
1298 if is_managed_value(v) {
1299 servers.remove("ai-memory");
1300 }
1301 }
1302 if servers.is_empty() {
1303 mcp.remove("servers");
1304 }
1305 if mcp.is_empty() {
1306 obj.remove("mcp");
1307 }
1308 }
1309}
1310
1311fn apply_cursor(obj: &mut Map<String, Value>, binary: &str) {
1314 let servers = obj
1315 .entry(KEY_MCP_SERVERS.to_string())
1316 .or_insert_with(|| Value::Object(Map::new()));
1317 if !servers.is_object() {
1318 *servers = Value::Object(Map::new());
1319 }
1320 let servers_obj = servers.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1321 servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
1322}
1323
1324fn remove_cursor(obj: &mut Map<String, Value>) {
1325 if let Some(servers) = obj.get_mut(KEY_MCP_SERVERS).and_then(|v| v.as_object_mut()) {
1326 if let Some(v) = servers.get("ai-memory") {
1327 if is_managed_value(v) {
1328 servers.remove("ai-memory");
1329 }
1330 }
1331 if servers.is_empty() {
1332 obj.remove(KEY_MCP_SERVERS);
1333 }
1334 }
1335}
1336
1337fn apply_cline(obj: &mut Map<String, Value>, binary: &str) {
1340 apply_cursor(obj, binary);
1342}
1343
1344fn remove_cline(obj: &mut Map<String, Value>) {
1345 remove_cursor(obj);
1346}
1347
1348fn apply_continue(obj: &mut Map<String, Value>, binary: &str) {
1351 let exp = obj
1354 .entry(KEY_EXPERIMENTAL.to_string())
1355 .or_insert_with(|| Value::Object(Map::new()));
1356 if !exp.is_object() {
1357 *exp = Value::Object(Map::new());
1358 }
1359 let exp_obj = exp.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1360 let arr = exp_obj
1361 .entry(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS.to_string())
1362 .or_insert_with(|| Value::Array(Vec::new()));
1363 if !arr.is_array() {
1364 *arr = Value::Array(Vec::new());
1365 }
1366 let arr = arr.as_array_mut().expect(EXPECT_JUST_INSERTED_ARRAY);
1367 arr.retain(|v| !is_managed_value(v));
1368 let entry = serde_json::json!({
1369 MARKER_START_KEY: MARKER_PAYLOAD,
1370 MANAGED_KEYS_PROPERTY: ["transport"],
1371 "transport": {
1372 "type": "stdio",
1373 "command": binary,
1374 "args": ["mcp"],
1375 },
1376 MARKER_END_KEY: MARKER_PAYLOAD,
1377 });
1378 arr.insert(0, entry);
1379}
1380
1381fn remove_continue(obj: &mut Map<String, Value>) {
1382 if let Some(exp) = obj
1383 .get_mut(KEY_EXPERIMENTAL)
1384 .and_then(|v| v.as_object_mut())
1385 {
1386 if let Some(arr) = exp
1387 .get_mut(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS)
1388 .and_then(|v| v.as_array_mut())
1389 {
1390 arr.retain(|v| !is_managed_value(v));
1391 if arr.is_empty() {
1392 exp.remove(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS);
1393 }
1394 }
1395 if exp.is_empty() {
1396 obj.remove(KEY_EXPERIMENTAL);
1397 }
1398 }
1399}
1400
1401fn apply_windsurf(obj: &mut Map<String, Value>, binary: &str) {
1404 apply_cursor(obj, binary);
1405}
1406
1407fn remove_windsurf(obj: &mut Map<String, Value>) {
1408 remove_cursor(obj);
1409}
1410
1411fn is_managed_value(v: &Value) -> bool {
1419 v.as_object()
1420 .and_then(|o| o.get(MARKER_START_KEY))
1421 .is_some()
1422}
1423
1424fn emit_diff(out: &mut CliOutput<'_>, before: &str, after: &str) -> Result<()> {
1433 let before_lines: Vec<&str> = before.lines().collect();
1434 let after_lines: Vec<&str> = after.lines().collect();
1435 let max_len = before_lines.len().max(after_lines.len());
1436 for i in 0..max_len {
1437 let b = before_lines.get(i).copied();
1438 let a = after_lines.get(i).copied();
1439 match (b, a) {
1440 (Some(bl), Some(al)) if bl == al => writeln!(out.stdout, " {bl}")?,
1441 (Some(bl), Some(al)) => {
1442 writeln!(out.stdout, "-{bl}")?;
1443 writeln!(out.stdout, "+{al}")?;
1444 }
1445 (Some(bl), None) => writeln!(out.stdout, "-{bl}")?,
1446 (None, Some(al)) => writeln!(out.stdout, "+{al}")?,
1447 (None, None) => {}
1448 }
1449 }
1450 Ok(())
1451}
1452
1453#[cfg(test)]
1458mod tests {
1459 use super::*;
1460 use crate::cli::test_utils::TestEnv;
1461 use std::fs;
1462
1463 fn args_for(target: Target, config: PathBuf) -> InstallArgs {
1464 let t = TargetArgs {
1465 config: Some(config),
1466 apply: false,
1467 dry_run: false,
1468 uninstall: false,
1469 binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
1470 hook: None,
1471 force: false,
1472 };
1473 let target_cmd = match target {
1474 Target::ClaudeCode => TargetCmd::ClaudeCode(t),
1475 Target::Openclaw => TargetCmd::Openclaw(t),
1476 Target::Cursor => TargetCmd::Cursor(t),
1477 Target::Cline => TargetCmd::Cline(t),
1478 Target::Continue => TargetCmd::Continue(t),
1479 Target::Windsurf => TargetCmd::Windsurf(t),
1480 Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
1481 Target::Codex => TargetCmd::Codex(t),
1482 Target::GrokCli => TargetCmd::GrokCli(t),
1483 Target::GeminiCli => TargetCmd::GeminiCli(t),
1484 };
1485 InstallArgs { target: target_cmd }
1486 }
1487
1488 fn args_for_apply(target: Target, config: PathBuf) -> InstallArgs {
1489 let mut a = args_for(target, config);
1490 match &mut a.target {
1491 TargetCmd::ClaudeCode(t)
1492 | TargetCmd::Openclaw(t)
1493 | TargetCmd::Cursor(t)
1494 | TargetCmd::Cline(t)
1495 | TargetCmd::Continue(t)
1496 | TargetCmd::Windsurf(t)
1497 | TargetCmd::ClaudeDesktop(t)
1498 | TargetCmd::Codex(t)
1499 | TargetCmd::GrokCli(t)
1500 | TargetCmd::GeminiCli(t) => {
1501 t.apply = true;
1502 }
1503 }
1504 a
1505 }
1506
1507 fn args_for_uninstall_apply(target: Target, config: PathBuf) -> InstallArgs {
1508 let mut a = args_for(target, config);
1509 match &mut a.target {
1510 TargetCmd::ClaudeCode(t)
1511 | TargetCmd::Openclaw(t)
1512 | TargetCmd::Cursor(t)
1513 | TargetCmd::Cline(t)
1514 | TargetCmd::Continue(t)
1515 | TargetCmd::Windsurf(t)
1516 | TargetCmd::ClaudeDesktop(t)
1517 | TargetCmd::Codex(t)
1518 | TargetCmd::GrokCli(t)
1519 | TargetCmd::GeminiCli(t) => {
1520 t.uninstall = true;
1521 t.apply = true;
1522 }
1523 }
1524 a
1525 }
1526
1527 fn config_path(env: &TestEnv, name: &str) -> PathBuf {
1528 env.db_path.parent().unwrap().join(name)
1529 }
1530
1531 fn seed(path: &Path, contents: &str) {
1532 if let Some(parent) = path.parent() {
1533 fs::create_dir_all(parent).unwrap();
1534 }
1535 fs::write(path, contents).unwrap();
1536 }
1537
1538 #[test]
1543 fn claude_code_install_dry_run_emits_diff_no_writes() {
1544 let mut env = TestEnv::fresh();
1545 let path = config_path(&env, "settings.json");
1546 seed(&path, "{\n}\n");
1547 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1548 let args = args_for(Target::ClaudeCode, path.clone());
1549 let mut out = env.output();
1550 run(&args, &mut out).unwrap();
1551 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1552 assert!(stdout.contains("dry-run"));
1553 assert!(stdout.contains("SessionStart"));
1554 assert!(stdout.contains("ai-memory"));
1555 assert!(stdout.contains(MARKER_START_KEY));
1556 let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
1557 assert_eq!(mtime_before, mtime_after, "dry-run must not write");
1558 }
1559
1560 #[test]
1561 fn claude_code_install_apply_writes_marker_block() {
1562 let mut env = TestEnv::fresh();
1563 let path = config_path(&env, "settings.json");
1564 seed(&path, "{}\n");
1565 let args = args_for_apply(Target::ClaudeCode, path.clone());
1566 let mut out = env.output();
1567 run(&args, &mut out).unwrap();
1568 let written = fs::read_to_string(&path).unwrap();
1569 assert!(written.contains(MARKER_START_KEY));
1570 assert!(written.contains(MARKER_END_KEY));
1571 assert!(written.contains("SessionStart"));
1572 assert!(written.contains("ai-memory"));
1573 let _: Value = serde_json::from_str(&written).unwrap();
1575 }
1576
1577 #[test]
1578 fn claude_code_install_apply_preserves_user_keys() {
1579 let mut env = TestEnv::fresh();
1580 let path = config_path(&env, "settings.json");
1581 seed(
1582 &path,
1583 r#"{"theme":"dark","permissions":{"allow":["npm:*"]}}"#,
1584 );
1585 let args = args_for_apply(Target::ClaudeCode, path.clone());
1586 let mut out = env.output();
1587 run(&args, &mut out).unwrap();
1588 let written = fs::read_to_string(&path).unwrap();
1589 let parsed: Value = serde_json::from_str(&written).unwrap();
1590 assert_eq!(parsed["theme"], "dark");
1591 assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
1592 assert!(parsed["hooks"]["SessionStart"].is_array());
1593 }
1594
1595 #[test]
1596 fn claude_code_install_apply_is_idempotent() {
1597 let mut env = TestEnv::fresh();
1598 let path = config_path(&env, "settings.json");
1599 seed(&path, "{}\n");
1600 let args = args_for_apply(Target::ClaudeCode, path.clone());
1601 let mut out = env.output();
1602 run(&args, &mut out).unwrap();
1603 let after_first = fs::read_to_string(&path).unwrap();
1604 env.stdout.clear();
1606 let mut out2 = env.output();
1607 run(&args, &mut out2).unwrap();
1608 let after_second = fs::read_to_string(&path).unwrap();
1609 assert_eq!(after_first, after_second);
1610 let stdout2 = std::str::from_utf8(&env.stdout).unwrap();
1611 assert!(
1612 stdout2.contains("no-op"),
1613 "second install should be no-op: {stdout2}"
1614 );
1615 }
1616
1617 #[test]
1618 fn claude_code_uninstall_removes_marker_block_only() {
1619 let mut env = TestEnv::fresh();
1620 let path = config_path(&env, "settings.json");
1621 let original = "{\n \"theme\": \"dark\"\n}\n";
1622 seed(&path, original);
1623 run(
1625 &args_for_apply(Target::ClaudeCode, path.clone()),
1626 &mut env.output(),
1627 )
1628 .unwrap();
1629 let after_install = fs::read_to_string(&path).unwrap();
1630 assert!(after_install.contains(MARKER_START_KEY));
1631 run(
1632 &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
1633 &mut env.output(),
1634 )
1635 .unwrap();
1636 let after_uninstall = fs::read_to_string(&path).unwrap();
1637 let parsed: Value = serde_json::from_str(&after_uninstall).unwrap();
1638 assert_eq!(parsed["theme"], "dark");
1639 assert!(
1640 parsed.get("hooks").is_none(),
1641 "hooks should be gone after uninstall"
1642 );
1643 assert!(!after_uninstall.contains(MARKER_START_KEY));
1644 }
1645
1646 #[test]
1647 fn claude_code_install_refuses_malformed_config() {
1648 let mut env = TestEnv::fresh();
1649 let path = config_path(&env, "settings.json");
1650 seed(&path, "{not valid json");
1651 let args = args_for_apply(Target::ClaudeCode, path.clone());
1652 let mut out = env.output();
1653 let err = run(&args, &mut out).unwrap_err();
1654 let msg = format!("{err}");
1655 assert!(
1656 msg.contains("not valid JSON"),
1657 "error should explain malformed json: {msg}"
1658 );
1659 let still = fs::read_to_string(&path).unwrap();
1661 assert_eq!(still, "{not valid json");
1662 }
1663
1664 #[test]
1665 fn claude_code_install_writes_backup_file() {
1666 let mut env = TestEnv::fresh();
1667 let path = config_path(&env, "settings.json");
1668 seed(&path, "{}\n");
1669 let args = args_for_apply(Target::ClaudeCode, path.clone());
1670 let mut out = env.output();
1671 run(&args, &mut out).unwrap();
1672 let parent = path.parent().unwrap();
1674 let backups: Vec<_> = fs::read_dir(parent)
1675 .unwrap()
1676 .filter_map(|e| e.ok())
1677 .filter(|e| {
1678 e.file_name()
1679 .to_string_lossy()
1680 .starts_with("settings.json.bak.")
1681 || e.file_name().to_string_lossy().starts_with("settings.bak.")
1682 })
1683 .collect();
1684 assert!(
1685 !backups.is_empty(),
1686 "expected a settings.bak.<ts> backup beside the config; saw: {:?}",
1687 fs::read_dir(parent)
1688 .unwrap()
1689 .filter_map(|e| e.ok())
1690 .map(|e| e.file_name())
1691 .collect::<Vec<_>>()
1692 );
1693 }
1694
1695 #[test]
1700 fn cursor_install_dry_run_emits_diff_no_writes() {
1701 let mut env = TestEnv::fresh();
1702 let path = config_path(&env, "mcp.json");
1703 seed(&path, "{}\n");
1704 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1705 let args = args_for(Target::Cursor, path.clone());
1706 let mut out = env.output();
1707 run(&args, &mut out).unwrap();
1708 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1709 assert!(stdout.contains("dry-run"));
1710 assert!(stdout.contains("mcpServers"));
1711 let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
1712 assert_eq!(mtime_before, mtime_after);
1713 }
1714
1715 #[test]
1716 fn cursor_install_apply_writes_marker_block() {
1717 let mut env = TestEnv::fresh();
1718 let path = config_path(&env, "mcp.json");
1719 seed(&path, "{}\n");
1720 let args = args_for_apply(Target::Cursor, path.clone());
1721 run(&args, &mut env.output()).unwrap();
1722 let written = fs::read_to_string(&path).unwrap();
1723 let parsed: Value = serde_json::from_str(&written).unwrap();
1724 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1725 assert_eq!(
1726 parsed["mcpServers"]["ai-memory"]["command"],
1727 "/usr/local/bin/ai-memory"
1728 );
1729 }
1730
1731 #[test]
1732 fn cursor_install_apply_preserves_user_keys() {
1733 let mut env = TestEnv::fresh();
1734 let path = config_path(&env, "mcp.json");
1735 seed(
1736 &path,
1737 r#"{"mcpServers":{"my-other":{"command":"x"}},"telemetry":false}"#,
1738 );
1739 run(
1740 &args_for_apply(Target::Cursor, path.clone()),
1741 &mut env.output(),
1742 )
1743 .unwrap();
1744 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1745 assert_eq!(parsed["telemetry"], false);
1746 assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1747 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1748 }
1749
1750 #[test]
1751 fn cursor_install_apply_is_idempotent() {
1752 let mut env = TestEnv::fresh();
1753 let path = config_path(&env, "mcp.json");
1754 seed(&path, "{}\n");
1755 let args = args_for_apply(Target::Cursor, path.clone());
1756 run(&args, &mut env.output()).unwrap();
1757 let first = fs::read_to_string(&path).unwrap();
1758 run(&args, &mut env.output()).unwrap();
1759 let second = fs::read_to_string(&path).unwrap();
1760 assert_eq!(first, second);
1761 }
1762
1763 #[test]
1764 fn cursor_uninstall_removes_marker_block_only() {
1765 let mut env = TestEnv::fresh();
1766 let path = config_path(&env, "mcp.json");
1767 let original = r#"{"mcpServers":{"my-other":{"command":"x"}}}"#;
1768 seed(&path, original);
1769 run(
1770 &args_for_apply(Target::Cursor, path.clone()),
1771 &mut env.output(),
1772 )
1773 .unwrap();
1774 run(
1775 &args_for_uninstall_apply(Target::Cursor, path.clone()),
1776 &mut env.output(),
1777 )
1778 .unwrap();
1779 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1780 assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1781 assert!(
1782 parsed["mcpServers"]
1783 .as_object()
1784 .unwrap()
1785 .get("ai-memory")
1786 .is_none()
1787 );
1788 }
1789
1790 #[test]
1791 fn cursor_install_refuses_malformed_config() {
1792 let mut env = TestEnv::fresh();
1793 let path = config_path(&env, "mcp.json");
1794 seed(&path, "not json");
1795 let args = args_for_apply(Target::Cursor, path.clone());
1796 let err = run(&args, &mut env.output()).unwrap_err();
1797 assert!(format!("{err}").contains("not valid JSON"));
1798 }
1799
1800 #[test]
1801 fn cursor_install_writes_backup_file() {
1802 let mut env = TestEnv::fresh();
1803 let path = config_path(&env, "mcp.json");
1804 seed(&path, "{}\n");
1805 run(
1806 &args_for_apply(Target::Cursor, path.clone()),
1807 &mut env.output(),
1808 )
1809 .unwrap();
1810 let parent = path.parent().unwrap();
1811 let any_backup = fs::read_dir(parent)
1812 .unwrap()
1813 .filter_map(|e| e.ok())
1814 .any(|e| e.file_name().to_string_lossy().contains("bak."));
1815 assert!(any_backup);
1816 }
1817
1818 #[test]
1823 fn openclaw_install_dry_run_emits_diff_no_writes() {
1824 let mut env = TestEnv::fresh();
1825 let path = config_path(&env, "openclaw.json");
1826 seed(&path, "{}\n");
1827 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1828 run(&args_for(Target::Openclaw, path.clone()), &mut env.output()).unwrap();
1829 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1830 assert!(stdout.contains("dry-run"));
1831 assert!(stdout.contains("mcp"));
1832 assert_eq!(
1833 mtime_before,
1834 fs::metadata(&path).unwrap().modified().unwrap()
1835 );
1836 }
1837
1838 #[test]
1839 fn openclaw_install_apply_writes_marker_block() {
1840 let mut env = TestEnv::fresh();
1841 let path = config_path(&env, "openclaw.json");
1842 seed(&path, "{}\n");
1843 run(
1844 &args_for_apply(Target::Openclaw, path.clone()),
1845 &mut env.output(),
1846 )
1847 .unwrap();
1848 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1849 assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1850 }
1851
1852 #[test]
1853 fn openclaw_install_apply_preserves_user_keys() {
1854 let mut env = TestEnv::fresh();
1855 let path = config_path(&env, "openclaw.json");
1856 seed(
1857 &path,
1858 r#"{"mcp":{"servers":{"other":{"command":"y"}}},"editor":"vim"}"#,
1859 );
1860 run(
1861 &args_for_apply(Target::Openclaw, path.clone()),
1862 &mut env.output(),
1863 )
1864 .unwrap();
1865 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1866 assert_eq!(parsed["editor"], "vim");
1867 assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1868 assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1869 }
1870
1871 #[test]
1872 fn openclaw_install_apply_is_idempotent() {
1873 let mut env = TestEnv::fresh();
1874 let path = config_path(&env, "openclaw.json");
1875 seed(&path, "{}\n");
1876 let args = args_for_apply(Target::Openclaw, path.clone());
1877 run(&args, &mut env.output()).unwrap();
1878 let first = fs::read_to_string(&path).unwrap();
1879 run(&args, &mut env.output()).unwrap();
1880 let second = fs::read_to_string(&path).unwrap();
1881 assert_eq!(first, second);
1882 }
1883
1884 #[test]
1885 fn openclaw_uninstall_removes_marker_block_only() {
1886 let mut env = TestEnv::fresh();
1887 let path = config_path(&env, "openclaw.json");
1888 seed(&path, r#"{"mcp":{"servers":{"other":{"command":"y"}}}}"#);
1889 run(
1890 &args_for_apply(Target::Openclaw, path.clone()),
1891 &mut env.output(),
1892 )
1893 .unwrap();
1894 run(
1895 &args_for_uninstall_apply(Target::Openclaw, path.clone()),
1896 &mut env.output(),
1897 )
1898 .unwrap();
1899 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1900 assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1901 assert!(
1902 parsed["mcp"]["servers"]
1903 .as_object()
1904 .unwrap()
1905 .get("ai-memory")
1906 .is_none()
1907 );
1908 }
1909
1910 #[test]
1911 fn openclaw_install_refuses_malformed_config() {
1912 let mut env = TestEnv::fresh();
1913 let path = config_path(&env, "openclaw.json");
1914 seed(&path, "garbage");
1915 let err = run(
1916 &args_for_apply(Target::Openclaw, path.clone()),
1917 &mut env.output(),
1918 )
1919 .unwrap_err();
1920 assert!(format!("{err}").contains("not valid JSON"));
1921 }
1922
1923 #[test]
1924 fn openclaw_install_writes_backup_file() {
1925 let mut env = TestEnv::fresh();
1926 let path = config_path(&env, "openclaw.json");
1927 seed(&path, "{}\n");
1928 run(
1929 &args_for_apply(Target::Openclaw, path.clone()),
1930 &mut env.output(),
1931 )
1932 .unwrap();
1933 let parent = path.parent().unwrap();
1934 assert!(
1935 fs::read_dir(parent)
1936 .unwrap()
1937 .filter_map(|e| e.ok())
1938 .any(|e| e.file_name().to_string_lossy().contains("bak."))
1939 );
1940 }
1941
1942 #[test]
1947 fn cline_install_dry_run_emits_diff_no_writes() {
1948 let mut env = TestEnv::fresh();
1949 let path = config_path(&env, "cline.json");
1950 seed(&path, "{}\n");
1951 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1952 run(&args_for(Target::Cline, path.clone()), &mut env.output()).unwrap();
1953 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1954 assert!(stdout.contains("dry-run"));
1955 assert!(stdout.contains("mcpServers"));
1956 assert_eq!(
1957 mtime_before,
1958 fs::metadata(&path).unwrap().modified().unwrap()
1959 );
1960 }
1961
1962 #[test]
1963 fn cline_install_apply_writes_marker_block() {
1964 let mut env = TestEnv::fresh();
1965 let path = config_path(&env, "cline.json");
1966 seed(&path, "{}\n");
1967 run(
1968 &args_for_apply(Target::Cline, path.clone()),
1969 &mut env.output(),
1970 )
1971 .unwrap();
1972 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1973 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1974 }
1975
1976 #[test]
1977 fn cline_install_apply_preserves_user_keys() {
1978 let mut env = TestEnv::fresh();
1979 let path = config_path(&env, "cline.json");
1980 seed(&path, r#"{"mcpServers":{"x":{"command":"q"}},"foo":1}"#);
1981 run(
1982 &args_for_apply(Target::Cline, path.clone()),
1983 &mut env.output(),
1984 )
1985 .unwrap();
1986 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1987 assert_eq!(parsed["foo"], 1);
1988 assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
1989 }
1990
1991 #[test]
1992 fn cline_install_apply_is_idempotent() {
1993 let mut env = TestEnv::fresh();
1994 let path = config_path(&env, "cline.json");
1995 seed(&path, "{}\n");
1996 let args = args_for_apply(Target::Cline, path.clone());
1997 run(&args, &mut env.output()).unwrap();
1998 let first = fs::read_to_string(&path).unwrap();
1999 run(&args, &mut env.output()).unwrap();
2000 let second = fs::read_to_string(&path).unwrap();
2001 assert_eq!(first, second);
2002 }
2003
2004 #[test]
2005 fn cline_uninstall_removes_marker_block_only() {
2006 let mut env = TestEnv::fresh();
2007 let path = config_path(&env, "cline.json");
2008 seed(&path, r#"{"mcpServers":{"x":{"command":"q"}}}"#);
2009 run(
2010 &args_for_apply(Target::Cline, path.clone()),
2011 &mut env.output(),
2012 )
2013 .unwrap();
2014 run(
2015 &args_for_uninstall_apply(Target::Cline, path.clone()),
2016 &mut env.output(),
2017 )
2018 .unwrap();
2019 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2020 assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
2021 assert!(
2022 parsed["mcpServers"]
2023 .as_object()
2024 .unwrap()
2025 .get("ai-memory")
2026 .is_none()
2027 );
2028 }
2029
2030 #[test]
2031 fn cline_install_refuses_malformed_config() {
2032 let mut env = TestEnv::fresh();
2033 let path = config_path(&env, "cline.json");
2034 seed(&path, "totally not json");
2035 let err = run(
2036 &args_for_apply(Target::Cline, path.clone()),
2037 &mut env.output(),
2038 )
2039 .unwrap_err();
2040 assert!(format!("{err}").contains("not valid JSON"));
2041 }
2042
2043 #[test]
2044 fn cline_install_writes_backup_file() {
2045 let mut env = TestEnv::fresh();
2046 let path = config_path(&env, "cline.json");
2047 seed(&path, "{}\n");
2048 run(
2049 &args_for_apply(Target::Cline, path.clone()),
2050 &mut env.output(),
2051 )
2052 .unwrap();
2053 assert!(
2054 fs::read_dir(path.parent().unwrap())
2055 .unwrap()
2056 .filter_map(|e| e.ok())
2057 .any(|e| e.file_name().to_string_lossy().contains("bak."))
2058 );
2059 }
2060
2061 #[test]
2066 fn continue_install_dry_run_emits_diff_no_writes() {
2067 let mut env = TestEnv::fresh();
2068 let path = config_path(&env, "continue.json");
2069 seed(&path, "{}\n");
2070 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
2071 run(&args_for(Target::Continue, path.clone()), &mut env.output()).unwrap();
2072 let stdout = std::str::from_utf8(&env.stdout).unwrap();
2073 assert!(stdout.contains("dry-run"));
2074 assert!(stdout.contains("modelContextProtocolServers"));
2075 assert_eq!(
2076 mtime_before,
2077 fs::metadata(&path).unwrap().modified().unwrap()
2078 );
2079 }
2080
2081 #[test]
2082 fn continue_install_apply_writes_marker_block() {
2083 let mut env = TestEnv::fresh();
2084 let path = config_path(&env, "continue.json");
2085 seed(&path, "{}\n");
2086 run(
2087 &args_for_apply(Target::Continue, path.clone()),
2088 &mut env.output(),
2089 )
2090 .unwrap();
2091 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2092 let arr = parsed["experimental"]["modelContextProtocolServers"]
2093 .as_array()
2094 .unwrap();
2095 assert!(arr.iter().any(is_managed_value));
2096 }
2097
2098 #[test]
2099 fn continue_install_apply_preserves_user_keys() {
2100 let mut env = TestEnv::fresh();
2101 let path = config_path(&env, "continue.json");
2102 seed(
2103 &path,
2104 r#"{"models":[{"name":"x"}],"experimental":{"foo":true}}"#,
2105 );
2106 run(
2107 &args_for_apply(Target::Continue, path.clone()),
2108 &mut env.output(),
2109 )
2110 .unwrap();
2111 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2112 assert_eq!(parsed["models"][0]["name"], "x");
2113 assert_eq!(parsed["experimental"]["foo"], true);
2114 }
2115
2116 #[test]
2117 fn continue_install_apply_is_idempotent() {
2118 let mut env = TestEnv::fresh();
2119 let path = config_path(&env, "continue.json");
2120 seed(&path, "{}\n");
2121 let args = args_for_apply(Target::Continue, path.clone());
2122 run(&args, &mut env.output()).unwrap();
2123 let first = fs::read_to_string(&path).unwrap();
2124 run(&args, &mut env.output()).unwrap();
2125 let second = fs::read_to_string(&path).unwrap();
2126 assert_eq!(first, second);
2127 }
2128
2129 #[test]
2130 fn continue_uninstall_removes_marker_block_only() {
2131 let mut env = TestEnv::fresh();
2132 let path = config_path(&env, "continue.json");
2133 seed(&path, r#"{"models":[{"name":"x"}]}"#);
2134 run(
2135 &args_for_apply(Target::Continue, path.clone()),
2136 &mut env.output(),
2137 )
2138 .unwrap();
2139 run(
2140 &args_for_uninstall_apply(Target::Continue, path.clone()),
2141 &mut env.output(),
2142 )
2143 .unwrap();
2144 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2145 assert_eq!(parsed["models"][0]["name"], "x");
2146 assert!(parsed.get("experimental").is_none());
2147 }
2148
2149 #[test]
2150 fn continue_install_refuses_malformed_config() {
2151 let mut env = TestEnv::fresh();
2152 let path = config_path(&env, "continue.json");
2153 seed(&path, "[1,2,");
2154 let err = run(
2155 &args_for_apply(Target::Continue, path.clone()),
2156 &mut env.output(),
2157 )
2158 .unwrap_err();
2159 assert!(format!("{err}").contains("not valid JSON"));
2160 }
2161
2162 #[test]
2163 fn continue_install_writes_backup_file() {
2164 let mut env = TestEnv::fresh();
2165 let path = config_path(&env, "continue.json");
2166 seed(&path, "{}\n");
2167 run(
2168 &args_for_apply(Target::Continue, path.clone()),
2169 &mut env.output(),
2170 )
2171 .unwrap();
2172 assert!(
2173 fs::read_dir(path.parent().unwrap())
2174 .unwrap()
2175 .filter_map(|e| e.ok())
2176 .any(|e| e.file_name().to_string_lossy().contains("bak."))
2177 );
2178 }
2179
2180 #[test]
2185 fn windsurf_install_dry_run_emits_diff_no_writes() {
2186 let mut env = TestEnv::fresh();
2187 let path = config_path(&env, "mcp_config.json");
2188 seed(&path, "{}\n");
2189 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
2190 run(&args_for(Target::Windsurf, path.clone()), &mut env.output()).unwrap();
2191 let stdout = std::str::from_utf8(&env.stdout).unwrap();
2192 assert!(stdout.contains("dry-run"));
2193 assert!(stdout.contains("mcpServers"));
2194 assert_eq!(
2195 mtime_before,
2196 fs::metadata(&path).unwrap().modified().unwrap()
2197 );
2198 }
2199
2200 #[test]
2201 fn windsurf_install_apply_writes_marker_block() {
2202 let mut env = TestEnv::fresh();
2203 let path = config_path(&env, "mcp_config.json");
2204 seed(&path, "{}\n");
2205 run(
2206 &args_for_apply(Target::Windsurf, path.clone()),
2207 &mut env.output(),
2208 )
2209 .unwrap();
2210 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2211 assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
2212 }
2213
2214 #[test]
2215 fn windsurf_install_apply_preserves_user_keys() {
2216 let mut env = TestEnv::fresh();
2217 let path = config_path(&env, "mcp_config.json");
2218 seed(&path, r#"{"mcpServers":{"k":{"command":"l"}},"a":42}"#);
2219 run(
2220 &args_for_apply(Target::Windsurf, path.clone()),
2221 &mut env.output(),
2222 )
2223 .unwrap();
2224 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2225 assert_eq!(parsed["a"], 42);
2226 assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
2227 }
2228
2229 #[test]
2230 fn windsurf_install_apply_is_idempotent() {
2231 let mut env = TestEnv::fresh();
2232 let path = config_path(&env, "mcp_config.json");
2233 seed(&path, "{}\n");
2234 let args = args_for_apply(Target::Windsurf, path.clone());
2235 run(&args, &mut env.output()).unwrap();
2236 let first = fs::read_to_string(&path).unwrap();
2237 run(&args, &mut env.output()).unwrap();
2238 let second = fs::read_to_string(&path).unwrap();
2239 assert_eq!(first, second);
2240 }
2241
2242 #[test]
2243 fn windsurf_uninstall_removes_marker_block_only() {
2244 let mut env = TestEnv::fresh();
2245 let path = config_path(&env, "mcp_config.json");
2246 seed(&path, r#"{"mcpServers":{"k":{"command":"l"}}}"#);
2247 run(
2248 &args_for_apply(Target::Windsurf, path.clone()),
2249 &mut env.output(),
2250 )
2251 .unwrap();
2252 run(
2253 &args_for_uninstall_apply(Target::Windsurf, path.clone()),
2254 &mut env.output(),
2255 )
2256 .unwrap();
2257 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2258 assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
2259 assert!(
2260 parsed["mcpServers"]
2261 .as_object()
2262 .unwrap()
2263 .get("ai-memory")
2264 .is_none()
2265 );
2266 }
2267
2268 #[test]
2269 fn windsurf_install_refuses_malformed_config() {
2270 let mut env = TestEnv::fresh();
2271 let path = config_path(&env, "mcp_config.json");
2272 seed(&path, "::");
2273 let err = run(
2274 &args_for_apply(Target::Windsurf, path.clone()),
2275 &mut env.output(),
2276 )
2277 .unwrap_err();
2278 assert!(format!("{err}").contains("not valid JSON"));
2279 }
2280
2281 #[test]
2282 fn windsurf_install_writes_backup_file() {
2283 let mut env = TestEnv::fresh();
2284 let path = config_path(&env, "mcp_config.json");
2285 seed(&path, "{}\n");
2286 run(
2287 &args_for_apply(Target::Windsurf, path.clone()),
2288 &mut env.output(),
2289 )
2290 .unwrap();
2291 assert!(
2292 fs::read_dir(path.parent().unwrap())
2293 .unwrap()
2294 .filter_map(|e| e.ok())
2295 .any(|e| e.file_name().to_string_lossy().contains("bak."))
2296 );
2297 }
2298
2299 #[test]
2304 fn install_creates_missing_config_file_under_apply() {
2305 let mut env = TestEnv::fresh();
2306 let path = config_path(&env, "fresh-config.json");
2307 assert!(!path.exists());
2308 run(
2309 &args_for_apply(Target::Cursor, path.clone()),
2310 &mut env.output(),
2311 )
2312 .unwrap();
2313 assert!(path.exists());
2314 let _: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2315 }
2316
2317 #[test]
2318 fn install_round_trip_install_then_uninstall_restores_original_for_empty_seed() {
2319 let mut env = TestEnv::fresh();
2322 let path = config_path(&env, "rt.json");
2323 seed(&path, "{}\n");
2324 run(
2325 &args_for_apply(Target::Cursor, path.clone()),
2326 &mut env.output(),
2327 )
2328 .unwrap();
2329 run(
2330 &args_for_uninstall_apply(Target::Cursor, path.clone()),
2331 &mut env.output(),
2332 )
2333 .unwrap();
2334 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2335 assert_eq!(parsed, serde_json::json!({}));
2336 }
2337
2338 #[test]
2339 fn resolve_binary_uses_override_when_provided() {
2340 let p = std::path::PathBuf::from("/custom/path/ai-memory");
2341 let resolved = resolve_binary(Some(&p));
2342 assert_eq!(resolved, "/custom/path/ai-memory");
2343 }
2344
2345 fn assert_mcp_standard_apply(target: Target, fname: &str) {
2353 let mut env = TestEnv::fresh();
2354 let path = config_path(&env, fname);
2355 seed(&path, "{}\n");
2356 run(&args_for_apply(target, path.clone()), &mut env.output()).unwrap();
2357 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2358 assert!(
2360 parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string(),
2361 "{} missing managed-block marker",
2362 target.name()
2363 );
2364 let args = parsed["mcpServers"]["ai-memory"]["args"]
2366 .as_array()
2367 .unwrap();
2368 let strs: Vec<&str> = args.iter().filter_map(Value::as_str).collect();
2369 assert_eq!(
2370 strs,
2371 vec!["mcp", "--profile", "core"],
2372 "{} should write `mcp --profile core` args",
2373 target.name()
2374 );
2375 let cmd = parsed["mcpServers"]["ai-memory"]["command"]
2376 .as_str()
2377 .unwrap();
2378 assert_eq!(cmd, "/usr/local/bin/ai-memory");
2379 }
2380
2381 #[test]
2382 fn claude_desktop_apply_writes_mcp_standard_with_profile_core() {
2383 assert_mcp_standard_apply(Target::ClaudeDesktop, "claude_desktop_config.json");
2384 }
2385
2386 #[test]
2387 fn codex_apply_writes_mcp_standard_with_profile_core() {
2388 assert_mcp_standard_apply(Target::Codex, "codex_config.json");
2389 }
2390
2391 #[test]
2392 fn grok_cli_apply_writes_mcp_standard_with_profile_core() {
2393 assert_mcp_standard_apply(Target::GrokCli, "grok_mcp.json");
2394 }
2395
2396 #[test]
2397 fn gemini_cli_apply_writes_mcp_standard_with_profile_core() {
2398 assert_mcp_standard_apply(Target::GeminiCli, "gemini_mcp.json");
2399 }
2400
2401 #[test]
2402 fn mcp_standard_uninstall_round_trip_restores_empty() {
2403 let mut env = TestEnv::fresh();
2404 let path = config_path(&env, "claude_desktop_config.json");
2405 seed(&path, "{}\n");
2406 run(
2407 &args_for_apply(Target::ClaudeDesktop, path.clone()),
2408 &mut env.output(),
2409 )
2410 .unwrap();
2411 run(
2412 &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
2413 &mut env.output(),
2414 )
2415 .unwrap();
2416 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2417 assert!(
2419 !parsed.as_object().unwrap().contains_key("mcpServers"),
2420 "uninstall should remove the empty mcpServers wrapper"
2421 );
2422 }
2423
2424 #[test]
2425 fn mcp_standard_apply_preserves_user_keys() {
2426 let mut env = TestEnv::fresh();
2427 let path = config_path(&env, "codex_config.json");
2428 seed(
2429 &path,
2430 r#"{"mcpServers":{"other-mcp":{"command":"x","args":[]}},"unrelated":42}"#,
2431 );
2432 run(
2433 &args_for_apply(Target::Codex, path.clone()),
2434 &mut env.output(),
2435 )
2436 .unwrap();
2437 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2438 assert_eq!(parsed["mcpServers"]["other-mcp"]["command"], "x");
2440 assert_eq!(parsed["unrelated"], 42);
2442 assert!(parsed["mcpServers"]["ai-memory"].is_object());
2444 }
2445
2446 #[test]
2449 fn config_format_detect_distinguishes_toml_and_json() {
2450 assert_eq!(
2451 ConfigFormat::detect(Path::new("/x/config.toml")),
2452 ConfigFormat::Toml
2453 );
2454 assert_eq!(
2455 ConfigFormat::detect(Path::new("/x/config.TOML")),
2456 ConfigFormat::Toml
2457 );
2458 assert_eq!(
2459 ConfigFormat::detect(Path::new("/x/config.json")),
2460 ConfigFormat::Json
2461 );
2462 assert_eq!(
2463 ConfigFormat::detect(Path::new("/x/noext")),
2464 ConfigFormat::Json
2465 );
2466 }
2467
2468 #[test]
2469 fn codex_apply_toml_roundtrips_and_preserves_user_keys() {
2470 let mut env = TestEnv::fresh();
2474 let path = config_path(&env, "config.toml");
2475 seed(
2476 &path,
2477 "unrelated = 42\n\n[mcp_servers.other-mcp]\ncommand = \"x\"\nargs = []\n",
2478 );
2479 run(
2480 &args_for_apply(Target::Codex, path.clone()),
2481 &mut env.output(),
2482 )
2483 .unwrap();
2484 let txt = fs::read_to_string(&path).unwrap();
2485 let tv: toml::Value = toml::from_str(&txt).expect("output must be valid TOML");
2486 let jv: Value = serde_json::to_value(&tv).unwrap();
2487 assert!(jv["mcp_servers"]["ai-memory"].is_object());
2489 assert_eq!(
2490 jv["mcp_servers"]["ai-memory"]["command"],
2491 "/usr/local/bin/ai-memory"
2492 );
2493 assert_eq!(jv["mcp_servers"]["other-mcp"]["command"], "x");
2495 assert_eq!(jv["unrelated"], 42);
2496 }
2497
2498 #[test]
2499 fn read_config_or_empty_rejects_invalid_toml() {
2500 let env = TestEnv::fresh();
2501 let path = config_path(&env, "broken.toml");
2502 seed(&path, "this is = = not valid toml\n");
2503 let err = read_config_or_empty(&path).unwrap_err();
2504 assert!(err.to_string().contains("is not valid TOML"), "got: {err}");
2505 }
2506
2507 fn snippet_env_lock() -> &'static std::sync::Mutex<()> {
2519 use std::sync::{Mutex, OnceLock};
2520 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2521 LOCK.get_or_init(|| Mutex::new(()))
2522 }
2523
2524 fn emit_snippet_isolated(target: Target) -> (PathBuf, String) {
2530 let tmp = tempfile::tempdir().expect("tempdir");
2537 let tmp_path = tmp.path().to_path_buf();
2538 let snippet_path =
2539 write_system_prompt_snippet_to(target, &tmp_path).expect("snippet write");
2540 let body = fs::read_to_string(&snippet_path).expect("read snippet");
2541 std::mem::forget(tmp); (snippet_path, body)
2543 }
2544
2545 fn assert_snippet_anchors(target: Target, body: &str) {
2548 let harness = target.name();
2549 assert!(
2550 body.contains(harness),
2551 "snippet for {harness} missing harness literal; body was:\n{body}",
2552 );
2553 for anchor in [
2554 "memory_capabilities",
2555 "memory_load_family",
2556 "attest_level",
2557 "R5 hook",
2559 ] {
2560 assert!(
2561 body.contains(anchor),
2562 "snippet for {harness} missing anchor `{anchor}`; body was:\n{body}",
2563 );
2564 }
2565 }
2566
2567 fn assert_snippet_token_budget(body: &str) {
2574 let approx_tokens = body.chars().count() / 4;
2575 assert!(
2576 approx_tokens <= 200,
2577 "snippet exceeds 200-token budget (≈{approx_tokens} tokens, {} chars)",
2578 body.chars().count(),
2579 );
2580 }
2581
2582 #[test]
2583 fn snippet_claude_code_has_anchors_and_under_budget() {
2584 let (path, body) = emit_snippet_isolated(Target::ClaudeCode);
2585 assert!(path.ends_with("system-prompt-claude-code.md"));
2586 assert_snippet_anchors(Target::ClaudeCode, &body);
2587 assert_snippet_token_budget(&body);
2588 assert!(
2590 body.contains("ToolSearch"),
2591 "claude-code snippet should mention ToolSearch (deferred-tool registration)",
2592 );
2593 }
2594
2595 #[test]
2596 fn snippet_cursor_has_anchors_and_under_budget() {
2597 let (path, body) = emit_snippet_isolated(Target::Cursor);
2598 assert!(path.ends_with("system-prompt-cursor.md"));
2599 assert_snippet_anchors(Target::Cursor, &body);
2600 assert_snippet_token_budget(&body);
2601 }
2602
2603 #[test]
2604 fn snippet_codex_has_anchors_and_under_budget() {
2605 let (path, body) = emit_snippet_isolated(Target::Codex);
2606 assert!(path.ends_with("system-prompt-codex.md"));
2607 assert_snippet_anchors(Target::Codex, &body);
2608 assert_snippet_token_budget(&body);
2609 }
2610
2611 #[test]
2612 fn snippet_continue_has_anchors_and_under_budget() {
2613 let (path, body) = emit_snippet_isolated(Target::Continue);
2614 assert!(path.ends_with("system-prompt-continue.md"));
2615 assert_snippet_anchors(Target::Continue, &body);
2616 assert_snippet_token_budget(&body);
2617 }
2618
2619 #[test]
2620 fn snippet_every_target_emits_under_budget() {
2621 for target in [
2651 Target::ClaudeCode,
2652 Target::Openclaw,
2653 Target::Cursor,
2654 Target::Cline,
2655 Target::Continue,
2656 Target::Windsurf,
2657 Target::ClaudeDesktop,
2658 Target::Codex,
2659 Target::GrokCli,
2660 Target::GeminiCli,
2661 ] {
2662 let tmp = tempfile::tempdir().expect("tempdir");
2663 let tmp_path = tmp.path().to_path_buf();
2664 let snippet_path =
2665 write_system_prompt_snippet_to(target, &tmp_path).expect("snippet write");
2666 let body = fs::read_to_string(&snippet_path).expect("read snippet");
2667 std::mem::forget(tmp);
2668 assert!(
2669 snippet_path.exists(),
2670 "snippet file for {} not created",
2671 target.name(),
2672 );
2673 assert_snippet_anchors(target, &body);
2674 assert_snippet_token_budget(&body);
2675 }
2676 }
2677
2678 #[test]
2679 fn snippet_emitted_during_install_apply_via_env_override() {
2680 let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
2684 let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
2685 unsafe {
2688 std::env::set_var("AI_MEMORY_SYSTEM_PROMPT_DIR", snippet_dir.path());
2689 }
2690
2691 let mut env = TestEnv::fresh();
2692 let cfg = config_path(&env, "settings.json");
2693 seed(&cfg, "{}\n");
2694 run(
2695 &args_for_apply(Target::ClaudeCode, cfg.clone()),
2696 &mut env.output(),
2697 )
2698 .unwrap();
2699
2700 let stderr = env.stderr_str();
2701 assert!(
2702 stderr.contains("system-prompt snippet"),
2703 "stderr should announce snippet write; got:\n{stderr}",
2704 );
2705 assert!(
2706 stderr.contains("claude-code"),
2707 "stderr should mention the harness name; got:\n{stderr}",
2708 );
2709
2710 let snippet = snippet_dir.path().join("system-prompt-claude-code.md");
2711 assert!(
2712 snippet.exists(),
2713 "snippet should exist at {}",
2714 snippet.display(),
2715 );
2716 let body = fs::read_to_string(&snippet).unwrap();
2717 assert_snippet_anchors(Target::ClaudeCode, &body);
2718
2719 unsafe {
2720 std::env::remove_var("AI_MEMORY_SYSTEM_PROMPT_DIR");
2721 }
2722 drop(snippet_dir);
2723 }
2724
2725 #[test]
2726 fn snippet_not_emitted_on_uninstall() {
2727 let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
2730 let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
2731 unsafe {
2732 std::env::set_var("AI_MEMORY_SYSTEM_PROMPT_DIR", snippet_dir.path());
2733 }
2734
2735 let mut env = TestEnv::fresh();
2736 let cfg = config_path(&env, "settings.json");
2737 seed(&cfg, "{}\n");
2739 run(
2740 &args_for_apply(Target::ClaudeCode, cfg.clone()),
2741 &mut env.output(),
2742 )
2743 .unwrap();
2744 env.stderr.clear();
2746
2747 run(
2749 &args_for_uninstall_apply(Target::ClaudeCode, cfg.clone()),
2750 &mut env.output(),
2751 )
2752 .unwrap();
2753 let stderr = env.stderr_str();
2754 assert!(
2755 !stderr.contains("system-prompt snippet"),
2756 "uninstall must not announce a snippet write; got:\n{stderr}",
2757 );
2758
2759 unsafe {
2760 std::env::remove_var("AI_MEMORY_SYSTEM_PROMPT_DIR");
2761 }
2762 drop(snippet_dir);
2763 }
2764
2765 fn args_no_config(target: Target) -> InstallArgs {
2770 let t = TargetArgs {
2773 config: None,
2774 apply: false,
2775 dry_run: false,
2776 uninstall: false,
2777 binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
2778 hook: None,
2779 force: false,
2780 };
2781 let target_cmd = match target {
2782 Target::ClaudeCode => TargetCmd::ClaudeCode(t),
2783 Target::Openclaw => TargetCmd::Openclaw(t),
2784 Target::Cursor => TargetCmd::Cursor(t),
2785 Target::Cline => TargetCmd::Cline(t),
2786 Target::Continue => TargetCmd::Continue(t),
2787 Target::Windsurf => TargetCmd::Windsurf(t),
2788 Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
2789 Target::Codex => TargetCmd::Codex(t),
2790 Target::GrokCli => TargetCmd::GrokCli(t),
2791 Target::GeminiCli => TargetCmd::GeminiCli(t),
2792 };
2793 InstallArgs { target: target_cmd }
2794 }
2795
2796 #[test]
2797 fn resolve_config_path_openclaw_bails_without_config() {
2798 let r = resolve_config_path(
2799 Target::Openclaw,
2800 &TargetArgs {
2801 config: None,
2802 ..TargetArgs::default()
2803 },
2804 );
2805 let err = r.unwrap_err();
2806 assert!(format!("{err}").contains("openclaw config path"));
2807 }
2808
2809 #[test]
2810 fn resolve_config_path_cline_bails_without_config() {
2811 let r = resolve_config_path(
2812 Target::Cline,
2813 &TargetArgs {
2814 config: None,
2815 ..TargetArgs::default()
2816 },
2817 );
2818 let err = r.unwrap_err();
2819 assert!(format!("{err}").contains("cline config path"));
2820 }
2821
2822 #[test]
2823 fn resolve_config_path_codex_bails_without_config() {
2824 let r = resolve_config_path(
2825 Target::Codex,
2826 &TargetArgs {
2827 config: None,
2828 ..TargetArgs::default()
2829 },
2830 );
2831 let err = r.unwrap_err();
2832 assert!(format!("{err}").contains("codex config path"));
2833 }
2834
2835 #[test]
2836 fn resolve_config_path_grok_cli_bails_without_config() {
2837 let r = resolve_config_path(
2838 Target::GrokCli,
2839 &TargetArgs {
2840 config: None,
2841 ..TargetArgs::default()
2842 },
2843 );
2844 let err = r.unwrap_err();
2845 assert!(format!("{err}").contains("grok-cli config path"));
2846 }
2847
2848 #[test]
2849 fn resolve_config_path_gemini_cli_bails_without_config() {
2850 let r = resolve_config_path(
2851 Target::GeminiCli,
2852 &TargetArgs {
2853 config: None,
2854 ..TargetArgs::default()
2855 },
2856 );
2857 let err = r.unwrap_err();
2858 assert!(format!("{err}").contains("gemini-cli config path"));
2859 }
2860
2861 #[test]
2862 fn resolve_config_path_claude_code_default_under_home() {
2863 let r = resolve_config_path(
2867 Target::ClaudeCode,
2868 &TargetArgs {
2869 config: None,
2870 ..TargetArgs::default()
2871 },
2872 )
2873 .expect("home dir present on test host");
2874 let s = r.to_string_lossy().to_string();
2875 assert!(s.ends_with(".claude/settings.json") || s.ends_with(".claude\\settings.json"));
2876 }
2877
2878 #[test]
2879 fn resolve_config_path_cursor_default_under_home() {
2880 let r = resolve_config_path(
2881 Target::Cursor,
2882 &TargetArgs {
2883 config: None,
2884 ..TargetArgs::default()
2885 },
2886 )
2887 .expect("home dir");
2888 let s = r.to_string_lossy().to_string();
2889 assert!(s.ends_with(".cursor/mcp.json") || s.ends_with(".cursor\\mcp.json"));
2890 }
2891
2892 #[test]
2893 fn resolve_config_path_continue_default_under_home() {
2894 let r = resolve_config_path(
2895 Target::Continue,
2896 &TargetArgs {
2897 config: None,
2898 ..TargetArgs::default()
2899 },
2900 )
2901 .expect("home dir");
2902 let s = r.to_string_lossy().to_string();
2903 assert!(s.ends_with(".continue/config.json") || s.ends_with(".continue\\config.json"));
2904 }
2905
2906 #[test]
2907 fn resolve_config_path_windsurf_default_under_home() {
2908 let r = resolve_config_path(
2909 Target::Windsurf,
2910 &TargetArgs {
2911 config: None,
2912 ..TargetArgs::default()
2913 },
2914 )
2915 .expect("home dir");
2916 let s = r.to_string_lossy().to_string();
2917 assert!(s.ends_with("mcp_config.json"), "got: {s}");
2918 }
2919
2920 #[cfg(target_os = "macos")]
2921 #[test]
2922 fn resolve_config_path_claude_desktop_default_under_macos() {
2923 let r = resolve_config_path(
2924 Target::ClaudeDesktop,
2925 &TargetArgs {
2926 config: None,
2927 ..TargetArgs::default()
2928 },
2929 )
2930 .expect("home dir");
2931 let s = r.to_string_lossy().to_string();
2932 assert!(s.ends_with("claude_desktop_config.json"), "got: {s}");
2933 }
2934
2935 #[test]
2936 fn install_dispatches_through_run_with_default_config_on_unsupported_target() {
2937 let args = args_no_config(Target::Codex);
2941 let mut env = TestEnv::fresh();
2942 let err = run(&args, &mut env.output()).unwrap_err();
2943 assert!(format!("{err}").contains("codex config path"));
2944 }
2945
2946 #[test]
2947 fn read_config_or_empty_handles_whitespace_only_file() {
2948 let tmp = tempfile::tempdir().unwrap();
2950 let p = tmp.path().join("blank.json");
2951 std::fs::write(&p, " \n \n").unwrap();
2952 let (text, val) = read_config_or_empty(&p).unwrap();
2953 assert!(!text.is_empty()); assert!(val.is_object() && val.as_object().unwrap().is_empty());
2955 }
2956
2957 #[test]
2958 fn read_config_or_empty_handles_missing_file() {
2959 let tmp = tempfile::tempdir().unwrap();
2961 let p = tmp.path().join("nonexistent.json");
2962 let (text, val) = read_config_or_empty(&p).unwrap();
2963 assert!(text.is_empty());
2964 assert!(val.is_object() && val.as_object().unwrap().is_empty());
2965 }
2966
2967 #[test]
2968 fn install_apply_rejects_non_object_json_root() {
2969 let mut env = TestEnv::fresh();
2971 let path = config_path(&env, "array.json");
2972 seed(&path, "[]");
2973 let err = run(
2974 &args_for_apply(Target::Cursor, path.clone()),
2975 &mut env.output(),
2976 )
2977 .unwrap_err();
2978 assert!(format!("{err}").contains("not a JSON object"));
2979 }
2980
2981 #[test]
2982 fn install_dry_run_emits_unified_diff_with_minus_and_plus_lines() {
2983 let mut env = TestEnv::fresh();
2986 let path = config_path(&env, "diff-source.json");
2987 seed(&path, "{\n \"theme\": \"dark\"\n}\n");
2990 run(&args_for(Target::Cursor, path.clone()), &mut env.output()).unwrap();
2991 let stdout = env.stdout_str();
2992 assert!(
2995 stdout.lines().any(|l| l.starts_with('+')),
2996 "expected at least one added line, got:\n{stdout}"
2997 );
2998 }
2999
3000 #[test]
3001 fn remove_mcp_standard_no_op_on_clean_config() {
3002 let mut env = TestEnv::fresh();
3005 let path = config_path(&env, "clean.json");
3006 seed(&path, "{}\n");
3007 run(
3008 &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
3009 &mut env.output(),
3010 )
3011 .unwrap();
3012 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3013 assert!(parsed.as_object().unwrap().is_empty());
3014 }
3015
3016 #[test]
3017 fn remove_claude_code_no_op_when_user_has_empty_hooks() {
3018 let mut env = TestEnv::fresh();
3022 let path = config_path(&env, "settings.json");
3023 seed(&path, r#"{"hooks":{}}"#);
3024 run(
3026 &args_for_apply(Target::ClaudeCode, path.clone()),
3027 &mut env.output(),
3028 )
3029 .unwrap();
3030 run(
3031 &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
3032 &mut env.output(),
3033 )
3034 .unwrap();
3035 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3036 assert!(parsed.get("hooks").is_none());
3039 }
3040
3041 #[test]
3042 fn install_run_creates_missing_parent_directory() {
3043 let mut env = TestEnv::fresh();
3046 let dir = env.db_path.parent().unwrap().to_path_buf();
3047 let nested = dir.join("not").join("yet").join("here").join("mcp.json");
3048 assert!(!nested.parent().unwrap().exists());
3049 run(
3050 &args_for_apply(Target::Cursor, nested.clone()),
3051 &mut env.output(),
3052 )
3053 .unwrap();
3054 assert!(nested.exists());
3055 }
3056
3057 #[test]
3058 fn resolve_binary_falls_through_when_no_override() {
3059 let s = resolve_binary(None);
3063 assert!(!s.is_empty(), "resolved binary path should be non-empty");
3064 }
3065
3066 #[test]
3067 fn which_ai_memory_returns_some_when_path_has_binary() {
3068 use std::sync::Mutex;
3072 static PATH_LOCK: Mutex<()> = Mutex::new(());
3073 let _g = PATH_LOCK.lock().unwrap();
3074
3075 let tmp = tempfile::tempdir().unwrap();
3076 let bin = tmp.path().join("ai-memory");
3077 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
3078 #[cfg(unix)]
3080 {
3081 use std::os::unix::fs::PermissionsExt;
3082 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
3083 }
3084 let orig = std::env::var_os("PATH");
3085 unsafe {
3087 std::env::set_var("PATH", tmp.path());
3088 }
3089 let found = which_ai_memory();
3090 unsafe {
3092 if let Some(p) = orig {
3093 std::env::set_var("PATH", p);
3094 } else {
3095 std::env::remove_var("PATH");
3096 }
3097 }
3098 assert!(found.is_some(), "expected to find ai-memory under $PATH");
3099 }
3100
3101 fn args_for_pretool_apply(config: PathBuf) -> InstallArgs {
3128 let t = TargetArgs {
3129 config: Some(config),
3130 apply: true,
3131 dry_run: false,
3132 uninstall: false,
3133 binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
3134 hook: Some(HookKind::Pretool),
3135 force: false,
3136 };
3137 InstallArgs {
3138 target: TargetCmd::ClaudeCode(t),
3139 }
3140 }
3141
3142 fn args_for_pretool_dry_run(config: PathBuf) -> InstallArgs {
3143 let mut a = args_for_pretool_apply(config);
3144 match &mut a.target {
3145 TargetCmd::ClaudeCode(t) => t.apply = false,
3146 _ => unreachable!(),
3147 }
3148 a
3149 }
3150
3151 fn args_for_pretool_uninstall(config: PathBuf) -> InstallArgs {
3152 let mut a = args_for_pretool_apply(config);
3153 match &mut a.target {
3154 TargetCmd::ClaudeCode(t) => {
3155 t.uninstall = true;
3156 }
3157 _ => unreachable!(),
3158 }
3159 a
3160 }
3161
3162 fn args_for_pretool_apply_force(config: PathBuf) -> InstallArgs {
3163 let mut a = args_for_pretool_apply(config);
3164 match &mut a.target {
3165 TargetCmd::ClaudeCode(t) => t.force = true,
3166 _ => unreachable!(),
3167 }
3168 a
3169 }
3170
3171 #[test]
3172 fn pretool_entry_shape_matches_documented_form() {
3173 let v = claude_code_pretool_entry();
3174 assert_eq!(v["matcher"], "*");
3175 assert_eq!(v["hooks"][0]["type"], "mcp_tool");
3176 assert_eq!(v["hooks"][0]["tool"], PRETOOL_HOOK_TOOL_NAME);
3177 assert_eq!(v["hooks"][0]["tool"], "memory_check_agent_action");
3178 assert!(v[MARKER_START_KEY].is_string());
3179 assert!(v[MARKER_END_KEY].is_string());
3180 }
3181
3182 #[test]
3183 fn pretool_conflict_detector_recognises_same_tool() {
3184 let v = serde_json::json!({
3185 "matcher": "Bash",
3186 "hooks": [
3187 { "type": "mcp_tool", "tool": "memory_check_agent_action" }
3188 ]
3189 });
3190 assert_eq!(pretool_conflict_matcher(&v).as_deref(), Some("Bash"));
3191 }
3192
3193 #[test]
3194 fn pretool_conflict_detector_ignores_managed_blocks() {
3195 let v = claude_code_pretool_entry();
3196 assert!(pretool_conflict_matcher(&v).is_none());
3197 }
3198
3199 #[test]
3200 fn pretool_conflict_detector_ignores_other_tools() {
3201 let v = serde_json::json!({
3202 "matcher": "*",
3203 "hooks": [
3204 { "type": "command", "command": "echo hi" }
3205 ]
3206 });
3207 assert!(pretool_conflict_matcher(&v).is_none());
3208 }
3209
3210 #[test]
3211 fn pretool_install_apply_writes_documented_entry() {
3212 let mut env = TestEnv::fresh();
3213 let path = config_path(&env, "settings.json");
3214 seed(&path, "{}\n");
3215 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3216 let written = fs::read_to_string(&path).unwrap();
3217 let parsed: Value = serde_json::from_str(&written).unwrap();
3218 let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3219 assert_eq!(arr.len(), 1);
3221 let entry = &arr[0];
3222 assert_eq!(entry["matcher"], "*");
3223 assert_eq!(entry["hooks"][0]["type"], "mcp_tool");
3224 assert_eq!(entry["hooks"][0]["tool"], "memory_check_agent_action");
3225 assert!(env.stdout_str().contains("installed PreToolUse hook ->"));
3226 }
3227
3228 #[test]
3229 fn pretool_install_preserves_existing_keys() {
3230 let mut env = TestEnv::fresh();
3231 let path = config_path(&env, "settings.json");
3232 seed(
3233 &path,
3234 r#"{"permissions":{"allow":["npm:*"]},"env":{"FOO":"bar"}}"#,
3235 );
3236 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3237 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3238 assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
3239 assert_eq!(parsed["env"]["FOO"], "bar");
3240 assert!(parsed["hooks"]["PreToolUse"].is_array());
3241 }
3242
3243 #[test]
3244 fn pretool_install_appends_to_existing_pretooluse_array() {
3245 let mut env = TestEnv::fresh();
3246 let path = config_path(&env, "settings.json");
3247 seed(
3249 &path,
3250 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}"#,
3251 );
3252 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3253 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3254 let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3255 assert_eq!(arr.len(), 2, "operator entry + our managed entry");
3256 assert_eq!(arr[0]["matcher"], "Bash");
3258 assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
3259 assert_eq!(arr[1]["matcher"], "*");
3260 assert_eq!(arr[1]["hooks"][0]["tool"], "memory_check_agent_action");
3261 }
3262
3263 #[test]
3264 fn pretool_install_is_idempotent() {
3265 let mut env = TestEnv::fresh();
3266 let path = config_path(&env, "settings.json");
3267 seed(&path, "{}\n");
3268 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3269 let first = fs::read_to_string(&path).unwrap();
3270 env.stdout.clear();
3271 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3272 let second = fs::read_to_string(&path).unwrap();
3273 assert_eq!(first, second);
3274 assert!(env.stdout_str().contains("no-op"));
3275 }
3276
3277 #[test]
3278 fn pretool_install_refuses_overwrite_without_force() {
3279 let mut env = TestEnv::fresh();
3280 let path = config_path(&env, "settings.json");
3281 seed(
3285 &path,
3286 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","tool":"memory_check_agent_action"}]}]}}"#,
3287 );
3288 let err = run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap_err();
3289 let msg = format!("{err}");
3290 assert!(
3291 msg.contains("--force"),
3292 "error should mention --force: {msg}"
3293 );
3294 let still = serde_json::from_str::<Value>(&fs::read_to_string(&path).unwrap()).unwrap();
3296 let arr = still["hooks"]["PreToolUse"].as_array().unwrap();
3297 assert_eq!(arr.len(), 1, "no new entry appended on refusal");
3298 assert_eq!(arr[0]["matcher"], "Bash");
3299 assert!(
3301 env.stderr_str().contains("existing PreToolUse entry"),
3302 "stderr should contain conflict warning: {}",
3303 env.stderr_str()
3304 );
3305 }
3306
3307 #[test]
3308 fn pretool_install_overwrites_conflict_with_force() {
3309 let mut env = TestEnv::fresh();
3310 let path = config_path(&env, "settings.json");
3311 seed(
3312 &path,
3313 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","tool":"memory_check_agent_action"}]}]}}"#,
3314 );
3315 run(
3316 &args_for_pretool_apply_force(path.clone()),
3317 &mut env.output(),
3318 )
3319 .unwrap();
3320 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3321 let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3322 assert_eq!(arr.len(), 1);
3324 assert_eq!(arr[0]["matcher"], "*");
3325 assert_eq!(arr[0]["hooks"][0]["tool"], "memory_check_agent_action");
3326 assert!(arr[0][MARKER_START_KEY].is_string());
3327 }
3328
3329 #[test]
3330 fn pretool_uninstall_removes_managed_block_only() {
3331 let mut env = TestEnv::fresh();
3332 let path = config_path(&env, "settings.json");
3333 seed(
3334 &path,
3335 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]},"theme":"dark"}"#,
3336 );
3337 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3339 run(&args_for_pretool_uninstall(path.clone()), &mut env.output()).unwrap();
3341 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3342 assert_eq!(parsed["theme"], "dark");
3343 let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3345 assert_eq!(arr.len(), 1);
3346 assert_eq!(arr[0]["matcher"], "Bash");
3347 assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
3348 }
3349
3350 #[test]
3351 fn pretool_uninstall_clean_config_is_safe_noop() {
3352 let mut env = TestEnv::fresh();
3353 let path = config_path(&env, "settings.json");
3354 seed(&path, "{}\n");
3355 run(&args_for_pretool_uninstall(path.clone()), &mut env.output()).unwrap();
3357 let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3358 assert!(parsed.as_object().unwrap().is_empty());
3359 }
3360
3361 #[test]
3362 fn pretool_dry_run_does_not_write() {
3363 let mut env = TestEnv::fresh();
3364 let path = config_path(&env, "settings.json");
3365 seed(&path, "{\n}\n");
3366 let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
3367 run(&args_for_pretool_dry_run(path.clone()), &mut env.output()).unwrap();
3368 let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
3369 assert_eq!(mtime_before, mtime_after, "dry-run must not write");
3370 let stdout = env.stdout_str();
3371 assert!(stdout.contains("dry-run"));
3372 assert!(stdout.contains("PreToolUse"));
3373 assert!(stdout.contains("memory_check_agent_action"));
3374 }
3375
3376 #[test]
3377 fn pretool_install_rejects_hook_flag_on_non_claude_code() {
3378 let mut env = TestEnv::fresh();
3379 let path = config_path(&env, "mcp.json");
3380 seed(&path, "{}\n");
3381 let mut a = args_for_pretool_apply(path.clone());
3382 let t_args = match a.target {
3384 TargetCmd::ClaudeCode(t) => t,
3385 _ => unreachable!(),
3386 };
3387 a.target = TargetCmd::Cursor(t_args);
3388 let err = run(&a, &mut env.output()).unwrap_err();
3389 assert!(
3390 format!("{err}").contains("only supported for `claude-code`"),
3391 "got: {err}"
3392 );
3393 }
3394
3395 #[test]
3396 fn pretool_install_does_not_emit_system_prompt_snippet() {
3397 let mut env = TestEnv::fresh();
3401 let path = config_path(&env, "settings.json");
3402 seed(&path, "{}\n");
3403 run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3404 assert!(
3405 !env.stderr_str().contains("system-prompt snippet"),
3406 "stderr should NOT mention the system-prompt snippet under --hook pretool: {}",
3407 env.stderr_str()
3408 );
3409 }
3410}