// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! `ai-memory install <agent>` — wire `ai-memory boot` and the
//! `ai-memory-mcp` server into AI agents' config files (issue #487 PR-2/3).
//!
//! Each target writes a precisely-marked **managed block** so re-running
//! `install` is a no-op and `--uninstall` removes the block surgically
//! without disturbing user-added keys.
//!
//! ## Behavior summary
//!
//! - **Default `--dry-run`**: prints the diff (pretty before/after JSON,
//!   plus a unified diff over the JSON serialization) and writes nothing.
//! - **`--apply`**: writes the modified config; backs up the original to
//!   `<config>.bak.<timestamp>` first.
//! - **`--uninstall`**: removes the managed block, leaving any unrelated
//!   keys the user added intact.
//!
//! ## Idempotent marker
//!
//! Every managed block has a sentinel key:
//!
//! ```text
//! "// ai-memory:managed-block:start": "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487"
//! ```
//!
//! plus an `// ai-memory:managed-block:end` sibling. When present, the
//! installer recognises the existing block and:
//!
//! - On install: replaces it with the freshly-rendered block (so config
//!   bumps in future ai-memory releases land cleanly).
//! - On uninstall: removes both sentinel keys and any siblings the
//!   installer originally inserted.
//!
//! ## Targets
//!
//! See [`Target`] for the full list. Each target's specifics
//! (config path discovery, JSON shape) live in `apply_target_*` helpers.

use crate::cli::CliOutput;
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, Subcommand, ValueEnum};
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};

// ── #1558 batch 6 — repeated `.expect` labels on just-built JSON nodes ──
const EXPECT_JUST_INSERTED_OBJECT: &str = "just-inserted object";
const EXPECT_JUST_INSERTED_ARRAY: &str = "just-inserted array";

/// Sentinel key that marks the start of a managed block. Used by both
/// install (to recognise an existing block) and uninstall (to find the
/// block to remove).
const MARKER_START_KEY: &str = "// ai-memory:managed-block:start";
const MARKER_END_KEY: &str = "// ai-memory:managed-block:end";

/// Marker payload — the human-readable note bundled with the start key.
/// Updating this string is a no-op on installs already in the wild
/// because the recognition predicate keys off `MARKER_START_KEY` only.
const MARKER_PAYLOAD: &str = "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487";

/// Sibling-key list each target stamps inside the managed block. We track
/// this list so uninstall removes exactly the keys we wrote and leaves
/// any user-added siblings alone (defence-in-depth against a user
/// editing `// ai-memory:managed-block:end` out of the file).
const MANAGED_KEYS_PROPERTY: &str = "// ai-memory:managed-keys";

// --- Harness config key names (#1558) --------------------------------------

/// Agent-target key for the Claude Code harness (`Target::ClaudeCode`
/// display name + managed-keys metadata).
const AGENT_TARGET_CLAUDE_CODE: &str = "claude-code";

/// File name of Claude Desktop's MCP config under its OS-specific
/// application-support directory. Only referenced on the macOS /
/// Windows auto-discovery arms (Linux requires `--config`).
#[cfg(any(target_os = "macos", target_os = "windows"))]
const CLAUDE_DESKTOP_CONFIG_FILENAME: &str = "claude_desktop_config.json";

/// MCP-spec camelCase servers key used by every JSON-config harness.
/// `pub(crate)` because the `config migrate --also-clean-claude-json`
/// rewriter in `src/cli/commands/config.rs` strips `env` blocks from
/// entries under the same key.
pub(crate) const KEY_MCP_SERVERS: &str = "mcpServers";

/// Continue's wrapper key — its MCP block lives under
/// `experimental.modelContextProtocolServers`.
const KEY_EXPERIMENTAL: &str = "experimental";

/// Continue's MCP server-list key under [`KEY_EXPERIMENTAL`].
const KEY_MODEL_CONTEXT_PROTOCOL_SERVERS: &str = "modelContextProtocolServers";

/// Claude Code hook-event key for the boot (memory-rehydration) hook.
const HOOK_EVENT_SESSION_START: &str = "SessionStart";

/// Claude Code hook-event key for the policy-engine tool-call hook.
const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse";

/// Args for `ai-memory install`.
#[derive(Args, Debug)]
pub struct InstallArgs {
    /// The agent target to install into.
    #[command(subcommand)]
    pub target: TargetCmd,
}

/// Per-target subcommand. Each variant carries the same shared option
/// set (`--apply`, `--uninstall`, `--config <path>`) — clap-derive
/// renders one subcommand per target so users get tab-completion on
/// the agent name and per-target `--help`.
#[derive(Subcommand, Debug)]
pub enum TargetCmd {
    /// Claude Code SessionStart hook. Writes `~/.claude/settings.json`.
    ClaudeCode(TargetArgs),
    /// OpenClaw MCP servers. Path documented at
    /// <https://docs.openclaw.ai/cli/mcp>; pass `--config <path>` if your
    /// install puts it elsewhere.
    Openclaw(TargetArgs),
    /// Cursor MCP servers. Writes `~/.cursor/mcp.json`.
    Cursor(TargetArgs),
    /// Cline MCP settings. Path varies by Cline version; pass
    /// `--config <path>` to override.
    Cline(TargetArgs),
    /// Continue MCP servers. Writes `~/.continue/config.json`.
    Continue(TargetArgs),
    /// Windsurf (Codeium) MCP servers. Writes
    /// `~/.codeium/windsurf/mcp_config.json`.
    Windsurf(TargetArgs),

    // ---- v0.6.4-010 — cross-harness install profiles ----
    /// Claude Desktop MCP servers (writes the macOS/Windows config;
    /// pass `--config <path>` on Linux). Args include
    /// `--profile core` (the v0.6.4 default).
    ClaudeDesktop(TargetArgs),
    /// OpenAI Codex CLI MCP servers. Pass `--config <path>` since the
    /// canonical Codex config path varies by Codex version. Args
    /// include `--profile core`.
    Codex(TargetArgs),
    /// xAI Grok CLI MCP servers. Pass `--config <path>` since the
    /// Grok CLI config path varies by version. Args include
    /// `--profile core`.
    GrokCli(TargetArgs),
    /// Google Gemini CLI MCP servers. Pass `--config <path>` since the
    /// Gemini CLI config path varies by version. Args include
    /// `--profile core`.
    GeminiCli(TargetArgs),
}

/// Shared per-target args. Constructed identically for every target so
/// the dispatch table can pull them out generically.
#[derive(Args, Debug, Default, Clone)]
pub struct TargetArgs {
    /// Override the default config path (the home-dir resolution).
    /// REQUIRED for tests so they never touch `~/.claude/settings.json`
    /// on the host machine.
    #[arg(long, value_name = "PATH")]
    pub config: Option<PathBuf>,

    /// Actually write the file. Without `--apply`, the installer
    /// runs in dry-run mode (the default) and prints what would change.
    /// Mutually exclusive with `--dry-run`. Combine with `--uninstall`
    /// to actually remove the managed block.
    #[arg(long, default_value_t = false, conflicts_with = "dry_run")]
    pub apply: bool,

    /// Force dry-run mode. This is the default, so the flag is mostly
    /// useful in scripts that want to make the no-write contract
    /// explicit. Mutually exclusive with `--apply`.
    #[arg(long, default_value_t = false)]
    pub dry_run: bool,

    /// Remove the managed block instead of installing it. Default mode
    /// is dry-run; pair with `--apply` to actually delete the block.
    #[arg(long, default_value_t = false)]
    pub uninstall: bool,

    /// Override the resolved `ai-memory` binary path written into the
    /// generated config's `command` field. By default the installer
    /// uses the binary's own `current_exe()` if `ai-memory` is not on
    /// `$PATH`, otherwise the bare string `ai-memory`.
    #[arg(long, value_name = "PATH")]
    pub binary: Option<PathBuf>,

    /// Install a harness-side hook variant in place of the default
    /// managed block. Supported value: `pretool` — installs Claude
    /// Code's `PreToolUse` hook routing every Bash / Edit / Write tool
    /// call through `memory_check_agent_action` (v0.7.0 policy-engine
    /// item 2, issue #691).
    ///
    /// Only meaningful for `claude-code`; other targets reject this
    /// flag with a clear error.
    #[arg(long, value_name = "KIND")]
    pub hook: Option<HookKind>,

    /// When installing a hook (`--hook`), overwrite any pre-existing
    /// entry whose `matcher` / `tool` shape conflicts with ours.
    /// Without `--force`, the installer refuses to clobber a
    /// differing-but-similar config and points the operator at this
    /// flag in the stderr warning.
    #[arg(long, default_value_t = false)]
    pub force: bool,
}

/// Harness-side hook variant selectable via `--hook <kind>`. Today
/// only `Pretool` is wired; future variants (e.g. `PostToolUse`,
/// `Stop`) plug into the same dispatch shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HookKind {
    /// Claude Code's `PreToolUse` hook. Routes every Bash / Edit /
    /// Write tool call through `memory_check_agent_action` so the
    /// substrate-rules engine can refuse or warn before the action
    /// dispatches.
    Pretool,
}

/// Concrete target enum used internally. `TargetCmd` carries clap
/// metadata; `Target` is a stable tag for the dispatch table and
/// derives `ValueEnum` for completeness.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Target {
    ClaudeCode,
    Openclaw,
    Cursor,
    Cline,
    Continue,
    Windsurf,
    // v0.6.4-010 — additional MCP harnesses.
    ClaudeDesktop,
    Codex,
    GrokCli,
    GeminiCli,
}

impl Target {
    /// Display name used in stdout and managed-keys metadata.
    fn name(self) -> &'static str {
        match self {
            Self::ClaudeCode => AGENT_TARGET_CLAUDE_CODE,
            Self::Openclaw => "openclaw",
            Self::Cursor => "cursor",
            Self::Cline => "cline",
            Self::Continue => "continue",
            Self::Windsurf => "windsurf",
            Self::ClaudeDesktop => "claude-desktop",
            Self::Codex => "codex",
            Self::GrokCli => "grok-cli",
            Self::GeminiCli => "gemini-cli",
        }
    }
}

impl TargetCmd {
    fn target(&self) -> Target {
        match self {
            Self::ClaudeCode(_) => Target::ClaudeCode,
            Self::Openclaw(_) => Target::Openclaw,
            Self::Cursor(_) => Target::Cursor,
            Self::Cline(_) => Target::Cline,
            Self::Continue(_) => Target::Continue,
            Self::Windsurf(_) => Target::Windsurf,
            Self::ClaudeDesktop(_) => Target::ClaudeDesktop,
            Self::Codex(_) => Target::Codex,
            Self::GrokCli(_) => Target::GrokCli,
            Self::GeminiCli(_) => Target::GeminiCli,
        }
    }

    fn args(&self) -> &TargetArgs {
        match self {
            Self::ClaudeCode(a)
            | Self::Openclaw(a)
            | Self::Cursor(a)
            | Self::Cline(a)
            | Self::Continue(a)
            | Self::Windsurf(a)
            | Self::ClaudeDesktop(a)
            | Self::Codex(a)
            | Self::GrokCli(a)
            | Self::GeminiCli(a) => a,
        }
    }
}

