Skip to main content

ai_memory/cli/
install.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory install <agent>` — wire `ai-memory boot` and the
5//! `ai-memory-mcp` server into AI agents' config files (issue #487 PR-2/3).
6//!
7//! Each target writes a precisely-marked **managed block** so re-running
8//! `install` is a no-op and `--uninstall` removes the block surgically
9//! without disturbing user-added keys.
10//!
11//! ## Behavior summary
12//!
13//! - **Default `--dry-run`**: prints the diff (pretty before/after JSON,
14//!   plus a unified diff over the JSON serialization) and writes nothing.
15//! - **`--apply`**: writes the modified config; backs up the original to
16//!   `<config>.bak.<timestamp>` first.
17//! - **`--uninstall`**: removes the managed block, leaving any unrelated
18//!   keys the user added intact.
19//!
20//! ## Idempotent marker
21//!
22//! Every managed block has a sentinel key:
23//!
24//! ```text
25//! "// ai-memory:managed-block:start": "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487"
26//! ```
27//!
28//! plus an `// ai-memory:managed-block:end` sibling. When present, the
29//! installer recognises the existing block and:
30//!
31//! - On install: replaces it with the freshly-rendered block (so config
32//!   bumps in future ai-memory releases land cleanly).
33//! - On uninstall: removes both sentinel keys and any siblings the
34//!   installer originally inserted.
35//!
36//! ## Targets
37//!
38//! See [`Target`] for the full list. Each target's specifics
39//! (config path discovery, JSON shape) live in `apply_target_*` helpers.
40
41use crate::cli::CliOutput;
42use anyhow::{Context, Result, anyhow, bail};
43use clap::{Args, Subcommand, ValueEnum};
44use serde_json::{Map, Value};
45use std::path::{Path, PathBuf};
46
47/// Sentinel key that marks the start of a managed block. Used by both
48/// install (to recognise an existing block) and uninstall (to find the
49/// block to remove).
50const MARKER_START_KEY: &str = "// ai-memory:managed-block:start";
51const MARKER_END_KEY: &str = "// ai-memory:managed-block:end";
52
53/// Marker payload — the human-readable note bundled with the start key.
54/// Updating this string is a no-op on installs already in the wild
55/// because the recognition predicate keys off `MARKER_START_KEY` only.
56const MARKER_PAYLOAD: &str = "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487";
57
58/// Sibling-key list each target stamps inside the managed block. We track
59/// this list so uninstall removes exactly the keys we wrote and leaves
60/// any user-added siblings alone (defence-in-depth against a user
61/// editing `// ai-memory:managed-block:end` out of the file).
62const MANAGED_KEYS_PROPERTY: &str = "// ai-memory:managed-keys";
63
64/// Args for `ai-memory install`.
65#[derive(Args, Debug)]
66pub struct InstallArgs {
67    /// The agent target to install into.
68    #[command(subcommand)]
69    pub target: TargetCmd,
70}
71
72/// Per-target subcommand. Each variant carries the same shared option
73/// set (`--apply`, `--uninstall`, `--config <path>`) — clap-derive
74/// renders one subcommand per target so users get tab-completion on
75/// the agent name and per-target `--help`.
76#[derive(Subcommand, Debug)]
77pub enum TargetCmd {
78    /// Claude Code SessionStart hook. Writes `~/.claude/settings.json`.
79    ClaudeCode(TargetArgs),
80    /// OpenClaw MCP servers. Path documented at
81    /// <https://docs.openclaw.ai/cli/mcp>; pass `--config <path>` if your
82    /// install puts it elsewhere.
83    Openclaw(TargetArgs),
84    /// Cursor MCP servers. Writes `~/.cursor/mcp.json`.
85    Cursor(TargetArgs),
86    /// Cline MCP settings. Path varies by Cline version; pass
87    /// `--config <path>` to override.
88    Cline(TargetArgs),
89    /// Continue MCP servers. Writes `~/.continue/config.json`.
90    Continue(TargetArgs),
91    /// Windsurf (Codeium) MCP servers. Writes
92    /// `~/.codeium/windsurf/mcp_config.json`.
93    Windsurf(TargetArgs),
94
95    // ---- v0.6.4-010 — cross-harness install profiles ----
96    /// Claude Desktop MCP servers (writes the macOS/Windows config;
97    /// pass `--config <path>` on Linux). Args include
98    /// `--profile core` (the v0.6.4 default).
99    ClaudeDesktop(TargetArgs),
100    /// OpenAI Codex CLI MCP servers. Pass `--config <path>` since the
101    /// canonical Codex config path varies by Codex version. Args
102    /// include `--profile core`.
103    Codex(TargetArgs),
104    /// xAI Grok CLI MCP servers. Pass `--config <path>` since the
105    /// Grok CLI config path varies by version. Args include
106    /// `--profile core`.
107    GrokCli(TargetArgs),
108    /// Google Gemini CLI MCP servers. Pass `--config <path>` since the
109    /// Gemini CLI config path varies by version. Args include
110    /// `--profile core`.
111    GeminiCli(TargetArgs),
112}
113
114/// Shared per-target args. Constructed identically for every target so
115/// the dispatch table can pull them out generically.
116#[derive(Args, Debug, Default, Clone)]
117pub struct TargetArgs {
118    /// Override the default config path (the home-dir resolution).
119    /// REQUIRED for tests so they never touch `~/.claude/settings.json`
120    /// on the host machine.
121    #[arg(long, value_name = "PATH")]
122    pub config: Option<PathBuf>,
123
124    /// Actually write the file. Without `--apply`, the installer
125    /// runs in dry-run mode (the default) and prints what would change.
126    /// Mutually exclusive with `--dry-run`. Combine with `--uninstall`
127    /// to actually remove the managed block.
128    #[arg(long, default_value_t = false, conflicts_with = "dry_run")]
129    pub apply: bool,
130
131    /// Force dry-run mode. This is the default, so the flag is mostly
132    /// useful in scripts that want to make the no-write contract
133    /// explicit. Mutually exclusive with `--apply`.
134    #[arg(long, default_value_t = false)]
135    pub dry_run: bool,
136
137    /// Remove the managed block instead of installing it. Default mode
138    /// is dry-run; pair with `--apply` to actually delete the block.
139    #[arg(long, default_value_t = false)]
140    pub uninstall: bool,
141
142    /// Override the resolved `ai-memory` binary path written into the
143    /// generated config's `command` field. By default the installer
144    /// uses the binary's own `current_exe()` if `ai-memory` is not on
145    /// `$PATH`, otherwise the bare string `ai-memory`.
146    #[arg(long, value_name = "PATH")]
147    pub binary: Option<PathBuf>,
148}
149
150/// Concrete target enum used internally. `TargetCmd` carries clap
151/// metadata; `Target` is a stable tag for the dispatch table and
152/// derives `ValueEnum` for completeness.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
154pub enum Target {
155    ClaudeCode,
156    Openclaw,
157    Cursor,
158    Cline,
159    Continue,
160    Windsurf,
161    // v0.6.4-010 — additional MCP harnesses.
162    ClaudeDesktop,
163    Codex,
164    GrokCli,
165    GeminiCli,
166}
167
168impl Target {
169    /// Display name used in stdout and managed-keys metadata.
170    fn name(self) -> &'static str {
171        match self {
172            Self::ClaudeCode => "claude-code",
173            Self::Openclaw => "openclaw",
174            Self::Cursor => "cursor",
175            Self::Cline => "cline",
176            Self::Continue => "continue",
177            Self::Windsurf => "windsurf",
178            Self::ClaudeDesktop => "claude-desktop",
179            Self::Codex => "codex",
180            Self::GrokCli => "grok-cli",
181            Self::GeminiCli => "gemini-cli",
182        }
183    }
184}
185
186impl TargetCmd {
187    fn target(&self) -> Target {
188        match self {
189            Self::ClaudeCode(_) => Target::ClaudeCode,
190            Self::Openclaw(_) => Target::Openclaw,
191            Self::Cursor(_) => Target::Cursor,
192            Self::Cline(_) => Target::Cline,
193            Self::Continue(_) => Target::Continue,
194            Self::Windsurf(_) => Target::Windsurf,
195            Self::ClaudeDesktop(_) => Target::ClaudeDesktop,
196            Self::Codex(_) => Target::Codex,
197            Self::GrokCli(_) => Target::GrokCli,
198            Self::GeminiCli(_) => Target::GeminiCli,
199        }
200    }
201
202    fn args(&self) -> &TargetArgs {
203        match self {
204            Self::ClaudeCode(a)
205            | Self::Openclaw(a)
206            | Self::Cursor(a)
207            | Self::Cline(a)
208            | Self::Continue(a)
209            | Self::Windsurf(a)
210            | Self::ClaudeDesktop(a)
211            | Self::Codex(a)
212            | Self::GrokCli(a)
213            | Self::GeminiCli(a) => a,
214        }
215    }
216}
217
218/// `ai-memory install <agent>` entry point.
219///
220/// # Errors
221///
222/// Returns an error when the existing config is not valid JSON, when the
223/// resolved config path can't be determined (and `--config` was not
224/// passed), or when an `--apply` write fails (permission denied,
225/// disk full, etc.).
226pub fn run(args: &InstallArgs, out: &mut CliOutput<'_>) -> Result<()> {
227    let target = args.target.target();
228    let t_args = args.target.args();
229
230    let config_path = resolve_config_path(target, t_args)?;
231    let binary = resolve_binary(t_args.binary.as_deref());
232
233    // Read existing config (if any) and parse. If absent, treat as `{}`.
234    // If present and malformed, error out — never overwrite a malformed
235    // config (the user might have made a typo we can help them fix).
236    let (before_text, before_value) = read_config_or_empty(&config_path)?;
237
238    // Compute the desired after-state.
239    let after_value = if t_args.uninstall {
240        remove_managed_block(target, before_value.clone())?
241    } else {
242        apply_managed_block(target, before_value.clone(), &binary)?
243    };
244
245    // Pretty-print both for diff display and for the eventual write.
246    let after_text = serde_json::to_string_pretty(&after_value)? + "\n";
247
248    // Round-trip check: re-parse what we serialized so we never write
249    // bytes we couldn't read back.
250    let _: Value = serde_json::from_str(&after_text)
251        .context("internal error: serialised config did not round-trip through JSON parser")?;
252
253    let action_label = if t_args.uninstall {
254        "uninstall"
255    } else {
256        "install"
257    };
258
259    if before_text.trim() == after_text.trim() {
260        writeln!(
261            out.stdout,
262            "ai-memory install: {target} {action} is a no-op (managed block already in desired state)",
263            target = target.name(),
264            action = action_label,
265        )?;
266        return Ok(());
267    }
268
269    if !t_args.apply {
270        // Dry-run mode (the default). Emit a unified-style diff so the
271        // caller can scrutinise the change before opting in to write.
272        writeln!(
273            out.stdout,
274            "ai-memory install: dry-run for {target} {action} at {path}",
275            target = target.name(),
276            action = action_label,
277            path = config_path.display(),
278        )?;
279        writeln!(out.stdout, "--- before")?;
280        writeln!(out.stdout, "+++ after")?;
281        emit_diff(out, &before_text, &after_text)?;
282        writeln!(
283            out.stdout,
284            "ai-memory install: re-run with --apply to write the changes"
285        )?;
286        return Ok(());
287    }
288
289    // Apply mode. Backup first, then write.
290    let backup_path = if config_path.exists() {
291        let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
292        let backup = config_path.with_extension(format!(
293            "{ext}bak.{ts}",
294            ext = match config_path.extension().and_then(|e| e.to_str()) {
295                Some(existing) => format!("{existing}."),
296                None => String::new(),
297            }
298        ));
299        std::fs::copy(&config_path, &backup).with_context(|| {
300            format!(
301                "backing up {} to {}",
302                config_path.display(),
303                backup.display()
304            )
305        })?;
306        Some(backup)
307    } else {
308        None
309    };
310
311    if let Some(parent) = config_path.parent()
312        && !parent.as_os_str().is_empty()
313    {
314        std::fs::create_dir_all(parent)
315            .with_context(|| format!("creating parent directory {}", parent.display()))?;
316    }
317
318    std::fs::write(&config_path, &after_text)
319        .with_context(|| format!("writing {}", config_path.display()))?;
320
321    writeln!(
322        out.stdout,
323        "ai-memory install: {action} applied to {path}",
324        action = action_label,
325        path = config_path.display(),
326    )?;
327    if let Some(b) = backup_path {
328        writeln!(out.stdout, "ai-memory install: backup at {}", b.display())?;
329    }
330    Ok(())
331}
332
333// ---------------------------------------------------------------------------
334// Config-path resolution
335// ---------------------------------------------------------------------------
336
337fn resolve_config_path(target: Target, args: &TargetArgs) -> Result<PathBuf> {
338    if let Some(ref p) = args.config {
339        return Ok(p.clone());
340    }
341    let home = dirs::home_dir()
342        .ok_or_else(|| anyhow!("could not resolve home directory; pass --config <path>"))?;
343    let p = match target {
344        Target::ClaudeCode => home.join(".claude").join("settings.json"),
345        Target::Openclaw => {
346            // OpenClaw's documented MCP config path is not stable across
347            // versions; the canonical location is documented at
348            // https://docs.openclaw.ai/cli/mcp. We require --config for
349            // OpenClaw to avoid guessing and writing to the wrong file.
350            // TODO(#487): once OpenClaw publishes a stable canonical path,
351            // wire it in here.
352            bail!(
353                "openclaw config path is not auto-discovered yet; pass --config <path>. \
354                 See https://docs.openclaw.ai/cli/mcp for the canonical location."
355            );
356        }
357        Target::Cursor => home.join(".cursor").join("mcp.json"),
358        Target::Cline => {
359            // Cline's config path varies by version (mcp_settings.json
360            // location moved between releases). Require explicit --config
361            // until upstream stabilises.
362            // TODO(#487): once Cline pins a canonical path, wire it.
363            bail!(
364                "cline config path varies by version; pass --config <path> \
365                 (typically ~/.cline/mcp_settings.json or under the VS Code \
366                 extension data dir)."
367            );
368        }
369        Target::Continue => home.join(".continue").join("config.json"),
370        Target::Windsurf => home
371            .join(".codeium")
372            .join("windsurf")
373            .join("mcp_config.json"),
374        // v0.6.4-010 — claude-desktop has documented OS-specific paths.
375        // Linux is unstable (depends on AppImage / Flatpak distribution),
376        // so require --config there.
377        Target::ClaudeDesktop => {
378            #[cfg(target_os = "macos")]
379            {
380                home.join("Library")
381                    .join("Application Support")
382                    .join("Claude")
383                    .join("claude_desktop_config.json")
384            }
385            #[cfg(target_os = "windows")]
386            {
387                std::env::var_os("APPDATA")
388                    .map(|p| {
389                        std::path::PathBuf::from(p)
390                            .join("Claude")
391                            .join("claude_desktop_config.json")
392                    })
393                    .unwrap_or_else(|| {
394                        home.join("AppData")
395                            .join("Roaming")
396                            .join("Claude")
397                            .join("claude_desktop_config.json")
398                    })
399            }
400            #[cfg(not(any(target_os = "macos", target_os = "windows")))]
401            {
402                bail!(
403                    "claude-desktop config path is OS-specific and not auto-discovered \
404                     on Linux; pass --config <path>. Common location: \
405                     ~/.config/Claude/claude_desktop_config.json"
406                );
407            }
408        }
409        // v0.6.4-010 — codex, grok-cli, gemini-cli configs vary by version.
410        // Mirror the openclaw/cline pattern: require --config explicitly
411        // to avoid writing to the wrong file.
412        Target::Codex => {
413            bail!(
414                "codex config path varies by version; pass --config <path>. \
415                 Common location: ~/.codex/config.json or ~/.config/codex/mcp.json"
416            );
417        }
418        Target::GrokCli => {
419            bail!(
420                "grok-cli config path varies by version; pass --config <path>. \
421                 Common location: ~/.grok/mcp.json"
422            );
423        }
424        Target::GeminiCli => {
425            bail!(
426                "gemini-cli config path varies by version; pass --config <path>. \
427                 Common location: ~/.gemini/mcp.json"
428            );
429        }
430    };
431    Ok(p)
432}
433
434/// Resolve the `ai-memory` binary path for the generated config's
435/// `command` field. If the user passes `--binary`, use that. Otherwise:
436///
437/// 1. If `ai-memory` is on `$PATH`, use the bare string `ai-memory` so
438///    the config stays portable across machines that have it linked to
439///    different absolute paths.
440/// 2. Otherwise, use the running binary's `current_exe()` so the
441///    generated config is at least functional on the host.
442fn resolve_binary(override_path: Option<&Path>) -> String {
443    if let Some(p) = override_path {
444        return p.display().to_string();
445    }
446    if which_ai_memory().is_some() {
447        return "ai-memory".to_string();
448    }
449    if let Ok(exe) = std::env::current_exe() {
450        return exe.display().to_string();
451    }
452    "ai-memory".to_string()
453}
454
455fn which_ai_memory() -> Option<PathBuf> {
456    let path_var = std::env::var_os("PATH")?;
457    for dir in std::env::split_paths(&path_var) {
458        let candidate = dir.join("ai-memory");
459        if candidate.is_file() {
460            return Some(candidate);
461        }
462        let candidate_exe = dir.join("ai-memory.exe");
463        if candidate_exe.is_file() {
464            return Some(candidate_exe);
465        }
466    }
467    None
468}
469
470// ---------------------------------------------------------------------------
471// Read / parse
472// ---------------------------------------------------------------------------
473
474/// Read `path` and parse as JSON. Returns `("", {})` if the file does
475/// not exist (a fresh install on a host that's never run the agent).
476/// Errors clearly when the file exists but is not valid JSON.
477fn read_config_or_empty(path: &Path) -> Result<(String, Value)> {
478    if !path.exists() {
479        return Ok((String::new(), Value::Object(Map::new())));
480    }
481    let text =
482        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
483    if text.trim().is_empty() {
484        return Ok((text, Value::Object(Map::new())));
485    }
486    let value: Value = serde_json::from_str(&text).map_err(|e| {
487        anyhow!(
488            "existing config at {} is not valid JSON ({e}). \
489             Refusing to overwrite — fix the file by hand or remove it, \
490             then re-run `ai-memory install`.",
491            path.display()
492        )
493    })?;
494    Ok((text, value))
495}
496
497// ---------------------------------------------------------------------------
498// Apply / remove managed block
499// ---------------------------------------------------------------------------
500
501/// Insert or replace the managed block for `target` inside `cfg`.
502fn apply_managed_block(target: Target, mut cfg: Value, binary: &str) -> Result<Value> {
503    let obj = ensure_object(&mut cfg)?;
504    match target {
505        Target::ClaudeCode => apply_claude_code(obj, binary),
506        Target::Openclaw => apply_openclaw(obj, binary),
507        Target::Cursor => apply_cursor(obj, binary),
508        Target::Cline => apply_cline(obj, binary),
509        Target::Continue => apply_continue(obj, binary),
510        Target::Windsurf => apply_windsurf(obj, binary),
511        // v0.6.4-010 — these four harnesses use the canonical
512        // `mcpServers.ai-memory.{command, args, env}` shape.
513        Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
514            apply_mcp_standard(obj, binary);
515        }
516    }
517    Ok(cfg)
518}
519
520/// Remove the managed block for `target` from `cfg` (if present).
521fn remove_managed_block(target: Target, mut cfg: Value) -> Result<Value> {
522    let obj = match cfg.as_object_mut() {
523        Some(o) => o,
524        None => return Ok(cfg),
525    };
526    match target {
527        Target::ClaudeCode => remove_claude_code(obj),
528        Target::Openclaw => remove_openclaw(obj),
529        Target::Cursor => remove_cursor(obj),
530        Target::Cline => remove_cline(obj),
531        Target::Continue => remove_continue(obj),
532        Target::Windsurf => remove_windsurf(obj),
533        // v0.6.4-010 — shared mcpServers.ai-memory shape (claude-desktop,
534        // codex, grok-cli, gemini-cli).
535        Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
536            remove_mcp_standard(obj);
537        }
538    }
539    Ok(cfg)
540}
541
542// --- v0.6.4-010 shared MCP-standard writer --------------------------------
543//
544// claude-desktop, codex, grok-cli, and gemini-cli all consume the
545// canonical `mcpServers.<name>.{command,args,env}` shape — the
546// MCP-spec-defined server-config form. `apply_mcp_standard` writes the
547// ai-memory entry with `--profile core` baked into the args (the v0.6.4
548// default). Operators who want the v0.6.3 surface 1:1 can hand-edit the
549// args to `["mcp", "--profile", "full"]`; the install dry-run + diff
550// makes that change visible before they apply it.
551
552fn apply_mcp_standard(obj: &mut Map<String, Value>, binary: &str) {
553    let mcp_servers = obj
554        .entry("mcpServers".to_string())
555        .or_insert_with(|| Value::Object(Map::new()));
556    if !mcp_servers.is_object() {
557        *mcp_servers = Value::Object(Map::new());
558    }
559    let mcp_obj = mcp_servers.as_object_mut().expect("just-inserted object");
560    mcp_obj.insert(
561        "ai-memory".to_string(),
562        serde_json::json!({
563            MARKER_START_KEY: MARKER_PAYLOAD,
564            MANAGED_KEYS_PROPERTY: ["command", "args", "env"],
565            "command": binary,
566            // v0.6.4-010 — explicitly request the v0.6.4 default surface.
567            // The runtime would default to `core` anyway via
568            // effective_profile(), but having it written here makes the
569            // selection self-documenting in the user's config file.
570            "args": ["mcp", "--profile", "core"],
571            "env": {},
572            MARKER_END_KEY: MARKER_PAYLOAD,
573        }),
574    );
575}
576
577fn remove_mcp_standard(obj: &mut Map<String, Value>) {
578    if let Some(mcp_servers) = obj.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
579        mcp_servers.remove("ai-memory");
580        if mcp_servers.is_empty() {
581            obj.remove("mcpServers");
582        }
583    }
584}
585
586fn ensure_object(v: &mut Value) -> Result<&mut Map<String, Value>> {
587    if !v.is_object() {
588        bail!("existing config root is not a JSON object; refusing to clobber");
589    }
590    Ok(v.as_object_mut().expect("checked is_object"))
591}
592
593// --- Claude Code ----------------------------------------------------------
594
595/// Hook command stored in claude-code's SessionStart entry. Mirrors the
596/// recipe documented in `docs/integrations/claude-code.md`.
597fn claude_code_hook_command(binary: &str) -> String {
598    format!("{binary} boot --quiet --limit 10 --budget-tokens 4096")
599}
600
601fn apply_claude_code(obj: &mut Map<String, Value>, binary: &str) {
602    // Build the desired SessionStart entry under the marker.
603    let cmd = claude_code_hook_command(binary);
604    let entry = serde_json::json!({
605        MARKER_START_KEY: MARKER_PAYLOAD,
606        MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
607        "matcher": "*",
608        "hooks": [
609            { "type": "command", "command": cmd }
610        ],
611        MARKER_END_KEY: MARKER_PAYLOAD,
612    });
613
614    // Drop into hooks.SessionStart, removing any existing managed entry,
615    // then prepend ours.
616    let hooks = obj
617        .entry("hooks".to_string())
618        .or_insert_with(|| Value::Object(Map::new()));
619    if !hooks.is_object() {
620        *hooks = Value::Object(Map::new());
621    }
622    let hooks_obj = hooks.as_object_mut().expect("just-inserted object");
623    let session_start = hooks_obj
624        .entry("SessionStart".to_string())
625        .or_insert_with(|| Value::Array(Vec::new()));
626    if !session_start.is_array() {
627        *session_start = Value::Array(Vec::new());
628    }
629    let arr = session_start.as_array_mut().expect("just-inserted array");
630    arr.retain(|v| !is_managed_value(v));
631    arr.insert(0, entry);
632}
633
634fn remove_claude_code(obj: &mut Map<String, Value>) {
635    if let Some(hooks) = obj.get_mut("hooks").and_then(|h| h.as_object_mut())
636        && let Some(arr) = hooks.get_mut("SessionStart").and_then(|s| s.as_array_mut())
637    {
638        arr.retain(|v| !is_managed_value(v));
639        if arr.is_empty() {
640            hooks.remove("SessionStart");
641        }
642    }
643    // Don't strip an empty hooks object if the user had one — leave their
644    // structure exactly as we found it minus our block.
645    if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
646        && hooks.is_empty()
647    {
648        obj.remove("hooks");
649    }
650}
651
652// --- OpenClaw -------------------------------------------------------------
653
654fn ai_memory_server_value(binary: &str) -> Value {
655    serde_json::json!({
656        MARKER_START_KEY: MARKER_PAYLOAD,
657        MANAGED_KEYS_PROPERTY: ["command", "args"],
658        "command": binary,
659        "args": ["mcp"],
660        MARKER_END_KEY: MARKER_PAYLOAD,
661    })
662}
663
664fn apply_openclaw(obj: &mut Map<String, Value>, binary: &str) {
665    let mcp = obj
666        .entry("mcp".to_string())
667        .or_insert_with(|| Value::Object(Map::new()));
668    if !mcp.is_object() {
669        *mcp = Value::Object(Map::new());
670    }
671    let mcp_obj = mcp.as_object_mut().expect("just-inserted object");
672    let servers = mcp_obj
673        .entry("servers".to_string())
674        .or_insert_with(|| Value::Object(Map::new()));
675    if !servers.is_object() {
676        *servers = Value::Object(Map::new());
677    }
678    let servers_obj = servers.as_object_mut().expect("just-inserted object");
679    servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
680}
681
682fn remove_openclaw(obj: &mut Map<String, Value>) {
683    if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut())
684        && let Some(servers) = mcp.get_mut("servers").and_then(|v| v.as_object_mut())
685    {
686        if let Some(v) = servers.get("ai-memory") {
687            if is_managed_value(v) {
688                servers.remove("ai-memory");
689            }
690        }
691        if servers.is_empty() {
692            mcp.remove("servers");
693        }
694        if mcp.is_empty() {
695            obj.remove("mcp");
696        }
697    }
698}
699
700// --- Cursor ---------------------------------------------------------------
701
702fn apply_cursor(obj: &mut Map<String, Value>, binary: &str) {
703    let servers = obj
704        .entry("mcpServers".to_string())
705        .or_insert_with(|| Value::Object(Map::new()));
706    if !servers.is_object() {
707        *servers = Value::Object(Map::new());
708    }
709    let servers_obj = servers.as_object_mut().expect("just-inserted object");
710    servers_obj.insert("ai-memory".to_string(), ai_memory_server_value(binary));
711}
712
713fn remove_cursor(obj: &mut Map<String, Value>) {
714    if let Some(servers) = obj.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
715        if let Some(v) = servers.get("ai-memory") {
716            if is_managed_value(v) {
717                servers.remove("ai-memory");
718            }
719        }
720        if servers.is_empty() {
721            obj.remove("mcpServers");
722        }
723    }
724}
725
726// --- Cline ----------------------------------------------------------------
727
728fn apply_cline(obj: &mut Map<String, Value>, binary: &str) {
729    // Cline shape mirrors Cursor (mcpServers).
730    apply_cursor(obj, binary);
731}
732
733fn remove_cline(obj: &mut Map<String, Value>) {
734    remove_cursor(obj);
735}
736
737// --- Continue -------------------------------------------------------------
738
739fn apply_continue(obj: &mut Map<String, Value>, binary: &str) {
740    // Continue's MCP config lives under experimental.modelContextProtocolServers
741    // (an array of transport entries).
742    let exp = obj
743        .entry("experimental".to_string())
744        .or_insert_with(|| Value::Object(Map::new()));
745    if !exp.is_object() {
746        *exp = Value::Object(Map::new());
747    }
748    let exp_obj = exp.as_object_mut().expect("just-inserted object");
749    let arr = exp_obj
750        .entry("modelContextProtocolServers".to_string())
751        .or_insert_with(|| Value::Array(Vec::new()));
752    if !arr.is_array() {
753        *arr = Value::Array(Vec::new());
754    }
755    let arr = arr.as_array_mut().expect("just-inserted array");
756    arr.retain(|v| !is_managed_value(v));
757    let entry = serde_json::json!({
758        MARKER_START_KEY: MARKER_PAYLOAD,
759        MANAGED_KEYS_PROPERTY: ["transport"],
760        "transport": {
761            "type": "stdio",
762            "command": binary,
763            "args": ["mcp"],
764        },
765        MARKER_END_KEY: MARKER_PAYLOAD,
766    });
767    arr.insert(0, entry);
768}
769
770fn remove_continue(obj: &mut Map<String, Value>) {
771    if let Some(exp) = obj.get_mut("experimental").and_then(|v| v.as_object_mut()) {
772        if let Some(arr) = exp
773            .get_mut("modelContextProtocolServers")
774            .and_then(|v| v.as_array_mut())
775        {
776            arr.retain(|v| !is_managed_value(v));
777            if arr.is_empty() {
778                exp.remove("modelContextProtocolServers");
779            }
780        }
781        if exp.is_empty() {
782            obj.remove("experimental");
783        }
784    }
785}
786
787// --- Windsurf -------------------------------------------------------------
788
789fn apply_windsurf(obj: &mut Map<String, Value>, binary: &str) {
790    apply_cursor(obj, binary);
791}
792
793fn remove_windsurf(obj: &mut Map<String, Value>) {
794    remove_cursor(obj);
795}
796
797// ---------------------------------------------------------------------------
798// Marker recognition
799// ---------------------------------------------------------------------------
800
801/// Returns true when `v` is a JSON object carrying our managed-block
802/// start sentinel. Used to recognise an existing managed block so
803/// install can replace it precisely and uninstall can remove it.
804fn is_managed_value(v: &Value) -> bool {
805    v.as_object()
806        .and_then(|o| o.get(MARKER_START_KEY))
807        .is_some()
808}
809
810// ---------------------------------------------------------------------------
811// Diff emission
812// ---------------------------------------------------------------------------
813
814/// Write a minimal unified-style diff between `before` and `after` to
815/// `out.stdout`. We avoid pulling a real diff crate; the implementation
816/// is intentionally simple — line-by-line, no LCS — because the diff is
817/// *advisory* (the caller can still inspect after with `--apply`).
818fn emit_diff(out: &mut CliOutput<'_>, before: &str, after: &str) -> Result<()> {
819    let before_lines: Vec<&str> = before.lines().collect();
820    let after_lines: Vec<&str> = after.lines().collect();
821    let max_len = before_lines.len().max(after_lines.len());
822    for i in 0..max_len {
823        let b = before_lines.get(i).copied();
824        let a = after_lines.get(i).copied();
825        match (b, a) {
826            (Some(bl), Some(al)) if bl == al => writeln!(out.stdout, " {bl}")?,
827            (Some(bl), Some(al)) => {
828                writeln!(out.stdout, "-{bl}")?;
829                writeln!(out.stdout, "+{al}")?;
830            }
831            (Some(bl), None) => writeln!(out.stdout, "-{bl}")?,
832            (None, Some(al)) => writeln!(out.stdout, "+{al}")?,
833            (None, None) => {}
834        }
835    }
836    Ok(())
837}
838
839// ---------------------------------------------------------------------------
840// Tests
841// ---------------------------------------------------------------------------
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846    use crate::cli::test_utils::TestEnv;
847    use std::fs;
848
849    fn args_for(target: Target, config: PathBuf) -> InstallArgs {
850        let t = TargetArgs {
851            config: Some(config),
852            apply: false,
853            dry_run: false,
854            uninstall: false,
855            binary: Some(PathBuf::from("/usr/local/bin/ai-memory")),
856        };
857        let target_cmd = match target {
858            Target::ClaudeCode => TargetCmd::ClaudeCode(t),
859            Target::Openclaw => TargetCmd::Openclaw(t),
860            Target::Cursor => TargetCmd::Cursor(t),
861            Target::Cline => TargetCmd::Cline(t),
862            Target::Continue => TargetCmd::Continue(t),
863            Target::Windsurf => TargetCmd::Windsurf(t),
864            Target::ClaudeDesktop => TargetCmd::ClaudeDesktop(t),
865            Target::Codex => TargetCmd::Codex(t),
866            Target::GrokCli => TargetCmd::GrokCli(t),
867            Target::GeminiCli => TargetCmd::GeminiCli(t),
868        };
869        InstallArgs { target: target_cmd }
870    }
871
872    fn args_for_apply(target: Target, config: PathBuf) -> InstallArgs {
873        let mut a = args_for(target, config);
874        match &mut a.target {
875            TargetCmd::ClaudeCode(t)
876            | TargetCmd::Openclaw(t)
877            | TargetCmd::Cursor(t)
878            | TargetCmd::Cline(t)
879            | TargetCmd::Continue(t)
880            | TargetCmd::Windsurf(t)
881            | TargetCmd::ClaudeDesktop(t)
882            | TargetCmd::Codex(t)
883            | TargetCmd::GrokCli(t)
884            | TargetCmd::GeminiCli(t) => {
885                t.apply = true;
886            }
887        }
888        a
889    }
890
891    fn args_for_uninstall_apply(target: Target, config: PathBuf) -> InstallArgs {
892        let mut a = args_for(target, config);
893        match &mut a.target {
894            TargetCmd::ClaudeCode(t)
895            | TargetCmd::Openclaw(t)
896            | TargetCmd::Cursor(t)
897            | TargetCmd::Cline(t)
898            | TargetCmd::Continue(t)
899            | TargetCmd::Windsurf(t)
900            | TargetCmd::ClaudeDesktop(t)
901            | TargetCmd::Codex(t)
902            | TargetCmd::GrokCli(t)
903            | TargetCmd::GeminiCli(t) => {
904                t.uninstall = true;
905                t.apply = true;
906            }
907        }
908        a
909    }
910
911    fn config_path(env: &TestEnv, name: &str) -> PathBuf {
912        env.db_path.parent().unwrap().join(name)
913    }
914
915    fn seed(path: &Path, contents: &str) {
916        if let Some(parent) = path.parent() {
917            fs::create_dir_all(parent).unwrap();
918        }
919        fs::write(path, contents).unwrap();
920    }
921
922    // --------------------------------------------------------------
923    // claude-code
924    // --------------------------------------------------------------
925
926    #[test]
927    fn claude_code_install_dry_run_emits_diff_no_writes() {
928        let mut env = TestEnv::fresh();
929        let path = config_path(&env, "settings.json");
930        seed(&path, "{\n}\n");
931        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
932        let args = args_for(Target::ClaudeCode, path.clone());
933        let mut out = env.output();
934        run(&args, &mut out).unwrap();
935        let stdout = std::str::from_utf8(&env.stdout).unwrap();
936        assert!(stdout.contains("dry-run"));
937        assert!(stdout.contains("SessionStart"));
938        assert!(stdout.contains("ai-memory"));
939        assert!(stdout.contains(MARKER_START_KEY));
940        let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
941        assert_eq!(mtime_before, mtime_after, "dry-run must not write");
942    }
943
944    #[test]
945    fn claude_code_install_apply_writes_marker_block() {
946        let mut env = TestEnv::fresh();
947        let path = config_path(&env, "settings.json");
948        seed(&path, "{}\n");
949        let args = args_for_apply(Target::ClaudeCode, path.clone());
950        let mut out = env.output();
951        run(&args, &mut out).unwrap();
952        let written = fs::read_to_string(&path).unwrap();
953        assert!(written.contains(MARKER_START_KEY));
954        assert!(written.contains(MARKER_END_KEY));
955        assert!(written.contains("SessionStart"));
956        assert!(written.contains("ai-memory"));
957        // Must remain valid JSON.
958        let _: Value = serde_json::from_str(&written).unwrap();
959    }
960
961    #[test]
962    fn claude_code_install_apply_preserves_user_keys() {
963        let mut env = TestEnv::fresh();
964        let path = config_path(&env, "settings.json");
965        seed(
966            &path,
967            r#"{"theme":"dark","permissions":{"allow":["npm:*"]}}"#,
968        );
969        let args = args_for_apply(Target::ClaudeCode, path.clone());
970        let mut out = env.output();
971        run(&args, &mut out).unwrap();
972        let written = fs::read_to_string(&path).unwrap();
973        let parsed: Value = serde_json::from_str(&written).unwrap();
974        assert_eq!(parsed["theme"], "dark");
975        assert_eq!(parsed["permissions"]["allow"][0], "npm:*");
976        assert!(parsed["hooks"]["SessionStart"].is_array());
977    }
978
979    #[test]
980    fn claude_code_install_apply_is_idempotent() {
981        let mut env = TestEnv::fresh();
982        let path = config_path(&env, "settings.json");
983        seed(&path, "{}\n");
984        let args = args_for_apply(Target::ClaudeCode, path.clone());
985        let mut out = env.output();
986        run(&args, &mut out).unwrap();
987        let after_first = fs::read_to_string(&path).unwrap();
988        // Second run should produce a no-op message and no change.
989        env.stdout.clear();
990        let mut out2 = env.output();
991        run(&args, &mut out2).unwrap();
992        let after_second = fs::read_to_string(&path).unwrap();
993        assert_eq!(after_first, after_second);
994        let stdout2 = std::str::from_utf8(&env.stdout).unwrap();
995        assert!(
996            stdout2.contains("no-op"),
997            "second install should be no-op: {stdout2}"
998        );
999    }
1000
1001    #[test]
1002    fn claude_code_uninstall_removes_marker_block_only() {
1003        let mut env = TestEnv::fresh();
1004        let path = config_path(&env, "settings.json");
1005        let original = "{\n  \"theme\": \"dark\"\n}\n";
1006        seed(&path, original);
1007        // Install, then uninstall.
1008        run(
1009            &args_for_apply(Target::ClaudeCode, path.clone()),
1010            &mut env.output(),
1011        )
1012        .unwrap();
1013        let after_install = fs::read_to_string(&path).unwrap();
1014        assert!(after_install.contains(MARKER_START_KEY));
1015        run(
1016            &args_for_uninstall_apply(Target::ClaudeCode, path.clone()),
1017            &mut env.output(),
1018        )
1019        .unwrap();
1020        let after_uninstall = fs::read_to_string(&path).unwrap();
1021        let parsed: Value = serde_json::from_str(&after_uninstall).unwrap();
1022        assert_eq!(parsed["theme"], "dark");
1023        assert!(
1024            parsed.get("hooks").is_none(),
1025            "hooks should be gone after uninstall"
1026        );
1027        assert!(!after_uninstall.contains(MARKER_START_KEY));
1028    }
1029
1030    #[test]
1031    fn claude_code_install_refuses_malformed_config() {
1032        let mut env = TestEnv::fresh();
1033        let path = config_path(&env, "settings.json");
1034        seed(&path, "{not valid json");
1035        let args = args_for_apply(Target::ClaudeCode, path.clone());
1036        let mut out = env.output();
1037        let err = run(&args, &mut out).unwrap_err();
1038        let msg = format!("{err}");
1039        assert!(
1040            msg.contains("not valid JSON"),
1041            "error should explain malformed json: {msg}"
1042        );
1043        // File must NOT have been overwritten.
1044        let still = fs::read_to_string(&path).unwrap();
1045        assert_eq!(still, "{not valid json");
1046    }
1047
1048    #[test]
1049    fn claude_code_install_writes_backup_file() {
1050        let mut env = TestEnv::fresh();
1051        let path = config_path(&env, "settings.json");
1052        seed(&path, "{}\n");
1053        let args = args_for_apply(Target::ClaudeCode, path.clone());
1054        let mut out = env.output();
1055        run(&args, &mut out).unwrap();
1056        // Find a sibling whose name starts with `settings.json.bak.`.
1057        let parent = path.parent().unwrap();
1058        let backups: Vec<_> = fs::read_dir(parent)
1059            .unwrap()
1060            .filter_map(|e| e.ok())
1061            .filter(|e| {
1062                e.file_name()
1063                    .to_string_lossy()
1064                    .starts_with("settings.json.bak.")
1065                    || e.file_name().to_string_lossy().starts_with("settings.bak.")
1066            })
1067            .collect();
1068        assert!(
1069            !backups.is_empty(),
1070            "expected a settings.bak.<ts> backup beside the config; saw: {:?}",
1071            fs::read_dir(parent)
1072                .unwrap()
1073                .filter_map(|e| e.ok())
1074                .map(|e| e.file_name())
1075                .collect::<Vec<_>>()
1076        );
1077    }
1078
1079    // --------------------------------------------------------------
1080    // cursor
1081    // --------------------------------------------------------------
1082
1083    #[test]
1084    fn cursor_install_dry_run_emits_diff_no_writes() {
1085        let mut env = TestEnv::fresh();
1086        let path = config_path(&env, "mcp.json");
1087        seed(&path, "{}\n");
1088        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1089        let args = args_for(Target::Cursor, path.clone());
1090        let mut out = env.output();
1091        run(&args, &mut out).unwrap();
1092        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1093        assert!(stdout.contains("dry-run"));
1094        assert!(stdout.contains("mcpServers"));
1095        let mtime_after = fs::metadata(&path).unwrap().modified().unwrap();
1096        assert_eq!(mtime_before, mtime_after);
1097    }
1098
1099    #[test]
1100    fn cursor_install_apply_writes_marker_block() {
1101        let mut env = TestEnv::fresh();
1102        let path = config_path(&env, "mcp.json");
1103        seed(&path, "{}\n");
1104        let args = args_for_apply(Target::Cursor, path.clone());
1105        run(&args, &mut env.output()).unwrap();
1106        let written = fs::read_to_string(&path).unwrap();
1107        let parsed: Value = serde_json::from_str(&written).unwrap();
1108        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1109        assert_eq!(
1110            parsed["mcpServers"]["ai-memory"]["command"],
1111            "/usr/local/bin/ai-memory"
1112        );
1113    }
1114
1115    #[test]
1116    fn cursor_install_apply_preserves_user_keys() {
1117        let mut env = TestEnv::fresh();
1118        let path = config_path(&env, "mcp.json");
1119        seed(
1120            &path,
1121            r#"{"mcpServers":{"my-other":{"command":"x"}},"telemetry":false}"#,
1122        );
1123        run(
1124            &args_for_apply(Target::Cursor, path.clone()),
1125            &mut env.output(),
1126        )
1127        .unwrap();
1128        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1129        assert_eq!(parsed["telemetry"], false);
1130        assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1131        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1132    }
1133
1134    #[test]
1135    fn cursor_install_apply_is_idempotent() {
1136        let mut env = TestEnv::fresh();
1137        let path = config_path(&env, "mcp.json");
1138        seed(&path, "{}\n");
1139        let args = args_for_apply(Target::Cursor, path.clone());
1140        run(&args, &mut env.output()).unwrap();
1141        let first = fs::read_to_string(&path).unwrap();
1142        run(&args, &mut env.output()).unwrap();
1143        let second = fs::read_to_string(&path).unwrap();
1144        assert_eq!(first, second);
1145    }
1146
1147    #[test]
1148    fn cursor_uninstall_removes_marker_block_only() {
1149        let mut env = TestEnv::fresh();
1150        let path = config_path(&env, "mcp.json");
1151        let original = r#"{"mcpServers":{"my-other":{"command":"x"}}}"#;
1152        seed(&path, original);
1153        run(
1154            &args_for_apply(Target::Cursor, path.clone()),
1155            &mut env.output(),
1156        )
1157        .unwrap();
1158        run(
1159            &args_for_uninstall_apply(Target::Cursor, path.clone()),
1160            &mut env.output(),
1161        )
1162        .unwrap();
1163        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1164        assert_eq!(parsed["mcpServers"]["my-other"]["command"], "x");
1165        assert!(
1166            parsed["mcpServers"]
1167                .as_object()
1168                .unwrap()
1169                .get("ai-memory")
1170                .is_none()
1171        );
1172    }
1173
1174    #[test]
1175    fn cursor_install_refuses_malformed_config() {
1176        let mut env = TestEnv::fresh();
1177        let path = config_path(&env, "mcp.json");
1178        seed(&path, "not json");
1179        let args = args_for_apply(Target::Cursor, path.clone());
1180        let err = run(&args, &mut env.output()).unwrap_err();
1181        assert!(format!("{err}").contains("not valid JSON"));
1182    }
1183
1184    #[test]
1185    fn cursor_install_writes_backup_file() {
1186        let mut env = TestEnv::fresh();
1187        let path = config_path(&env, "mcp.json");
1188        seed(&path, "{}\n");
1189        run(
1190            &args_for_apply(Target::Cursor, path.clone()),
1191            &mut env.output(),
1192        )
1193        .unwrap();
1194        let parent = path.parent().unwrap();
1195        let any_backup = fs::read_dir(parent)
1196            .unwrap()
1197            .filter_map(|e| e.ok())
1198            .any(|e| e.file_name().to_string_lossy().contains("bak."));
1199        assert!(any_backup);
1200    }
1201
1202    // --------------------------------------------------------------
1203    // openclaw
1204    // --------------------------------------------------------------
1205
1206    #[test]
1207    fn openclaw_install_dry_run_emits_diff_no_writes() {
1208        let mut env = TestEnv::fresh();
1209        let path = config_path(&env, "openclaw.json");
1210        seed(&path, "{}\n");
1211        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1212        run(&args_for(Target::Openclaw, path.clone()), &mut env.output()).unwrap();
1213        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1214        assert!(stdout.contains("dry-run"));
1215        assert!(stdout.contains("mcp"));
1216        assert_eq!(
1217            mtime_before,
1218            fs::metadata(&path).unwrap().modified().unwrap()
1219        );
1220    }
1221
1222    #[test]
1223    fn openclaw_install_apply_writes_marker_block() {
1224        let mut env = TestEnv::fresh();
1225        let path = config_path(&env, "openclaw.json");
1226        seed(&path, "{}\n");
1227        run(
1228            &args_for_apply(Target::Openclaw, path.clone()),
1229            &mut env.output(),
1230        )
1231        .unwrap();
1232        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1233        assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1234    }
1235
1236    #[test]
1237    fn openclaw_install_apply_preserves_user_keys() {
1238        let mut env = TestEnv::fresh();
1239        let path = config_path(&env, "openclaw.json");
1240        seed(
1241            &path,
1242            r#"{"mcp":{"servers":{"other":{"command":"y"}}},"editor":"vim"}"#,
1243        );
1244        run(
1245            &args_for_apply(Target::Openclaw, path.clone()),
1246            &mut env.output(),
1247        )
1248        .unwrap();
1249        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1250        assert_eq!(parsed["editor"], "vim");
1251        assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1252        assert!(parsed["mcp"]["servers"]["ai-memory"][MARKER_START_KEY].is_string());
1253    }
1254
1255    #[test]
1256    fn openclaw_install_apply_is_idempotent() {
1257        let mut env = TestEnv::fresh();
1258        let path = config_path(&env, "openclaw.json");
1259        seed(&path, "{}\n");
1260        let args = args_for_apply(Target::Openclaw, path.clone());
1261        run(&args, &mut env.output()).unwrap();
1262        let first = fs::read_to_string(&path).unwrap();
1263        run(&args, &mut env.output()).unwrap();
1264        let second = fs::read_to_string(&path).unwrap();
1265        assert_eq!(first, second);
1266    }
1267
1268    #[test]
1269    fn openclaw_uninstall_removes_marker_block_only() {
1270        let mut env = TestEnv::fresh();
1271        let path = config_path(&env, "openclaw.json");
1272        seed(&path, r#"{"mcp":{"servers":{"other":{"command":"y"}}}}"#);
1273        run(
1274            &args_for_apply(Target::Openclaw, path.clone()),
1275            &mut env.output(),
1276        )
1277        .unwrap();
1278        run(
1279            &args_for_uninstall_apply(Target::Openclaw, path.clone()),
1280            &mut env.output(),
1281        )
1282        .unwrap();
1283        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1284        assert_eq!(parsed["mcp"]["servers"]["other"]["command"], "y");
1285        assert!(
1286            parsed["mcp"]["servers"]
1287                .as_object()
1288                .unwrap()
1289                .get("ai-memory")
1290                .is_none()
1291        );
1292    }
1293
1294    #[test]
1295    fn openclaw_install_refuses_malformed_config() {
1296        let mut env = TestEnv::fresh();
1297        let path = config_path(&env, "openclaw.json");
1298        seed(&path, "garbage");
1299        let err = run(
1300            &args_for_apply(Target::Openclaw, path.clone()),
1301            &mut env.output(),
1302        )
1303        .unwrap_err();
1304        assert!(format!("{err}").contains("not valid JSON"));
1305    }
1306
1307    #[test]
1308    fn openclaw_install_writes_backup_file() {
1309        let mut env = TestEnv::fresh();
1310        let path = config_path(&env, "openclaw.json");
1311        seed(&path, "{}\n");
1312        run(
1313            &args_for_apply(Target::Openclaw, path.clone()),
1314            &mut env.output(),
1315        )
1316        .unwrap();
1317        let parent = path.parent().unwrap();
1318        assert!(
1319            fs::read_dir(parent)
1320                .unwrap()
1321                .filter_map(|e| e.ok())
1322                .any(|e| e.file_name().to_string_lossy().contains("bak."))
1323        );
1324    }
1325
1326    // --------------------------------------------------------------
1327    // cline (shape ≈ cursor)
1328    // --------------------------------------------------------------
1329
1330    #[test]
1331    fn cline_install_dry_run_emits_diff_no_writes() {
1332        let mut env = TestEnv::fresh();
1333        let path = config_path(&env, "cline.json");
1334        seed(&path, "{}\n");
1335        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1336        run(&args_for(Target::Cline, path.clone()), &mut env.output()).unwrap();
1337        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1338        assert!(stdout.contains("dry-run"));
1339        assert!(stdout.contains("mcpServers"));
1340        assert_eq!(
1341            mtime_before,
1342            fs::metadata(&path).unwrap().modified().unwrap()
1343        );
1344    }
1345
1346    #[test]
1347    fn cline_install_apply_writes_marker_block() {
1348        let mut env = TestEnv::fresh();
1349        let path = config_path(&env, "cline.json");
1350        seed(&path, "{}\n");
1351        run(
1352            &args_for_apply(Target::Cline, path.clone()),
1353            &mut env.output(),
1354        )
1355        .unwrap();
1356        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1357        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1358    }
1359
1360    #[test]
1361    fn cline_install_apply_preserves_user_keys() {
1362        let mut env = TestEnv::fresh();
1363        let path = config_path(&env, "cline.json");
1364        seed(&path, r#"{"mcpServers":{"x":{"command":"q"}},"foo":1}"#);
1365        run(
1366            &args_for_apply(Target::Cline, path.clone()),
1367            &mut env.output(),
1368        )
1369        .unwrap();
1370        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1371        assert_eq!(parsed["foo"], 1);
1372        assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
1373    }
1374
1375    #[test]
1376    fn cline_install_apply_is_idempotent() {
1377        let mut env = TestEnv::fresh();
1378        let path = config_path(&env, "cline.json");
1379        seed(&path, "{}\n");
1380        let args = args_for_apply(Target::Cline, path.clone());
1381        run(&args, &mut env.output()).unwrap();
1382        let first = fs::read_to_string(&path).unwrap();
1383        run(&args, &mut env.output()).unwrap();
1384        let second = fs::read_to_string(&path).unwrap();
1385        assert_eq!(first, second);
1386    }
1387
1388    #[test]
1389    fn cline_uninstall_removes_marker_block_only() {
1390        let mut env = TestEnv::fresh();
1391        let path = config_path(&env, "cline.json");
1392        seed(&path, r#"{"mcpServers":{"x":{"command":"q"}}}"#);
1393        run(
1394            &args_for_apply(Target::Cline, path.clone()),
1395            &mut env.output(),
1396        )
1397        .unwrap();
1398        run(
1399            &args_for_uninstall_apply(Target::Cline, path.clone()),
1400            &mut env.output(),
1401        )
1402        .unwrap();
1403        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1404        assert_eq!(parsed["mcpServers"]["x"]["command"], "q");
1405        assert!(
1406            parsed["mcpServers"]
1407                .as_object()
1408                .unwrap()
1409                .get("ai-memory")
1410                .is_none()
1411        );
1412    }
1413
1414    #[test]
1415    fn cline_install_refuses_malformed_config() {
1416        let mut env = TestEnv::fresh();
1417        let path = config_path(&env, "cline.json");
1418        seed(&path, "totally not json");
1419        let err = run(
1420            &args_for_apply(Target::Cline, path.clone()),
1421            &mut env.output(),
1422        )
1423        .unwrap_err();
1424        assert!(format!("{err}").contains("not valid JSON"));
1425    }
1426
1427    #[test]
1428    fn cline_install_writes_backup_file() {
1429        let mut env = TestEnv::fresh();
1430        let path = config_path(&env, "cline.json");
1431        seed(&path, "{}\n");
1432        run(
1433            &args_for_apply(Target::Cline, path.clone()),
1434            &mut env.output(),
1435        )
1436        .unwrap();
1437        assert!(
1438            fs::read_dir(path.parent().unwrap())
1439                .unwrap()
1440                .filter_map(|e| e.ok())
1441                .any(|e| e.file_name().to_string_lossy().contains("bak."))
1442        );
1443    }
1444
1445    // --------------------------------------------------------------
1446    // continue
1447    // --------------------------------------------------------------
1448
1449    #[test]
1450    fn continue_install_dry_run_emits_diff_no_writes() {
1451        let mut env = TestEnv::fresh();
1452        let path = config_path(&env, "continue.json");
1453        seed(&path, "{}\n");
1454        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1455        run(&args_for(Target::Continue, path.clone()), &mut env.output()).unwrap();
1456        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1457        assert!(stdout.contains("dry-run"));
1458        assert!(stdout.contains("modelContextProtocolServers"));
1459        assert_eq!(
1460            mtime_before,
1461            fs::metadata(&path).unwrap().modified().unwrap()
1462        );
1463    }
1464
1465    #[test]
1466    fn continue_install_apply_writes_marker_block() {
1467        let mut env = TestEnv::fresh();
1468        let path = config_path(&env, "continue.json");
1469        seed(&path, "{}\n");
1470        run(
1471            &args_for_apply(Target::Continue, path.clone()),
1472            &mut env.output(),
1473        )
1474        .unwrap();
1475        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1476        let arr = parsed["experimental"]["modelContextProtocolServers"]
1477            .as_array()
1478            .unwrap();
1479        assert!(arr.iter().any(is_managed_value));
1480    }
1481
1482    #[test]
1483    fn continue_install_apply_preserves_user_keys() {
1484        let mut env = TestEnv::fresh();
1485        let path = config_path(&env, "continue.json");
1486        seed(
1487            &path,
1488            r#"{"models":[{"name":"x"}],"experimental":{"foo":true}}"#,
1489        );
1490        run(
1491            &args_for_apply(Target::Continue, path.clone()),
1492            &mut env.output(),
1493        )
1494        .unwrap();
1495        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1496        assert_eq!(parsed["models"][0]["name"], "x");
1497        assert_eq!(parsed["experimental"]["foo"], true);
1498    }
1499
1500    #[test]
1501    fn continue_install_apply_is_idempotent() {
1502        let mut env = TestEnv::fresh();
1503        let path = config_path(&env, "continue.json");
1504        seed(&path, "{}\n");
1505        let args = args_for_apply(Target::Continue, path.clone());
1506        run(&args, &mut env.output()).unwrap();
1507        let first = fs::read_to_string(&path).unwrap();
1508        run(&args, &mut env.output()).unwrap();
1509        let second = fs::read_to_string(&path).unwrap();
1510        assert_eq!(first, second);
1511    }
1512
1513    #[test]
1514    fn continue_uninstall_removes_marker_block_only() {
1515        let mut env = TestEnv::fresh();
1516        let path = config_path(&env, "continue.json");
1517        seed(&path, r#"{"models":[{"name":"x"}]}"#);
1518        run(
1519            &args_for_apply(Target::Continue, path.clone()),
1520            &mut env.output(),
1521        )
1522        .unwrap();
1523        run(
1524            &args_for_uninstall_apply(Target::Continue, path.clone()),
1525            &mut env.output(),
1526        )
1527        .unwrap();
1528        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1529        assert_eq!(parsed["models"][0]["name"], "x");
1530        assert!(parsed.get("experimental").is_none());
1531    }
1532
1533    #[test]
1534    fn continue_install_refuses_malformed_config() {
1535        let mut env = TestEnv::fresh();
1536        let path = config_path(&env, "continue.json");
1537        seed(&path, "[1,2,");
1538        let err = run(
1539            &args_for_apply(Target::Continue, path.clone()),
1540            &mut env.output(),
1541        )
1542        .unwrap_err();
1543        assert!(format!("{err}").contains("not valid JSON"));
1544    }
1545
1546    #[test]
1547    fn continue_install_writes_backup_file() {
1548        let mut env = TestEnv::fresh();
1549        let path = config_path(&env, "continue.json");
1550        seed(&path, "{}\n");
1551        run(
1552            &args_for_apply(Target::Continue, path.clone()),
1553            &mut env.output(),
1554        )
1555        .unwrap();
1556        assert!(
1557            fs::read_dir(path.parent().unwrap())
1558                .unwrap()
1559                .filter_map(|e| e.ok())
1560                .any(|e| e.file_name().to_string_lossy().contains("bak."))
1561        );
1562    }
1563
1564    // --------------------------------------------------------------
1565    // windsurf (shape ≈ cursor)
1566    // --------------------------------------------------------------
1567
1568    #[test]
1569    fn windsurf_install_dry_run_emits_diff_no_writes() {
1570        let mut env = TestEnv::fresh();
1571        let path = config_path(&env, "mcp_config.json");
1572        seed(&path, "{}\n");
1573        let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
1574        run(&args_for(Target::Windsurf, path.clone()), &mut env.output()).unwrap();
1575        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1576        assert!(stdout.contains("dry-run"));
1577        assert!(stdout.contains("mcpServers"));
1578        assert_eq!(
1579            mtime_before,
1580            fs::metadata(&path).unwrap().modified().unwrap()
1581        );
1582    }
1583
1584    #[test]
1585    fn windsurf_install_apply_writes_marker_block() {
1586        let mut env = TestEnv::fresh();
1587        let path = config_path(&env, "mcp_config.json");
1588        seed(&path, "{}\n");
1589        run(
1590            &args_for_apply(Target::Windsurf, path.clone()),
1591            &mut env.output(),
1592        )
1593        .unwrap();
1594        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1595        assert!(parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string());
1596    }
1597
1598    #[test]
1599    fn windsurf_install_apply_preserves_user_keys() {
1600        let mut env = TestEnv::fresh();
1601        let path = config_path(&env, "mcp_config.json");
1602        seed(&path, r#"{"mcpServers":{"k":{"command":"l"}},"a":42}"#);
1603        run(
1604            &args_for_apply(Target::Windsurf, path.clone()),
1605            &mut env.output(),
1606        )
1607        .unwrap();
1608        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1609        assert_eq!(parsed["a"], 42);
1610        assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
1611    }
1612
1613    #[test]
1614    fn windsurf_install_apply_is_idempotent() {
1615        let mut env = TestEnv::fresh();
1616        let path = config_path(&env, "mcp_config.json");
1617        seed(&path, "{}\n");
1618        let args = args_for_apply(Target::Windsurf, path.clone());
1619        run(&args, &mut env.output()).unwrap();
1620        let first = fs::read_to_string(&path).unwrap();
1621        run(&args, &mut env.output()).unwrap();
1622        let second = fs::read_to_string(&path).unwrap();
1623        assert_eq!(first, second);
1624    }
1625
1626    #[test]
1627    fn windsurf_uninstall_removes_marker_block_only() {
1628        let mut env = TestEnv::fresh();
1629        let path = config_path(&env, "mcp_config.json");
1630        seed(&path, r#"{"mcpServers":{"k":{"command":"l"}}}"#);
1631        run(
1632            &args_for_apply(Target::Windsurf, path.clone()),
1633            &mut env.output(),
1634        )
1635        .unwrap();
1636        run(
1637            &args_for_uninstall_apply(Target::Windsurf, path.clone()),
1638            &mut env.output(),
1639        )
1640        .unwrap();
1641        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1642        assert_eq!(parsed["mcpServers"]["k"]["command"], "l");
1643        assert!(
1644            parsed["mcpServers"]
1645                .as_object()
1646                .unwrap()
1647                .get("ai-memory")
1648                .is_none()
1649        );
1650    }
1651
1652    #[test]
1653    fn windsurf_install_refuses_malformed_config() {
1654        let mut env = TestEnv::fresh();
1655        let path = config_path(&env, "mcp_config.json");
1656        seed(&path, "::");
1657        let err = run(
1658            &args_for_apply(Target::Windsurf, path.clone()),
1659            &mut env.output(),
1660        )
1661        .unwrap_err();
1662        assert!(format!("{err}").contains("not valid JSON"));
1663    }
1664
1665    #[test]
1666    fn windsurf_install_writes_backup_file() {
1667        let mut env = TestEnv::fresh();
1668        let path = config_path(&env, "mcp_config.json");
1669        seed(&path, "{}\n");
1670        run(
1671            &args_for_apply(Target::Windsurf, path.clone()),
1672            &mut env.output(),
1673        )
1674        .unwrap();
1675        assert!(
1676            fs::read_dir(path.parent().unwrap())
1677                .unwrap()
1678                .filter_map(|e| e.ok())
1679                .any(|e| e.file_name().to_string_lossy().contains("bak."))
1680        );
1681    }
1682
1683    // --------------------------------------------------------------
1684    // generic / cross-cutting
1685    // --------------------------------------------------------------
1686
1687    #[test]
1688    fn install_creates_missing_config_file_under_apply() {
1689        let mut env = TestEnv::fresh();
1690        let path = config_path(&env, "fresh-config.json");
1691        assert!(!path.exists());
1692        run(
1693            &args_for_apply(Target::Cursor, path.clone()),
1694            &mut env.output(),
1695        )
1696        .unwrap();
1697        assert!(path.exists());
1698        let _: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1699    }
1700
1701    #[test]
1702    fn install_round_trip_install_then_uninstall_restores_original_for_empty_seed() {
1703        // For a config that started as `{}\n`, install + uninstall should
1704        // produce a configuration that re-parses to `{}` (key set is empty).
1705        let mut env = TestEnv::fresh();
1706        let path = config_path(&env, "rt.json");
1707        seed(&path, "{}\n");
1708        run(
1709            &args_for_apply(Target::Cursor, path.clone()),
1710            &mut env.output(),
1711        )
1712        .unwrap();
1713        run(
1714            &args_for_uninstall_apply(Target::Cursor, path.clone()),
1715            &mut env.output(),
1716        )
1717        .unwrap();
1718        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1719        assert_eq!(parsed, serde_json::json!({}));
1720    }
1721
1722    #[test]
1723    fn resolve_binary_uses_override_when_provided() {
1724        let p = std::path::PathBuf::from("/custom/path/ai-memory");
1725        let resolved = resolve_binary(Some(&p));
1726        assert_eq!(resolved, "/custom/path/ai-memory");
1727    }
1728
1729    // ---- v0.6.4-010 — per-harness install profiles ----
1730    //
1731    // The four MCP-standard harnesses (claude-desktop, codex, grok-cli,
1732    // gemini-cli) use the same `mcpServers.ai-memory.{command,args,env}`
1733    // shape. We test all four with a single shared assertion fixture
1734    // since the writer is shared.
1735
1736    fn assert_mcp_standard_apply(target: Target, fname: &str) {
1737        let mut env = TestEnv::fresh();
1738        let path = config_path(&env, fname);
1739        seed(&path, "{}\n");
1740        run(&args_for_apply(target, path.clone()), &mut env.output()).unwrap();
1741        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1742        // Standard MCP shape.
1743        assert!(
1744            parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string(),
1745            "{} missing managed-block marker",
1746            target.name()
1747        );
1748        // v0.6.4 default profile baked into args.
1749        let args = parsed["mcpServers"]["ai-memory"]["args"]
1750            .as_array()
1751            .unwrap();
1752        let strs: Vec<&str> = args.iter().filter_map(Value::as_str).collect();
1753        assert_eq!(
1754            strs,
1755            vec!["mcp", "--profile", "core"],
1756            "{} should write `mcp --profile core` args",
1757            target.name()
1758        );
1759        let cmd = parsed["mcpServers"]["ai-memory"]["command"]
1760            .as_str()
1761            .unwrap();
1762        assert_eq!(cmd, "/usr/local/bin/ai-memory");
1763    }
1764
1765    #[test]
1766    fn claude_desktop_apply_writes_mcp_standard_with_profile_core() {
1767        assert_mcp_standard_apply(Target::ClaudeDesktop, "claude_desktop_config.json");
1768    }
1769
1770    #[test]
1771    fn codex_apply_writes_mcp_standard_with_profile_core() {
1772        assert_mcp_standard_apply(Target::Codex, "codex_config.json");
1773    }
1774
1775    #[test]
1776    fn grok_cli_apply_writes_mcp_standard_with_profile_core() {
1777        assert_mcp_standard_apply(Target::GrokCli, "grok_mcp.json");
1778    }
1779
1780    #[test]
1781    fn gemini_cli_apply_writes_mcp_standard_with_profile_core() {
1782        assert_mcp_standard_apply(Target::GeminiCli, "gemini_mcp.json");
1783    }
1784
1785    #[test]
1786    fn mcp_standard_uninstall_round_trip_restores_empty() {
1787        let mut env = TestEnv::fresh();
1788        let path = config_path(&env, "claude_desktop_config.json");
1789        seed(&path, "{}\n");
1790        run(
1791            &args_for_apply(Target::ClaudeDesktop, path.clone()),
1792            &mut env.output(),
1793        )
1794        .unwrap();
1795        run(
1796            &args_for_uninstall_apply(Target::ClaudeDesktop, path.clone()),
1797            &mut env.output(),
1798        )
1799        .unwrap();
1800        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1801        // Empty mcpServers should be removed entirely.
1802        assert!(
1803            !parsed.as_object().unwrap().contains_key("mcpServers"),
1804            "uninstall should remove the empty mcpServers wrapper"
1805        );
1806    }
1807
1808    #[test]
1809    fn mcp_standard_apply_preserves_user_keys() {
1810        let mut env = TestEnv::fresh();
1811        let path = config_path(&env, "codex_config.json");
1812        seed(
1813            &path,
1814            r#"{"mcpServers":{"other-mcp":{"command":"x","args":[]}},"unrelated":42}"#,
1815        );
1816        run(
1817            &args_for_apply(Target::Codex, path.clone()),
1818            &mut env.output(),
1819        )
1820        .unwrap();
1821        let parsed: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1822        // Sibling server preserved.
1823        assert_eq!(parsed["mcpServers"]["other-mcp"]["command"], "x");
1824        // Sibling top-level key preserved.
1825        assert_eq!(parsed["unrelated"], 42);
1826        // ai-memory entry written.
1827        assert!(parsed["mcpServers"]["ai-memory"].is_object());
1828    }
1829}