Skip to main content

ai_memory/cli/
install.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory install <agent>` — wire `ai-memory boot` and the
5//! `ai-memory-mcp` server into AI agents' config files (issue #487 PR-2/3).
6//!
7//! Each target writes a precisely-marked **managed block** so re-running
8//! `install` is a no-op and `--uninstall` removes the block surgically
9//! without disturbing user-added keys.
10//!
11//! ## Behavior summary
12//!
13//! - **Default `--dry-run`**: prints the diff (pretty before/after JSON,
14//!   plus a unified diff over the JSON serialization) and writes nothing.
15//! - **`--apply`**: writes the modified config; backs up the original to
16//!   `<config>.bak.<timestamp>` first.
17//! - **`--uninstall`**: removes the managed block, leaving any unrelated
18//!   keys the user added intact.
19//!
20//! ## Idempotent marker
21//!
22//! Every managed block has a sentinel key:
23//!
24//! ```text
25//! "// ai-memory:managed-block:start": "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487"
26//! ```
27//!
28//! plus an `// ai-memory:managed-block:end` sibling. When present, the
29//! installer recognises the existing block and:
30//!
31//! - On install: replaces it with the freshly-rendered block (so config
32//!   bumps in future ai-memory releases land cleanly).
33//! - On uninstall: removes both sentinel keys and any siblings the
34//!   installer originally inserted.
35//!
36//! ## Targets
37//!
38//! See [`Target`] for the full list. Each target's specifics
39//! (config path discovery, JSON shape) live in `apply_target_*` helpers.
40
41use 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
47// ── #1558 batch 6 — repeated `.expect` labels on just-built JSON nodes ──
48const EXPECT_JUST_INSERTED_OBJECT: &str = "just-inserted object";
49const EXPECT_JUST_INSERTED_ARRAY: &str = "just-inserted array";
50
51/// Sentinel key that marks the start of a managed block. Used by both
52/// install (to recognise an existing block) and uninstall (to find the
53/// block to remove).
54const MARKER_START_KEY: &str = "// ai-memory:managed-block:start";
55const MARKER_END_KEY: &str = "// ai-memory:managed-block:end";
56
57/// Marker payload — the human-readable note bundled with the start key.
58/// Updating this string is a no-op on installs already in the wild
59/// because the recognition predicate keys off `MARKER_START_KEY` only.
60const MARKER_PAYLOAD: &str = "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487";
61
62/// Sibling-key list each target stamps inside the managed block. We track
63/// this list so uninstall removes exactly the keys we wrote and leaves
64/// any user-added siblings alone (defence-in-depth against a user
65/// editing `// ai-memory:managed-block:end` out of the file).
66const MANAGED_KEYS_PROPERTY: &str = "// ai-memory:managed-keys";
67
68// --- Harness config key names (#1558) --------------------------------------
69
70/// Agent-target key for the Claude Code harness (`Target::ClaudeCode`
71/// display name + managed-keys metadata).
72const AGENT_TARGET_CLAUDE_CODE: &str = "claude-code";
73
74/// File name of Claude Desktop's MCP config under its OS-specific
75/// application-support directory. Only referenced on the macOS /
76/// Windows auto-discovery arms (Linux requires `--config`).
77#[cfg(any(target_os = "macos", target_os = "windows"))]
78const CLAUDE_DESKTOP_CONFIG_FILENAME: &str = "claude_desktop_config.json";
79
80/// MCP-spec camelCase servers key used by every JSON-config harness.
81/// `pub(crate)` because the `config migrate --also-clean-claude-json`
82/// rewriter in `src/cli/commands/config.rs` strips `env` blocks from
83/// entries under the same key.
84pub(crate) const KEY_MCP_SERVERS: &str = "mcpServers";
85
86/// Continue's wrapper key — its MCP block lives under
87/// `experimental.modelContextProtocolServers`.
88const KEY_EXPERIMENTAL: &str = "experimental";
89
90/// Continue's MCP server-list key under [`KEY_EXPERIMENTAL`].
91const KEY_MODEL_CONTEXT_PROTOCOL_SERVERS: &str = "modelContextProtocolServers";
92
93/// Claude Code hook-event key for the boot (memory-rehydration) hook.
94const HOOK_EVENT_SESSION_START: &str = "SessionStart";
95
96/// Claude Code hook-event key for the policy-engine tool-call hook.
97const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse";
98
99/// Args for `ai-memory install`.
100#[derive(Args, Debug)]
101pub struct InstallArgs {
102    /// The agent target to install into.
103    #[command(subcommand)]
104    pub target: TargetCmd,
105}
106
107/// Per-target subcommand. Each variant carries the same shared option
108/// set (`--apply`, `--uninstall`, `--config <path>`) — clap-derive
109/// renders one subcommand per target so users get tab-completion on
110/// the agent name and per-target `--help`.
111#[derive(Subcommand, Debug)]
112pub enum TargetCmd {
113    /// Claude Code SessionStart hook. Writes `~/.claude/settings.json`.
114    ClaudeCode(TargetArgs),
115    /// OpenClaw MCP servers. Path documented at
116    /// <https://docs.openclaw.ai/cli/mcp>; pass `--config <path>` if your
117    /// install puts it elsewhere.
118    Openclaw(TargetArgs),
119    /// Cursor MCP servers. Writes `~/.cursor/mcp.json`.
120    Cursor(TargetArgs),
121    /// Cline MCP settings. Path varies by Cline version; pass
122    /// `--config <path>` to override.
123    Cline(TargetArgs),
124    /// Continue MCP servers. Writes `~/.continue/config.json`.
125    Continue(TargetArgs),
126    /// Windsurf (Codeium) MCP servers. Writes
127    /// `~/.codeium/windsurf/mcp_config.json`.
128    Windsurf(TargetArgs),
129
130    // ---- v0.6.4-010 — cross-harness install profiles ----
131    /// Claude Desktop MCP servers (writes the macOS/Windows config;
132    /// pass `--config <path>` on Linux). Args include
133    /// `--profile core` (the v0.6.4 default).
134    ClaudeDesktop(TargetArgs),
135    /// OpenAI Codex CLI MCP servers. Pass `--config <path>` since the
136    /// canonical Codex config path varies by Codex version. Args
137    /// include `--profile core`.
138    Codex(TargetArgs),
139    /// xAI Grok CLI MCP servers. Pass `--config <path>` since the
140    /// Grok CLI config path varies by version. Args include
141    /// `--profile core`.
142    GrokCli(TargetArgs),
143    /// Google Gemini CLI MCP servers. Pass `--config <path>` since the
144    /// Gemini CLI config path varies by version. Args include
145    /// `--profile core`.
146    GeminiCli(TargetArgs),
147}
148
149/// Shared per-target args. Constructed identically for every target so
150/// the dispatch table can pull them out generically.
151#[derive(Args, Debug, Default, Clone)]
152pub struct TargetArgs {
153    /// Override the default config path (the home-dir resolution).
154    /// REQUIRED for tests so they never touch `~/.claude/settings.json`
155    /// on the host machine.
156    #[arg(long, value_name = "PATH")]
157    pub config: Option<PathBuf>,
158
159    /// Actually write the file. Without `--apply`, the installer
160    /// runs in dry-run mode (the default) and prints what would change.
161    /// Mutually exclusive with `--dry-run`. Combine with `--uninstall`
162    /// to actually remove the managed block.
163    #[arg(long, default_value_t = false, conflicts_with = "dry_run")]
164    pub apply: bool,
165
166    /// Force dry-run mode. This is the default, so the flag is mostly
167    /// useful in scripts that want to make the no-write contract
168    /// explicit. Mutually exclusive with `--apply`.
169    #[arg(long, default_value_t = false)]
170    pub dry_run: bool,
171
172    /// Remove the managed block instead of installing it. Default mode
173    /// is dry-run; pair with `--apply` to actually delete the block.
174    #[arg(long, default_value_t = false)]
175    pub uninstall: bool,
176
177    /// Override the resolved `ai-memory` binary path written into the
178    /// generated config's `command` field. By default the installer
179    /// uses the binary's own `current_exe()` if `ai-memory` is not on
180    /// `$PATH`, otherwise the bare string `ai-memory`.
181    #[arg(long, value_name = "PATH")]
182    pub binary: Option<PathBuf>,
183
184    /// Install a harness-side hook variant in place of the default
185    /// managed block. Supported value: `pretool` — installs Claude
186    /// Code's `PreToolUse` hook routing every Bash / Edit / Write tool
187    /// call through `memory_check_agent_action` (v0.7.0 policy-engine
188    /// item 2, issue #691).
189    ///
190    /// Only meaningful for `claude-code`; other targets reject this
191    /// flag with a clear error.
192    #[arg(long, value_name = "KIND")]
193    pub hook: Option<HookKind>,
194
195    /// When installing a hook (`--hook`), overwrite any pre-existing
196    /// entry whose `matcher` / `tool` shape conflicts with ours.
197    /// Without `--force`, the installer refuses to clobber a
198    /// differing-but-similar config and points the operator at this
199    /// flag in the stderr warning.
200    #[arg(long, default_value_t = false)]
201    pub force: bool,
202}
203
204/// Harness-side hook variant selectable via `--hook <kind>`. Today
205/// only `Pretool` is wired; future variants (e.g. `PostToolUse`,
206/// `Stop`) plug into the same dispatch shape.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
208pub enum HookKind {
209    /// Claude Code's `PreToolUse` hook. Routes every Bash / Edit /
210    /// Write tool call through `memory_check_agent_action` so the
211    /// substrate-rules engine can refuse or warn before the action
212    /// dispatches.
213    Pretool,
214}
215
216/// Concrete target enum used internally. `TargetCmd` carries clap
217/// metadata; `Target` is a stable tag for the dispatch table and
218/// derives `ValueEnum` for completeness.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
220pub enum Target {
221    ClaudeCode,
222    Openclaw,
223    Cursor,
224    Cline,
225    Continue,
226    Windsurf,
227    // v0.6.4-010 — additional MCP harnesses.
228    ClaudeDesktop,
229    Codex,
230    GrokCli,
231    GeminiCli,
232}
233
234impl Target {
235    /// Display name used in stdout and managed-keys metadata.
236    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
284/// `ai-memory install <agent>` entry point.
285///
286/// # Errors
287///
288/// Returns an error when the existing config is not valid JSON, when the
289/// resolved config path can't be determined (and `--config` was not
290/// passed), or when an `--apply` write fails (permission denied,
291/// disk full, etc.).
292pub fn run(args: &InstallArgs, out: &mut CliOutput<'_>) -> Result<()> {
293    let target = args.target.target();
294    let t_args = args.target.args();
295
296    // --hook is meaningful for claude-code only today; reject loudly on
297    // other targets so operators don't silently lose the flag.
298    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    // Read existing config (if any) and parse. If absent, treat as `{}`.
310    // If present and malformed, error out — never overwrite a malformed
311    // config (the user might have made a typo we can help them fix).
312    let (before_text, before_value) = read_config_or_empty(&config_path)?;
313
314    // v0.7.0 #1378 — config format detected early so the
315    // apply/remove paths can use the right MCP-servers key shape
316    // (`mcpServers` camelCase for JSON, `mcp_servers` snake_case for
317    // TOML per Codex convention) AND so the eventual serializer
318    // matches the input format.
319    let config_format = ConfigFormat::detect(&config_path);
320
321    // Compute the desired after-state.
322    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    // v0.7.0 #1378 — pretty-print in the format the input file used.
335    // Codex config is TOML at ~/.codex/config.toml; other MCP-standard
336    // harnesses use JSON. The format-aware serializer keeps the wire
337    // shape canonical to the surface.
338    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            // Round-trip through toml::Value via serde so the TOML
343            // emitter can render. The toml crate's `to_string_pretty`
344            // emits table-with-named-keys shape.
345            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    // Round-trip check: re-parse what we serialized so we never write
354    // bytes we couldn't read back.
355    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        // Dry-run mode (the default). Emit a unified-style diff so the
386        // caller can scrutinise the change before opting in to write.
387        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    // Apply mode. Backup first, then write.
405    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    // v0.7.0 policy-engine item 2: when `--hook pretool` was the trigger,
444    // emit the operator-readable confirmation line documented in the
445    // installer contract (`installed PreToolUse hook -> <path>`).
446    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    // v0.7-D3: emit the install-time system-prompt snippet alongside the
464    // managed-block write. Skip on uninstall — there is nothing to teach
465    // the agent once the server is gone. Failures here are surfaced as
466    // a stderr warning, not a hard error: the install itself succeeded
467    // and operators can re-derive the snippet from the docs.
468    // For the `--hook pretool` variant the system-prompt snippet is
469    // unrelated (the hook is the load-bearing mechanism), so we skip it
470    // there too.
471    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
494// ---------------------------------------------------------------------------
495// v0.7-D3 — install-time system-prompt snippet
496// ---------------------------------------------------------------------------
497//
498// Each harness gets a small (≤200 tokens) Markdown snippet operators can
499// paste into the harness's system-prompt slot. Content teaches the agent
500// the four anchors the v0.7 surface needs: capability discovery
501// (`memory_capabilities`), pre-load (`memory_load_family`), R5 hook
502// auto-extraction, and signed-link `attest_level`.
503//
504// Snippet bodies are static — no templating beyond `<harness>` literal
505// substitution — so they are safe to embed verbatim in tests as anchor
506// strings. The snippet is keyed by `Target` so the wording can mention
507// harness-specific UX cues (e.g. claude-code's `ToolSearch`).
508//
509// The snippet path defaults to
510// `<config_dir>/ai-memory/system-prompt-<harness>.md` (Linux:
511// `~/.config/ai-memory/...`, macOS:
512// `~/Library/Application Support/ai-memory/...`). Tests override the base
513// directory via the `AI_MEMORY_SYSTEM_PROMPT_DIR` env var so they never
514// touch the operator's real config tree.
515
516/// Generate the system-prompt snippet body for `target`.
517///
518/// Bodies are crafted to stay ≤200 tokens (≈800 chars) while covering all
519/// four required anchors: `memory_capabilities`, `memory_load_family`,
520/// the R5 transcript hook, and `attest_level` on signed links.
521fn snippet_body(target: Target) -> String {
522    let harness = target.name();
523    // The narrow, harness-specific bullet sits at the top so the four
524    // shared anchors below are identical across variants. This keeps
525    // snippet maintenance to one place when the v0.7 surface evolves.
526    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
564/// Resolve the snippet base directory. Tests override via
565/// `AI_MEMORY_SYSTEM_PROMPT_DIR`; production uses
566/// `dirs::config_dir().join("ai-memory")`. Under `cfg(test)` the default
567/// also routes to a per-process tempdir so unit tests calling `run()` in
568/// apply mode do not write to the operator's real config tree.
569fn 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)`-only: per-process tempdir backing the default snippet
592/// path so tests never write into `~/.config/ai-memory/`.
593#[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        // Leak the TempDir handle: we want this path to live for the
601        // entire test binary's lifetime, not be cleaned up between
602        // tests. The OS sweeps `/tmp` on reboot.
603        std::mem::forget(tmp);
604        p
605    })
606    .clone()
607}
608
609/// Write the system-prompt snippet for `target` to disk and return the
610/// path. Creates parent directories as needed; overwrites any existing
611/// file (snippet bodies are deterministic, so this is idempotent).
612///
613/// Production callers use [`write_system_prompt_snippet`] which resolves
614/// the base dir via [`snippet_base_dir`] (env var + cfg(test) fallback);
615/// tests should pass an explicit `dir` to [`write_system_prompt_snippet_to`]
616/// to avoid the global env-var dance that historically flaked under
617/// `--test-threads > 1`.
618fn 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
633// ---------------------------------------------------------------------------
634// Config-path resolution
635// ---------------------------------------------------------------------------
636
637fn 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            // OpenClaw's documented MCP config path is not stable across
647            // versions; the canonical location is documented at
648            // https://docs.openclaw.ai/cli/mcp. We require --config for
649            // OpenClaw to avoid guessing and writing to the wrong file.
650            // TODO(#487): once OpenClaw publishes a stable canonical path,
651            // wire it in here.
652            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            // Cline's config path varies by version (mcp_settings.json
660            // location moved between releases). Require explicit --config
661            // until upstream stabilises.
662            // TODO(#487): once Cline pins a canonical path, wire it.
663            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        // v0.6.4-010 — claude-desktop has documented OS-specific paths.
675        // Linux is unstable (depends on AppImage / Flatpak distribution),
676        // so require --config there.
677        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        // v0.6.4-010 — codex, grok-cli, gemini-cli configs vary by version.
710        // Mirror the openclaw/cline pattern: require --config explicitly
711        // to avoid writing to the wrong file.
712        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
734/// Resolve the `ai-memory` binary path for the generated config's
735/// `command` field. If the user passes `--binary`, use that. Otherwise:
736///
737/// 1. If `ai-memory` is on `$PATH`, use the bare string `ai-memory` so
738///    the config stays portable across machines that have it linked to
739///    different absolute paths.
740/// 2. Otherwise, use the running binary's `current_exe()` so the
741///    generated config is at least functional on the host.
742fn 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// ---------------------------------------------------------------------------
771// Read / parse
772// ---------------------------------------------------------------------------
773
774/// v0.7.0 #1378 — config-file format discriminator. Real-world Codex
775/// CLI configs are TOML (`~/.codex/config.toml`); the other MCP-
776/// standard harnesses (claude-desktop, grok-cli, gemini-cli, plus the
777/// IDE plugins) use JSON. The installer routes through the right
778/// parser based on the file extension to surface a TOML-shaped error
779/// message when an operator passes a TOML file to a JSON-only target.
780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
781pub(super) enum ConfigFormat {
782    Json,
783    Toml,
784}
785
786impl ConfigFormat {
787    /// Detect format from the file extension. `.toml` → TOML; anything
788    /// else → JSON (the historical default + every other MCP-standard
789    /// surface).
790    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
803/// Read `path` and parse as JSON OR TOML depending on the file
804/// extension. Returns `("", {})` if the file does not exist (a fresh
805/// install on a host that's never run the agent). Errors clearly when
806/// the file exists but is not valid in the expected format.
807///
808/// v0.7.0 #1378 — TOML branch added for Codex CLI parity. The TOML
809/// content is parsed into a `toml::Value` then converted into a
810/// `serde_json::Value` for downstream mutation; the canonical
811/// MCP-standard shape (`mcpServers.<name>.{command,args,env}`)
812/// round-trips cleanly across both formats.
813fn 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            // Parse TOML → toml::Value, then serialize through serde
836            // into serde_json::Value. The MCP-standard shape (string
837            // maps + arrays of strings) round-trips cleanly; TOML
838            // datetimes / heterogeneous arrays would NOT round-trip
839            // (those don't appear in the MCP shape).
840            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            // Ensure the top-level is an object (the MCP-standard
856            // mutation routines assume `Value::Object`).
857            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
871// ---------------------------------------------------------------------------
872// Apply / remove managed block
873// ---------------------------------------------------------------------------
874
875/// Insert or replace the managed block for `target` inside `cfg`.
876///
877/// v0.7.0 #1378 — `format` parameter routes Codex TOML configs to the
878/// snake_case `mcp_servers` key; everything else uses the camelCase
879/// `mcpServers` key per MCP-spec JSON convention.
880fn 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        // v0.6.4-010 — these four harnesses use the canonical
895        // `mcpServers.ai-memory.{command, args, env}` shape.
896        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
903/// v0.7.0 #1378 — resolve the MCP-servers key name for the given
904/// target × format combination. Codex TOML uses snake_case
905/// `mcp_servers`; every other surface uses the MCP-spec camelCase
906/// `mcpServers`. Centralised here so the apply/remove paths agree on
907/// the key.
908fn 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
915/// Remove the managed block for `target` from `cfg` (if present).
916///
917/// v0.7.0 #1378 — `format` parameter routes Codex TOML to the
918/// snake_case `mcp_servers` key; every other surface uses
919/// `mcpServers`. See [`mcp_servers_key`].
920fn 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        // v0.6.4-010 — shared mcpServers.ai-memory shape (claude-desktop,
933        // codex, grok-cli, gemini-cli).
934        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
941// --- v0.6.4-010 shared MCP-standard writer --------------------------------
942//
943// claude-desktop, codex, grok-cli, and gemini-cli all consume the
944// canonical `mcpServers.<name>.{command,args,env}` shape — the
945// MCP-spec-defined server-config form. `apply_mcp_standard` writes the
946// ai-memory entry with `--profile core` baked into the args (the v0.6.4
947// default). Operators who want the v0.6.3 surface 1:1 can hand-edit the
948// args to `["mcp", "--profile", "full"]`; the install dry-run + diff
949// makes that change visible before they apply it.
950
951fn 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            // v0.6.4-010 — explicitly request the v0.6.4 default surface.
968            // The runtime would default to `core` anyway via
969            // effective_profile(), but having it written here makes the
970            // selection self-documenting in the user's config file.
971            "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
994// --- Claude Code ----------------------------------------------------------
995
996/// Hook command stored in claude-code's SessionStart entry. Mirrors the
997/// recipe documented in `docs/integrations/claude-code.md`.
998fn 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    // Build the desired SessionStart entry under the marker.
1004    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    // Drop into hooks.SessionStart, removing any existing managed entry,
1016    // then prepend ours.
1017    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    // Don't strip an empty hooks object if the user had one — leave their
1049    // structure exactly as we found it minus our block.
1050    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
1057// --- v0.7.0 policy-engine item 2 — Claude Code PreToolUse hook ------------
1058//
1059// Wires the substrate-rules engine into every Bash / Edit / Write tool
1060// call Claude Code proposes. The hook's `type=mcp_tool` form invokes
1061// `memory_check_agent_action` and honors the Allow / Refuse / Warn
1062// decision before the tool dispatches. The MCP tool itself already
1063// exists (issue #691 L1-6 A); the installer is the missing turnkey.
1064//
1065// Design choices:
1066//
1067// - The PreToolUse entry lives under a separate managed block (different
1068//   `MANAGED_KEYS_PROPERTY` payload) so a future operator can install
1069//   SessionStart and PreToolUse independently — uninstalling one does
1070//   not strip the other.
1071// - We APPEND to an existing `PreToolUse` array, preserving operator-
1072//   authored entries' order. The substrate-check is positioned LAST so
1073//   the operator's earlier hooks still run; this matches the
1074//   "defence-in-depth" guidance in docs/governance/agent-action-rules.md.
1075// - Conflict detection: if an existing entry already names
1076//   `memory_check_agent_action` but with a DIFFERENT `matcher`, we
1077//   refuse to overwrite without `--force`. Operators sometimes
1078//   intentionally scope the hook to a subset of tools; clobbering that
1079//   would silently change their policy.
1080
1081/// Reference name for the MCP tool the PreToolUse hook invokes.
1082///
1083/// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep): routes
1084/// through the canonical [`crate::mcp::registry::tool_names`] const,
1085/// so renaming a tool is one edit (the const value) and the installer
1086/// follows automatically.
1087const PRETOOL_HOOK_TOOL_NAME: &str = crate::mcp::registry::tool_names::MEMORY_CHECK_AGENT_ACTION;
1088
1089/// Tool-name matcher for the PreToolUse hook.
1090///
1091/// Scoped to the agent-external action surface the substrate rule
1092/// engine actually models — `AgentAction` is `bash | filesystem_write
1093/// | network_request | process_spawn | custom`
1094/// (`crate::governance::agent_action::action_kinds`), and the harness
1095/// only synthesizes a `kind` for Bash / Edit / Write tool dispatches.
1096/// The Claude Code matcher is a regex, so `Edit` also covers
1097/// `MultiEdit` / `NotebookEdit` by substring.
1098///
1099/// A bare `"*"` here (the pre-#1667 value) fired the hook on EVERY
1100/// tool, including `mcp_tool` / read dispatches like
1101/// `mcp__memory__memory_get`, for which the harness builds no
1102/// `AgentAction` and therefore supplies no `kind` — tripping the
1103/// `memory_check_agent_action` `required: ["kind"]` schema on every
1104/// memory MCP call. See issue #1667.
1105const PRETOOL_HOOK_MATCHER: &str = "Bash|Edit|Write";
1106
1107/// Build the PreToolUse entry the installer writes. Uses the
1108/// type=`mcp_tool` form (vs `type=command`) so Claude Code dispatches
1109/// over the MCP channel directly — no shell, no fork, no PATH
1110/// dependence. The marker keys live alongside the operator-visible
1111/// fields so the entry round-trips through Claude Code's reader
1112/// unchanged.
1113fn 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
1125/// Predicate: does `v` look like a non-managed PreToolUse entry that
1126/// *also* points at our MCP tool (so installing on top would silently
1127/// shadow operator intent)? Returns the matcher string if so.
1128fn 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
1146/// Apply the PreToolUse managed block. APPENDS the entry to the existing
1147/// `hooks.PreToolUse` array so any operator-authored hooks earlier in
1148/// the list still run. Returns `bail!` on a conflict-without-force.
1149fn 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    // Detect any operator-authored entry that points at the same MCP
1172    // tool with a different `matcher`. That's the conflict path —
1173    // refuse without --force.
1174    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    // Drop any previous managed entry (idempotent re-runs) AND any
1195    // conflicting entries when --force is set. Operator-authored entries
1196    // that don't touch our tool are left untouched.
1197    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
1210/// Remove the PreToolUse managed block (the inverse of
1211/// [`apply_claude_code_pretool`]). Idempotent on a clean config.
1212fn 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
1230/// Apply the requested hook variant. Today only `--hook pretool` for
1231/// claude-code is wired; the dispatch is split out so future hook
1232/// kinds (PostToolUse, Stop) plug in without touching `run`.
1233fn 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        // Other (target, kind) pairs are rejected upstream in `run` so
1246        // this match is exhaustive in practice. Keep the explicit
1247        // `_` arm to document the design intent.
1248        _ => bail!(
1249            "internal error: unsupported (target, hook) combination ({:?}, {:?})",
1250            target,
1251            kind
1252        ),
1253    }
1254    Ok(cfg)
1255}
1256
1257/// Remove the requested hook variant (the inverse of
1258/// [`apply_hook_block`]). Idempotent on a clean config.
1259fn 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            // Same rationale as `apply_hook_block` — surface internal
1270            // errors loudly rather than silently no-op.
1271            bail!(
1272                "internal error: unsupported (target, hook) combination ({:?}, {:?})",
1273                target,
1274                kind
1275            );
1276        }
1277    }
1278    Ok(cfg)
1279}
1280
1281// --- OpenClaw -------------------------------------------------------------
1282
1283fn 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
1329// --- Cursor ---------------------------------------------------------------
1330
1331fn 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
1355// --- Cline ----------------------------------------------------------------
1356
1357fn apply_cline(obj: &mut Map<String, Value>, binary: &str) {
1358    // Cline shape mirrors Cursor (mcpServers).
1359    apply_cursor(obj, binary);
1360}
1361
1362fn remove_cline(obj: &mut Map<String, Value>) {
1363    remove_cursor(obj);
1364}
1365
1366// --- Continue -------------------------------------------------------------
1367
1368fn apply_continue(obj: &mut Map<String, Value>, binary: &str) {
1369    // Continue's MCP config lives under experimental.modelContextProtocolServers
1370    // (an array of transport entries).
1371    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
1419// --- Windsurf -------------------------------------------------------------
1420
1421fn 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
1429// ---------------------------------------------------------------------------
1430// Marker recognition
1431// ---------------------------------------------------------------------------
1432
1433/// Returns true when `v` is a JSON object carrying our managed-block
1434/// start sentinel. Used to recognise an existing managed block so
1435/// install can replace it precisely and uninstall can remove it.
1436fn is_managed_value(v: &Value) -> bool {
1437    v.as_object()
1438        .and_then(|o| o.get(MARKER_START_KEY))
1439        .is_some()
1440}
1441
1442// ---------------------------------------------------------------------------
1443// Diff emission
1444// ---------------------------------------------------------------------------
1445
1446/// Write a minimal unified-style diff between `before` and `after` to
1447/// `out.stdout`. We avoid pulling a real diff crate; the implementation
1448/// is intentionally simple — line-by-line, no LCS — because the diff is
1449/// *advisory* (the caller can still inspect after with `--apply`).
1450fn 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// ---------------------------------------------------------------------------
1472// Tests
1473// ---------------------------------------------------------------------------
1474
1475#[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    // --------------------------------------------------------------
1557    // claude-code
1558    // --------------------------------------------------------------
1559
1560    #[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        // Must remain valid JSON.
1592        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        // Second run should produce a no-op message and no change.
1623        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        // Install, then uninstall.
1642        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        // File must NOT have been overwritten.
1678        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        // Find a sibling whose name starts with `settings.json.bak.`.
1691        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    // --------------------------------------------------------------
1714    // cursor
1715    // --------------------------------------------------------------
1716
1717    #[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    // --------------------------------------------------------------
1837    // openclaw
1838    // --------------------------------------------------------------
1839
1840    #[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    // --------------------------------------------------------------
1961    // cline (shape ≈ cursor)
1962    // --------------------------------------------------------------
1963
1964    #[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    // --------------------------------------------------------------
2080    // continue
2081    // --------------------------------------------------------------
2082
2083    #[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    // --------------------------------------------------------------
2199    // windsurf (shape ≈ cursor)
2200    // --------------------------------------------------------------
2201
2202    #[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    // --------------------------------------------------------------
2318    // generic / cross-cutting
2319    // --------------------------------------------------------------
2320
2321    #[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        // For a config that started as `{}\n`, install + uninstall should
2338        // produce a configuration that re-parses to `{}` (key set is empty).
2339        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    // ---- v0.6.4-010 — per-harness install profiles ----
2364    //
2365    // The four MCP-standard harnesses (claude-desktop, codex, grok-cli,
2366    // gemini-cli) use the same `mcpServers.ai-memory.{command,args,env}`
2367    // shape. We test all four with a single shared assertion fixture
2368    // since the writer is shared.
2369
2370    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        // Standard MCP shape.
2377        assert!(
2378            parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string(),
2379            "{} missing managed-block marker",
2380            target.name()
2381        );
2382        // v0.6.4 default profile baked into args.
2383        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        // Empty mcpServers should be removed entirely.
2436        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        // Sibling server preserved.
2457        assert_eq!(parsed["mcpServers"]["other-mcp"]["command"], "x");
2458        // Sibling top-level key preserved.
2459        assert_eq!(parsed["unrelated"], 42);
2460        // ai-memory entry written.
2461        assert!(parsed["mcpServers"]["ai-memory"].is_object());
2462    }
2463
2464    // v0.7.0 #1378 — Codex TOML config coverage.
2465
2466    #[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        // End-to-end TOML path: read TOML → mutate → serialise TOML →
2489        // round-trip-verify. Exercises the serialize_config + round-trip
2490        // TOML arms and read_config_or_empty's TOML success branch.
2491        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        // Codex TOML uses the snake_case `mcp_servers` key.
2506        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        // Sibling server + top-level key preserved across the round-trip.
2512        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    // --------------------------------------------------------------
2526    // v0.7-D3 — install-time system-prompt snippet
2527    //
2528    // These tests serialize on a module-local Mutex so they can each
2529    // point `AI_MEMORY_SYSTEM_PROMPT_DIR` at their own tempdir without
2530    // racing other concurrent tests. Other apply-tests in this module
2531    // rely on the `cfg(test)` default (a per-process tempdir) so they
2532    // never touch the operator's real `~/.config/ai-memory/`.
2533    // --------------------------------------------------------------
2534
2535    /// Serialise env-var mutation across snippet tests.
2536    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    /// Run the snippet emit path for `target` against an isolated
2543    /// tempdir and return `(snippet_path, snippet_body)`. The tempdir
2544    /// is leaked so the snippet file remains on disk for the caller
2545    /// to inspect after the helper returns; the OS sweeps `/tmp` on
2546    /// reboot.
2547    fn emit_snippet_isolated(target: Target) -> (PathBuf, String) {
2548        // Uses the dir-parameterised helper so the test does NOT touch
2549        // the process-global `AI_MEMORY_SYSTEM_PROMPT_DIR` env var.
2550        // Eliminates the `snippet_env_lock` cross-test race that flaked
2551        // `snippet_every_target_emits_under_budget` under
2552        // `--test-threads > 1` (§16 12-gate sweep observation,
2553        // 2026-05-13, v0.7.1-fold).
2554        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); // path must outlive caller
2560        (snippet_path, body)
2561    }
2562
2563    /// Every snippet must mention all four anchor strings the v0.7
2564    /// surface depends on, plus the harness name verbatim.
2565    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 transcript hook — wording is "R5 hook" in the body.
2576            "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    /// ≤200 token budget per spec. We use a coarse char-count proxy
2586    /// (4 chars/token is the standard rule of thumb for English BPE),
2587    /// so 200 tokens ≈ 800 chars. The actual cl100k-base count for
2588    /// English prose with code-fence backticks runs ~10–15% lower than
2589    /// chars/4, so a 800-char ceiling under-estimates the budget
2590    /// conservatively.
2591    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        // Claude Code's harness-specific bullet calls out ToolSearch.
2607        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        // The full per-target sweep — every harness gets its own
2640        // snippet file, each within budget and carrying every anchor.
2641        //
2642        // Hold `snippet_env_lock` across the entire iteration. Without
2643        // it, the per-target tests below (snippet_claude_code_*,
2644        // snippet_cursor_*, etc.) can grab the lock between iterations
2645        // and clobber `AI_MEMORY_SYSTEM_PROMPT_DIR`, racing with the
2646        // emit + readback this loop performs. Observed as flaky
2647        // assertion at `assert!(body.contains(harness))` under
2648        // `cargo test` default `--test-threads=N` on Linux + macOS CI.
2649        //
2650        // v0.7.0 pre-cert audit: confirmed each rendered snippet fits
2651        // ~173–183 chars/4 tokens against the 200-token ceiling
2652        // enforced by `assert_snippet_token_budget`. Headroom is
2653        // intentionally tight — when adding a new shared anchor or a
2654        // longer per-harness hint, recompute the worst-case render
2655        // length first (the longest body is currently `claude-code`
2656        // because of the ToolSearch hint). A budget overshoot here is
2657        // load-bearing: the snippet ships verbatim into every
2658        // downstream harness's system prompt, so a regression
2659        // silently eats their context window. If you trip this
2660        // assertion, trim the offending bullet — do NOT bump the
2661        // budget without a spec change.
2662        // Uses the dir-parameterised helper so each target gets its own
2663        // tempdir without touching the process-global
2664        // `AI_MEMORY_SYSTEM_PROMPT_DIR` env var. Eliminates the
2665        // `snippet_env_lock` cross-test race that flaked this assertion
2666        // under `--test-threads > 1` (§16 12-gate sweep observation,
2667        // 2026-05-13, v0.7.1-fold).
2668        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        // End-to-end: invoking `run()` in apply mode for a target
2699        // produces the snippet file at the env-overridden path and
2700        // logs the "wrote system-prompt snippet" line to stderr.
2701        let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
2702        let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
2703        // SAFETY: mutation serialised by snippet_env_lock for this
2704        // test's duration.
2705        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        // Uninstall path skips the snippet write — there's nothing
2746        // to teach the agent once the server is gone.
2747        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 an existing managed block so uninstall has work to do.
2756        seed(&cfg, "{}\n");
2757        run(
2758            &args_for_apply(Target::ClaudeCode, cfg.clone()),
2759            &mut env.output(),
2760        )
2761        .unwrap();
2762        // Reset stderr capture so we only see the uninstall-phase output.
2763        env.stderr.clear();
2764
2765        // Now uninstall.
2766        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    // ------------------------------------------------------------------
2784    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
2785    // ------------------------------------------------------------------
2786
2787    fn args_no_config(target: Target) -> InstallArgs {
2788        // TargetArgs with config=None forces the default-path discovery
2789        // branch in resolve_config_path.
2790        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        // Drives the `home.join(".claude").join("settings.json")` branch
2882        // (line 499). We don't assert the home directory contents — just
2883        // that the resolution succeeds and ends in `.claude/settings.json`.
2884        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        // Driving `run()` end-to-end with `args.config=None` for a
2956        // target whose default-path is a `bail!` covers the
2957        // `run` -> `resolve_config_path` error propagation path.
2958        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        // Drives the `text.trim().is_empty()` branch (line 638).
2967        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()); // we returned the original text
2972        assert!(val.is_object() && val.as_object().unwrap().is_empty());
2973    }
2974
2975    #[test]
2976    fn read_config_or_empty_handles_missing_file() {
2977        // Drives the `!path.exists()` branch (line 633).
2978        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        // ensure_object refuses array-shaped roots (line 743).
2988        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        // Drives every variant of `emit_diff`: equal line (line 981),
3002        // changed line (982-984), removed-only (986), added-only (987).
3003        let mut env = TestEnv::fresh();
3004        let path = config_path(&env, "diff-source.json");
3005        // Use a baseline with multiple keys so the install diff has
3006        // identical lines, changed lines, and added lines.
3007        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        // The diff format emits ` ` (context) / `+` (added) / `-` (changed)
3011        // line prefixes for the cursor block's new lines.
3012        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        // Drives the path through `remove_mcp_standard` where there is
3021        // no `mcpServers` key at all (line 738 not-entered branch).
3022        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        // Pre-install we set `hooks: {}` in the config; install adds
3037        // SessionStart, uninstall must leave nothing behind. Drives the
3038        // empty-hooks branch (lines 796-797, 800-804).
3039        let mut env = TestEnv::fresh();
3040        let path = config_path(&env, "settings.json");
3041        seed(&path, r#"{"hooks":{}}"#);
3042        // Install then uninstall.
3043        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        // We expected the user's empty `hooks` to either survive or be
3055        // pruned — `remove_claude_code` removes empty hooks objects.
3056        assert!(parsed.get("hooks").is_none());
3057    }
3058
3059    #[test]
3060    fn install_run_creates_missing_parent_directory() {
3061        // Drives the `create_dir_all` branch (lines 311-316) by passing
3062        // a config path whose parent does not exist.
3063        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        // Drives the resolve_binary branch without a `--binary` override.
3078        // Either `which_ai_memory` returns Some, or the function falls
3079        // back to current_exe(). Both branches exit through this fn.
3080        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        // Drives the success branch of `which_ai_memory` (line 614).
3087        // We construct a tempdir, drop a synthetic "ai-memory" binary
3088        // inside, then temporarily set $PATH to point at it.
3089        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        // Chmod 0755 so `is_file()` returns true.
3097        #[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        // SAFETY: serialized via PATH_LOCK; restored at scope end.
3104        unsafe {
3105            std::env::set_var("PATH", tmp.path());
3106        }
3107        let found = which_ai_memory();
3108        // Restore PATH.
3109        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    // ------------------------------------------------------------------
3120    // v0.7.0 policy-engine item 2 — PreToolUse hook installer
3121    // ------------------------------------------------------------------
3122    //
3123    // Tier B coverage (≥95%) target on every new code path in this
3124    // module. Each branch below pins one behaviour of the installer's
3125    // PreToolUse mode:
3126    //
3127    // - `pretool_entry_shape_matches_documented_form`: the JSON we
3128    //   emit is the same shape Claude Code consumes (matcher + hooks
3129    //   array, type=mcp_tool, tool=memory_check_agent_action).
3130    // - `apply_then_remove_round_trips`: install + uninstall returns
3131    //   the config to its original shape, modulo whitespace.
3132    // - `apply_appends_to_existing_pretooluse`: an operator-authored
3133    //   entry is preserved when we install on top.
3134    // - `apply_is_idempotent_when_rerun`: re-installing under the
3135    //   managed marker leaves exactly one managed entry.
3136    // - `apply_refuses_conflict_without_force`: a non-managed entry
3137    //   that ALSO names our MCP tool with a different matcher
3138    //   triggers the refusal path.
3139    // - `apply_overwrites_conflict_with_force`: same with --force
3140    //   replaces the conflicting entry.
3141    // - `apply_rejects_hook_flag_on_non_claude_code`: the cross-target
3142    //   gate in `run` errors loudly when --hook is set on, say,
3143    //   cursor.
3144
3145    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        // Scoped to the modeled action surface, NOT "*" (issue #1667).
3193        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        // Exactly one managed entry.
3240        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        // Operator already has one PreToolUse entry (an unrelated command hook).
3268        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        // First entry is the operator's; second is ours.
3277        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        // Pre-existing PreToolUse entry that ALSO names memory_check_agent_action
3302        // but with a non-`*` matcher — i.e. the operator scoped the hook
3303        // intentionally. Clobbering would silently change their policy.
3304        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        // File must NOT have been modified.
3315        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        // Stderr should explain the conflict.
3320        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        // Conflicting entry replaced with ours (scoped matcher, #1667).
3343        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        // Install on top.
3358        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3359        // Uninstall.
3360        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        // The operator's PreToolUse entry survives; ours is gone.
3364        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        // Uninstall against a config that never had the hook.
3376        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        // Wrap a Cursor target around the same TargetArgs.
3403        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        // Hook-mode installs are the load-bearing mechanism; no snippet
3418        // is required. Pin that behaviour so we don't accidentally
3419        // re-add the snippet emission and clutter operator stderr.
3420        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}