/// `ai-memory install <agent>` entry point.
///
/// # Errors
///
/// Returns an error when the existing config is not valid JSON, when the
/// resolved config path can't be determined (and `--config` was not
/// passed), or when an `--apply` write fails (permission denied,
/// disk full, etc.).
pub fn run(args: &InstallArgs, out: &mut CliOutput<'_>) -> Result<()> {
    let target = args.target.target();
    let t_args = args.target.args();

    // --hook is meaningful for claude-code only today; reject loudly on
    // other targets so operators don't silently lose the flag.
    if t_args.hook.is_some() && target != Target::ClaudeCode {
        bail!(
            "--hook {kind:?} is only supported for `claude-code` today; \
             other harnesses do not expose a PreToolUse-equivalent hook surface.",
            kind = t_args.hook.unwrap(),
        );
    }

    let config_path = resolve_config_path(target, t_args)?;
    let binary = resolve_binary(t_args.binary.as_deref());

    // Read existing config (if any) and parse. If absent, treat as `{}`.
    // If present and malformed, error out — never overwrite a malformed
    // config (the user might have made a typo we can help them fix).
    let (before_text, before_value) = read_config_or_empty(&config_path)?;

    // v0.7.0 #1378 — config format detected early so the
    // apply/remove paths can use the right MCP-servers key shape
    // (`mcpServers` camelCase for JSON, `mcp_servers` snake_case for
    // TOML per Codex convention) AND so the eventual serializer
    // matches the input format.
    let config_format = ConfigFormat::detect(&config_path);

    // Compute the desired after-state.
    let after_value = if let Some(hook_kind) = t_args.hook {
        if t_args.uninstall {
            remove_hook_block(target, hook_kind, before_value.clone())?
        } else {
            apply_hook_block(
                target,
                hook_kind,
                before_value.clone(),
                &binary,
                t_args.force,
                out,
            )?
        }
    } else if t_args.uninstall {
        remove_managed_block(target, before_value.clone(), config_format)?
    } else {
        apply_managed_block(target, before_value.clone(), &binary, config_format)?
    };

    // v0.7.0 #1378 — pretty-print in the format the input file used.
    // Codex config is TOML at ~/.codex/config.toml; other MCP-standard
    // harnesses use JSON. The format-aware serializer keeps the wire
    // shape canonical to the surface.
    let config_format = ConfigFormat::detect(&config_path);
    let after_text = match config_format {
        ConfigFormat::Json => serde_json::to_string_pretty(&after_value)? + "\n",
        ConfigFormat::Toml => {
            // Round-trip through toml::Value via serde so the TOML
            // emitter can render. The toml crate's `to_string_pretty`
            // emits table-with-named-keys shape.
            let toml_value: toml::Value = toml::Value::try_from(&after_value).map_err(|e| {
                anyhow!("internal error: cannot convert JSON Value into toml::Value ({e})")
            })?;
            toml::to_string_pretty(&toml_value)
                .map_err(|e| anyhow!("internal error: cannot serialize TOML Value: {e}"))?
        }
    };

    // Round-trip check: re-parse what we serialized so we never write
    // bytes we couldn't read back.
    match config_format {
        ConfigFormat::Json => {
            let _: Value = serde_json::from_str(&after_text).context(
                "internal error: serialised config did not round-trip through JSON parser",
            )?;
        }
        ConfigFormat::Toml => {
            let _: toml::Value = toml::from_str(&after_text).context(
                "internal error: serialised config did not round-trip through TOML parser",
            )?;
        }
    }

    let action_label = if t_args.uninstall {
        "uninstall"
    } else {
        "install"
    };

    if before_text.trim() == after_text.trim() {
        writeln!(
            out.stdout,
            "ai-memory install: {target} {action} is a no-op (managed block already in desired state)",
            target = target.name(),
            action = action_label,
        )?;
        return Ok(());
    }

    if !t_args.apply {
        // Dry-run mode (the default). Emit a unified-style diff so the
        // caller can scrutinise the change before opting in to write.
        writeln!(
            out.stdout,
            "ai-memory install: dry-run for {target} {action} at {path}",
            target = target.name(),
            action = action_label,
            path = config_path.display(),
        )?;
        writeln!(out.stdout, "--- before")?;
        writeln!(out.stdout, "+++ after")?;
        emit_diff(out, &before_text, &after_text)?;
        writeln!(
            out.stdout,
            "ai-memory install: re-run with --apply to write the changes"
        )?;
        return Ok(());
    }

    // Apply mode. Backup first, then write.
    let backup_path = if config_path.exists() {
        let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
        let backup = config_path.with_extension(format!(
            "{ext}bak.{ts}",
            ext = match config_path.extension().and_then(|e| e.to_str()) {
                Some(existing) => format!("{existing}."),
                None => String::new(),
            }
        ));
        std::fs::copy(&config_path, &backup).with_context(|| {
            format!(
                "backing up {} to {}",
                config_path.display(),
                backup.display()
            )
        })?;
        Some(backup)
    } else {
        None
    };

    if let Some(parent) = config_path.parent()
        && !parent.as_os_str().is_empty()
    {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("creating parent directory {}", parent.display()))?;
    }

    std::fs::write(&config_path, &after_text)
        .with_context(|| crate::errors::msg::writing(config_path.display()))?;

    writeln!(
        out.stdout,
        "ai-memory install: {action} applied to {path}",
        action = action_label,
        path = config_path.display(),
    )?;

    // v0.7.0 policy-engine item 2: when `--hook pretool` was the trigger,
    // emit the operator-readable confirmation line documented in the
    // installer contract (`installed PreToolUse hook -> <path>`).
    if let Some(hook_kind) = t_args.hook
        && !t_args.uninstall
    {
        match hook_kind {
            HookKind::Pretool => {
                writeln!(
                    out.stdout,
                    "installed PreToolUse hook -> {}",
                    config_path.display(),
                )?;
            }
        }
    }
    if let Some(b) = backup_path {
        writeln!(out.stdout, "ai-memory install: backup at {}", b.display())?;
    }

    // v0.7-D3: emit the install-time system-prompt snippet alongside the
    // managed-block write. Skip on uninstall — there is nothing to teach
    // the agent once the server is gone. Failures here are surfaced as
    // a stderr warning, not a hard error: the install itself succeeded
    // and operators can re-derive the snippet from the docs.
    // For the `--hook pretool` variant the system-prompt snippet is
    // unrelated (the hook is the load-bearing mechanism), so we skip it
    // there too.
    if !t_args.uninstall && t_args.hook.is_none() {
        match write_system_prompt_snippet(target) {
            Ok(snippet_path) => {
                writeln!(
                    out.stderr,
                    "ai-memory install: wrote system-prompt snippet to {}. \
                     Paste into your {} system instructions.",
                    snippet_path.display(),
                    target.name(),
                )?;
            }
            Err(e) => {
                writeln!(
                    out.stderr,
                    "ai-memory install: warning — could not write system-prompt snippet: {e}"
                )?;
            }
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// v0.7-D3 — install-time system-prompt snippet
// ---------------------------------------------------------------------------
//
// Each harness gets a small (≤200 tokens) Markdown snippet operators can
// paste into the harness's system-prompt slot. Content teaches the agent
// the four anchors the v0.7 surface needs: capability discovery
// (`memory_capabilities`), pre-load (`memory_load_family`), R5 hook
// auto-extraction, and signed-link `attest_level`.
//
// Snippet bodies are static — no templating beyond `<harness>` literal
// substitution — so they are safe to embed verbatim in tests as anchor
// strings. The snippet is keyed by `Target` so the wording can mention
// harness-specific UX cues (e.g. claude-code's `ToolSearch`).
//
// The snippet path defaults to
// `<config_dir>/ai-memory/system-prompt-<harness>.md` (Linux:
// `~/.config/ai-memory/...`, macOS:
// `~/Library/Application Support/ai-memory/...`). Tests override the base
// directory via the `AI_MEMORY_SYSTEM_PROMPT_DIR` env var so they never
// touch the operator's real config tree.

/// Generate the system-prompt snippet body for `target`.
///
/// Bodies are crafted to stay ≤200 tokens (≈800 chars) while covering all
/// four required anchors: `memory_capabilities`, `memory_load_family`,
/// the R5 transcript hook, and `attest_level` on signed links.
fn snippet_body(target: Target) -> String {
    let harness = target.name();
    // The narrow, harness-specific bullet sits at the top so the four
    // shared anchors below are identical across variants. This keeps
    // snippet maintenance to one place when the v0.7 surface evolves.
    let harness_hint: &str = match target {
        Target::ClaudeCode => {
            "Claude Code supports deferred-tool registration via ToolSearch \
             — new tools surface mid-session."
        }
        Target::Cursor => {
            "Cursor eager-loads tools; call this in your first turn so its \
             tools are visible."
        }
        Target::Cline | Target::Continue => {
            "VS Code MCP clients eager-load tools; call this in your first turn."
        }
        Target::Codex => "Codex CLI eager-loads tools; call this in your first turn.",
        Target::Windsurf | Target::GrokCli | Target::GeminiCli | Target::ClaudeDesktop => {
            "This harness eager-loads tools; call this in your first turn."
        }
        Target::Openclaw => "OpenClaw eager-loads tools; call this in your first turn.",
    };

    format!(
        "# ai-memory system prompt — {harness}\n\
         \n\
         You have persistent memory via the `ai-memory` MCP server.\n\
         \n\
         1. Call `memory_capabilities` first. It returns the live tool \
         surface and a pre-computed `to_describe_to_user` summary. Trust \
         it over any cached belief.\n\
         2. Use `memory_load_family` to pre-load the context family you \
         need (`core`, `lifecycle`, `graph`, `governance`). {harness_hint}\n\
         3. Transcripts auto-extract via the R5 hook after each turn — \
         do not call `memory_store` for chat history; extract only \
         durable insights.\n\
         4. Signed links carry `attest_level` \
         (`unsigned`/`self_attested`/`peer_verified`). Treat anything \
         below `peer_verified` as advisory.\n",
    )
}

/// Resolve the snippet base directory. Tests override via
/// `AI_MEMORY_SYSTEM_PROMPT_DIR`; production uses
/// `dirs::config_dir().join("ai-memory")`. Under `cfg(test)` the default
/// also routes to a per-process tempdir so unit tests calling `run()` in
/// apply mode do not write to the operator's real config tree.
fn snippet_base_dir() -> Result<PathBuf> {
    if let Ok(v) = std::env::var("AI_MEMORY_SYSTEM_PROMPT_DIR")
        && !v.is_empty()
    {
        return Ok(PathBuf::from(v));
    }
    #[cfg(test)]
    {
        return Ok(test_default_snippet_dir());
    }
    #[cfg(not(test))]
    {
        let base = dirs::config_dir().ok_or_else(|| {
            anyhow!(
                "OS did not advertise a config directory; \
                 set AI_MEMORY_SYSTEM_PROMPT_DIR to choose where the snippet is written"
            )
        })?;
        Ok(base.join("ai-memory"))
    }
}

/// `cfg(test)`-only: per-process tempdir backing the default snippet
/// path so tests never write into `~/.config/ai-memory/`.
#[cfg(test)]
fn test_default_snippet_dir() -> PathBuf {
    use std::sync::OnceLock;
    static DIR: OnceLock<PathBuf> = OnceLock::new();
    DIR.get_or_init(|| {
        let tmp = tempfile::tempdir().expect("tempdir for snippet test default");
        let p = tmp.path().to_path_buf();
        // Leak the TempDir handle: we want this path to live for the
        // entire test binary's lifetime, not be cleaned up between
        // tests. The OS sweeps `/tmp` on reboot.
        std::mem::forget(tmp);
        p
    })
    .clone()
}

/// Write the system-prompt snippet for `target` to disk and return the
/// path. Creates parent directories as needed; overwrites any existing
/// file (snippet bodies are deterministic, so this is idempotent).
///
/// Production callers use [`write_system_prompt_snippet`] which resolves
/// the base dir via [`snippet_base_dir`] (env var + cfg(test) fallback);
/// tests should pass an explicit `dir` to [`write_system_prompt_snippet_to`]
/// to avoid the global env-var dance that historically flaked under
/// `--test-threads > 1`.
fn write_system_prompt_snippet_to(target: Target, dir: &std::path::Path) -> Result<PathBuf> {
    std::fs::create_dir_all(dir)
        .with_context(|| format!("creating snippet directory {}", dir.display()))?;
    let path = dir.join(format!("system-prompt-{}.md", target.name()));
    let body = snippet_body(target);
    std::fs::write(&path, body)
        .with_context(|| format!("writing snippet to {}", path.display()))?;
    Ok(path)
}

fn write_system_prompt_snippet(target: Target) -> Result<PathBuf> {
    let dir = snippet_base_dir()?;
    write_system_prompt_snippet_to(target, &dir)
}

// ---------------------------------------------------------------------------
// Config-path resolution
// ---------------------------------------------------------------------------

fn resolve_config_path(target: Target, args: &TargetArgs) -> Result<PathBuf> {
    if let Some(ref p) = args.config {
        return Ok(p.clone());
    }
    let home = dirs::home_dir()
        .ok_or_else(|| anyhow!("could not resolve home directory; pass --config <path>"))?;
    let p = match target {
        Target::ClaudeCode => home.join(".claude").join("settings.json"),
        Target::Openclaw => {
            // OpenClaw's documented MCP config path is not stable across
            // versions; the canonical location is documented at
            // https://docs.openclaw.ai/cli/mcp. We require --config for
            // OpenClaw to avoid guessing and writing to the wrong file.
            // TODO(#487): once OpenClaw publishes a stable canonical path,
            // wire it in here.
            bail!(
                "openclaw config path is not auto-discovered yet; pass --config <path>. \
                 See https://docs.openclaw.ai/cli/mcp for the canonical location."
            );
        }
        Target::Cursor => home.join(".cursor").join("mcp.json"),
        Target::Cline => {
            // Cline's config path varies by version (mcp_settings.json
            // location moved between releases). Require explicit --config
            // until upstream stabilises.
            // TODO(#487): once Cline pins a canonical path, wire it.
            bail!(
                "cline config path varies by version; pass --config <path> \
                 (typically ~/.cline/mcp_settings.json or under the VS Code \
                 extension data dir)."
            );
        }
        Target::Continue => home.join(".continue").join("config.json"),
        Target::Windsurf => home
            .join(".codeium")
            .join("windsurf")
            .join("mcp_config.json"),
        // v0.6.4-010 — claude-desktop has documented OS-specific paths.
        // Linux is unstable (depends on AppImage / Flatpak distribution),
        // so require --config there.
        Target::ClaudeDesktop => {
            #[cfg(target_os = "macos")]
            {
                home.join("Library")
                    .join("Application Support")
                    .join("Claude")
                    .join(CLAUDE_DESKTOP_CONFIG_FILENAME)
            }
            #[cfg(target_os = "windows")]
            {
                std::env::var_os("APPDATA")
                    .map(|p| {
                        std::path::PathBuf::from(p)
                            .join("Claude")
                            .join(CLAUDE_DESKTOP_CONFIG_FILENAME)
                    })
                    .unwrap_or_else(|| {
                        home.join("AppData")
                            .join("Roaming")
                            .join("Claude")
                            .join(CLAUDE_DESKTOP_CONFIG_FILENAME)
                    })
            }
            #[cfg(not(any(target_os = "macos", target_os = "windows")))]
            {
                bail!(
                    "claude-desktop config path is OS-specific and not auto-discovered \
                     on Linux; pass --config <path>. Common location: \
                     ~/.config/Claude/claude_desktop_config.json"
                );
            }
        }
        // v0.6.4-010 — codex, grok-cli, gemini-cli configs vary by version.
        // Mirror the openclaw/cline pattern: require --config explicitly
        // to avoid writing to the wrong file.
        Target::Codex => {
            bail!(
                "codex config path varies by version; pass --config <path>. \
                 Common location: ~/.codex/config.json or ~/.config/codex/mcp.json"
            );
        }
        Target::GrokCli => {
            bail!(
                "grok-cli config path varies by version; pass --config <path>. \
                 Common location: ~/.grok/mcp.json"
            );
        }
        Target::GeminiCli => {
            bail!(
                "gemini-cli config path varies by version; pass --config <path>. \
                 Common location: ~/.gemini/mcp.json"
            );
        }
    };
    Ok(p)
}

/// Resolve the `ai-memory` binary path for the generated config's
/// `command` field. If the user passes `--binary`, use that. Otherwise:
///
/// 1. If `ai-memory` is on `$PATH`, use the bare string `ai-memory` so
///    the config stays portable across machines that have it linked to
///    different absolute paths.
/// 2. Otherwise, use the running binary's `current_exe()` so the
///    generated config is at least functional on the host.
fn resolve_binary(override_path: Option<&Path>) -> String {
    if let Some(p) = override_path {
        return p.display().to_string();
    }
    if which_ai_memory().is_some() {
        return "ai-memory".to_string();
    }
    if let Ok(exe) = std::env::current_exe() {
        return exe.display().to_string();
    }
    "ai-memory".to_string()
}

fn which_ai_memory() -> Option<PathBuf> {
    let path_var = std::env::var_os("PATH")?;
    for dir in std::env::split_paths(&path_var) {
        let candidate = dir.join("ai-memory");
        if candidate.is_file() {
            return Some(candidate);
        }
        let candidate_exe = dir.join("ai-memory.exe");
        if candidate_exe.is_file() {
            return Some(candidate_exe);
        }
    }
    None
}

// ---------------------------------------------------------------------------
// Read / parse
// ---------------------------------------------------------------------------

/// v0.7.0 #1378 — config-file format discriminator. Real-world Codex
/// CLI configs are TOML (`~/.codex/config.toml`); the other MCP-
/// standard harnesses (claude-desktop, grok-cli, gemini-cli, plus the
/// IDE plugins) use JSON. The installer routes through the right
/// parser based on the file extension to surface a TOML-shaped error
/// message when an operator passes a TOML file to a JSON-only target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ConfigFormat {
    Json,
    Toml,
}

impl ConfigFormat {
    /// Detect format from the file extension. `.toml` → TOML; anything
    /// else → JSON (the historical default + every other MCP-standard
    /// surface).
    fn detect(path: &Path) -> Self {
        if path
            .extension()
            .and_then(|e| e.to_str())
            .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
        {
            Self::Toml
        } else {
            Self::Json
        }
    }
}

/// Read `path` and parse as JSON OR TOML depending on the file
/// extension. Returns `("", {})` if the file does not exist (a fresh
/// install on a host that's never run the agent). Errors clearly when
/// the file exists but is not valid in the expected format.
///
/// v0.7.0 #1378 — TOML branch added for Codex CLI parity. The TOML
/// content is parsed into a `toml::Value` then converted into a
/// `serde_json::Value` for downstream mutation; the canonical
/// MCP-standard shape (`mcpServers.<name>.{command,args,env}`)
/// round-trips cleanly across both formats.
fn read_config_or_empty(path: &Path) -> Result<(String, Value)> {
    if !path.exists() {
        return Ok((String::new(), Value::Object(Map::new())));
    }
    let text = std::fs::read_to_string(path)
        .with_context(|| crate::errors::msg::reading(path.display()))?;
    if text.trim().is_empty() {
        return Ok((text, Value::Object(Map::new())));
    }
    match ConfigFormat::detect(path) {
        ConfigFormat::Json => {
            let value: Value = serde_json::from_str(&text).map_err(|e| {
                anyhow!(
                    "existing config at {} is not valid JSON ({e}). \
                     Refusing to overwrite — fix the file by hand or remove it, \
                     then re-run `ai-memory install`.",
                    path.display()
                )
            })?;
            Ok((text, value))
        }
        ConfigFormat::Toml => {
            // Parse TOML → toml::Value, then serialize through serde
            // into serde_json::Value. The MCP-standard shape (string
            // maps + arrays of strings) round-trips cleanly; TOML
            // datetimes / heterogeneous arrays would NOT round-trip
            // (those don't appear in the MCP shape).
            let toml_value: toml::Value = toml::from_str(&text).map_err(|e| {
                anyhow!(
                    "existing config at {} is not valid TOML ({e}). \
                     Refusing to overwrite — fix the file by hand or remove it, \
                     then re-run `ai-memory install`.",
                    path.display()
                )
            })?;
            let value: Value = serde_json::to_value(&toml_value).map_err(|e| {
                anyhow!(
                    "existing TOML at {} contains a shape that cannot \
                     round-trip through JSON ({e}). Refusing to overwrite.",
                    path.display()
                )
            })?;
            // Ensure the top-level is an object (the MCP-standard
            // mutation routines assume `Value::Object`).
            let value = if value.is_object() {
                value
            } else {
                anyhow::bail!(
                    "existing TOML at {} top-level must be a table; \
                     got {value:?}",
                    path.display()
                );
            };
            Ok((text, value))
        }
    }
}

// ---------------------------------------------------------------------------
// Apply / remove managed block
// ---------------------------------------------------------------------------

/// Insert or replace the managed block for `target` inside `cfg`.
///
/// v0.7.0 #1378 — `format` parameter routes Codex TOML configs to the
/// snake_case `mcp_servers` key; everything else uses the camelCase
/// `mcpServers` key per MCP-spec JSON convention.
fn apply_managed_block(
    target: Target,
    mut cfg: Value,
    binary: &str,
    format: ConfigFormat,
) -> Result<Value> {
    let obj = ensure_object(&mut cfg)?;
    match target {
        Target::ClaudeCode => apply_claude_code(obj, binary),
        Target::Openclaw => apply_openclaw(obj, binary),
        Target::Cursor => apply_cursor(obj, binary),
        Target::Cline => apply_cline(obj, binary),
        Target::Continue => apply_continue(obj, binary),
        Target::Windsurf => apply_windsurf(obj, binary),
        // v0.6.4-010 — these four harnesses use the canonical
        // `mcpServers.ai-memory.{command, args, env}` shape.
        Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
            apply_mcp_standard(obj, binary, mcp_servers_key(target, format));
        }
    }
    Ok(cfg)
}

