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/// Build the PreToolUse entry the installer writes. Uses the
1090/// type=`mcp_tool` form (vs `type=command`) so Claude Code dispatches
1091/// over the MCP channel directly — no shell, no fork, no PATH
1092/// dependence. The marker keys live alongside the operator-visible
1093/// fields so the entry round-trips through Claude Code's reader
1094/// unchanged.
1095fn claude_code_pretool_entry() -> Value {
1096    serde_json::json!({
1097        MARKER_START_KEY: MARKER_PAYLOAD,
1098        MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
1099        "matcher": "*",
1100        "hooks": [
1101            { "type": "mcp_tool", "tool": PRETOOL_HOOK_TOOL_NAME }
1102        ],
1103        MARKER_END_KEY: MARKER_PAYLOAD,
1104    })
1105}
1106
1107/// Predicate: does `v` look like a non-managed PreToolUse entry that
1108/// *also* points at our MCP tool (so installing on top would silently
1109/// shadow operator intent)? Returns the matcher string if so.
1110fn pretool_conflict_matcher(v: &Value) -> Option<String> {
1111    let obj = v.as_object()?;
1112    if obj.contains_key(MARKER_START_KEY) {
1113        return None;
1114    }
1115    let matcher = obj.get("matcher").and_then(Value::as_str)?;
1116    let hooks = obj.get("hooks").and_then(Value::as_array)?;
1117    for h in hooks {
1118        let h_obj = h.as_object()?;
1119        if h_obj.get("type").and_then(Value::as_str) == Some("mcp_tool")
1120            && h_obj.get("tool").and_then(Value::as_str) == Some(PRETOOL_HOOK_TOOL_NAME)
1121        {
1122            return Some(matcher.to_string());
1123        }
1124    }
1125    None
1126}
1127
1128/// Apply the PreToolUse managed block. APPENDS the entry to the existing
1129/// `hooks.PreToolUse` array so any operator-authored hooks earlier in
1130/// the list still run. Returns `bail!` on a conflict-without-force.
1131fn apply_claude_code_pretool(
1132    obj: &mut Map<String, Value>,
1133    force: bool,
1134    out: &mut CliOutput<'_>,
1135) -> Result<()> {
1136    let entry = claude_code_pretool_entry();
1137
1138    let hooks = obj
1139        .entry("hooks".to_string())
1140        .or_insert_with(|| Value::Object(Map::new()));
1141    if !hooks.is_object() {
1142        *hooks = Value::Object(Map::new());
1143    }
1144    let hooks_obj = hooks.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1145    let pretool = hooks_obj
1146        .entry(HOOK_EVENT_PRE_TOOL_USE.to_string())
1147        .or_insert_with(|| Value::Array(Vec::new()));
1148    if !pretool.is_array() {
1149        *pretool = Value::Array(Vec::new());
1150    }
1151    let arr = pretool.as_array_mut().expect(EXPECT_JUST_INSERTED_ARRAY);
1152
1153    // Detect any operator-authored entry that points at the same MCP
1154    // tool with a different `matcher`. That's the conflict path —
1155    // refuse without --force.
1156    let conflicting: Vec<String> = arr
1157        .iter()
1158        .filter_map(pretool_conflict_matcher)
1159        .filter(|m| m != "*")
1160        .collect();
1161    if !conflicting.is_empty() && !force {
1162        writeln!(
1163            out.stderr,
1164            "ai-memory install: warning — existing PreToolUse entry(s) already invoke \
1165             `{tool}` with matcher(s) {conflicts:?}. Pass --force to overwrite, or \
1166             remove the existing entries by hand if you want to keep your scoping.",
1167            tool = PRETOOL_HOOK_TOOL_NAME,
1168            conflicts = conflicting,
1169        )?;
1170        bail!(
1171            "refusing to overwrite a differing-but-similar PreToolUse hook \
1172             without --force; existing matcher(s): {conflicting:?}"
1173        );
1174    }
1175
1176    // Drop any previous managed entry (idempotent re-runs) AND any
1177    // conflicting entries when --force is set. Operator-authored entries
1178    // that don't touch our tool are left untouched.
1179    arr.retain(|v| {
1180        if is_managed_value(v) {
1181            return false;
1182        }
1183        if force && pretool_conflict_matcher(v).is_some() {
1184            return false;
1185        }
1186        true
1187    });
1188    arr.push(entry);
1189    Ok(())
1190}
1191
1192/// Remove the PreToolUse managed block (the inverse of
1193/// [`apply_claude_code_pretool`]). Idempotent on a clean config.
1194fn remove_claude_code_pretool(obj: &mut Map<String, Value>) {
1195    if let Some(hooks) = obj.get_mut("hooks").and_then(|h| h.as_object_mut())
1196        && let Some(arr) = hooks
1197            .get_mut(HOOK_EVENT_PRE_TOOL_USE)
1198            .and_then(|s| s.as_array_mut())
1199    {
1200        arr.retain(|v| !is_managed_value(v));
1201        if arr.is_empty() {
1202            hooks.remove(HOOK_EVENT_PRE_TOOL_USE);
1203        }
1204    }
1205    if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
1206        && hooks.is_empty()
1207    {
1208        obj.remove("hooks");
1209    }
1210}
1211
1212/// Apply the requested hook variant. Today only `--hook pretool` for
1213/// claude-code is wired; the dispatch is split out so future hook
1214/// kinds (PostToolUse, Stop) plug in without touching `run`.
1215fn apply_hook_block(
1216    target: Target,
1217    kind: HookKind,
1218    mut cfg: Value,
1219    force: bool,
1220    out: &mut CliOutput<'_>,
1221) -> Result<Value> {
1222    let obj = ensure_object(&mut cfg)?;
1223    match (target, kind) {
1224        (Target::ClaudeCode, HookKind::Pretool) => {
1225            apply_claude_code_pretool(obj, force, out)?;
1226        }
1227        // Other (target, kind) pairs are rejected upstream in `run` so
1228        // this match is exhaustive in practice. Keep the explicit
1229        // `_` arm to document the design intent.
1230        _ => bail!(
1231            "internal error: unsupported (target, hook) combination ({:?}, {:?})",
1232            target,
1233            kind
1234        ),
1235    }
1236    Ok(cfg)
1237}
1238
1239/// Remove the requested hook variant (the inverse of
1240/// [`apply_hook_block`]). Idempotent on a clean config.
1241fn remove_hook_block(target: Target, kind: HookKind, mut cfg: Value) -> Result<Value> {
1242    let obj = match cfg.as_object_mut() {
1243        Some(o) => o,
1244        None => return Ok(cfg),
1245    };
1246    match (target, kind) {
1247        (Target::ClaudeCode, HookKind::Pretool) => {
1248            remove_claude_code_pretool(obj);
1249        }
1250        _ => {
1251            // Same rationale as `apply_hook_block` — surface internal
1252            // errors loudly rather than silently no-op.
1253            bail!(
1254                "internal error: unsupported (target, hook) combination ({:?}, {:?})",
1255                target,
1256                kind
1257            );
1258        }
1259    }
1260    Ok(cfg)
1261}
1262
1263// --- OpenClaw -------------------------------------------------------------
1264
1265fn ai_memory_server_value(binary: &str) -> Value {
1266    serde_json::json!({
1267        MARKER_START_KEY: MARKER_PAYLOAD,
1268        MANAGED_KEYS_PROPERTY: ["command", "args"],
1269        "command": binary,
1270        "args": ["mcp"],
1271        MARKER_END_KEY: MARKER_PAYLOAD,
1272    })
1273}
1274
1275fn apply_openclaw(obj: &mut Map<String, Value>, binary: &str) {
1276    let mcp = obj
1277        .entry("mcp".to_string())
1278        .or_insert_with(|| Value::Object(Map::new()));
1279    if !mcp.is_object() {
1280        *mcp = Value::Object(Map::new());
1281    }
1282    let mcp_obj = mcp.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1283    let servers = mcp_obj
1284        .entry("servers".to_string())
1285        .or_insert_with(|| Value::Object(Map::new()));
1286    if !servers.is_object() {
1287        *servers = Value::Object(Map::new());
1288    }
1289    let servers_obj = servers.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1290    servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
1291}
1292
1293fn remove_openclaw(obj: &mut Map<String, Value>) {
1294    if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut())
1295        && let Some(servers) = mcp.get_mut("servers").and_then(|v| v.as_object_mut())
1296    {
1297        if let Some(v) = servers.get("ai-memory") {
1298            if is_managed_value(v) {
1299                servers.remove("ai-memory");
1300            }
1301        }
1302        if servers.is_empty() {
1303            mcp.remove("servers");
1304        }
1305        if mcp.is_empty() {
1306            obj.remove("mcp");
1307        }
1308    }
1309}
1310
1311// --- Cursor ---------------------------------------------------------------
1312
1313fn apply_cursor(obj: &mut Map<String, Value>, binary: &str) {
1314    let servers = obj
1315        .entry(KEY_MCP_SERVERS.to_string())
1316        .or_insert_with(|| Value::Object(Map::new()));
1317    if !servers.is_object() {
1318        *servers = Value::Object(Map::new());
1319    }
1320    let servers_obj = servers.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1321    servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
1322}
1323
1324fn remove_cursor(obj: &mut Map<String, Value>) {
1325    if let Some(servers) = obj.get_mut(KEY_MCP_SERVERS).and_then(|v| v.as_object_mut()) {
1326        if let Some(v) = servers.get("ai-memory") {
1327            if is_managed_value(v) {
1328                servers.remove("ai-memory");
1329            }
1330        }
1331        if servers.is_empty() {
1332            obj.remove(KEY_MCP_SERVERS);
1333        }
1334    }
1335}
1336
1337// --- Cline ----------------------------------------------------------------
1338
1339fn apply_cline(obj: &mut Map<String, Value>, binary: &str) {
1340    // Cline shape mirrors Cursor (mcpServers).
1341    apply_cursor(obj, binary);
1342}
1343
1344fn remove_cline(obj: &mut Map<String, Value>) {
1345    remove_cursor(obj);
1346}
1347
1348// --- Continue -------------------------------------------------------------
1349
1350fn apply_continue(obj: &mut Map<String, Value>, binary: &str) {
1351    // Continue's MCP config lives under experimental.modelContextProtocolServers
1352    // (an array of transport entries).
1353    let exp = obj
1354        .entry(KEY_EXPERIMENTAL.to_string())
1355        .or_insert_with(|| Value::Object(Map::new()));
1356    if !exp.is_object() {
1357        *exp = Value::Object(Map::new());
1358    }
1359    let exp_obj = exp.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
1360    let arr = exp_obj
1361        .entry(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS.to_string())
1362        .or_insert_with(|| Value::Array(Vec::new()));
1363    if !arr.is_array() {
1364        *arr = Value::Array(Vec::new());
1365    }
1366    let arr = arr.as_array_mut().expect(EXPECT_JUST_INSERTED_ARRAY);
1367    arr.retain(|v| !is_managed_value(v));
1368    let entry = serde_json::json!({
1369        MARKER_START_KEY: MARKER_PAYLOAD,
1370        MANAGED_KEYS_PROPERTY: ["transport"],
1371        "transport": {
1372            "type": "stdio",
1373            "command": binary,
1374            "args": ["mcp"],
1375        },
1376        MARKER_END_KEY: MARKER_PAYLOAD,
1377    });
1378    arr.insert(0, entry);
1379}
1380
1381fn remove_continue(obj: &mut Map<String, Value>) {
1382    if let Some(exp) = obj
1383        .get_mut(KEY_EXPERIMENTAL)
1384        .and_then(|v| v.as_object_mut())
1385    {
1386        if let Some(arr) = exp
1387            .get_mut(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS)
1388            .and_then(|v| v.as_array_mut())
1389        {
1390            arr.retain(|v| !is_managed_value(v));
1391            if arr.is_empty() {
1392                exp.remove(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS);
1393            }
1394        }
1395        if exp.is_empty() {
1396            obj.remove(KEY_EXPERIMENTAL);
1397        }
1398    }
1399}
1400
1401// --- Windsurf -------------------------------------------------------------
1402
1403fn apply_windsurf(obj: &mut Map<String, Value>, binary: &str) {
1404    apply_cursor(obj, binary);
1405}
1406
1407fn remove_windsurf(obj: &mut Map<String, Value>) {
1408    remove_cursor(obj);
1409}
1410
1411// ---------------------------------------------------------------------------
1412// Marker recognition
1413// ---------------------------------------------------------------------------
1414
1415/// Returns true when `v` is a JSON object carrying our managed-block
1416/// start sentinel. Used to recognise an existing managed block so
1417/// install can replace it precisely and uninstall can remove it.
1418fn is_managed_value(v: &Value) -> bool {
1419    v.as_object()
1420        .and_then(|o| o.get(MARKER_START_KEY))
1421        .is_some()
1422}
1423
1424// ---------------------------------------------------------------------------
1425// Diff emission
1426// ---------------------------------------------------------------------------
1427
1428/// Write a minimal unified-style diff between `before` and `after` to
1429/// `out.stdout`. We avoid pulling a real diff crate; the implementation
1430/// is intentionally simple — line-by-line, no LCS — because the diff is
1431/// *advisory* (the caller can still inspect after with `--apply`).
1432fn emit_diff(out: &mut CliOutput<'_>, before: &str, after: &str) -> Result<()> {
1433    let before_lines: Vec<&str> = before.lines().collect();
1434    let after_lines: Vec<&str> = after.lines().collect();
1435    let max_len = before_lines.len().max(after_lines.len());
1436    for i in 0..max_len {
1437        let b = before_lines.get(i).copied();
1438        let a = after_lines.get(i).copied();
1439        match (b, a) {
1440            (Some(bl), Some(al)) if bl == al => writeln!(out.stdout, " {bl}")?,
1441            (Some(bl), Some(al)) => {
1442                writeln!(out.stdout, "-{bl}")?;
1443                writeln!(out.stdout, "+{al}")?;
1444            }
1445            (Some(bl), None) => writeln!(out.stdout, "-{bl}")?,
1446            (None, Some(al)) => writeln!(out.stdout, "+{al}")?,
1447            (None, None) => {}
1448        }
1449    }
1450    Ok(())
1451}
1452
1453// ---------------------------------------------------------------------------
1454// Tests
1455// ---------------------------------------------------------------------------
1456
1457#[cfg(test)]
1458mod tests {
1459    use super::*;
1460    use crate::cli::test_utils::TestEnv;
1461    use std::fs;
1462
1463    fn args_for(target: Target, config: PathBuf) -> InstallArgs {
1464        let t = TargetArgs {
1465            config: Some(config),
1466            apply: false,
1467            dry_run: false,
1468            uninstall: false,
1469            binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
1470            hook: None,
1471            force: false,
1472        };
1473        let target_cmd = match target {
1474            Target::ClaudeCode => TargetCmd::ClaudeCode(t),
1475            Target::Openclaw => TargetCmd::Openclaw(t),
1476            Target::Cursor => TargetCmd::Cursor(t),
1477            Target::Cline => TargetCmd::Cline(t),
1478            Target::Continue => TargetCmd::Continue(t),
1479            Target::Windsurf => TargetCmd::Windsurf(t),
1480            Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
1481            Target::Codex => TargetCmd::Codex(t),
1482            Target::GrokCli => TargetCmd::GrokCli(t),
1483            Target::GeminiCli => TargetCmd::GeminiCli(t),
1484        };
1485        InstallArgs { target: target_cmd }
1486    }
1487
1488    fn args_for_apply(target: Target, config: PathBuf) -> InstallArgs {
1489        let mut a = args_for(target, config);
1490        match &mut a.target {
1491            TargetCmd::ClaudeCode(t)
1492            | TargetCmd::Openclaw(t)
1493            | TargetCmd::Cursor(t)
1494            | TargetCmd::Cline(t)
1495            | TargetCmd::Continue(t)
1496            | TargetCmd::Windsurf(t)
1497            | TargetCmd::ClaudeDesktop(t)
1498            | TargetCmd::Codex(t)
1499            | TargetCmd::GrokCli(t)
1500            | TargetCmd::GeminiCli(t) => {
1501                t.apply = true;
1502            }
1503        }
1504        a
1505    }
1506
1507    fn args_for_uninstall_apply(target: Target, config: PathBuf) -> InstallArgs {
1508        let mut a = args_for(target, config);
1509        match &mut a.target {
1510            TargetCmd::ClaudeCode(t)
1511            | TargetCmd::Openclaw(t)
1512            | TargetCmd::Cursor(t)
1513            | TargetCmd::Cline(t)
1514            | TargetCmd::Continue(t)
1515            | TargetCmd::Windsurf(t)
1516            | TargetCmd::ClaudeDesktop(t)
1517            | TargetCmd::Codex(t)
1518            | TargetCmd::GrokCli(t)
1519            | TargetCmd::GeminiCli(t) => {
1520                t.uninstall = true;
1521                t.apply = true;
1522            }
1523        }
1524        a
1525    }
1526
1527    fn config_path(env: &TestEnv, name: &str) -> PathBuf {
1528        env.db_path.parent().unwrap().join(name)
1529    }
1530
1531    fn seed(path: &Path, contents: &str) {
1532        if let Some(parent) = path.parent() {
1533            fs::create_dir_all(parent).unwrap();
1534        }
1535        fs::write(path, contents).unwrap();
1536    }
1537
1538    // --------------------------------------------------------------
1539    // claude-code
1540    // --------------------------------------------------------------
1541
1542    #[test]
1543    fn claude_code_install_dry_run_emits_diff_no_writes() {
1544        let mut env = TestEnv::fresh();
1545        let path = config_path(&env, "settings.json");
1546        seed(&path, "{\n}\n");
1547        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1548        let args = args_for(Target::ClaudeCode, path.clone());
1549        let mut out = env.output();
1550        run(&args, &mut out).unwrap();
1551        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1552        assert!(stdout.contains("dry-run"));
1553        assert!(stdout.contains("SessionStart"));
1554        assert!(stdout.contains("ai-memory"));
1555        assert!(stdout.contains(MARKER_START_KEY));
1556        let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
1557        assert_eq!(mtime_before, mtime_after, "dry-run must not write");
1558    }
1559
1560    #[test]
1561    fn claude_code_install_apply_writes_marker_block() {
1562        let mut env = TestEnv::fresh();
1563        let path = config_path(&env, "settings.json");
1564        seed(&path, "{}\n");
1565        let args = args_for_apply(Target::ClaudeCode, path.clone());
1566        let mut out = env.output();
1567        run(&args, &mut out).unwrap();
1568        let written = fs::read_to_string(&path).unwrap();
1569        assert!(written.contains(MARKER_START_KEY));
1570        assert!(written.contains(MARKER_END_KEY));
1571        assert!(written.contains("SessionStart"));
1572        assert!(written.contains("ai-memory"));
1573        // Must remain valid JSON.
1574        let _: Value = serde_json::from_str(&written).unwrap();
1575    }
1576
1577    #[test]
1578    fn claude_code_install_apply_preserves_user_keys() {
1579        let mut env = TestEnv::fresh();
1580        let path = config_path(&env, "settings.json");
1581        seed(
1582            &path,
1583            r#"{"theme":"dark","permissions":{"allow":["npm:*"]}}"#,
1584        );
1585        let args = args_for_apply(Target::ClaudeCode, path.clone());
1586        let mut out = env.output();
1587        run(&args, &mut out).unwrap();
1588        let written = fs::read_to_string(&path).unwrap();
1589        let parsed: Value = serde_json::from_str(&written).unwrap();
1590        assert_eq!(parsed["theme"], "dark");
1591        assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
1592        assert!(parsed["hooks"]["SessionStart"].is_array());
1593    }
1594
1595    #[test]
1596    fn claude_code_install_apply_is_idempotent() {
1597        let mut env = TestEnv::fresh();
1598        let path = config_path(&env, "settings.json");
1599        seed(&path, "{}\n");
1600        let args = args_for_apply(Target::ClaudeCode, path.clone());
1601        let mut out = env.output();
1602        run(&args, &mut out).unwrap();
1603        let after_first = fs::read_to_string(&path).unwrap();
1604        // Second run should produce a no-op message and no change.
1605        env.stdout.clear();
1606        let mut out2 = env.output();
1607        run(&args, &mut out2).unwrap();
1608        let after_second = fs::read_to_string(&path).unwrap();
1609        assert_eq!(after_first, after_second);
1610        let stdout2 = std::str::from_utf8(&env.stdout).unwrap();
1611        assert!(
1612            stdout2.contains("no-op"),
1613            "second install should be no-op: {stdout2}"
1614        );
1615    }
1616
1617    #[test]
1618    fn claude_code_uninstall_removes_marker_block_only() {
1619        let mut env = TestEnv::fresh();
1620        let path = config_path(&env, "settings.json");
1621        let original = "{\n  \"theme\": \"dark\"\n}\n";
1622        seed(&path, original);
1623        // Install, then uninstall.
1624        run(
1625            &args_for_apply(Target::ClaudeCode, path.clone()),
1626            &mut env.output(),
1627        )
1628        .unwrap();
1629        let after_install = fs::read_to_string(&path).unwrap();
1630        assert!(after_install.contains(MARKER_START_KEY));
1631        run(
1632            &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
1633            &mut env.output(),
1634        )
1635        .unwrap();
1636        let after_uninstall = fs::read_to_string(&path).unwrap();
1637        let parsed: Value = serde_json::from_str(&after_uninstall).unwrap();
1638        assert_eq!(parsed["theme"], "dark");
1639        assert!(
1640            parsed.get("hooks").is_none(),
1641            "hooks should be gone after uninstall"
1642        );
1643        assert!(!after_uninstall.contains(MARKER_START_KEY));
1644    }
1645
1646    #[test]
1647    fn claude_code_install_refuses_malformed_config() {
1648        let mut env = TestEnv::fresh();
1649        let path = config_path(&env, "settings.json");
1650        seed(&path, "{not valid json");
1651        let args = args_for_apply(Target::ClaudeCode, path.clone());
1652        let mut out = env.output();
1653        let err = run(&args, &mut out).unwrap_err();
1654        let msg = format!("{err}");
1655        assert!(
1656            msg.contains("not valid JSON"),
1657            "error should explain malformed json: {msg}"
1658        );
1659        // File must NOT have been overwritten.
1660        let still = fs::read_to_string(&path).unwrap();
1661        assert_eq!(still, "{not valid json");
1662    }
1663
1664    #[test]
1665    fn claude_code_install_writes_backup_file() {
1666        let mut env = TestEnv::fresh();
1667        let path = config_path(&env, "settings.json");
1668        seed(&path, "{}\n");
1669        let args = args_for_apply(Target::ClaudeCode, path.clone());
1670        let mut out = env.output();
1671        run(&args, &mut out).unwrap();
1672        // Find a sibling whose name starts with `settings.json.bak.`.
1673        let parent = path.parent().unwrap();
1674        let backups: Vec<_> = fs::read_dir(parent)
1675            .unwrap()
1676            .filter_map(|e| e.ok())
1677            .filter(|e| {
1678                e.file_name()
1679                    .to_string_lossy()
1680                    .starts_with("settings.json.bak.")
1681                    || e.file_name().to_string_lossy().starts_with("settings.bak.")
1682            })
1683            .collect();
1684        assert!(
1685            !backups.is_empty(),
1686            "expected a settings.bak.<ts> backup beside the config; saw: {:?}",
1687            fs::read_dir(parent)
1688                .unwrap()
1689                .filter_map(|e| e.ok())
1690                .map(|e| e.file_name())
1691                .collect::<Vec<_>>()
1692        );
1693    }
1694
1695    // --------------------------------------------------------------
1696    // cursor
1697    // --------------------------------------------------------------
1698
1699    #[test]
1700    fn cursor_install_dry_run_emits_diff_no_writes() {
1701        let mut env = TestEnv::fresh();
1702        let path = config_path(&env, "mcp.json");
1703        seed(&path, "{}\n");
1704        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1705        let args = args_for(Target::Cursor, path.clone());
1706        let mut out = env.output();
1707        run(&args, &mut out).unwrap();
1708        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1709        assert!(stdout.contains("dry-run"));
1710        assert!(stdout.contains("mcpServers"));
1711        let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
1712        assert_eq!(mtime_before, mtime_after);
1713    }
1714
1715    #[test]
1716    fn cursor_install_apply_writes_marker_block() {
1717        let mut env = TestEnv::fresh();
1718        let path = config_path(&env, "mcp.json");
1719        seed(&path, "{}\n");
1720        let args = args_for_apply(Target::Cursor, path.clone());
1721        run(&args, &mut env.output()).unwrap();
1722        let written = fs::read_to_string(&path).unwrap();
1723        let parsed: Value = serde_json::from_str(&written).unwrap();
1724        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1725        assert_eq!(
1726            parsed["mcpServers"]["ai-memory"]["command"],
1727            "/usr/local/bin/ai-memory"
1728        );
1729    }
1730
1731    #[test]
1732    fn cursor_install_apply_preserves_user_keys() {
1733        let mut env = TestEnv::fresh();
1734        let path = config_path(&env, "mcp.json");
1735        seed(
1736            &path,
1737            r#"{"mcpServers":{"my-other":{"command":"x"}},"telemetry":false}"#,
1738        );
1739        run(
1740            &args_for_apply(Target::Cursor, path.clone()),
1741            &mut env.output(),
1742        )
1743        .unwrap();
1744        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1745        assert_eq!(parsed["telemetry"], false);
1746        assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1747        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1748    }
1749
1750    #[test]
1751    fn cursor_install_apply_is_idempotent() {
1752        let mut env = TestEnv::fresh();
1753        let path = config_path(&env, "mcp.json");
1754        seed(&path, "{}\n");
1755        let args = args_for_apply(Target::Cursor, path.clone());
1756        run(&args, &mut env.output()).unwrap();
1757        let first = fs::read_to_string(&path).unwrap();
1758        run(&args, &mut env.output()).unwrap();
1759        let second = fs::read_to_string(&path).unwrap();
1760        assert_eq!(first, second);
1761    }
1762
1763    #[test]
1764    fn cursor_uninstall_removes_marker_block_only() {
1765        let mut env = TestEnv::fresh();
1766        let path = config_path(&env, "mcp.json");
1767        let original = r#"{"mcpServers":{"my-other":{"command":"x"}}}"#;
1768        seed(&path, original);
1769        run(
1770            &args_for_apply(Target::Cursor, path.clone()),
1771            &mut env.output(),
1772        )
1773        .unwrap();
1774        run(
1775            &args_for_uninstall_apply(Target::Cursor, path.clone()),
1776            &mut env.output(),
1777        )
1778        .unwrap();
1779        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1780        assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1781        assert!(
1782            parsed["mcpServers"]
1783                .as_object()
1784                .unwrap()
1785                .get("ai-memory")
1786                .is_none()
1787        );
1788    }
1789
1790    #[test]
1791    fn cursor_install_refuses_malformed_config() {
1792        let mut env = TestEnv::fresh();
1793        let path = config_path(&env, "mcp.json");
1794        seed(&path, "not json");
1795        let args = args_for_apply(Target::Cursor, path.clone());
1796        let err = run(&args, &mut env.output()).unwrap_err();
1797        assert!(format!("{err}").contains("not valid JSON"));
1798    }
1799
1800    #[test]
1801    fn cursor_install_writes_backup_file() {
1802        let mut env = TestEnv::fresh();
1803        let path = config_path(&env, "mcp.json");
1804        seed(&path, "{}\n");
1805        run(
1806            &args_for_apply(Target::Cursor, path.clone()),
1807            &mut env.output(),
1808        )
1809        .unwrap();
1810        let parent = path.parent().unwrap();
1811        let any_backup = fs::read_dir(parent)
1812            .unwrap()
1813            .filter_map(|e| e.ok())
1814            .any(|e| e.file_name().to_string_lossy().contains("bak."));
1815        assert!(any_backup);
1816    }
1817
1818    // --------------------------------------------------------------
1819    // openclaw
1820    // --------------------------------------------------------------
1821
1822    #[test]
1823    fn openclaw_install_dry_run_emits_diff_no_writes() {
1824        let mut env = TestEnv::fresh();
1825        let path = config_path(&env, "openclaw.json");
1826        seed(&path, "{}\n");
1827        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1828        run(&args_for(Target::Openclaw, path.clone()), &mut env.output()).unwrap();
1829        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1830        assert!(stdout.contains("dry-run"));
1831        assert!(stdout.contains("mcp"));
1832        assert_eq!(
1833            mtime_before,
1834            fs::metadata(&path).unwrap().modified().unwrap()
1835        );
1836    }
1837
1838    #[test]
1839    fn openclaw_install_apply_writes_marker_block() {
1840        let mut env = TestEnv::fresh();
1841        let path = config_path(&env, "openclaw.json");
1842        seed(&path, "{}\n");
1843        run(
1844            &args_for_apply(Target::Openclaw, path.clone()),
1845            &mut env.output(),
1846        )
1847        .unwrap();
1848        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1849        assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1850    }
1851
1852    #[test]
1853    fn openclaw_install_apply_preserves_user_keys() {
1854        let mut env = TestEnv::fresh();
1855        let path = config_path(&env, "openclaw.json");
1856        seed(
1857            &path,
1858            r#"{"mcp":{"servers":{"other":{"command":"y"}}},"editor":"vim"}"#,
1859        );
1860        run(
1861            &args_for_apply(Target::Openclaw, path.clone()),
1862            &mut env.output(),
1863        )
1864        .unwrap();
1865        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1866        assert_eq!(parsed["editor"], "vim");
1867        assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1868        assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1869    }
1870
1871    #[test]
1872    fn openclaw_install_apply_is_idempotent() {
1873        let mut env = TestEnv::fresh();
1874        let path = config_path(&env, "openclaw.json");
1875        seed(&path, "{}\n");
1876        let args = args_for_apply(Target::Openclaw, path.clone());
1877        run(&args, &mut env.output()).unwrap();
1878        let first = fs::read_to_string(&path).unwrap();
1879        run(&args, &mut env.output()).unwrap();
1880        let second = fs::read_to_string(&path).unwrap();
1881        assert_eq!(first, second);
1882    }
1883
1884    #[test]
1885    fn openclaw_uninstall_removes_marker_block_only() {
1886        let mut env = TestEnv::fresh();
1887        let path = config_path(&env, "openclaw.json");
1888        seed(&path, r#"{"mcp":{"servers":{"other":{"command":"y"}}}}"#);
1889        run(
1890            &args_for_apply(Target::Openclaw, path.clone()),
1891            &mut env.output(),
1892        )
1893        .unwrap();
1894        run(
1895            &args_for_uninstall_apply(Target::Openclaw, path.clone()),
1896            &mut env.output(),
1897        )
1898        .unwrap();
1899        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1900        assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1901        assert!(
1902            parsed["mcp"]["servers"]
1903                .as_object()
1904                .unwrap()
1905                .get("ai-memory")
1906                .is_none()
1907        );
1908    }
1909
1910    #[test]
1911    fn openclaw_install_refuses_malformed_config() {
1912        let mut env = TestEnv::fresh();
1913        let path = config_path(&env, "openclaw.json");
1914        seed(&path, "garbage");
1915        let err = run(
1916            &args_for_apply(Target::Openclaw, path.clone()),
1917            &mut env.output(),
1918        )
1919        .unwrap_err();
1920        assert!(format!("{err}").contains("not valid JSON"));
1921    }
1922
1923    #[test]
1924    fn openclaw_install_writes_backup_file() {
1925        let mut env = TestEnv::fresh();
1926        let path = config_path(&env, "openclaw.json");
1927        seed(&path, "{}\n");
1928        run(
1929            &args_for_apply(Target::Openclaw, path.clone()),
1930            &mut env.output(),
1931        )
1932        .unwrap();
1933        let parent = path.parent().unwrap();
1934        assert!(
1935            fs::read_dir(parent)
1936                .unwrap()
1937                .filter_map(|e| e.ok())
1938                .any(|e| e.file_name().to_string_lossy().contains("bak."))
1939        );
1940    }
1941
1942    // --------------------------------------------------------------
1943    // cline (shape ≈ cursor)
1944    // --------------------------------------------------------------
1945
1946    #[test]
1947    fn cline_install_dry_run_emits_diff_no_writes() {
1948        let mut env = TestEnv::fresh();
1949        let path = config_path(&env, "cline.json");
1950        seed(&path, "{}\n");
1951        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1952        run(&args_for(Target::Cline, path.clone()), &mut env.output()).unwrap();
1953        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1954        assert!(stdout.contains("dry-run"));
1955        assert!(stdout.contains("mcpServers"));
1956        assert_eq!(
1957            mtime_before,
1958            fs::metadata(&path).unwrap().modified().unwrap()
1959        );
1960    }
1961
1962    #[test]
1963    fn cline_install_apply_writes_marker_block() {
1964        let mut env = TestEnv::fresh();
1965        let path = config_path(&env, "cline.json");
1966        seed(&path, "{}\n");
1967        run(
1968            &args_for_apply(Target::Cline, path.clone()),
1969            &mut env.output(),
1970        )
1971        .unwrap();
1972        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1973        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1974    }
1975
1976    #[test]
1977    fn cline_install_apply_preserves_user_keys() {
1978        let mut env = TestEnv::fresh();
1979        let path = config_path(&env, "cline.json");
1980        seed(&path, r#"{"mcpServers":{"x":{"command":"q"}},"foo":1}"#);
1981        run(
1982            &args_for_apply(Target::Cline, path.clone()),
1983            &mut env.output(),
1984        )
1985        .unwrap();
1986        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1987        assert_eq!(parsed["foo"], 1);
1988        assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
1989    }
1990
1991    #[test]
1992    fn cline_install_apply_is_idempotent() {
1993        let mut env = TestEnv::fresh();
1994        let path = config_path(&env, "cline.json");
1995        seed(&path, "{}\n");
1996        let args = args_for_apply(Target::Cline, path.clone());
1997        run(&args, &mut env.output()).unwrap();
1998        let first = fs::read_to_string(&path).unwrap();
1999        run(&args, &mut env.output()).unwrap();
2000        let second = fs::read_to_string(&path).unwrap();
2001        assert_eq!(first, second);
2002    }
2003
2004    #[test]
2005    fn cline_uninstall_removes_marker_block_only() {
2006        let mut env = TestEnv::fresh();
2007        let path = config_path(&env, "cline.json");
2008        seed(&path, r#"{"mcpServers":{"x":{"command":"q"}}}"#);
2009        run(
2010            &args_for_apply(Target::Cline, path.clone()),
2011            &mut env.output(),
2012        )
2013        .unwrap();
2014        run(
2015            &args_for_uninstall_apply(Target::Cline, path.clone()),
2016            &mut env.output(),
2017        )
2018        .unwrap();
2019        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2020        assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
2021        assert!(
2022            parsed["mcpServers"]
2023                .as_object()
2024                .unwrap()
2025                .get("ai-memory")
2026                .is_none()
2027        );
2028    }
2029
2030    #[test]
2031    fn cline_install_refuses_malformed_config() {
2032        let mut env = TestEnv::fresh();
2033        let path = config_path(&env, "cline.json");
2034        seed(&path, "totally not json");
2035        let err = run(
2036            &args_for_apply(Target::Cline, path.clone()),
2037            &mut env.output(),
2038        )
2039        .unwrap_err();
2040        assert!(format!("{err}").contains("not valid JSON"));
2041    }
2042
2043    #[test]
2044    fn cline_install_writes_backup_file() {
2045        let mut env = TestEnv::fresh();
2046        let path = config_path(&env, "cline.json");
2047        seed(&path, "{}\n");
2048        run(
2049            &args_for_apply(Target::Cline, path.clone()),
2050            &mut env.output(),
2051        )
2052        .unwrap();
2053        assert!(
2054            fs::read_dir(path.parent().unwrap())
2055                .unwrap()
2056                .filter_map(|e| e.ok())
2057                .any(|e| e.file_name().to_string_lossy().contains("bak."))
2058        );
2059    }
2060
2061    // --------------------------------------------------------------
2062    // continue
2063    // --------------------------------------------------------------
2064
2065    #[test]
2066    fn continue_install_dry_run_emits_diff_no_writes() {
2067        let mut env = TestEnv::fresh();
2068        let path = config_path(&env, "continue.json");
2069        seed(&path, "{}\n");
2070        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
2071        run(&args_for(Target::Continue, path.clone()), &mut env.output()).unwrap();
2072        let stdout = std::str::from_utf8(&env.stdout).unwrap();
2073        assert!(stdout.contains("dry-run"));
2074        assert!(stdout.contains("modelContextProtocolServers"));
2075        assert_eq!(
2076            mtime_before,
2077            fs::metadata(&path).unwrap().modified().unwrap()
2078        );
2079    }
2080
2081    #[test]
2082    fn continue_install_apply_writes_marker_block() {
2083        let mut env = TestEnv::fresh();
2084        let path = config_path(&env, "continue.json");
2085        seed(&path, "{}\n");
2086        run(
2087            &args_for_apply(Target::Continue, path.clone()),
2088            &mut env.output(),
2089        )
2090        .unwrap();
2091        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2092        let arr = parsed["experimental"]["modelContextProtocolServers"]
2093            .as_array()
2094            .unwrap();
2095        assert!(arr.iter().any(is_managed_value));
2096    }
2097
2098    #[test]
2099    fn continue_install_apply_preserves_user_keys() {
2100        let mut env = TestEnv::fresh();
2101        let path = config_path(&env, "continue.json");
2102        seed(
2103            &path,
2104            r#"{"models":[{"name":"x"}],"experimental":{"foo":true}}"#,
2105        );
2106        run(
2107            &args_for_apply(Target::Continue, path.clone()),
2108            &mut env.output(),
2109        )
2110        .unwrap();
2111        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2112        assert_eq!(parsed["models"][0]["name"], "x");
2113        assert_eq!(parsed["experimental"]["foo"], true);
2114    }
2115
2116    #[test]
2117    fn continue_install_apply_is_idempotent() {
2118        let mut env = TestEnv::fresh();
2119        let path = config_path(&env, "continue.json");
2120        seed(&path, "{}\n");
2121        let args = args_for_apply(Target::Continue, path.clone());
2122        run(&args, &mut env.output()).unwrap();
2123        let first = fs::read_to_string(&path).unwrap();
2124        run(&args, &mut env.output()).unwrap();
2125        let second = fs::read_to_string(&path).unwrap();
2126        assert_eq!(first, second);
2127    }
2128
2129    #[test]
2130    fn continue_uninstall_removes_marker_block_only() {
2131        let mut env = TestEnv::fresh();
2132        let path = config_path(&env, "continue.json");
2133        seed(&path, r#"{"models":[{"name":"x"}]}"#);
2134        run(
2135            &args_for_apply(Target::Continue, path.clone()),
2136            &mut env.output(),
2137        )
2138        .unwrap();
2139        run(
2140            &args_for_uninstall_apply(Target::Continue, path.clone()),
2141            &mut env.output(),
2142        )
2143        .unwrap();
2144        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2145        assert_eq!(parsed["models"][0]["name"], "x");
2146        assert!(parsed.get("experimental").is_none());
2147    }
2148
2149    #[test]
2150    fn continue_install_refuses_malformed_config() {
2151        let mut env = TestEnv::fresh();
2152        let path = config_path(&env, "continue.json");
2153        seed(&path, "[1,2,");
2154        let err = run(
2155            &args_for_apply(Target::Continue, path.clone()),
2156            &mut env.output(),
2157        )
2158        .unwrap_err();
2159        assert!(format!("{err}").contains("not valid JSON"));
2160    }
2161
2162    #[test]
2163    fn continue_install_writes_backup_file() {
2164        let mut env = TestEnv::fresh();
2165        let path = config_path(&env, "continue.json");
2166        seed(&path, "{}\n");
2167        run(
2168            &args_for_apply(Target::Continue, path.clone()),
2169            &mut env.output(),
2170        )
2171        .unwrap();
2172        assert!(
2173            fs::read_dir(path.parent().unwrap())
2174                .unwrap()
2175                .filter_map(|e| e.ok())
2176                .any(|e| e.file_name().to_string_lossy().contains("bak."))
2177        );
2178    }
2179
2180    // --------------------------------------------------------------
2181    // windsurf (shape ≈ cursor)
2182    // --------------------------------------------------------------
2183
2184    #[test]
2185    fn windsurf_install_dry_run_emits_diff_no_writes() {
2186        let mut env = TestEnv::fresh();
2187        let path = config_path(&env, "mcp_config.json");
2188        seed(&path, "{}\n");
2189        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
2190        run(&args_for(Target::Windsurf, path.clone()), &mut env.output()).unwrap();
2191        let stdout = std::str::from_utf8(&env.stdout).unwrap();
2192        assert!(stdout.contains("dry-run"));
2193        assert!(stdout.contains("mcpServers"));
2194        assert_eq!(
2195            mtime_before,
2196            fs::metadata(&path).unwrap().modified().unwrap()
2197        );
2198    }
2199
2200    #[test]
2201    fn windsurf_install_apply_writes_marker_block() {
2202        let mut env = TestEnv::fresh();
2203        let path = config_path(&env, "mcp_config.json");
2204        seed(&path, "{}\n");
2205        run(
2206            &args_for_apply(Target::Windsurf, path.clone()),
2207            &mut env.output(),
2208        )
2209        .unwrap();
2210        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2211        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
2212    }
2213
2214    #[test]
2215    fn windsurf_install_apply_preserves_user_keys() {
2216        let mut env = TestEnv::fresh();
2217        let path = config_path(&env, "mcp_config.json");
2218        seed(&path, r#"{"mcpServers":{"k":{"command":"l"}},"a":42}"#);
2219        run(
2220            &args_for_apply(Target::Windsurf, path.clone()),
2221            &mut env.output(),
2222        )
2223        .unwrap();
2224        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2225        assert_eq!(parsed["a"], 42);
2226        assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
2227    }
2228
2229    #[test]
2230    fn windsurf_install_apply_is_idempotent() {
2231        let mut env = TestEnv::fresh();
2232        let path = config_path(&env, "mcp_config.json");
2233        seed(&path, "{}\n");
2234        let args = args_for_apply(Target::Windsurf, path.clone());
2235        run(&args, &mut env.output()).unwrap();
2236        let first = fs::read_to_string(&path).unwrap();
2237        run(&args, &mut env.output()).unwrap();
2238        let second = fs::read_to_string(&path).unwrap();
2239        assert_eq!(first, second);
2240    }
2241
2242    #[test]
2243    fn windsurf_uninstall_removes_marker_block_only() {
2244        let mut env = TestEnv::fresh();
2245        let path = config_path(&env, "mcp_config.json");
2246        seed(&path, r#"{"mcpServers":{"k":{"command":"l"}}}"#);
2247        run(
2248            &args_for_apply(Target::Windsurf, path.clone()),
2249            &mut env.output(),
2250        )
2251        .unwrap();
2252        run(
2253            &args_for_uninstall_apply(Target::Windsurf, path.clone()),
2254            &mut env.output(),
2255        )
2256        .unwrap();
2257        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2258        assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
2259        assert!(
2260            parsed["mcpServers"]
2261                .as_object()
2262                .unwrap()
2263                .get("ai-memory")
2264                .is_none()
2265        );
2266    }
2267
2268    #[test]
2269    fn windsurf_install_refuses_malformed_config() {
2270        let mut env = TestEnv::fresh();
2271        let path = config_path(&env, "mcp_config.json");
2272        seed(&path, "::");
2273        let err = run(
2274            &args_for_apply(Target::Windsurf, path.clone()),
2275            &mut env.output(),
2276        )
2277        .unwrap_err();
2278        assert!(format!("{err}").contains("not valid JSON"));
2279    }
2280
2281    #[test]
2282    fn windsurf_install_writes_backup_file() {
2283        let mut env = TestEnv::fresh();
2284        let path = config_path(&env, "mcp_config.json");
2285        seed(&path, "{}\n");
2286        run(
2287            &args_for_apply(Target::Windsurf, path.clone()),
2288            &mut env.output(),
2289        )
2290        .unwrap();
2291        assert!(
2292            fs::read_dir(path.parent().unwrap())
2293                .unwrap()
2294                .filter_map(|e| e.ok())
2295                .any(|e| e.file_name().to_string_lossy().contains("bak."))
2296        );
2297    }
2298
2299    // --------------------------------------------------------------
2300    // generic / cross-cutting
2301    // --------------------------------------------------------------
2302
2303    #[test]
2304    fn install_creates_missing_config_file_under_apply() {
2305        let mut env = TestEnv::fresh();
2306        let path = config_path(&env, "fresh-config.json");
2307        assert!(!path.exists());
2308        run(
2309            &args_for_apply(Target::Cursor, path.clone()),
2310            &mut env.output(),
2311        )
2312        .unwrap();
2313        assert!(path.exists());
2314        let _: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2315    }
2316
2317    #[test]
2318    fn install_round_trip_install_then_uninstall_restores_original_for_empty_seed() {
2319        // For a config that started as `{}\n`, install + uninstall should
2320        // produce a configuration that re-parses to `{}` (key set is empty).
2321        let mut env = TestEnv::fresh();
2322        let path = config_path(&env, "rt.json");
2323        seed(&path, "{}\n");
2324        run(
2325            &args_for_apply(Target::Cursor, path.clone()),
2326            &mut env.output(),
2327        )
2328        .unwrap();
2329        run(
2330            &args_for_uninstall_apply(Target::Cursor, path.clone()),
2331            &mut env.output(),
2332        )
2333        .unwrap();
2334        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2335        assert_eq!(parsed, serde_json::json!({}));
2336    }
2337
2338    #[test]
2339    fn resolve_binary_uses_override_when_provided() {
2340        let p = std::path::PathBuf::from("/custom/path/ai-memory");
2341        let resolved = resolve_binary(Some(&p));
2342        assert_eq!(resolved, "/custom/path/ai-memory");
2343    }
2344
2345    // ---- v0.6.4-010 — per-harness install profiles ----
2346    //
2347    // The four MCP-standard harnesses (claude-desktop, codex, grok-cli,
2348    // gemini-cli) use the same `mcpServers.ai-memory.{command,args,env}`
2349    // shape. We test all four with a single shared assertion fixture
2350    // since the writer is shared.
2351
2352    fn assert_mcp_standard_apply(target: Target, fname: &str) {
2353        let mut env = TestEnv::fresh();
2354        let path = config_path(&env, fname);
2355        seed(&path, "{}\n");
2356        run(&args_for_apply(target, path.clone()), &mut env.output()).unwrap();
2357        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2358        // Standard MCP shape.
2359        assert!(
2360            parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string(),
2361            "{} missing managed-block marker",
2362            target.name()
2363        );
2364        // v0.6.4 default profile baked into args.
2365        let args = parsed["mcpServers"]["ai-memory"]["args"]
2366            .as_array()
2367            .unwrap();
2368        let strs: Vec<&str> = args.iter().filter_map(Value::as_str).collect();
2369        assert_eq!(
2370            strs,
2371            vec!["mcp", "--profile", "core"],
2372            "{} should write `mcp --profile core` args",
2373            target.name()
2374        );
2375        let cmd = parsed["mcpServers"]["ai-memory"]["command"]
2376            .as_str()
2377            .unwrap();
2378        assert_eq!(cmd, "/usr/local/bin/ai-memory");
2379    }
2380
2381    #[test]
2382    fn claude_desktop_apply_writes_mcp_standard_with_profile_core() {
2383        assert_mcp_standard_apply(Target::ClaudeDesktop, "claude_desktop_config.json");
2384    }
2385
2386    #[test]
2387    fn codex_apply_writes_mcp_standard_with_profile_core() {
2388        assert_mcp_standard_apply(Target::Codex, "codex_config.json");
2389    }
2390
2391    #[test]
2392    fn grok_cli_apply_writes_mcp_standard_with_profile_core() {
2393        assert_mcp_standard_apply(Target::GrokCli, "grok_mcp.json");
2394    }
2395
2396    #[test]
2397    fn gemini_cli_apply_writes_mcp_standard_with_profile_core() {
2398        assert_mcp_standard_apply(Target::GeminiCli, "gemini_mcp.json");
2399    }
2400
2401    #[test]
2402    fn mcp_standard_uninstall_round_trip_restores_empty() {
2403        let mut env = TestEnv::fresh();
2404        let path = config_path(&env, "claude_desktop_config.json");
2405        seed(&path, "{}\n");
2406        run(
2407            &args_for_apply(Target::ClaudeDesktop, path.clone()),
2408            &mut env.output(),
2409        )
2410        .unwrap();
2411        run(
2412            &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
2413            &mut env.output(),
2414        )
2415        .unwrap();
2416        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2417        // Empty mcpServers should be removed entirely.
2418        assert!(
2419            !parsed.as_object().unwrap().contains_key("mcpServers"),
2420            "uninstall should remove the empty mcpServers wrapper"
2421        );
2422    }
2423
2424    #[test]
2425    fn mcp_standard_apply_preserves_user_keys() {
2426        let mut env = TestEnv::fresh();
2427        let path = config_path(&env, "codex_config.json");
2428        seed(
2429            &path,
2430            r#"{"mcpServers":{"other-mcp":{"command":"x","args":[]}},"unrelated":42}"#,
2431        );
2432        run(
2433            &args_for_apply(Target::Codex, path.clone()),
2434            &mut env.output(),
2435        )
2436        .unwrap();
2437        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2438        // Sibling server preserved.
2439        assert_eq!(parsed["mcpServers"]["other-mcp"]["command"], "x");
2440        // Sibling top-level key preserved.
2441        assert_eq!(parsed["unrelated"], 42);
2442        // ai-memory entry written.
2443        assert!(parsed["mcpServers"]["ai-memory"].is_object());
2444    }
2445
2446    // v0.7.0 #1378 — Codex TOML config coverage.
2447
2448    #[test]
2449    fn config_format_detect_distinguishes_toml_and_json() {
2450        assert_eq!(
2451            ConfigFormat::detect(Path::new("/x/config.toml")),
2452            ConfigFormat::Toml
2453        );
2454        assert_eq!(
2455            ConfigFormat::detect(Path::new("/x/config.TOML")),
2456            ConfigFormat::Toml
2457        );
2458        assert_eq!(
2459            ConfigFormat::detect(Path::new("/x/config.json")),
2460            ConfigFormat::Json
2461        );
2462        assert_eq!(
2463            ConfigFormat::detect(Path::new("/x/noext")),
2464            ConfigFormat::Json
2465        );
2466    }
2467
2468    #[test]
2469    fn codex_apply_toml_roundtrips_and_preserves_user_keys() {
2470        // End-to-end TOML path: read TOML → mutate → serialise TOML →
2471        // round-trip-verify. Exercises the serialize_config + round-trip
2472        // TOML arms and read_config_or_empty's TOML success branch.
2473        let mut env = TestEnv::fresh();
2474        let path = config_path(&env, "config.toml");
2475        seed(
2476            &path,
2477            "unrelated = 42\n\n[mcp_servers.other-mcp]\ncommand = \"x\"\nargs = []\n",
2478        );
2479        run(
2480            &args_for_apply(Target::Codex, path.clone()),
2481            &mut env.output(),
2482        )
2483        .unwrap();
2484        let txt = fs::read_to_string(&path).unwrap();
2485        let tv: toml::Value = toml::from_str(&txt).expect("output must be valid TOML");
2486        let jv: Value = serde_json::to_value(&tv).unwrap();
2487        // Codex TOML uses the snake_case `mcp_servers` key.
2488        assert!(jv["mcp_servers"]["ai-memory"].is_object());
2489        assert_eq!(
2490            jv["mcp_servers"]["ai-memory"]["command"],
2491            "/usr/local/bin/ai-memory"
2492        );
2493        // Sibling server + top-level key preserved across the round-trip.
2494        assert_eq!(jv["mcp_servers"]["other-mcp"]["command"], "x");
2495        assert_eq!(jv["unrelated"], 42);
2496    }
2497
2498    #[test]
2499    fn read_config_or_empty_rejects_invalid_toml() {
2500        let env = TestEnv::fresh();
2501        let path = config_path(&env, "broken.toml");
2502        seed(&path, "this is = = not valid toml\n");
2503        let err = read_config_or_empty(&path).unwrap_err();
2504        assert!(err.to_string().contains("is not valid TOML"), "got: {err}");
2505    }
2506
2507    // --------------------------------------------------------------
2508    // v0.7-D3 — install-time system-prompt snippet
2509    //
2510    // These tests serialize on a module-local Mutex so they can each
2511    // point `AI_MEMORY_SYSTEM_PROMPT_DIR` at their own tempdir without
2512    // racing other concurrent tests. Other apply-tests in this module
2513    // rely on the `cfg(test)` default (a per-process tempdir) so they
2514    // never touch the operator's real `~/.config/ai-memory/`.
2515    // --------------------------------------------------------------
2516
2517    /// Serialise env-var mutation across snippet tests.
2518    fn snippet_env_lock() -> &'static std::sync::Mutex<()> {
2519        use std::sync::{Mutex, OnceLock};
2520        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2521        LOCK.get_or_init(|| Mutex::new(()))
2522    }
2523
2524    /// Run the snippet emit path for `target` against an isolated
2525    /// tempdir and return `(snippet_path, snippet_body)`. The tempdir
2526    /// is leaked so the snippet file remains on disk for the caller
2527    /// to inspect after the helper returns; the OS sweeps `/tmp` on
2528    /// reboot.
2529    fn emit_snippet_isolated(target: Target) -> (PathBuf, String) {
2530        // Uses the dir-parameterised helper so the test does NOT touch
2531        // the process-global `AI_MEMORY_SYSTEM_PROMPT_DIR` env var.
2532        // Eliminates the `snippet_env_lock` cross-test race that flaked
2533        // `snippet_every_target_emits_under_budget` under
2534        // `--test-threads > 1` (§16 12-gate sweep observation,
2535        // 2026-05-13, v0.7.1-fold).
2536        let tmp = tempfile::tempdir().expect("tempdir");
2537        let tmp_path = tmp.path().to_path_buf();
2538        let snippet_path =
2539            write_system_prompt_snippet_to(target, &tmp_path).expect("snippet write");
2540        let body = fs::read_to_string(&snippet_path).expect("read snippet");
2541        std::mem::forget(tmp); // path must outlive caller
2542        (snippet_path, body)
2543    }
2544
2545    /// Every snippet must mention all four anchor strings the v0.7
2546    /// surface depends on, plus the harness name verbatim.
2547    fn assert_snippet_anchors(target: Target, body: &str) {
2548        let harness = target.name();
2549        assert!(
2550            body.contains(harness),
2551            "snippet for {harness} missing harness literal; body was:\n{body}",
2552        );
2553        for anchor in [
2554            "memory_capabilities",
2555            "memory_load_family",
2556            "attest_level",
2557            // R5 transcript hook — wording is "R5 hook" in the body.
2558            "R5 hook",
2559        ] {
2560            assert!(
2561                body.contains(anchor),
2562                "snippet for {harness} missing anchor `{anchor}`; body was:\n{body}",
2563            );
2564        }
2565    }
2566
2567    /// ≤200 token budget per spec. We use a coarse char-count proxy
2568    /// (4 chars/token is the standard rule of thumb for English BPE),
2569    /// so 200 tokens ≈ 800 chars. The actual cl100k-base count for
2570    /// English prose with code-fence backticks runs ~10–15% lower than
2571    /// chars/4, so a 800-char ceiling under-estimates the budget
2572    /// conservatively.
2573    fn assert_snippet_token_budget(body: &str) {
2574        let approx_tokens = body.chars().count() / 4;
2575        assert!(
2576            approx_tokens <= 200,
2577            "snippet exceeds 200-token budget (≈{approx_tokens} tokens, {} chars)",
2578            body.chars().count(),
2579        );
2580    }
2581
2582    #[test]
2583    fn snippet_claude_code_has_anchors_and_under_budget() {
2584        let (path, body) = emit_snippet_isolated(Target::ClaudeCode);
2585        assert!(path.ends_with("system-prompt-claude-code.md"));
2586        assert_snippet_anchors(Target::ClaudeCode, &body);
2587        assert_snippet_token_budget(&body);
2588        // Claude Code's harness-specific bullet calls out ToolSearch.
2589        assert!(
2590            body.contains("ToolSearch"),
2591            "claude-code snippet should mention ToolSearch (deferred-tool registration)",
2592        );
2593    }
2594
2595    #[test]
2596    fn snippet_cursor_has_anchors_and_under_budget() {
2597        let (path, body) = emit_snippet_isolated(Target::Cursor);
2598        assert!(path.ends_with("system-prompt-cursor.md"));
2599        assert_snippet_anchors(Target::Cursor, &body);
2600        assert_snippet_token_budget(&body);
2601    }
2602
2603    #[test]
2604    fn snippet_codex_has_anchors_and_under_budget() {
2605        let (path, body) = emit_snippet_isolated(Target::Codex);
2606        assert!(path.ends_with("system-prompt-codex.md"));
2607        assert_snippet_anchors(Target::Codex, &body);
2608        assert_snippet_token_budget(&body);
2609    }
2610
2611    #[test]
2612    fn snippet_continue_has_anchors_and_under_budget() {
2613        let (path, body) = emit_snippet_isolated(Target::Continue);
2614        assert!(path.ends_with("system-prompt-continue.md"));
2615        assert_snippet_anchors(Target::Continue, &body);
2616        assert_snippet_token_budget(&body);
2617    }
2618
2619    #[test]
2620    fn snippet_every_target_emits_under_budget() {
2621        // The full per-target sweep — every harness gets its own
2622        // snippet file, each within budget and carrying every anchor.
2623        //
2624        // Hold `snippet_env_lock` across the entire iteration. Without
2625        // it, the per-target tests below (snippet_claude_code_*,
2626        // snippet_cursor_*, etc.) can grab the lock between iterations
2627        // and clobber `AI_MEMORY_SYSTEM_PROMPT_DIR`, racing with the
2628        // emit + readback this loop performs. Observed as flaky
2629        // assertion at `assert!(body.contains(harness))` under
2630        // `cargo test` default `--test-threads=N` on Linux + macOS CI.
2631        //
2632        // v0.7.0 pre-cert audit: confirmed each rendered snippet fits
2633        // ~173–183 chars/4 tokens against the 200-token ceiling
2634        // enforced by `assert_snippet_token_budget`. Headroom is
2635        // intentionally tight — when adding a new shared anchor or a
2636        // longer per-harness hint, recompute the worst-case render
2637        // length first (the longest body is currently `claude-code`
2638        // because of the ToolSearch hint). A budget overshoot here is
2639        // load-bearing: the snippet ships verbatim into every
2640        // downstream harness's system prompt, so a regression
2641        // silently eats their context window. If you trip this
2642        // assertion, trim the offending bullet — do NOT bump the
2643        // budget without a spec change.
2644        // Uses the dir-parameterised helper so each target gets its own
2645        // tempdir without touching the process-global
2646        // `AI_MEMORY_SYSTEM_PROMPT_DIR` env var. Eliminates the
2647        // `snippet_env_lock` cross-test race that flaked this assertion
2648        // under `--test-threads > 1` (§16 12-gate sweep observation,
2649        // 2026-05-13, v0.7.1-fold).
2650        for target in [
2651            Target::ClaudeCode,
2652            Target::Openclaw,
2653            Target::Cursor,
2654            Target::Cline,
2655            Target::Continue,
2656            Target::Windsurf,
2657            Target::ClaudeDesktop,
2658            Target::Codex,
2659            Target::GrokCli,
2660            Target::GeminiCli,
2661        ] {
2662            let tmp = tempfile::tempdir().expect("tempdir");
2663            let tmp_path = tmp.path().to_path_buf();
2664            let snippet_path =
2665                write_system_prompt_snippet_to(target, &tmp_path).expect("snippet write");
2666            let body = fs::read_to_string(&snippet_path).expect("read snippet");
2667            std::mem::forget(tmp);
2668            assert!(
2669                snippet_path.exists(),
2670                "snippet file for {} not created",
2671                target.name(),
2672            );
2673            assert_snippet_anchors(target, &body);
2674            assert_snippet_token_budget(&body);
2675        }
2676    }
2677
2678    #[test]
2679    fn snippet_emitted_during_install_apply_via_env_override() {
2680        // End-to-end: invoking `run()` in apply mode for a target
2681        // produces the snippet file at the env-overridden path and
2682        // logs the "wrote system-prompt snippet" line to stderr.
2683        let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
2684        let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
2685        // SAFETY: mutation serialised by snippet_env_lock for this
2686        // test's duration.
2687        unsafe {
2688            std::env::set_var("AI_MEMORY_SYSTEM_PROMPT_DIR", snippet_dir.path());
2689        }
2690
2691        let mut env = TestEnv::fresh();
2692        let cfg = config_path(&env, "settings.json");
2693        seed(&cfg, "{}\n");
2694        run(
2695            &args_for_apply(Target::ClaudeCode, cfg.clone()),
2696            &mut env.output(),
2697        )
2698        .unwrap();
2699
2700        let stderr = env.stderr_str();
2701        assert!(
2702            stderr.contains("system-prompt snippet"),
2703            "stderr should announce snippet write; got:\n{stderr}",
2704        );
2705        assert!(
2706            stderr.contains("claude-code"),
2707            "stderr should mention the harness name; got:\n{stderr}",
2708        );
2709
2710        let snippet = snippet_dir.path().join("system-prompt-claude-code.md");
2711        assert!(
2712            snippet.exists(),
2713            "snippet should exist at {}",
2714            snippet.display(),
2715        );
2716        let body = fs::read_to_string(&snippet).unwrap();
2717        assert_snippet_anchors(Target::ClaudeCode, &body);
2718
2719        unsafe {
2720            std::env::remove_var("AI_MEMORY_SYSTEM_PROMPT_DIR");
2721        }
2722        drop(snippet_dir);
2723    }
2724
2725    #[test]
2726    fn snippet_not_emitted_on_uninstall() {
2727        // Uninstall path skips the snippet write — there's nothing
2728        // to teach the agent once the server is gone.
2729        let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
2730        let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
2731        unsafe {
2732            std::env::set_var("AI_MEMORY_SYSTEM_PROMPT_DIR", snippet_dir.path());
2733        }
2734
2735        let mut env = TestEnv::fresh();
2736        let cfg = config_path(&env, "settings.json");
2737        // Seed an existing managed block so uninstall has work to do.
2738        seed(&cfg, "{}\n");
2739        run(
2740            &args_for_apply(Target::ClaudeCode, cfg.clone()),
2741            &mut env.output(),
2742        )
2743        .unwrap();
2744        // Reset stderr capture so we only see the uninstall-phase output.
2745        env.stderr.clear();
2746
2747        // Now uninstall.
2748        run(
2749            &args_for_uninstall_apply(Target::ClaudeCode, cfg.clone()),
2750            &mut env.output(),
2751        )
2752        .unwrap();
2753        let stderr = env.stderr_str();
2754        assert!(
2755            !stderr.contains("system-prompt snippet"),
2756            "uninstall must not announce a snippet write; got:\n{stderr}",
2757        );
2758
2759        unsafe {
2760            std::env::remove_var("AI_MEMORY_SYSTEM_PROMPT_DIR");
2761        }
2762        drop(snippet_dir);
2763    }
2764
2765    // ------------------------------------------------------------------
2766    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
2767    // ------------------------------------------------------------------
2768
2769    fn args_no_config(target: Target) -> InstallArgs {
2770        // TargetArgs with config=None forces the default-path discovery
2771        // branch in resolve_config_path.
2772        let t = TargetArgs {
2773            config: None,
2774            apply: false,
2775            dry_run: false,
2776            uninstall: false,
2777            binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
2778            hook: None,
2779            force: false,
2780        };
2781        let target_cmd = match target {
2782            Target::ClaudeCode => TargetCmd::ClaudeCode(t),
2783            Target::Openclaw => TargetCmd::Openclaw(t),
2784            Target::Cursor => TargetCmd::Cursor(t),
2785            Target::Cline => TargetCmd::Cline(t),
2786            Target::Continue => TargetCmd::Continue(t),
2787            Target::Windsurf => TargetCmd::Windsurf(t),
2788            Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
2789            Target::Codex => TargetCmd::Codex(t),
2790            Target::GrokCli => TargetCmd::GrokCli(t),
2791            Target::GeminiCli => TargetCmd::GeminiCli(t),
2792        };
2793        InstallArgs { target: target_cmd }
2794    }
2795
2796    #[test]
2797    fn resolve_config_path_openclaw_bails_without_config() {
2798        let r = resolve_config_path(
2799            Target::Openclaw,
2800            &TargetArgs {
2801                config: None,
2802                ..TargetArgs::default()
2803            },
2804        );
2805        let err = r.unwrap_err();
2806        assert!(format!("{err}").contains("openclaw config path"));
2807    }
2808
2809    #[test]
2810    fn resolve_config_path_cline_bails_without_config() {
2811        let r = resolve_config_path(
2812            Target::Cline,
2813            &TargetArgs {
2814                config: None,
2815                ..TargetArgs::default()
2816            },
2817        );
2818        let err = r.unwrap_err();
2819        assert!(format!("{err}").contains("cline config path"));
2820    }
2821
2822    #[test]
2823    fn resolve_config_path_codex_bails_without_config() {
2824        let r = resolve_config_path(
2825            Target::Codex,
2826            &TargetArgs {
2827                config: None,
2828                ..TargetArgs::default()
2829            },
2830        );
2831        let err = r.unwrap_err();
2832        assert!(format!("{err}").contains("codex config path"));
2833    }
2834
2835    #[test]
2836    fn resolve_config_path_grok_cli_bails_without_config() {
2837        let r = resolve_config_path(
2838            Target::GrokCli,
2839            &TargetArgs {
2840                config: None,
2841                ..TargetArgs::default()
2842            },
2843        );
2844        let err = r.unwrap_err();
2845        assert!(format!("{err}").contains("grok-cli config path"));
2846    }
2847
2848    #[test]
2849    fn resolve_config_path_gemini_cli_bails_without_config() {
2850        let r = resolve_config_path(
2851            Target::GeminiCli,
2852            &TargetArgs {
2853                config: None,
2854                ..TargetArgs::default()
2855            },
2856        );
2857        let err = r.unwrap_err();
2858        assert!(format!("{err}").contains("gemini-cli config path"));
2859    }
2860
2861    #[test]
2862    fn resolve_config_path_claude_code_default_under_home() {
2863        // Drives the `home.join(".claude").join("settings.json")` branch
2864        // (line 499). We don't assert the home directory contents — just
2865        // that the resolution succeeds and ends in `.claude/settings.json`.
2866        let r = resolve_config_path(
2867            Target::ClaudeCode,
2868            &TargetArgs {
2869                config: None,
2870                ..TargetArgs::default()
2871            },
2872        )
2873        .expect("home dir present on test host");
2874        let s = r.to_string_lossy().to_string();
2875        assert!(s.ends_with(".claude/settings.json") || s.ends_with(".claude\\settings.json"));
2876    }
2877
2878    #[test]
2879    fn resolve_config_path_cursor_default_under_home() {
2880        let r = resolve_config_path(
2881            Target::Cursor,
2882            &TargetArgs {
2883                config: None,
2884                ..TargetArgs::default()
2885            },
2886        )
2887        .expect("home dir");
2888        let s = r.to_string_lossy().to_string();
2889        assert!(s.ends_with(".cursor/mcp.json") || s.ends_with(".cursor\\mcp.json"));
2890    }
2891
2892    #[test]
2893    fn resolve_config_path_continue_default_under_home() {
2894        let r = resolve_config_path(
2895            Target::Continue,
2896            &TargetArgs {
2897                config: None,
2898                ..TargetArgs::default()
2899            },
2900        )
2901        .expect("home dir");
2902        let s = r.to_string_lossy().to_string();
2903        assert!(s.ends_with(".continue/config.json") || s.ends_with(".continue\\config.json"));
2904    }
2905
2906    #[test]
2907    fn resolve_config_path_windsurf_default_under_home() {
2908        let r = resolve_config_path(
2909            Target::Windsurf,
2910            &TargetArgs {
2911                config: None,
2912                ..TargetArgs::default()
2913            },
2914        )
2915        .expect("home dir");
2916        let s = r.to_string_lossy().to_string();
2917        assert!(s.ends_with("mcp_config.json"), "got: {s}");
2918    }
2919
2920    #[cfg(target_os = "macos")]
2921    #[test]
2922    fn resolve_config_path_claude_desktop_default_under_macos() {
2923        let r = resolve_config_path(
2924            Target::ClaudeDesktop,
2925            &TargetArgs {
2926                config: None,
2927                ..TargetArgs::default()
2928            },
2929        )
2930        .expect("home dir");
2931        let s = r.to_string_lossy().to_string();
2932        assert!(s.ends_with("claude_desktop_config.json"), "got: {s}");
2933    }
2934
2935    #[test]
2936    fn install_dispatches_through_run_with_default_config_on_unsupported_target() {
2937        // Driving `run()` end-to-end with `args.config=None` for a
2938        // target whose default-path is a `bail!` covers the
2939        // `run` -> `resolve_config_path` error propagation path.
2940        let args = args_no_config(Target::Codex);
2941        let mut env = TestEnv::fresh();
2942        let err = run(&args, &mut env.output()).unwrap_err();
2943        assert!(format!("{err}").contains("codex config path"));
2944    }
2945
2946    #[test]
2947    fn read_config_or_empty_handles_whitespace_only_file() {
2948        // Drives the `text.trim().is_empty()` branch (line 638).
2949        let tmp = tempfile::tempdir().unwrap();
2950        let p = tmp.path().join("blank.json");
2951        std::fs::write(&p, "   \n  \n").unwrap();
2952        let (text, val) = read_config_or_empty(&p).unwrap();
2953        assert!(!text.is_empty()); // we returned the original text
2954        assert!(val.is_object() && val.as_object().unwrap().is_empty());
2955    }
2956
2957    #[test]
2958    fn read_config_or_empty_handles_missing_file() {
2959        // Drives the `!path.exists()` branch (line 633).
2960        let tmp = tempfile::tempdir().unwrap();
2961        let p = tmp.path().join("nonexistent.json");
2962        let (text, val) = read_config_or_empty(&p).unwrap();
2963        assert!(text.is_empty());
2964        assert!(val.is_object() && val.as_object().unwrap().is_empty());
2965    }
2966
2967    #[test]
2968    fn install_apply_rejects_non_object_json_root() {
2969        // ensure_object refuses array-shaped roots (line 743).
2970        let mut env = TestEnv::fresh();
2971        let path = config_path(&env, "array.json");
2972        seed(&path, "[]");
2973        let err = run(
2974            &args_for_apply(Target::Cursor, path.clone()),
2975            &mut env.output(),
2976        )
2977        .unwrap_err();
2978        assert!(format!("{err}").contains("not a JSON object"));
2979    }
2980
2981    #[test]
2982    fn install_dry_run_emits_unified_diff_with_minus_and_plus_lines() {
2983        // Drives every variant of `emit_diff`: equal line (line 981),
2984        // changed line (982-984), removed-only (986), added-only (987).
2985        let mut env = TestEnv::fresh();
2986        let path = config_path(&env, "diff-source.json");
2987        // Use a baseline with multiple keys so the install diff has
2988        // identical lines, changed lines, and added lines.
2989        seed(&path, "{\n  \"theme\": \"dark\"\n}\n");
2990        run(&args_for(Target::Cursor, path.clone()), &mut env.output()).unwrap();
2991        let stdout = env.stdout_str();
2992        // The diff format emits ` ` (context) / `+` (added) / `-` (changed)
2993        // line prefixes for the cursor block's new lines.
2994        assert!(
2995            stdout.lines().any(|l| l.starts_with('+')),
2996            "expected at least one added line, got:\n{stdout}"
2997        );
2998    }
2999
3000    #[test]
3001    fn remove_mcp_standard_no_op_on_clean_config() {
3002        // Drives the path through `remove_mcp_standard` where there is
3003        // no `mcpServers` key at all (line 738 not-entered branch).
3004        let mut env = TestEnv::fresh();
3005        let path = config_path(&env, "clean.json");
3006        seed(&path, "{}\n");
3007        run(
3008            &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
3009            &mut env.output(),
3010        )
3011        .unwrap();
3012        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3013        assert!(parsed.as_object().unwrap().is_empty());
3014    }
3015
3016    #[test]
3017    fn remove_claude_code_no_op_when_user_has_empty_hooks() {
3018        // Pre-install we set `hooks: {}` in the config; install adds
3019        // SessionStart, uninstall must leave nothing behind. Drives the
3020        // empty-hooks branch (lines 796-797, 800-804).
3021        let mut env = TestEnv::fresh();
3022        let path = config_path(&env, "settings.json");
3023        seed(&path, r#"{"hooks":{}}"#);
3024        // Install then uninstall.
3025        run(
3026            &args_for_apply(Target::ClaudeCode, path.clone()),
3027            &mut env.output(),
3028        )
3029        .unwrap();
3030        run(
3031            &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
3032            &mut env.output(),
3033        )
3034        .unwrap();
3035        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3036        // We expected the user's empty `hooks` to either survive or be
3037        // pruned — `remove_claude_code` removes empty hooks objects.
3038        assert!(parsed.get("hooks").is_none());
3039    }
3040
3041    #[test]
3042    fn install_run_creates_missing_parent_directory() {
3043        // Drives the `create_dir_all` branch (lines 311-316) by passing
3044        // a config path whose parent does not exist.
3045        let mut env = TestEnv::fresh();
3046        let dir = env.db_path.parent().unwrap().to_path_buf();
3047        let nested = dir.join("not").join("yet").join("here").join("mcp.json");
3048        assert!(!nested.parent().unwrap().exists());
3049        run(
3050            &args_for_apply(Target::Cursor, nested.clone()),
3051            &mut env.output(),
3052        )
3053        .unwrap();
3054        assert!(nested.exists());
3055    }
3056
3057    #[test]
3058    fn resolve_binary_falls_through_when_no_override() {
3059        // Drives the resolve_binary branch without a `--binary` override.
3060        // Either `which_ai_memory` returns Some, or the function falls
3061        // back to current_exe(). Both branches exit through this fn.
3062        let s = resolve_binary(None);
3063        assert!(!s.is_empty(), "resolved binary path should be non-empty");
3064    }
3065
3066    #[test]
3067    fn which_ai_memory_returns_some_when_path_has_binary() {
3068        // Drives the success branch of `which_ai_memory` (line 614).
3069        // We construct a tempdir, drop a synthetic "ai-memory" binary
3070        // inside, then temporarily set $PATH to point at it.
3071        use std::sync::Mutex;
3072        static PATH_LOCK: Mutex<()> = Mutex::new(());
3073        let _g = PATH_LOCK.lock().unwrap();
3074
3075        let tmp = tempfile::tempdir().unwrap();
3076        let bin = tmp.path().join("ai-memory");
3077        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
3078        // Chmod 0755 so `is_file()` returns true.
3079        #[cfg(unix)]
3080        {
3081            use std::os::unix::fs::PermissionsExt;
3082            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
3083        }
3084        let orig = std::env::var_os("PATH");
3085        // SAFETY: serialized via PATH_LOCK; restored at scope end.
3086        unsafe {
3087            std::env::set_var("PATH", tmp.path());
3088        }
3089        let found = which_ai_memory();
3090        // Restore PATH.
3091        unsafe {
3092            if let Some(p) = orig {
3093                std::env::set_var("PATH", p);
3094            } else {
3095                std::env::remove_var("PATH");
3096            }
3097        }
3098        assert!(found.is_some(), "expected to find ai-memory under $PATH");
3099    }
3100
3101    // ------------------------------------------------------------------
3102    // v0.7.0 policy-engine item 2 — PreToolUse hook installer
3103    // ------------------------------------------------------------------
3104    //
3105    // Tier B coverage (≥95%) target on every new code path in this
3106    // module. Each branch below pins one behaviour of the installer's
3107    // PreToolUse mode:
3108    //
3109    // - `pretool_entry_shape_matches_documented_form`: the JSON we
3110    //   emit is the same shape Claude Code consumes (matcher + hooks
3111    //   array, type=mcp_tool, tool=memory_check_agent_action).
3112    // - `apply_then_remove_round_trips`: install + uninstall returns
3113    //   the config to its original shape, modulo whitespace.
3114    // - `apply_appends_to_existing_pretooluse`: an operator-authored
3115    //   entry is preserved when we install on top.
3116    // - `apply_is_idempotent_when_rerun`: re-installing under the
3117    //   managed marker leaves exactly one managed entry.
3118    // - `apply_refuses_conflict_without_force`: a non-managed entry
3119    //   that ALSO names our MCP tool with a different matcher
3120    //   triggers the refusal path.
3121    // - `apply_overwrites_conflict_with_force`: same with --force
3122    //   replaces the conflicting entry.
3123    // - `apply_rejects_hook_flag_on_non_claude_code`: the cross-target
3124    //   gate in `run` errors loudly when --hook is set on, say,
3125    //   cursor.
3126
3127    fn args_for_pretool_apply(config: PathBuf) -> InstallArgs {
3128        let t = TargetArgs {
3129            config: Some(config),
3130            apply: true,
3131            dry_run: false,
3132            uninstall: false,
3133            binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
3134            hook: Some(HookKind::Pretool),
3135            force: false,
3136        };
3137        InstallArgs {
3138            target: TargetCmd::ClaudeCode(t),
3139        }
3140    }
3141
3142    fn args_for_pretool_dry_run(config: PathBuf) -> InstallArgs {
3143        let mut a = args_for_pretool_apply(config);
3144        match &mut a.target {
3145            TargetCmd::ClaudeCode(t) => t.apply = false,
3146            _ => unreachable!(),
3147        }
3148        a
3149    }
3150
3151    fn args_for_pretool_uninstall(config: PathBuf) -> InstallArgs {
3152        let mut a = args_for_pretool_apply(config);
3153        match &mut a.target {
3154            TargetCmd::ClaudeCode(t) => {
3155                t.uninstall = true;
3156            }
3157            _ => unreachable!(),
3158        }
3159        a
3160    }
3161
3162    fn args_for_pretool_apply_force(config: PathBuf) -> InstallArgs {
3163        let mut a = args_for_pretool_apply(config);
3164        match &mut a.target {
3165            TargetCmd::ClaudeCode(t) => t.force = true,
3166            _ => unreachable!(),
3167        }
3168        a
3169    }
3170
3171    #[test]
3172    fn pretool_entry_shape_matches_documented_form() {
3173        let v = claude_code_pretool_entry();
3174        assert_eq!(v["matcher"], "*");
3175        assert_eq!(v["hooks"][0]["type"], "mcp_tool");
3176        assert_eq!(v["hooks"][0]["tool"], PRETOOL_HOOK_TOOL_NAME);
3177        assert_eq!(v["hooks"][0]["tool"], "memory_check_agent_action");
3178        assert!(v[MARKER_START_KEY].is_string());
3179        assert!(v[MARKER_END_KEY].is_string());
3180    }
3181
3182    #[test]
3183    fn pretool_conflict_detector_recognises_same_tool() {
3184        let v = serde_json::json!({
3185            "matcher": "Bash",
3186            "hooks": [
3187                { "type": "mcp_tool", "tool": "memory_check_agent_action" }
3188            ]
3189        });
3190        assert_eq!(pretool_conflict_matcher(&v).as_deref(), Some("Bash"));
3191    }
3192
3193    #[test]
3194    fn pretool_conflict_detector_ignores_managed_blocks() {
3195        let v = claude_code_pretool_entry();
3196        assert!(pretool_conflict_matcher(&v).is_none());
3197    }
3198
3199    #[test]
3200    fn pretool_conflict_detector_ignores_other_tools() {
3201        let v = serde_json::json!({
3202            "matcher": "*",
3203            "hooks": [
3204                { "type": "command", "command": "echo hi" }
3205            ]
3206        });
3207        assert!(pretool_conflict_matcher(&v).is_none());
3208    }
3209
3210    #[test]
3211    fn pretool_install_apply_writes_documented_entry() {
3212        let mut env = TestEnv::fresh();
3213        let path = config_path(&env, "settings.json");
3214        seed(&path, "{}\n");
3215        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3216        let written = fs::read_to_string(&path).unwrap();
3217        let parsed: Value = serde_json::from_str(&written).unwrap();
3218        let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3219        // Exactly one managed entry.
3220        assert_eq!(arr.len(), 1);
3221        let entry = &arr[0];
3222        assert_eq!(entry["matcher"], "*");
3223        assert_eq!(entry["hooks"][0]["type"], "mcp_tool");
3224        assert_eq!(entry["hooks"][0]["tool"], "memory_check_agent_action");
3225        assert!(env.stdout_str().contains("installed PreToolUse hook ->"));
3226    }
3227
3228    #[test]
3229    fn pretool_install_preserves_existing_keys() {
3230        let mut env = TestEnv::fresh();
3231        let path = config_path(&env, "settings.json");
3232        seed(
3233            &path,
3234            r#"{"permissions":{"allow":["npm:*"]},"env":{"FOO":"bar"}}"#,
3235        );
3236        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3237        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3238        assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
3239        assert_eq!(parsed["env"]["FOO"], "bar");
3240        assert!(parsed["hooks"]["PreToolUse"].is_array());
3241    }
3242
3243    #[test]
3244    fn pretool_install_appends_to_existing_pretooluse_array() {
3245        let mut env = TestEnv::fresh();
3246        let path = config_path(&env, "settings.json");
3247        // Operator already has one PreToolUse entry (an unrelated command hook).
3248        seed(
3249            &path,
3250            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}"#,
3251        );
3252        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3253        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3254        let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3255        assert_eq!(arr.len(), 2, "operator entry + our managed entry");
3256        // First entry is the operator's; second is ours.
3257        assert_eq!(arr[0]["matcher"], "Bash");
3258        assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
3259        assert_eq!(arr[1]["matcher"], "*");
3260        assert_eq!(arr[1]["hooks"][0]["tool"], "memory_check_agent_action");
3261    }
3262
3263    #[test]
3264    fn pretool_install_is_idempotent() {
3265        let mut env = TestEnv::fresh();
3266        let path = config_path(&env, "settings.json");
3267        seed(&path, "{}\n");
3268        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3269        let first = fs::read_to_string(&path).unwrap();
3270        env.stdout.clear();
3271        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3272        let second = fs::read_to_string(&path).unwrap();
3273        assert_eq!(first, second);
3274        assert!(env.stdout_str().contains("no-op"));
3275    }
3276
3277    #[test]
3278    fn pretool_install_refuses_overwrite_without_force() {
3279        let mut env = TestEnv::fresh();
3280        let path = config_path(&env, "settings.json");
3281        // Pre-existing PreToolUse entry that ALSO names memory_check_agent_action
3282        // but with a non-`*` matcher — i.e. the operator scoped the hook
3283        // intentionally. Clobbering would silently change their policy.
3284        seed(
3285            &path,
3286            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","tool":"memory_check_agent_action"}]}]}}"#,
3287        );
3288        let err = run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap_err();
3289        let msg = format!("{err}");
3290        assert!(
3291            msg.contains("--force"),
3292            "error should mention --force: {msg}"
3293        );
3294        // File must NOT have been modified.
3295        let still = serde_json::from_str::<Value>(&fs::read_to_string(&path).unwrap()).unwrap();
3296        let arr = still["hooks"]["PreToolUse"].as_array().unwrap();
3297        assert_eq!(arr.len(), 1, "no new entry appended on refusal");
3298        assert_eq!(arr[0]["matcher"], "Bash");
3299        // Stderr should explain the conflict.
3300        assert!(
3301            env.stderr_str().contains("existing PreToolUse entry"),
3302            "stderr should contain conflict warning: {}",
3303            env.stderr_str()
3304        );
3305    }
3306
3307    #[test]
3308    fn pretool_install_overwrites_conflict_with_force() {
3309        let mut env = TestEnv::fresh();
3310        let path = config_path(&env, "settings.json");
3311        seed(
3312            &path,
3313            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","tool":"memory_check_agent_action"}]}]}}"#,
3314        );
3315        run(
3316            &args_for_pretool_apply_force(path.clone()),
3317            &mut env.output(),
3318        )
3319        .unwrap();
3320        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3321        let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3322        // Conflicting entry replaced with ours (matcher="*").
3323        assert_eq!(arr.len(), 1);
3324        assert_eq!(arr[0]["matcher"], "*");
3325        assert_eq!(arr[0]["hooks"][0]["tool"], "memory_check_agent_action");
3326        assert!(arr[0][MARKER_START_KEY].is_string());
3327    }
3328
3329    #[test]
3330    fn pretool_uninstall_removes_managed_block_only() {
3331        let mut env = TestEnv::fresh();
3332        let path = config_path(&env, "settings.json");
3333        seed(
3334            &path,
3335            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]},"theme":"dark"}"#,
3336        );
3337        // Install on top.
3338        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3339        // Uninstall.
3340        run(&args_for_pretool_uninstall(path.clone()), &mut env.output()).unwrap();
3341        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3342        assert_eq!(parsed["theme"], "dark");
3343        // The operator's PreToolUse entry survives; ours is gone.
3344        let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3345        assert_eq!(arr.len(), 1);
3346        assert_eq!(arr[0]["matcher"], "Bash");
3347        assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
3348    }
3349
3350    #[test]
3351    fn pretool_uninstall_clean_config_is_safe_noop() {
3352        let mut env = TestEnv::fresh();
3353        let path = config_path(&env, "settings.json");
3354        seed(&path, "{}\n");
3355        // Uninstall against a config that never had the hook.
3356        run(&args_for_pretool_uninstall(path.clone()), &mut env.output()).unwrap();
3357        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3358        assert!(parsed.as_object().unwrap().is_empty());
3359    }
3360
3361    #[test]
3362    fn pretool_dry_run_does_not_write() {
3363        let mut env = TestEnv::fresh();
3364        let path = config_path(&env, "settings.json");
3365        seed(&path, "{\n}\n");
3366        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
3367        run(&args_for_pretool_dry_run(path.clone()), &mut env.output()).unwrap();
3368        let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
3369        assert_eq!(mtime_before, mtime_after, "dry-run must not write");
3370        let stdout = env.stdout_str();
3371        assert!(stdout.contains("dry-run"));
3372        assert!(stdout.contains("PreToolUse"));
3373        assert!(stdout.contains("memory_check_agent_action"));
3374    }
3375
3376    #[test]
3377    fn pretool_install_rejects_hook_flag_on_non_claude_code() {
3378        let mut env = TestEnv::fresh();
3379        let path = config_path(&env, "mcp.json");
3380        seed(&path, "{}\n");
3381        let mut a = args_for_pretool_apply(path.clone());
3382        // Wrap a Cursor target around the same TargetArgs.
3383        let t_args = match a.target {
3384            TargetCmd::ClaudeCode(t) => t,
3385            _ => unreachable!(),
3386        };
3387        a.target = TargetCmd::Cursor(t_args);
3388        let err = run(&a, &mut env.output()).unwrap_err();
3389        assert!(
3390            format!("{err}").contains("only supported for `claude-code`"),
3391            "got: {err}"
3392        );
3393    }
3394
3395    #[test]
3396    fn pretool_install_does_not_emit_system_prompt_snippet() {
3397        // Hook-mode installs are the load-bearing mechanism; no snippet
3398        // is required. Pin that behaviour so we don't accidentally
3399        // re-add the snippet emission and clutter operator stderr.
3400        let mut env = TestEnv::fresh();
3401        let path = config_path(&env, "settings.json");
3402        seed(&path, "{}\n");
3403        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
3404        assert!(
3405            !env.stderr_str().contains("system-prompt snippet"),
3406            "stderr should NOT mention the system-prompt snippet under --hook pretool: {}",
3407            env.stderr_str()
3408        );
3409    }
3410}