/// v0.7.0 #1378 — resolve the MCP-servers key name for the given
/// target × format combination. Codex TOML uses snake_case
/// `mcp_servers`; every other surface uses the MCP-spec camelCase
/// `mcpServers`. Centralised here so the apply/remove paths agree on
/// the key.
fn mcp_servers_key(target: Target, format: ConfigFormat) -> &'static str {
    match (target, format) {
        (Target::Codex, ConfigFormat::Toml) => "mcp_servers",
        _ => KEY_MCP_SERVERS,
    }
}

/// Remove the managed block for `target` from `cfg` (if present).
///
/// v0.7.0 #1378 — `format` parameter routes Codex TOML to the
/// snake_case `mcp_servers` key; every other surface uses
/// `mcpServers`. See [`mcp_servers_key`].
fn remove_managed_block(target: Target, mut cfg: Value, format: ConfigFormat) -> Result<Value> {
    let obj = match cfg.as_object_mut() {
        Some(o) => o,
        None => return Ok(cfg),
    };
    match target {
        Target::ClaudeCode => remove_claude_code(obj),
        Target::Openclaw => remove_openclaw(obj),
        Target::Cursor => remove_cursor(obj),
        Target::Cline => remove_cline(obj),
        Target::Continue => remove_continue(obj),
        Target::Windsurf => remove_windsurf(obj),
        // v0.6.4-010 — shared mcpServers.ai-memory shape (claude-desktop,
        // codex, grok-cli, gemini-cli).
        Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
            remove_mcp_standard(obj, mcp_servers_key(target, format));
        }
    }
    Ok(cfg)
}

// --- v0.6.4-010 shared MCP-standard writer --------------------------------
//
// claude-desktop, codex, grok-cli, and gemini-cli all consume the
// canonical `mcpServers.<name>.{command,args,env}` shape — the
// MCP-spec-defined server-config form. `apply_mcp_standard` writes the
// ai-memory entry with `--profile core` baked into the args (the v0.6.4
// default). Operators who want the v0.6.3 surface 1:1 can hand-edit the
// args to `["mcp", "--profile", "full"]`; the install dry-run + diff
// makes that change visible before they apply it.

fn apply_mcp_standard(obj: &mut Map<String, Value>, binary: &str, mcp_key: &str) {
    let mcp_servers = obj
        .entry(mcp_key.to_string())
        .or_insert_with(|| Value::Object(Map::new()));
    if !mcp_servers.is_object() {
        *mcp_servers = Value::Object(Map::new());
    }
    let mcp_obj = mcp_servers
        .as_object_mut()
        .expect(EXPECT_JUST_INSERTED_OBJECT);
    mcp_obj.insert(
        "ai-memory".to_string(),
        serde_json::json!({
            MARKER_START_KEY: MARKER_PAYLOAD,
            MANAGED_KEYS_PROPERTY: ["command", "args", "env"],
            "command": binary,
            // v0.6.4-010 — explicitly request the v0.6.4 default surface.
            // The runtime would default to `core` anyway via
            // effective_profile(), but having it written here makes the
            // selection self-documenting in the user's config file.
            "args": ["mcp", "--profile", "core"],
            "env": {},
            MARKER_END_KEY: MARKER_PAYLOAD,
        }),
    );
}

fn remove_mcp_standard(obj: &mut Map<String, Value>, mcp_key: &str) {
    if let Some(mcp_servers) = obj.get_mut(mcp_key).and_then(|v| v.as_object_mut()) {
        mcp_servers.remove("ai-memory");
        if mcp_servers.is_empty() {
            obj.remove(mcp_key);
        }
    }
}

fn ensure_object(v: &mut Value) -> Result<&mut Map<String, Value>> {
    if !v.is_object() {
        bail!("existing config root is not a JSON object; refusing to clobber");
    }
    Ok(v.as_object_mut().expect("checked is_object"))
}

// --- Claude Code ----------------------------------------------------------

/// Hook command stored in claude-code's SessionStart entry. Mirrors the
/// recipe documented in `docs/integrations/claude-code.md`.
fn claude_code_hook_command(binary: &str) -> String {
    format!("{binary} boot --quiet --limit 10 --budget-tokens 4096")
}

fn apply_claude_code(obj: &mut Map<String, Value>, binary: &str) {
    // Build the desired SessionStart entry under the marker.
    let cmd = claude_code_hook_command(binary);
    let entry = serde_json::json!({
        MARKER_START_KEY: MARKER_PAYLOAD,
        MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
        "matcher": "*",
        "hooks": [
            { "type": "command", "command": cmd }
        ],
        MARKER_END_KEY: MARKER_PAYLOAD,
    });

    // Drop into hooks.SessionStart, removing any existing managed entry,
    // then prepend ours.
    let hooks = obj
        .entry("hooks".to_string())
        .or_insert_with(|| Value::Object(Map::new()));
    if !hooks.is_object() {
        *hooks = Value::Object(Map::new());
    }
    let hooks_obj = hooks.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
    let session_start = hooks_obj
        .entry(HOOK_EVENT_SESSION_START.to_string())
        .or_insert_with(|| Value::Array(Vec::new()));
    if !session_start.is_array() {
        *session_start = Value::Array(Vec::new());
    }
    let arr = session_start
        .as_array_mut()
        .expect(EXPECT_JUST_INSERTED_ARRAY);
    arr.retain(|v| !is_managed_value(v));
    arr.insert(0, entry);
}

fn remove_claude_code(obj: &mut Map<String, Value>) {
    if let Some(hooks) = obj.get_mut("hooks").and_then(|h| h.as_object_mut())
        && let Some(arr) = hooks
            .get_mut(HOOK_EVENT_SESSION_START)
            .and_then(|s| s.as_array_mut())
    {
        arr.retain(|v| !is_managed_value(v));
        if arr.is_empty() {
            hooks.remove(HOOK_EVENT_SESSION_START);
        }
    }
    // Don't strip an empty hooks object if the user had one — leave their
    // structure exactly as we found it minus our block.
    if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
        && hooks.is_empty()
    {
        obj.remove("hooks");
    }
}

// --- v0.7.0 policy-engine item 2 — Claude Code PreToolUse hook ------------
//
// Wires the substrate-rules engine into every Bash / Edit / Write tool
// call Claude Code proposes. The hook's `type=mcp_tool` form invokes
// `memory_check_agent_action` and honors the Allow / Refuse / Warn
// decision before the tool dispatches. The MCP tool itself already
// exists (issue #691 L1-6 A); the installer is the missing turnkey.
//
// Design choices:
//
// - The PreToolUse entry lives under a separate managed block (different
//   `MANAGED_KEYS_PROPERTY` payload) so a future operator can install
//   SessionStart and PreToolUse independently — uninstalling one does
//   not strip the other.
// - We APPEND to an existing `PreToolUse` array, preserving operator-
//   authored entries' order. The substrate-check is positioned LAST so
//   the operator's earlier hooks still run; this matches the
//   "defence-in-depth" guidance in docs/governance/agent-action-rules.md.
// - Conflict detection: if an existing entry already names
//   `memory_check_agent_action` but with a DIFFERENT `matcher`, we
//   refuse to overwrite without `--force`. Operators sometimes
//   intentionally scope the hook to a subset of tools; clobbering that
//   would silently change their policy.

/// Reference name for the MCP tool the PreToolUse hook invokes.
///
/// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep): routes
/// through the canonical [`crate::mcp::registry::tool_names`] const,
/// so renaming a tool is one edit (the const value) and the installer
/// follows automatically.
const PRETOOL_HOOK_TOOL_NAME: &str = crate::mcp::registry::tool_names::MEMORY_CHECK_AGENT_ACTION;

/// Tool-name matcher for the PreToolUse hook.
///
/// Scoped to the agent-external action surface the substrate rule
/// engine actually models — `AgentAction` is `bash | filesystem_write
/// | network_request | process_spawn | custom`
/// (`crate::governance::agent_action::action_kinds`), and the harness
/// only synthesizes a `kind` for Bash / Edit / Write tool dispatches.
/// The Claude Code matcher is a regex, so `Edit` also covers
/// `MultiEdit` / `NotebookEdit` by substring.
///
/// A bare `"*"` here (the pre-#1667 value) fired the hook on EVERY
/// tool, including `mcp_tool` / read dispatches like
/// `mcp__memory__memory_get`, for which the harness builds no
/// `AgentAction` and therefore supplies no `kind` — tripping the
/// `memory_check_agent_action` `required: ["kind"]` schema on every
/// memory MCP call. See issue #1667.
const PRETOOL_HOOK_MATCHER: &str = "Bash|Edit|Write";

/// Shell command stored in claude-code's PreToolUse `type:command` hook.
/// Routes the proposed Bash / Edit / Write through the substrate rules
/// engine via the `--from-pretool-stdin` wrapper, which reads the
/// PreToolUse event off stdin and emits the Claude Code decision.
fn claude_code_pretool_command(binary: &str) -> String {
    format!("{binary} governance check-action --from-pretool-stdin")
}

/// Build the PreToolUse entry the installer writes.
///
/// #1811 — uses the `type=command` form (NOT `type=mcp_tool`). A
/// `type=mcp_tool` hook CANNOT enforce: per the Claude Code hooks
/// contract an mcp_tool hook whose tool returns `isError:true` (the old
/// "kind is required" failure) "produces a non-blocking error and
/// execution continues", and a tool response that is not the CC decision
/// JSON is "shown as plain text" — so `memory_check_agent_action`'s
/// `{"decision":{"decision":"refuse"}}` never blocked the tool. The
/// `--from-pretool-stdin` wrapper (`src/cli/governance_check_action.rs`)
/// owns the harness contract in-binary and emits
/// `hookSpecificOutput.permissionDecision=deny` on a Refuse, so the gate
/// actually blocks. One entry suffices because the wrapper self-maps the
/// tool name to the action kind (Bash→bash, Edit/Write→filesystem_write).
/// Trade-off accepted (5-agent vote `4d3ea1c5`): a shell fork + PATH
/// dependence on `ai-memory`, in exchange for an enforcing, testable gate.
fn claude_code_pretool_entry(binary: &str) -> Value {
    serde_json::json!({
        MARKER_START_KEY: MARKER_PAYLOAD,
        MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
        "matcher": PRETOOL_HOOK_MATCHER,
        "hooks": [
            { "type": "command", "command": claude_code_pretool_command(binary) }
        ],
        MARKER_END_KEY: MARKER_PAYLOAD,
    })
}

/// Predicate: does `v` look like a non-managed PreToolUse entry that
/// *also* points at our MCP tool (so installing on top would silently
/// shadow operator intent)? Returns the matcher string if so.
fn pretool_conflict_matcher(v: &Value) -> Option<String> {
    let obj = v.as_object()?;
    if obj.contains_key(MARKER_START_KEY) {
        return None;
    }
    let matcher = obj.get("matcher").and_then(Value::as_str)?;
    let hooks = obj.get("hooks").and_then(Value::as_array)?;
    for h in hooks {
        let h_obj = h.as_object()?;
        if h_obj.get("type").and_then(Value::as_str) == Some("mcp_tool")
            && h_obj.get("tool").and_then(Value::as_str) == Some(PRETOOL_HOOK_TOOL_NAME)
        {
            return Some(matcher.to_string());
        }
    }
    None
}

/// Apply the PreToolUse managed block. APPENDS the entry to the existing
/// `hooks.PreToolUse` array so any operator-authored hooks earlier in
/// the list still run. Returns `bail!` on a conflict-without-force.
fn apply_claude_code_pretool(
    obj: &mut Map<String, Value>,
    binary: &str,
    force: bool,
    out: &mut CliOutput<'_>,
) -> Result<()> {
    let entry = claude_code_pretool_entry(binary);

    let hooks = obj
        .entry("hooks".to_string())
        .or_insert_with(|| Value::Object(Map::new()));
    if !hooks.is_object() {
        *hooks = Value::Object(Map::new());
    }
    let hooks_obj = hooks.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
    let pretool = hooks_obj
        .entry(HOOK_EVENT_PRE_TOOL_USE.to_string())
        .or_insert_with(|| Value::Array(Vec::new()));
    if !pretool.is_array() {
        *pretool = Value::Array(Vec::new());
    }
    let arr = pretool.as_array_mut().expect(EXPECT_JUST_INSERTED_ARRAY);

    // Detect any operator-authored entry that points at the same MCP
    // tool with a different `matcher`. That's the conflict path —
    // refuse without --force.
    let conflicting: Vec<String> = arr
        .iter()
        .filter_map(pretool_conflict_matcher)
        .filter(|m| m != PRETOOL_HOOK_MATCHER)
        .collect();
    if !conflicting.is_empty() && !force {
        writeln!(
            out.stderr,
            "ai-memory install: warning — existing PreToolUse entry(s) already invoke \
             `{tool}` with matcher(s) {conflicts:?}. Pass --force to overwrite, or \
             remove the existing entries by hand if you want to keep your scoping.",
            tool = PRETOOL_HOOK_TOOL_NAME,
            conflicts = conflicting,
        )?;
        bail!(
            "refusing to overwrite a differing-but-similar PreToolUse hook \
             without --force; existing matcher(s): {conflicting:?}"
        );
    }

    // Drop any previous managed entry (idempotent re-runs) AND any
    // conflicting entries when --force is set. Operator-authored entries
    // that don't touch our tool are left untouched.
    arr.retain(|v| {
        if is_managed_value(v) {
            return false;
        }
        if force && pretool_conflict_matcher(v).is_some() {
            return false;
        }
        true
    });
    arr.push(entry);
    Ok(())
}

/// Remove the PreToolUse managed block (the inverse of
/// [`apply_claude_code_pretool`]). Idempotent on a clean config.
fn remove_claude_code_pretool(obj: &mut Map<String, Value>) {
    if let Some(hooks) = obj.get_mut("hooks").and_then(|h| h.as_object_mut())
        && let Some(arr) = hooks
            .get_mut(HOOK_EVENT_PRE_TOOL_USE)
            .and_then(|s| s.as_array_mut())
    {
        arr.retain(|v| !is_managed_value(v));
        if arr.is_empty() {
            hooks.remove(HOOK_EVENT_PRE_TOOL_USE);
        }
    }
    if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
        && hooks.is_empty()
    {
        obj.remove("hooks");
    }
}

/// Apply the requested hook variant. Today only `--hook pretool` for
/// claude-code is wired; the dispatch is split out so future hook
/// kinds (PostToolUse, Stop) plug in without touching `run`.
fn apply_hook_block(
    target: Target,
    kind: HookKind,
    mut cfg: Value,
    binary: &str,
    force: bool,
    out: &mut CliOutput<'_>,
) -> Result<Value> {
    let obj = ensure_object(&mut cfg)?;
    match (target, kind) {
        (Target::ClaudeCode, HookKind::Pretool) => {
            apply_claude_code_pretool(obj, binary, force, out)?;
        }
        // Other (target, kind) pairs are rejected upstream in `run` so
        // this match is exhaustive in practice. Keep the explicit
        // `_` arm to document the design intent.
        _ => bail!(
            "internal error: unsupported (target, hook) combination ({:?}, {:?})",
            target,
            kind
        ),
    }
    Ok(cfg)
}

/// Remove the requested hook variant (the inverse of
/// [`apply_hook_block`]). Idempotent on a clean config.
fn remove_hook_block(target: Target, kind: HookKind, mut cfg: Value) -> Result<Value> {
    let obj = match cfg.as_object_mut() {
        Some(o) => o,
        None => return Ok(cfg),
    };
    match (target, kind) {
        (Target::ClaudeCode, HookKind::Pretool) => {
            remove_claude_code_pretool(obj);
        }
        _ => {
            // Same rationale as `apply_hook_block` — surface internal
            // errors loudly rather than silently no-op.
            bail!(
                "internal error: unsupported (target, hook) combination ({:?}, {:?})",
                target,
                kind
            );
        }
    }
    Ok(cfg)
}

// --- OpenClaw -------------------------------------------------------------

fn ai_memory_server_value(binary: &str) -> Value {
    serde_json::json!({
        MARKER_START_KEY: MARKER_PAYLOAD,
        MANAGED_KEYS_PROPERTY: ["command", "args"],
        "command": binary,
        "args": ["mcp"],
        MARKER_END_KEY: MARKER_PAYLOAD,
    })
}

fn apply_openclaw(obj: &mut Map<String, Value>, binary: &str) {
    let mcp = obj
        .entry("mcp".to_string())
        .or_insert_with(|| Value::Object(Map::new()));
    if !mcp.is_object() {
        *mcp = Value::Object(Map::new());
    }
    let mcp_obj = mcp.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
    let servers = mcp_obj
        .entry("servers".to_string())
        .or_insert_with(|| Value::Object(Map::new()));
    if !servers.is_object() {
        *servers = Value::Object(Map::new());
    }
    let servers_obj = servers.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
    servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
}

fn remove_openclaw(obj: &mut Map<String, Value>) {
    if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut())
        && let Some(servers) = mcp.get_mut("servers").and_then(|v| v.as_object_mut())
    {
        if let Some(v) = servers.get("ai-memory") {
            if is_managed_value(v) {
                servers.remove("ai-memory");
            }
        }
        if servers.is_empty() {
            mcp.remove("servers");
        }
        if mcp.is_empty() {
            obj.remove("mcp");
        }
    }
}

// --- Cursor ---------------------------------------------------------------

fn apply_cursor(obj: &mut Map<String, Value>, binary: &str) {
    let servers = obj
        .entry(KEY_MCP_SERVERS.to_string())
        .or_insert_with(|| Value::Object(Map::new()));
    if !servers.is_object() {
        *servers = Value::Object(Map::new());
    }
    let servers_obj = servers.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
    servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
}

fn remove_cursor(obj: &mut Map<String, Value>) {
    if let Some(servers) = obj.get_mut(KEY_MCP_SERVERS).and_then(|v| v.as_object_mut()) {
        if let Some(v) = servers.get("ai-memory") {
            if is_managed_value(v) {
                servers.remove("ai-memory");
            }
        }
        if servers.is_empty() {
            obj.remove(KEY_MCP_SERVERS);
        }
    }
}

// --- Cline ----------------------------------------------------------------

fn apply_cline(obj: &mut Map<String, Value>, binary: &str) {
    // Cline shape mirrors Cursor (mcpServers).
    apply_cursor(obj, binary);
}

fn remove_cline(obj: &mut Map<String, Value>) {
    remove_cursor(obj);
}

// --- Continue -------------------------------------------------------------

fn apply_continue(obj: &mut Map<String, Value>, binary: &str) {
    // Continue's MCP config lives under experimental.modelContextProtocolServers
    // (an array of transport entries).
    let exp = obj
        .entry(KEY_EXPERIMENTAL.to_string())
        .or_insert_with(|| Value::Object(Map::new()));
    if !exp.is_object() {
        *exp = Value::Object(Map::new());
    }
    let exp_obj = exp.as_object_mut().expect(EXPECT_JUST_INSERTED_OBJECT);
    let arr = exp_obj
        .entry(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS.to_string())
        .or_insert_with(|| Value::Array(Vec::new()));
    if !arr.is_array() {
        *arr = Value::Array(Vec::new());
    }
    let arr = arr.as_array_mut().expect(EXPECT_JUST_INSERTED_ARRAY);
    arr.retain(|v| !is_managed_value(v));
    let entry = serde_json::json!({
        MARKER_START_KEY: MARKER_PAYLOAD,
        MANAGED_KEYS_PROPERTY: ["transport"],
        "transport": {
            "type": "stdio",
            "command": binary,
            "args": ["mcp"],
        },
        MARKER_END_KEY: MARKER_PAYLOAD,
    });
    arr.insert(0, entry);
}

fn remove_continue(obj: &mut Map<String, Value>) {
    if let Some(exp) = obj
        .get_mut(KEY_EXPERIMENTAL)
        .and_then(|v| v.as_object_mut())
    {
        if let Some(arr) = exp
            .get_mut(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS)
            .and_then(|v| v.as_array_mut())
        {
            arr.retain(|v| !is_managed_value(v));
            if arr.is_empty() {
                exp.remove(KEY_MODEL_CONTEXT_PROTOCOL_SERVERS);
            }
        }
        if exp.is_empty() {
            obj.remove(KEY_EXPERIMENTAL);
        }
    }
}

// --- Windsurf -------------------------------------------------------------

fn apply_windsurf(obj: &mut Map<String, Value>, binary: &str) {
    apply_cursor(obj, binary);
}

fn remove_windsurf(obj: &mut Map<String, Value>) {
    remove_cursor(obj);
}

// ---------------------------------------------------------------------------
// Marker recognition
// ---------------------------------------------------------------------------

/// Returns true when `v` is a JSON object carrying our managed-block
/// start sentinel. Used to recognise an existing managed block so
/// install can replace it precisely and uninstall can remove it.
fn is_managed_value(v: &Value) -> bool {
    v.as_object()
        .and_then(|o| o.get(MARKER_START_KEY))
        .is_some()
}

// ---------------------------------------------------------------------------
// Diff emission
// ---------------------------------------------------------------------------

/// Write a minimal unified-style diff between `before` and `after` to
/// `out.stdout`. We avoid pulling a real diff crate; the implementation
/// is intentionally simple — line-by-line, no LCS — because the diff is
/// *advisory* (the caller can still inspect after with `--apply`).
fn emit_diff(out: &mut CliOutput<'_>, before: &str, after: &str) -> Result<()> {
    let before_lines: Vec<&str> = before.lines().collect();
    let after_lines: Vec<&str> = after.lines().collect();
    let max_len = before_lines.len().max(after_lines.len());
    for i in 0..max_len {
        let b = before_lines.get(i).copied();
        let a = after_lines.get(i).copied();
        match (b, a) {
            (Some(bl), Some(al)) if bl == al => writeln!(out.stdout, " {bl}")?,
            (Some(bl), Some(al)) => {
                writeln!(out.stdout, "-{bl}")?;
                writeln!(out.stdout, "+{al}")?;
            }
            (Some(bl), None) => writeln!(out.stdout, "-{bl}")?,
            (None, Some(al)) => writeln!(out.stdout, "+{al}")?,
            (None, None) => {}
        }
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::test_utils::TestEnv;
    use std::fs;

    fn args_for(target: Target, config: PathBuf) -> InstallArgs {
        let t = TargetArgs {
            config: Some(config),
            apply: false,
            dry_run: false,
            uninstall: false,
            binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
            hook: None,
            force: false,
        };
        let target_cmd = match target {
            Target::ClaudeCode => TargetCmd::ClaudeCode(t),
            Target::Openclaw => TargetCmd::Openclaw(t),
            Target::Cursor => TargetCmd::Cursor(t),
            Target::Cline => TargetCmd::Cline(t),
            Target::Continue => TargetCmd::Continue(t),
            Target::Windsurf => TargetCmd::Windsurf(t),
            Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
            Target::Codex => TargetCmd::Codex(t),
            Target::GrokCli => TargetCmd::GrokCli(t),
            Target::GeminiCli => TargetCmd::GeminiCli(t),
        };
        InstallArgs { target: target_cmd }
    }

    fn args_for_apply(target: Target, config: PathBuf) -> InstallArgs {
        let mut a = args_for(target, config);
        match &mut a.target {
            TargetCmd::ClaudeCode(t)
            | TargetCmd::Openclaw(t)
            | TargetCmd::Cursor(t)
            | TargetCmd::Cline(t)
            | TargetCmd::Continue(t)
            | TargetCmd::Windsurf(t)
            | TargetCmd::ClaudeDesktop(t)
            | TargetCmd::Codex(t)
            | TargetCmd::GrokCli(t)
            | TargetCmd::GeminiCli(t) => {
                t.apply = true;
            }
        }
        a
    }

    fn args_for_uninstall_apply(target: Target, config: PathBuf) -> InstallArgs {
        let mut a = args_for(target, config);
        match &mut a.target {
            TargetCmd::ClaudeCode(t)
            | TargetCmd::Openclaw(t)
            | TargetCmd::Cursor(t)
            | TargetCmd::Cline(t)
            | TargetCmd::Continue(t)
            | TargetCmd::Windsurf(t)
            | TargetCmd::ClaudeDesktop(t)
            | TargetCmd::Codex(t)
            | TargetCmd::GrokCli(t)
            | TargetCmd::GeminiCli(t) => {
                t.uninstall = true;
                t.apply = true;
            }
        }
        a
    }

    fn config_path(env: &TestEnv, name: &str) -> PathBuf {
        env.db_path.parent().unwrap().join(name)
    }

    fn seed(path: &Path, contents: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, contents).unwrap();
    }

    // --------------------------------------------------------------
    // claude-code
    // --------------------------------------------------------------

    #[test]
    fn claude_code_install_dry_run_emits_diff_no_writes() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{\n}\n");
        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
        let args = args_for(Target::ClaudeCode, path.clone());
        let mut out = env.output();
        run(&args, &mut out).unwrap();
        let stdout = std::str::from_utf8(&env.stdout).unwrap();
        assert!(stdout.contains("dry-run"));
        assert!(stdout.contains("SessionStart"));
        assert!(stdout.contains("ai-memory"));
        assert!(stdout.contains(MARKER_START_KEY));
        let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
        assert_eq!(mtime_before, mtime_after, "dry-run must not write");
    }

    #[test]
    fn claude_code_install_apply_writes_marker_block() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::ClaudeCode, path.clone());
        let mut out = env.output();
        run(&args, &mut out).unwrap();
        let written = fs::read_to_string(&path).unwrap();
        assert!(written.contains(MARKER_START_KEY));
        assert!(written.contains(MARKER_END_KEY));
        assert!(written.contains("SessionStart"));
        assert!(written.contains("ai-memory"));
        // Must remain valid JSON.
        let _: Value = serde_json::from_str(&written).unwrap();
    }

    #[test]
    fn claude_code_install_apply_preserves_user_keys() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(
            &path,
            r#"{"theme":"dark","permissions":{"allow":["npm:*"]}}"#,
        );
        let args = args_for_apply(Target::ClaudeCode, path.clone());
        let mut out = env.output();
        run(&args, &mut out).unwrap();
        let written = fs::read_to_string(&path).unwrap();
        let parsed: Value = serde_json::from_str(&written).unwrap();
        assert_eq!(parsed["theme"], "dark");
        assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
        assert!(parsed["hooks"]["SessionStart"].is_array());
    }

    #[test]
    fn claude_code_install_apply_is_idempotent() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::ClaudeCode, path.clone());
        let mut out = env.output();
        run(&args, &mut out).unwrap();
        let after_first = fs::read_to_string(&path).unwrap();
        // Second run should produce a no-op message and no change.
        env.stdout.clear();
        let mut out2 = env.output();
        run(&args, &mut out2).unwrap();
        let after_second = fs::read_to_string(&path).unwrap();
        assert_eq!(after_first, after_second);
        let stdout2 = std::str::from_utf8(&env.stdout).unwrap();
        assert!(
            stdout2.contains("no-op"),
            "second install should be no-op: {stdout2}"
        );
    }

    #[test]
    fn claude_code_uninstall_removes_marker_block_only() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        let original = "{\n  \"theme\": \"dark\"\n}\n";
        seed(&path, original);
        // Install, then uninstall.
        run(
            &args_for_apply(Target::ClaudeCode, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let after_install = fs::read_to_string(&path).unwrap();
        assert!(after_install.contains(MARKER_START_KEY));
        run(
            &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let after_uninstall = fs::read_to_string(&path).unwrap();
        let parsed: Value = serde_json::from_str(&after_uninstall).unwrap();
        assert_eq!(parsed["theme"], "dark");
        assert!(
            parsed.get("hooks").is_none(),
            "hooks should be gone after uninstall"
        );
        assert!(!after_uninstall.contains(MARKER_START_KEY));
    }

    #[test]
    fn claude_code_install_refuses_malformed_config() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{not valid json");
        let args = args_for_apply(Target::ClaudeCode, path.clone());
        let mut out = env.output();
        let err = run(&args, &mut out).unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("not valid JSON"),
            "error should explain malformed json: {msg}"
        );
        // File must NOT have been overwritten.
        let still = fs::read_to_string(&path).unwrap();
        assert_eq!(still, "{not valid json");
    }

    #[test]
    fn claude_code_install_writes_backup_file() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::ClaudeCode, path.clone());
        let mut out = env.output();
        run(&args, &mut out).unwrap();
        // Find a sibling whose name starts with `settings.json.bak.`.
        let parent = path.parent().unwrap();
        let backups: Vec<_> = fs::read_dir(parent)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| {
                e.file_name()
                    .to_string_lossy()
                    .starts_with("settings.json.bak.")
                    || e.file_name().to_string_lossy().starts_with("settings.bak.")
            })
            .collect();
        assert!(
            !backups.is_empty(),
            "expected a settings.bak.<ts> backup beside the config; saw: {:?}",
            fs::read_dir(parent)
                .unwrap()
                .filter_map(|e| e.ok())
                .map(|e| e.file_name())
                .collect::<Vec<_>>()
        );
    }

    // --------------------------------------------------------------
    // cursor
    // --------------------------------------------------------------

    #[test]
    fn cursor_install_dry_run_emits_diff_no_writes() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp.json");
        seed(&path, "{}\n");
        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
        let args = args_for(Target::Cursor, path.clone());
        let mut out = env.output();
        run(&args, &mut out).unwrap();
        let stdout = std::str::from_utf8(&env.stdout).unwrap();
        assert!(stdout.contains("dry-run"));
        assert!(stdout.contains("mcpServers"));
        let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
        assert_eq!(mtime_before, mtime_after);
    }

    #[test]
    fn cursor_install_apply_writes_marker_block() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::Cursor, path.clone());
        run(&args, &mut env.output()).unwrap();
        let written = fs::read_to_string(&path).unwrap();
        let parsed: Value = serde_json::from_str(&written).unwrap();
        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
        assert_eq!(
            parsed["mcpServers"]["ai-memory"]["command"],
            "/usr/local/bin/ai-memory"
        );
    }

    #[test]
    fn cursor_install_apply_preserves_user_keys() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp.json");
        seed(
            &path,
            r#"{"mcpServers":{"my-other":{"command":"x"}},"telemetry":false}"#,
        );
        run(
            &args_for_apply(Target::Cursor, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["telemetry"], false);
        assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
    }

    #[test]
    fn cursor_install_apply_is_idempotent() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::Cursor, path.clone());
        run(&args, &mut env.output()).unwrap();
        let first = fs::read_to_string(&path).unwrap();
        run(&args, &mut env.output()).unwrap();
        let second = fs::read_to_string(&path).unwrap();
        assert_eq!(first, second);
    }

    #[test]
    fn cursor_uninstall_removes_marker_block_only() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp.json");
        let original = r#"{"mcpServers":{"my-other":{"command":"x"}}}"#;
        seed(&path, original);
        run(
            &args_for_apply(Target::Cursor, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        run(
            &args_for_uninstall_apply(Target::Cursor, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
        assert!(
            parsed["mcpServers"]
                .as_object()
                .unwrap()
                .get("ai-memory")
                .is_none()
        );
    }

    #[test]
    fn cursor_install_refuses_malformed_config() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp.json");
        seed(&path, "not json");
        let args = args_for_apply(Target::Cursor, path.clone());
        let err = run(&args, &mut env.output()).unwrap_err();
        assert!(format!("{err}").contains("not valid JSON"));
    }

    #[test]
    fn cursor_install_writes_backup_file() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Cursor, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parent = path.parent().unwrap();
        let any_backup = fs::read_dir(parent)
            .unwrap()
            .filter_map(|e| e.ok())
            .any(|e| e.file_name().to_string_lossy().contains("bak."));
        assert!(any_backup);
    }

    // --------------------------------------------------------------
    // openclaw
    // --------------------------------------------------------------

    #[test]
    fn openclaw_install_dry_run_emits_diff_no_writes() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "openclaw.json");
        seed(&path, "{}\n");
        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
        run(&args_for(Target::Openclaw, path.clone()), &mut env.output()).unwrap();
        let stdout = std::str::from_utf8(&env.stdout).unwrap();
        assert!(stdout.contains("dry-run"));
        assert!(stdout.contains("mcp"));
        assert_eq!(
            mtime_before,
            fs::metadata(&path).unwrap().modified().unwrap()
        );
    }

    #[test]
    fn openclaw_install_apply_writes_marker_block() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "openclaw.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Openclaw, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
    }

    #[test]
    fn openclaw_install_apply_preserves_user_keys() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "openclaw.json");
        seed(
            &path,
            r#"{"mcp":{"servers":{"other":{"command":"y"}}},"editor":"vim"}"#,
        );
        run(
            &args_for_apply(Target::Openclaw, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["editor"], "vim");
        assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
        assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
    }

    #[test]
    fn openclaw_install_apply_is_idempotent() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "openclaw.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::Openclaw, path.clone());
        run(&args, &mut env.output()).unwrap();
        let first = fs::read_to_string(&path).unwrap();
        run(&args, &mut env.output()).unwrap();
        let second = fs::read_to_string(&path).unwrap();
        assert_eq!(first, second);
    }

    #[test]
    fn openclaw_uninstall_removes_marker_block_only() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "openclaw.json");
        seed(&path, r#"{"mcp":{"servers":{"other":{"command":"y"}}}}"#);
        run(
            &args_for_apply(Target::Openclaw, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        run(
            &args_for_uninstall_apply(Target::Openclaw, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
        assert!(
            parsed["mcp"]["servers"]
                .as_object()
                .unwrap()
                .get("ai-memory")
                .is_none()
        );
    }

    #[test]
    fn openclaw_install_refuses_malformed_config() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "openclaw.json");
        seed(&path, "garbage");
        let err = run(
            &args_for_apply(Target::Openclaw, path.clone()),
            &mut env.output(),
        )
        .unwrap_err();
        assert!(format!("{err}").contains("not valid JSON"));
    }

    #[test]
    fn openclaw_install_writes_backup_file() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "openclaw.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Openclaw, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parent = path.parent().unwrap();
        assert!(
            fs::read_dir(parent)
                .unwrap()
                .filter_map(|e| e.ok())
                .any(|e| e.file_name().to_string_lossy().contains("bak."))
        );
    }

    // --------------------------------------------------------------
    // cline (shape ≈ cursor)
    // --------------------------------------------------------------

    #[test]
    fn cline_install_dry_run_emits_diff_no_writes() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "cline.json");
        seed(&path, "{}\n");
        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
        run(&args_for(Target::Cline, path.clone()), &mut env.output()).unwrap();
        let stdout = std::str::from_utf8(&env.stdout).unwrap();
        assert!(stdout.contains("dry-run"));
        assert!(stdout.contains("mcpServers"));
        assert_eq!(
            mtime_before,
            fs::metadata(&path).unwrap().modified().unwrap()
        );
    }

    #[test]
    fn cline_install_apply_writes_marker_block() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "cline.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Cline, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
    }

    #[test]
    fn cline_install_apply_preserves_user_keys() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "cline.json");
        seed(&path, r#"{"mcpServers":{"x":{"command":"q"}},"foo":1}"#);
        run(
            &args_for_apply(Target::Cline, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["foo"], 1);
        assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
    }

    #[test]
    fn cline_install_apply_is_idempotent() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "cline.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::Cline, path.clone());
        run(&args, &mut env.output()).unwrap();
        let first = fs::read_to_string(&path).unwrap();
        run(&args, &mut env.output()).unwrap();
        let second = fs::read_to_string(&path).unwrap();
        assert_eq!(first, second);
    }

    #[test]
    fn cline_uninstall_removes_marker_block_only() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "cline.json");
        seed(&path, r#"{"mcpServers":{"x":{"command":"q"}}}"#);
        run(
            &args_for_apply(Target::Cline, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        run(
            &args_for_uninstall_apply(Target::Cline, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
        assert!(
            parsed["mcpServers"]
                .as_object()
                .unwrap()
                .get("ai-memory")
                .is_none()
        );
    }

    #[test]
    fn cline_install_refuses_malformed_config() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "cline.json");
        seed(&path, "totally not json");
        let err = run(
            &args_for_apply(Target::Cline, path.clone()),
            &mut env.output(),
        )
        .unwrap_err();
        assert!(format!("{err}").contains("not valid JSON"));
    }

    #[test]
    fn cline_install_writes_backup_file() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "cline.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Cline, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        assert!(
            fs::read_dir(path.parent().unwrap())
                .unwrap()
                .filter_map(|e| e.ok())
                .any(|e| e.file_name().to_string_lossy().contains("bak."))
        );
    }

    // --------------------------------------------------------------
    // continue
    // --------------------------------------------------------------

    #[test]
    fn continue_install_dry_run_emits_diff_no_writes() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "continue.json");
        seed(&path, "{}\n");
        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
        run(&args_for(Target::Continue, path.clone()), &mut env.output()).unwrap();
        let stdout = std::str::from_utf8(&env.stdout).unwrap();
        assert!(stdout.contains("dry-run"));
        assert!(stdout.contains("modelContextProtocolServers"));
        assert_eq!(
            mtime_before,
            fs::metadata(&path).unwrap().modified().unwrap()
        );
    }

    #[test]
    fn continue_install_apply_writes_marker_block() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "continue.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Continue, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        let arr = parsed["experimental"]["modelContextProtocolServers"]
            .as_array()
            .unwrap();
        assert!(arr.iter().any(is_managed_value));
    }

    #[test]
    fn continue_install_apply_preserves_user_keys() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "continue.json");
        seed(
            &path,
            r#"{"models":[{"name":"x"}],"experimental":{"foo":true}}"#,
        );
        run(
            &args_for_apply(Target::Continue, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["models"][0]["name"], "x");
        assert_eq!(parsed["experimental"]["foo"], true);
    }

    #[test]
    fn continue_install_apply_is_idempotent() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "continue.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::Continue, path.clone());
        run(&args, &mut env.output()).unwrap();
        let first = fs::read_to_string(&path).unwrap();
        run(&args, &mut env.output()).unwrap();
        let second = fs::read_to_string(&path).unwrap();
        assert_eq!(first, second);
    }

    #[test]
    fn continue_uninstall_removes_marker_block_only() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "continue.json");
        seed(&path, r#"{"models":[{"name":"x"}]}"#);
        run(
            &args_for_apply(Target::Continue, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        run(
            &args_for_uninstall_apply(Target::Continue, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["models"][0]["name"], "x");
        assert!(parsed.get("experimental").is_none());
    }

    #[test]
    fn continue_install_refuses_malformed_config() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "continue.json");
        seed(&path, "[1,2,");
        let err = run(
            &args_for_apply(Target::Continue, path.clone()),
            &mut env.output(),
        )
        .unwrap_err();
        assert!(format!("{err}").contains("not valid JSON"));
    }

    #[test]
    fn continue_install_writes_backup_file() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "continue.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Continue, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        assert!(
            fs::read_dir(path.parent().unwrap())
                .unwrap()
                .filter_map(|e| e.ok())
                .any(|e| e.file_name().to_string_lossy().contains("bak."))
        );
    }

    // --------------------------------------------------------------
    // windsurf (shape ≈ cursor)
    // --------------------------------------------------------------

    #[test]
    fn windsurf_install_dry_run_emits_diff_no_writes() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp_config.json");
        seed(&path, "{}\n");
        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
        run(&args_for(Target::Windsurf, path.clone()), &mut env.output()).unwrap();
        let stdout = std::str::from_utf8(&env.stdout).unwrap();
        assert!(stdout.contains("dry-run"));
        assert!(stdout.contains("mcpServers"));
        assert_eq!(
            mtime_before,
            fs::metadata(&path).unwrap().modified().unwrap()
        );
    }

    #[test]
    fn windsurf_install_apply_writes_marker_block() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp_config.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Windsurf, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
    }

    #[test]
    fn windsurf_install_apply_preserves_user_keys() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp_config.json");
        seed(&path, r#"{"mcpServers":{"k":{"command":"l"}},"a":42}"#);
        run(
            &args_for_apply(Target::Windsurf, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["a"], 42);
        assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
    }

    #[test]
    fn windsurf_install_apply_is_idempotent() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp_config.json");
        seed(&path, "{}\n");
        let args = args_for_apply(Target::Windsurf, path.clone());
        run(&args, &mut env.output()).unwrap();
        let first = fs::read_to_string(&path).unwrap();
        run(&args, &mut env.output()).unwrap();
        let second = fs::read_to_string(&path).unwrap();
        assert_eq!(first, second);
    }

    #[test]
    fn windsurf_uninstall_removes_marker_block_only() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp_config.json");
        seed(&path, r#"{"mcpServers":{"k":{"command":"l"}}}"#);
        run(
            &args_for_apply(Target::Windsurf, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        run(
            &args_for_uninstall_apply(Target::Windsurf, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
        assert!(
            parsed["mcpServers"]
                .as_object()
                .unwrap()
                .get("ai-memory")
                .is_none()
        );
    }

    #[test]
    fn windsurf_install_refuses_malformed_config() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp_config.json");
        seed(&path, "::");
        let err = run(
            &args_for_apply(Target::Windsurf, path.clone()),
            &mut env.output(),
        )
        .unwrap_err();
        assert!(format!("{err}").contains("not valid JSON"));
    }

    #[test]
    fn windsurf_install_writes_backup_file() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp_config.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Windsurf, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        assert!(
            fs::read_dir(path.parent().unwrap())
                .unwrap()
                .filter_map(|e| e.ok())
                .any(|e| e.file_name().to_string_lossy().contains("bak."))
        );
    }

    // --------------------------------------------------------------
    // generic / cross-cutting
    // --------------------------------------------------------------

    #[test]
    fn install_creates_missing_config_file_under_apply() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "fresh-config.json");
        assert!(!path.exists());
        run(
            &args_for_apply(Target::Cursor, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        assert!(path.exists());
        let _: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
    }

    #[test]
    fn install_round_trip_install_then_uninstall_restores_original_for_empty_seed() {
        // For a config that started as `{}\n`, install + uninstall should
        // produce a configuration that re-parses to `{}` (key set is empty).
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "rt.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::Cursor, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        run(
            &args_for_uninstall_apply(Target::Cursor, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed, serde_json::json!({}));
    }

    #[test]
    fn resolve_binary_uses_override_when_provided() {
        let p = std::path::PathBuf::from("/custom/path/ai-memory");
        let resolved = resolve_binary(Some(&p));
        assert_eq!(resolved, "/custom/path/ai-memory");
    }

    // ---- v0.6.4-010 — per-harness install profiles ----
    //
    // The four MCP-standard harnesses (claude-desktop, codex, grok-cli,
    // gemini-cli) use the same `mcpServers.ai-memory.{command,args,env}`
    // shape. We test all four with a single shared assertion fixture
    // since the writer is shared.

    fn assert_mcp_standard_apply(target: Target, fname: &str) {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, fname);
        seed(&path, "{}\n");
        run(&args_for_apply(target, path.clone()), &mut env.output()).unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        // Standard MCP shape.
        assert!(
            parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string(),
            "{} missing managed-block marker",
            target.name()
        );
        // v0.6.4 default profile baked into args.
        let args = parsed["mcpServers"]["ai-memory"]["args"]
            .as_array()
            .unwrap();
        let strs: Vec<&str> = args.iter().filter_map(Value::as_str).collect();
        assert_eq!(
            strs,
            vec!["mcp", "--profile", "core"],
            "{} should write `mcp --profile core` args",
            target.name()
        );
        let cmd = parsed["mcpServers"]["ai-memory"]["command"]
            .as_str()
            .unwrap();
        assert_eq!(cmd, "/usr/local/bin/ai-memory");
    }

    #[test]
    fn claude_desktop_apply_writes_mcp_standard_with_profile_core() {
        assert_mcp_standard_apply(Target::ClaudeDesktop, "claude_desktop_config.json");
    }

    #[test]
    fn codex_apply_writes_mcp_standard_with_profile_core() {
        assert_mcp_standard_apply(Target::Codex, "codex_config.json");
    }

    #[test]
    fn grok_cli_apply_writes_mcp_standard_with_profile_core() {
        assert_mcp_standard_apply(Target::GrokCli, "grok_mcp.json");
    }

    #[test]
    fn gemini_cli_apply_writes_mcp_standard_with_profile_core() {
        assert_mcp_standard_apply(Target::GeminiCli, "gemini_mcp.json");
    }

    #[test]
    fn mcp_standard_uninstall_round_trip_restores_empty() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "claude_desktop_config.json");
        seed(&path, "{}\n");
        run(
            &args_for_apply(Target::ClaudeDesktop, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        run(
            &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        // Empty mcpServers should be removed entirely.
        assert!(
            !parsed.as_object().unwrap().contains_key("mcpServers"),
            "uninstall should remove the empty mcpServers wrapper"
        );
    }

    #[test]
    fn mcp_standard_apply_preserves_user_keys() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "codex_config.json");
        seed(
            &path,
            r#"{"mcpServers":{"other-mcp":{"command":"x","args":[]}},"unrelated":42}"#,
        );
        run(
            &args_for_apply(Target::Codex, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        // Sibling server preserved.
        assert_eq!(parsed["mcpServers"]["other-mcp"]["command"], "x");
        // Sibling top-level key preserved.
        assert_eq!(parsed["unrelated"], 42);
        // ai-memory entry written.
        assert!(parsed["mcpServers"]["ai-memory"].is_object());
    }

    // v0.7.0 #1378 — Codex TOML config coverage.

    #[test]
    fn config_format_detect_distinguishes_toml_and_json() {
        assert_eq!(
            ConfigFormat::detect(Path::new("/x/config.toml")),
            ConfigFormat::Toml
        );
        assert_eq!(
            ConfigFormat::detect(Path::new("/x/config.TOML")),
            ConfigFormat::Toml
        );
        assert_eq!(
            ConfigFormat::detect(Path::new("/x/config.json")),
            ConfigFormat::Json
        );
        assert_eq!(
            ConfigFormat::detect(Path::new("/x/noext")),
            ConfigFormat::Json
        );
    }

    #[test]
    fn codex_apply_toml_roundtrips_and_preserves_user_keys() {
        // End-to-end TOML path: read TOML → mutate → serialise TOML →
        // round-trip-verify. Exercises the serialize_config + round-trip
        // TOML arms and read_config_or_empty's TOML success branch.
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "config.toml");
        seed(
            &path,
            "unrelated = 42\n\n[mcp_servers.other-mcp]\ncommand = \"x\"\nargs = []\n",
        );
        run(
            &args_for_apply(Target::Codex, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let txt = fs::read_to_string(&path).unwrap();
        let tv: toml::Value = toml::from_str(&txt).expect("output must be valid TOML");
        let jv: Value = serde_json::to_value(&tv).unwrap();
        // Codex TOML uses the snake_case `mcp_servers` key.
        assert!(jv["mcp_servers"]["ai-memory"].is_object());
        assert_eq!(
            jv["mcp_servers"]["ai-memory"]["command"],
            "/usr/local/bin/ai-memory"
        );
        // Sibling server + top-level key preserved across the round-trip.
        assert_eq!(jv["mcp_servers"]["other-mcp"]["command"], "x");
        assert_eq!(jv["unrelated"], 42);
    }

    #[test]
    fn read_config_or_empty_rejects_invalid_toml() {
        let env = TestEnv::fresh();
        let path = config_path(&env, "broken.toml");
        seed(&path, "this is = = not valid toml\n");
        let err = read_config_or_empty(&path).unwrap_err();
        assert!(err.to_string().contains("is not valid TOML"), "got: {err}");
    }

    // --------------------------------------------------------------
    // v0.7-D3 — install-time system-prompt snippet
    //
    // These tests serialize on a module-local Mutex so they can each
    // point `AI_MEMORY_SYSTEM_PROMPT_DIR` at their own tempdir without
    // racing other concurrent tests. Other apply-tests in this module
    // rely on the `cfg(test)` default (a per-process tempdir) so they
    // never touch the operator's real `~/.config/ai-memory/`.
    // --------------------------------------------------------------

    /// Serialise env-var mutation across snippet tests.
    fn snippet_env_lock() -> &'static std::sync::Mutex<()> {
        use std::sync::{Mutex, OnceLock};
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    /// Run the snippet emit path for `target` against an isolated
    /// tempdir and return `(snippet_path, snippet_body)`. The tempdir
    /// is leaked so the snippet file remains on disk for the caller
    /// to inspect after the helper returns; the OS sweeps `/tmp` on
    /// reboot.
    fn emit_snippet_isolated(target: Target) -> (PathBuf, String) {
        // Uses the dir-parameterised helper so the test does NOT touch
        // the process-global `AI_MEMORY_SYSTEM_PROMPT_DIR` env var.
        // Eliminates the `snippet_env_lock` cross-test race that flaked
        // `snippet_every_target_emits_under_budget` under
        // `--test-threads > 1` (§16 12-gate sweep observation,
        // 2026-05-13, v0.7.1-fold).
        let tmp = tempfile::tempdir().expect("tempdir");
        let tmp_path = tmp.path().to_path_buf();
        let snippet_path =
            write_system_prompt_snippet_to(target, &tmp_path).expect("snippet write");
        let body = fs::read_to_string(&snippet_path).expect("read snippet");
        std::mem::forget(tmp); // path must outlive caller
        (snippet_path, body)
    }

    /// Every snippet must mention all four anchor strings the v0.7
    /// surface depends on, plus the harness name verbatim.
    fn assert_snippet_anchors(target: Target, body: &str) {
        let harness = target.name();
        assert!(
            body.contains(harness),
            "snippet for {harness} missing harness literal; body was:\n{body}",
        );
        for anchor in [
            "memory_capabilities",
            "memory_load_family",
            "attest_level",
            // R5 transcript hook — wording is "R5 hook" in the body.
            "R5 hook",
        ] {
            assert!(
                body.contains(anchor),
                "snippet for {harness} missing anchor `{anchor}`; body was:\n{body}",
            );
        }
    }

    /// ≤200 token budget per spec. We use a coarse char-count proxy
    /// (4 chars/token is the standard rule of thumb for English BPE),
    /// so 200 tokens ≈ 800 chars. The actual cl100k-base count for
    /// English prose with code-fence backticks runs ~10–15% lower than
    /// chars/4, so a 800-char ceiling under-estimates the budget
    /// conservatively.
    fn assert_snippet_token_budget(body: &str) {
        let approx_tokens = body.chars().count() / 4;
        assert!(
            approx_tokens <= 200,
            "snippet exceeds 200-token budget (≈{approx_tokens} tokens, {} chars)",
            body.chars().count(),
        );
    }

    #[test]
    fn snippet_claude_code_has_anchors_and_under_budget() {
        let (path, body) = emit_snippet_isolated(Target::ClaudeCode);
        assert!(path.ends_with("system-prompt-claude-code.md"));
        assert_snippet_anchors(Target::ClaudeCode, &body);
        assert_snippet_token_budget(&body);
        // Claude Code's harness-specific bullet calls out ToolSearch.
        assert!(
            body.contains("ToolSearch"),
            "claude-code snippet should mention ToolSearch (deferred-tool registration)",
        );
    }

    #[test]
    fn snippet_cursor_has_anchors_and_under_budget() {
        let (path, body) = emit_snippet_isolated(Target::Cursor);
        assert!(path.ends_with("system-prompt-cursor.md"));
        assert_snippet_anchors(Target::Cursor, &body);
        assert_snippet_token_budget(&body);
    }

    #[test]
    fn snippet_codex_has_anchors_and_under_budget() {
        let (path, body) = emit_snippet_isolated(Target::Codex);
        assert!(path.ends_with("system-prompt-codex.md"));
        assert_snippet_anchors(Target::Codex, &body);
        assert_snippet_token_budget(&body);
    }

    #[test]
    fn snippet_continue_has_anchors_and_under_budget() {
        let (path, body) = emit_snippet_isolated(Target::Continue);
        assert!(path.ends_with("system-prompt-continue.md"));
        assert_snippet_anchors(Target::Continue, &body);
        assert_snippet_token_budget(&body);
    }

    #[test]
    fn snippet_every_target_emits_under_budget() {
        // The full per-target sweep — every harness gets its own
        // snippet file, each within budget and carrying every anchor.
        //
        // Hold `snippet_env_lock` across the entire iteration. Without
        // it, the per-target tests below (snippet_claude_code_*,
        // snippet_cursor_*, etc.) can grab the lock between iterations
        // and clobber `AI_MEMORY_SYSTEM_PROMPT_DIR`, racing with the
        // emit + readback this loop performs. Observed as flaky
        // assertion at `assert!(body.contains(harness))` under
        // `cargo test` default `--test-threads=N` on Linux + macOS CI.
        //
        // v0.7.0 pre-cert audit: confirmed each rendered snippet fits
        // ~173–183 chars/4 tokens against the 200-token ceiling
        // enforced by `assert_snippet_token_budget`. Headroom is
        // intentionally tight — when adding a new shared anchor or a
        // longer per-harness hint, recompute the worst-case render
        // length first (the longest body is currently `claude-code`
        // because of the ToolSearch hint). A budget overshoot here is
        // load-bearing: the snippet ships verbatim into every
        // downstream harness's system prompt, so a regression
        // silently eats their context window. If you trip this
        // assertion, trim the offending bullet — do NOT bump the
        // budget without a spec change.
        // Uses the dir-parameterised helper so each target gets its own
        // tempdir without touching the process-global
        // `AI_MEMORY_SYSTEM_PROMPT_DIR` env var. Eliminates the
        // `snippet_env_lock` cross-test race that flaked this assertion
        // under `--test-threads > 1` (§16 12-gate sweep observation,
        // 2026-05-13, v0.7.1-fold).
        for target in [
            Target::ClaudeCode,
            Target::Openclaw,
            Target::Cursor,
            Target::Cline,
            Target::Continue,
            Target::Windsurf,
            Target::ClaudeDesktop,
            Target::Codex,
            Target::GrokCli,
            Target::GeminiCli,
        ] {
            let tmp = tempfile::tempdir().expect("tempdir");
            let tmp_path = tmp.path().to_path_buf();
            let snippet_path =
                write_system_prompt_snippet_to(target, &tmp_path).expect("snippet write");
            let body = fs::read_to_string(&snippet_path).expect("read snippet");
            std::mem::forget(tmp);
            assert!(
                snippet_path.exists(),
                "snippet file for {} not created",
                target.name(),
            );
            assert_snippet_anchors(target, &body);
            assert_snippet_token_budget(&body);
        }
    }

    #[test]
    fn snippet_emitted_during_install_apply_via_env_override() {
        // End-to-end: invoking `run()` in apply mode for a target
        // produces the snippet file at the env-overridden path and
        // logs the "wrote system-prompt snippet" line to stderr.
        let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
        let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
        // SAFETY: mutation serialised by snippet_env_lock for this
        // test's duration.
        unsafe {
            std::env::set_var("AI_MEMORY_SYSTEM_PROMPT_DIR", snippet_dir.path());
        }

        let mut env = TestEnv::fresh();
        let cfg = config_path(&env, "settings.json");
        seed(&cfg, "{}\n");
        run(
            &args_for_apply(Target::ClaudeCode, cfg.clone()),
            &mut env.output(),
        )
        .unwrap();

        let stderr = env.stderr_str();
        assert!(
            stderr.contains("system-prompt snippet"),
            "stderr should announce snippet write; got:\n{stderr}",
        );
        assert!(
            stderr.contains("claude-code"),
            "stderr should mention the harness name; got:\n{stderr}",
        );

        let snippet = snippet_dir.path().join("system-prompt-claude-code.md");
        assert!(
            snippet.exists(),
            "snippet should exist at {}",
            snippet.display(),
        );
        let body = fs::read_to_string(&snippet).unwrap();
        assert_snippet_anchors(Target::ClaudeCode, &body);

        unsafe {
            std::env::remove_var("AI_MEMORY_SYSTEM_PROMPT_DIR");
        }
        drop(snippet_dir);
    }

    #[test]
    fn snippet_not_emitted_on_uninstall() {
        // Uninstall path skips the snippet write — there's nothing
        // to teach the agent once the server is gone.
        let _g = snippet_env_lock().lock().unwrap_or_else(|e| e.into_inner());
        let snippet_dir = tempfile::tempdir().expect("snippet tempdir");
        unsafe {
            std::env::set_var("AI_MEMORY_SYSTEM_PROMPT_DIR", snippet_dir.path());
        }

        let mut env = TestEnv::fresh();
        let cfg = config_path(&env, "settings.json");
        // Seed an existing managed block so uninstall has work to do.
        seed(&cfg, "{}\n");
        run(
            &args_for_apply(Target::ClaudeCode, cfg.clone()),
            &mut env.output(),
        )
        .unwrap();
        // Reset stderr capture so we only see the uninstall-phase output.
        env.stderr.clear();

        // Now uninstall.
        run(
            &args_for_uninstall_apply(Target::ClaudeCode, cfg.clone()),
            &mut env.output(),
        )
        .unwrap();
        let stderr = env.stderr_str();
        assert!(
            !stderr.contains("system-prompt snippet"),
            "uninstall must not announce a snippet write; got:\n{stderr}",
        );

        unsafe {
            std::env::remove_var("AI_MEMORY_SYSTEM_PROMPT_DIR");
        }
        drop(snippet_dir);
    }

    // ------------------------------------------------------------------
    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
    // ------------------------------------------------------------------

    fn args_no_config(target: Target) -> InstallArgs {
        // TargetArgs with config=None forces the default-path discovery
        // branch in resolve_config_path.
        let t = TargetArgs {
            config: None,
            apply: false,
            dry_run: false,
            uninstall: false,
            binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
            hook: None,
            force: false,
        };
        let target_cmd = match target {
            Target::ClaudeCode => TargetCmd::ClaudeCode(t),
            Target::Openclaw => TargetCmd::Openclaw(t),
            Target::Cursor => TargetCmd::Cursor(t),
            Target::Cline => TargetCmd::Cline(t),
            Target::Continue => TargetCmd::Continue(t),
            Target::Windsurf => TargetCmd::Windsurf(t),
            Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
            Target::Codex => TargetCmd::Codex(t),
            Target::GrokCli => TargetCmd::GrokCli(t),
            Target::GeminiCli => TargetCmd::GeminiCli(t),
        };
        InstallArgs { target: target_cmd }
    }

    #[test]
    fn resolve_config_path_openclaw_bails_without_config() {
        let r = resolve_config_path(
            Target::Openclaw,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        );
        let err = r.unwrap_err();
        assert!(format!("{err}").contains("openclaw config path"));
    }

    #[test]
    fn resolve_config_path_cline_bails_without_config() {
        let r = resolve_config_path(
            Target::Cline,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        );
        let err = r.unwrap_err();
        assert!(format!("{err}").contains("cline config path"));
    }

    #[test]
    fn resolve_config_path_codex_bails_without_config() {
        let r = resolve_config_path(
            Target::Codex,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        );
        let err = r.unwrap_err();
        assert!(format!("{err}").contains("codex config path"));
    }

    #[test]
    fn resolve_config_path_grok_cli_bails_without_config() {
        let r = resolve_config_path(
            Target::GrokCli,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        );
        let err = r.unwrap_err();
        assert!(format!("{err}").contains("grok-cli config path"));
    }

    #[test]
    fn resolve_config_path_gemini_cli_bails_without_config() {
        let r = resolve_config_path(
            Target::GeminiCli,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        );
        let err = r.unwrap_err();
        assert!(format!("{err}").contains("gemini-cli config path"));
    }

    #[test]
    fn resolve_config_path_claude_code_default_under_home() {
        // Drives the `home.join(".claude").join("settings.json")` branch
        // (line 499). We don't assert the home directory contents — just
        // that the resolution succeeds and ends in `.claude/settings.json`.
        let r = resolve_config_path(
            Target::ClaudeCode,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        )
        .expect("home dir present on test host");
        let s = r.to_string_lossy().to_string();
        assert!(s.ends_with(".claude/settings.json") || s.ends_with(".claude\\settings.json"));
    }

    #[test]
    fn resolve_config_path_cursor_default_under_home() {
        let r = resolve_config_path(
            Target::Cursor,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        )
        .expect("home dir");
        let s = r.to_string_lossy().to_string();
        assert!(s.ends_with(".cursor/mcp.json") || s.ends_with(".cursor\\mcp.json"));
    }

    #[test]
    fn resolve_config_path_continue_default_under_home() {
        let r = resolve_config_path(
            Target::Continue,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        )
        .expect("home dir");
        let s = r.to_string_lossy().to_string();
        assert!(s.ends_with(".continue/config.json") || s.ends_with(".continue\\config.json"));
    }

    #[test]
    fn resolve_config_path_windsurf_default_under_home() {
        let r = resolve_config_path(
            Target::Windsurf,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        )
        .expect("home dir");
        let s = r.to_string_lossy().to_string();
        assert!(s.ends_with("mcp_config.json"), "got: {s}");
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn resolve_config_path_claude_desktop_default_under_macos() {
        let r = resolve_config_path(
            Target::ClaudeDesktop,
            &TargetArgs {
                config: None,
                ..TargetArgs::default()
            },
        )
        .expect("home dir");
        let s = r.to_string_lossy().to_string();
        assert!(s.ends_with("claude_desktop_config.json"), "got: {s}");
    }

    #[test]
    fn install_dispatches_through_run_with_default_config_on_unsupported_target() {
        // Driving `run()` end-to-end with `args.config=None` for a
        // target whose default-path is a `bail!` covers the
        // `run` -> `resolve_config_path` error propagation path.
        let args = args_no_config(Target::Codex);
        let mut env = TestEnv::fresh();
        let err = run(&args, &mut env.output()).unwrap_err();
        assert!(format!("{err}").contains("codex config path"));
    }

    #[test]
    fn read_config_or_empty_handles_whitespace_only_file() {
        // Drives the `text.trim().is_empty()` branch (line 638).
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join("blank.json");
        std::fs::write(&p, "   \n  \n").unwrap();
        let (text, val) = read_config_or_empty(&p).unwrap();
        assert!(!text.is_empty()); // we returned the original text
        assert!(val.is_object() && val.as_object().unwrap().is_empty());
    }

    #[test]
    fn read_config_or_empty_handles_missing_file() {
        // Drives the `!path.exists()` branch (line 633).
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join("nonexistent.json");
        let (text, val) = read_config_or_empty(&p).unwrap();
        assert!(text.is_empty());
        assert!(val.is_object() && val.as_object().unwrap().is_empty());
    }

    #[test]
    fn install_apply_rejects_non_object_json_root() {
        // ensure_object refuses array-shaped roots (line 743).
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "array.json");
        seed(&path, "[]");
        let err = run(
            &args_for_apply(Target::Cursor, path.clone()),
            &mut env.output(),
        )
        .unwrap_err();
        assert!(format!("{err}").contains("not a JSON object"));
    }

    #[test]
    fn install_dry_run_emits_unified_diff_with_minus_and_plus_lines() {
        // Drives every variant of `emit_diff`: equal line (line 981),
        // changed line (982-984), removed-only (986), added-only (987).
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "diff-source.json");
        // Use a baseline with multiple keys so the install diff has
        // identical lines, changed lines, and added lines.
        seed(&path, "{\n  \"theme\": \"dark\"\n}\n");
        run(&args_for(Target::Cursor, path.clone()), &mut env.output()).unwrap();
        let stdout = env.stdout_str();
        // The diff format emits ` ` (context) / `+` (added) / `-` (changed)
        // line prefixes for the cursor block's new lines.
        assert!(
            stdout.lines().any(|l| l.starts_with('+')),
            "expected at least one added line, got:\n{stdout}"
        );
    }

    #[test]
    fn remove_mcp_standard_no_op_on_clean_config() {
        // Drives the path through `remove_mcp_standard` where there is
        // no `mcpServers` key at all (line 738 not-entered branch).
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "clean.json");
        seed(&path, "{}\n");
        run(
            &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert!(parsed.as_object().unwrap().is_empty());
    }

    #[test]
    fn remove_claude_code_no_op_when_user_has_empty_hooks() {
        // Pre-install we set `hooks: {}` in the config; install adds
        // SessionStart, uninstall must leave nothing behind. Drives the
        // empty-hooks branch (lines 796-797, 800-804).
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, r#"{"hooks":{}}"#);
        // Install then uninstall.
        run(
            &args_for_apply(Target::ClaudeCode, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        run(
            &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        // We expected the user's empty `hooks` to either survive or be
        // pruned — `remove_claude_code` removes empty hooks objects.
        assert!(parsed.get("hooks").is_none());
    }

    #[test]
    fn install_run_creates_missing_parent_directory() {
        // Drives the `create_dir_all` branch (lines 311-316) by passing
        // a config path whose parent does not exist.
        let mut env = TestEnv::fresh();
        let dir = env.db_path.parent().unwrap().to_path_buf();
        let nested = dir.join("not").join("yet").join("here").join("mcp.json");
        assert!(!nested.parent().unwrap().exists());
        run(
            &args_for_apply(Target::Cursor, nested.clone()),
            &mut env.output(),
        )
        .unwrap();
        assert!(nested.exists());
    }

    #[test]
    fn resolve_binary_falls_through_when_no_override() {
        // Drives the resolve_binary branch without a `--binary` override.
        // Either `which_ai_memory` returns Some, or the function falls
        // back to current_exe(). Both branches exit through this fn.
        let s = resolve_binary(None);
        assert!(!s.is_empty(), "resolved binary path should be non-empty");
    }

    #[test]
    fn which_ai_memory_returns_some_when_path_has_binary() {
        // Drives the success branch of `which_ai_memory` (line 614).
        // We construct a tempdir, drop a synthetic "ai-memory" binary
        // inside, then temporarily set $PATH to point at it.
        use std::sync::Mutex;
        static PATH_LOCK: Mutex<()> = Mutex::new(());
        let _g = PATH_LOCK.lock().unwrap();

        let tmp = tempfile::tempdir().unwrap();
        let bin = tmp.path().join("ai-memory");
        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
        // Chmod 0755 so `is_file()` returns true.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
        let orig = std::env::var_os("PATH");
        // SAFETY: serialized via PATH_LOCK; restored at scope end.
        unsafe {
            std::env::set_var("PATH", tmp.path());
        }
        let found = which_ai_memory();
        // Restore PATH.
        unsafe {
            if let Some(p) = orig {
                std::env::set_var("PATH", p);
            } else {
                std::env::remove_var("PATH");
            }
        }
        assert!(found.is_some(), "expected to find ai-memory under $PATH");
    }

    // ------------------------------------------------------------------
    // v0.7.0 policy-engine item 2 — PreToolUse hook installer
    // ------------------------------------------------------------------
    //
    // Tier B coverage (≥95%) target on every new code path in this
    // module. Each branch below pins one behaviour of the installer's
    // PreToolUse mode:
    //
    // - `pretool_entry_shape_matches_documented_form`: the JSON we
    //   emit is the same shape Claude Code consumes (matcher + hooks
    //   array, type=mcp_tool, tool=memory_check_agent_action).
    // - `apply_then_remove_round_trips`: install + uninstall returns
    //   the config to its original shape, modulo whitespace.
    // - `apply_appends_to_existing_pretooluse`: an operator-authored
    //   entry is preserved when we install on top.
    // - `apply_is_idempotent_when_rerun`: re-installing under the
    //   managed marker leaves exactly one managed entry.
    // - `apply_refuses_conflict_without_force`: a non-managed entry
    //   that ALSO names our MCP tool with a different matcher
    //   triggers the refusal path.
    // - `apply_overwrites_conflict_with_force`: same with --force
    //   replaces the conflicting entry.
    // - `apply_rejects_hook_flag_on_non_claude_code`: the cross-target
    //   gate in `run` errors loudly when --hook is set on, say,
    //   cursor.

    fn args_for_pretool_apply(config: PathBuf) -> InstallArgs {
        let t = TargetArgs {
            config: Some(config),
            apply: true,
            dry_run: false,
            uninstall: false,
            binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
            hook: Some(HookKind::Pretool),
            force: false,
        };
        InstallArgs {
            target: TargetCmd::ClaudeCode(t),
        }
    }

    fn args_for_pretool_dry_run(config: PathBuf) -> InstallArgs {
        let mut a = args_for_pretool_apply(config);
        match &mut a.target {
            TargetCmd::ClaudeCode(t) => t.apply = false,
            _ => unreachable!(),
        }
        a
    }

    fn args_for_pretool_uninstall(config: PathBuf) -> InstallArgs {
        let mut a = args_for_pretool_apply(config);
        match &mut a.target {
            TargetCmd::ClaudeCode(t) => {
                t.uninstall = true;
            }
            _ => unreachable!(),
        }
        a
    }

    fn args_for_pretool_apply_force(config: PathBuf) -> InstallArgs {
        let mut a = args_for_pretool_apply(config);
        match &mut a.target {
            TargetCmd::ClaudeCode(t) => t.force = true,
            _ => unreachable!(),
        }
        a
    }

    #[test]
    fn pretool_entry_shape_matches_documented_form() {
        // #1811 — the entry is now a `type:command` wrapper (mcp_tool
        // hooks cannot block on Refuse). 5-agent vote `4d3ea1c5`.
        let v = claude_code_pretool_entry("ai-memory");
        // Scoped to the modeled action surface, NOT "*" (issue #1667).
        assert_eq!(PRETOOL_HOOK_MATCHER, "Bash|Edit|Write");
        assert_eq!(v["matcher"], PRETOOL_HOOK_MATCHER);
        assert_eq!(v["hooks"][0]["type"], "command");
        let cmd = v["hooks"][0]["command"].as_str().unwrap();
        assert!(
            cmd.contains("governance check-action --from-pretool-stdin"),
            "the hook must invoke the enforcing stdin wrapper, got: {cmd}"
        );
        assert!(
            cmd.starts_with("ai-memory "),
            "uses the resolved binary, got: {cmd}"
        );
        assert!(v[MARKER_START_KEY].is_string());
        assert!(v[MARKER_END_KEY].is_string());
    }

    #[test]
    fn pretool_conflict_detector_recognises_same_tool() {
        let v = serde_json::json!({
            "matcher": "Bash",
            "hooks": [
                { "type": "mcp_tool", "tool": "memory_check_agent_action" }
            ]
        });
        assert_eq!(pretool_conflict_matcher(&v).as_deref(), Some("Bash"));
    }

    #[test]
    fn pretool_conflict_detector_ignores_managed_blocks() {
        let v = claude_code_pretool_entry("ai-memory");
        assert!(pretool_conflict_matcher(&v).is_none());
    }

    #[test]
    fn pretool_conflict_detector_ignores_other_tools() {
        let v = serde_json::json!({
            "matcher": "*",
            "hooks": [
                { "type": "command", "command": "echo hi" }
            ]
        });
        assert!(pretool_conflict_matcher(&v).is_none());
    }

    #[test]
    fn pretool_install_apply_writes_documented_entry() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{}\n");
        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
        let written = fs::read_to_string(&path).unwrap();
        let parsed: Value = serde_json::from_str(&written).unwrap();
        let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
        // Exactly one managed entry.
        assert_eq!(arr.len(), 1);
        let entry = &arr[0];
        assert_eq!(entry["matcher"], PRETOOL_HOOK_MATCHER);
        // #1811 — `type:command` wrapper (mcp_tool cannot block). Vote 4d3ea1c5.
        assert_eq!(entry["hooks"][0]["type"], "command");
        assert!(
            entry["hooks"][0]["command"]
                .as_str()
                .unwrap()
                .contains("governance check-action --from-pretool-stdin")
        );
        assert!(env.stdout_str().contains("installed PreToolUse hook ->"));
    }

    #[test]
    fn pretool_install_preserves_existing_keys() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(
            &path,
            r#"{"permissions":{"allow":["npm:*"]},"env":{"FOO":"bar"}}"#,
        );
        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
        assert_eq!(parsed["env"]["FOO"], "bar");
        assert!(parsed["hooks"]["PreToolUse"].is_array());
    }

    #[test]
    fn pretool_install_appends_to_existing_pretooluse_array() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        // Operator already has one PreToolUse entry (an unrelated command hook).
        seed(
            &path,
            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}"#,
        );
        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
        assert_eq!(arr.len(), 2, "operator entry + our managed entry");
        // First entry is the operator's; second is ours.
        assert_eq!(arr[0]["matcher"], "Bash");
        assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
        assert_eq!(arr[1]["matcher"], PRETOOL_HOOK_MATCHER);
        assert_eq!(arr[1]["hooks"][0]["type"], "command");
        assert!(
            arr[1]["hooks"][0]["command"]
                .as_str()
                .unwrap()
                .contains("governance check-action --from-pretool-stdin")
        );
    }

    #[test]
    fn pretool_install_is_idempotent() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{}\n");
        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
        let first = fs::read_to_string(&path).unwrap();
        env.stdout.clear();
        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
        let second = fs::read_to_string(&path).unwrap();
        assert_eq!(first, second);
        assert!(env.stdout_str().contains("no-op"));
    }

    #[test]
    fn pretool_install_refuses_overwrite_without_force() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        // Pre-existing PreToolUse entry that ALSO names memory_check_agent_action
        // but with a non-`*` matcher — i.e. the operator scoped the hook
        // intentionally. Clobbering would silently change their policy.
        seed(
            &path,
            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","tool":"memory_check_agent_action"}]}]}}"#,
        );
        let err = run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("--force"),
            "error should mention --force: {msg}"
        );
        // File must NOT have been modified.
        let still = serde_json::from_str::<Value>(&fs::read_to_string(&path).unwrap()).unwrap();
        let arr = still["hooks"]["PreToolUse"].as_array().unwrap();
        assert_eq!(arr.len(), 1, "no new entry appended on refusal");
        assert_eq!(arr[0]["matcher"], "Bash");
        // Stderr should explain the conflict.
        assert!(
            env.stderr_str().contains("existing PreToolUse entry"),
            "stderr should contain conflict warning: {}",
            env.stderr_str()
        );
    }

    #[test]
    fn pretool_install_overwrites_conflict_with_force() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(
            &path,
            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","tool":"memory_check_agent_action"}]}]}}"#,
        );
        run(
            &args_for_pretool_apply_force(path.clone()),
            &mut env.output(),
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
        // Conflicting entry replaced with ours (scoped matcher, #1667).
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["matcher"], PRETOOL_HOOK_MATCHER);
        assert_eq!(arr[0]["hooks"][0]["type"], "command");
        assert!(
            arr[0]["hooks"][0]["command"]
                .as_str()
                .unwrap()
                .contains("governance check-action --from-pretool-stdin")
        );
        assert!(arr[0][MARKER_START_KEY].is_string());
    }

    #[test]
    fn pretool_uninstall_removes_managed_block_only() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(
            &path,
            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]},"theme":"dark"}"#,
        );
        // Install on top.
        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
        // Uninstall.
        run(&args_for_pretool_uninstall(path.clone()), &mut env.output()).unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed["theme"], "dark");
        // The operator's PreToolUse entry survives; ours is gone.
        let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["matcher"], "Bash");
        assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
    }

    #[test]
    fn pretool_uninstall_clean_config_is_safe_noop() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{}\n");
        // Uninstall against a config that never had the hook.
        run(&args_for_pretool_uninstall(path.clone()), &mut env.output()).unwrap();
        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert!(parsed.as_object().unwrap().is_empty());
    }

    #[test]
    fn pretool_dry_run_does_not_write() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{\n}\n");
        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
        run(&args_for_pretool_dry_run(path.clone()), &mut env.output()).unwrap();
        let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
        assert_eq!(mtime_before, mtime_after, "dry-run must not write");
        let stdout = env.stdout_str();
        assert!(stdout.contains("dry-run"));
        assert!(stdout.contains("PreToolUse"));
        // #1811 — the dry-run preview now shows the `type:command` wrapper.
        assert!(stdout.contains("governance check-action"));
    }

    #[test]
    fn pretool_install_rejects_hook_flag_on_non_claude_code() {
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "mcp.json");
        seed(&path, "{}\n");
        let mut a = args_for_pretool_apply(path.clone());
        // Wrap a Cursor target around the same TargetArgs.
        let t_args = match a.target {
            TargetCmd::ClaudeCode(t) => t,
            _ => unreachable!(),
        };
        a.target = TargetCmd::Cursor(t_args);
        let err = run(&a, &mut env.output()).unwrap_err();
        assert!(
            format!("{err}").contains("only supported for `claude-code`"),
            "got: {err}"
        );
    }

    #[test]
    fn pretool_install_does_not_emit_system_prompt_snippet() {
        // Hook-mode installs are the load-bearing mechanism; no snippet
        // is required. Pin that behaviour so we don't accidentally
        // re-add the snippet emission and clutter operator stderr.
        let mut env = TestEnv::fresh();
        let path = config_path(&env, "settings.json");
        seed(&path, "{}\n");
        run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
        assert!(
            !env.stderr_str().contains("system-prompt snippet"),
            "stderr should NOT mention the system-prompt snippet under --hook pretool: {}",
            env.stderr_str()
        );
    }
}
