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};
const EXPECT_JUST_INSERTED_OBJECT: &str = "just-inserted object";
const EXPECT_JUST_INSERTED_ARRAY: &str = "just-inserted array";
const MARKER_START_KEY: &str = "// ai-memory:managed-block:start";
const MARKER_END_KEY: &str = "// ai-memory:managed-block:end";
const MARKER_PAYLOAD: &str = "Do not edit. Managed by `ai-memory install`. https://github.com/alphaonedev/ai-memory-mcp/issues/487";
const MANAGED_KEYS_PROPERTY: &str = "// ai-memory:managed-keys";
const AGENT_TARGET_CLAUDE_CODE: &str = "claude-code";
#[cfg(any(target_os = "macos", target_os = "windows"))]
const CLAUDE_DESKTOP_CONFIG_FILENAME: &str = "claude_desktop_config.json";
pub(crate) const KEY_MCP_SERVERS: &str = "mcpServers";
const KEY_EXPERIMENTAL: &str = "experimental";
const KEY_MODEL_CONTEXT_PROTOCOL_SERVERS: &str = "modelContextProtocolServers";
const HOOK_EVENT_SESSION_START: &str = "SessionStart";
const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse";
#[derive(Args, Debug)]
pub struct InstallArgs {
#[command(subcommand)]
pub target: TargetCmd,
}
#[derive(Subcommand, Debug)]
pub enum TargetCmd {
ClaudeCode(TargetArgs),
Openclaw(TargetArgs),
Cursor(TargetArgs),
Cline(TargetArgs),
Continue(TargetArgs),
Windsurf(TargetArgs),
ClaudeDesktop(TargetArgs),
Codex(TargetArgs),
GrokCli(TargetArgs),
GeminiCli(TargetArgs),
}
#[derive(Args, Debug, Default, Clone)]
pub struct TargetArgs {
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
#[arg(long, default_value_t = false, conflicts_with = "dry_run")]
pub apply: bool,
#[arg(long, default_value_t = false)]
pub dry_run: bool,
#[arg(long, default_value_t = false)]
pub uninstall: bool,
#[arg(long, value_name = "PATH")]
pub binary: Option<PathBuf>,
#[arg(long, value_name = "KIND")]
pub hook: Option<HookKind>,
#[arg(long, default_value_t = false)]
pub force: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HookKind {
Pretool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Target {
ClaudeCode,
Openclaw,
Cursor,
Cline,
Continue,
Windsurf,
ClaudeDesktop,
Codex,
GrokCli,
GeminiCli,
}
impl Target {
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,
}
}
}
pub fn run(args: &InstallArgs, out: &mut CliOutput<'_>) -> Result<()> {
let target = args.target.target();
let t_args = args.target.args();
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());
let (before_text, before_value) = read_config_or_empty(&config_path)?;
let config_format = ConfigFormat::detect(&config_path);
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(), 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)?
};
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 => {
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}"))?
}
};
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 {
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(());
}
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(),
)?;
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())?;
}
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(())
}
fn snippet_body(target: Target) -> String {
let harness = target.name();
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",
)
}
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)]
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();
std::mem::forget(tmp);
p
})
.clone()
}
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)
}
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 => {
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 => {
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"),
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"
);
}
}
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)
}
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
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ConfigFormat {
Json,
Toml,
}
impl ConfigFormat {
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
}
}
}
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 => {
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()
)
})?;
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))
}
}
}
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),
Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
apply_mcp_standard(obj, binary, mcp_servers_key(target, format));
}
}
Ok(cfg)
}
fn mcp_servers_key(target: Target, format: ConfigFormat) -> &'static str {
match (target, format) {
(Target::Codex, ConfigFormat::Toml) => "mcp_servers",
_ => KEY_MCP_SERVERS,
}
}
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),
Target::ClaudeDesktop | Target::Codex | Target::GrokCli | Target::GeminiCli => {
remove_mcp_standard(obj, mcp_servers_key(target, format));
}
}
Ok(cfg)
}
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,
"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"))
}
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) {
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,
});
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);
}
}
if let Some(hooks) = obj.get("hooks").and_then(|h| h.as_object())
&& hooks.is_empty()
{
obj.remove("hooks");
}
}
const PRETOOL_HOOK_TOOL_NAME: &str = crate::mcp::registry::tool_names::MEMORY_CHECK_AGENT_ACTION;
const PRETOOL_HOOK_MATCHER: &str = "Bash|Edit|Write";
fn claude_code_pretool_entry() -> Value {
serde_json::json!({
MARKER_START_KEY: MARKER_PAYLOAD,
MANAGED_KEYS_PROPERTY: ["matcher", "hooks"],
"matcher": PRETOOL_HOOK_MATCHER,
"hooks": [
{ "type": "mcp_tool", "tool": PRETOOL_HOOK_TOOL_NAME }
],
MARKER_END_KEY: MARKER_PAYLOAD,
})
}
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
}
fn apply_claude_code_pretool(
obj: &mut Map<String, Value>,
force: bool,
out: &mut CliOutput<'_>,
) -> Result<()> {
let entry = claude_code_pretool_entry();
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);
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:?}"
);
}
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(())
}
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");
}
}
fn apply_hook_block(
target: Target,
kind: HookKind,
mut cfg: Value,
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, force, out)?;
}
_ => bail!(
"internal error: unsupported (target, hook) combination ({:?}, {:?})",
target,
kind
),
}
Ok(cfg)
}
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);
}
_ => {
bail!(
"internal error: unsupported (target, hook) combination ({:?}, {:?})",
target,
kind
);
}
}
Ok(cfg)
}
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");
}
}
}
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);
}
}
}
fn apply_cline(obj: &mut Map<String, Value>, binary: &str) {
apply_cursor(obj, binary);
}
fn remove_cline(obj: &mut Map<String, Value>) {
remove_cursor(obj);
}
fn apply_continue(obj: &mut Map<String, Value>, binary: &str) {
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);
}
}
}
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);
}
fn is_managed_value(v: &Value) -> bool {
v.as_object()
.and_then(|o| o.get(MARKER_START_KEY))
.is_some()
}
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(())
}
#[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();
}
#[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"));
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();
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);
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}"
);
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();
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<_>>()
);
}
#[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);
}
#[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."))
);
}
#[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."))
);
}
#[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."))
);
}
#[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."))
);
}
#[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() {
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");
}
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();
assert!(
parsed["mcpServers"]["ai-memory"][MARKER_START_KEY].is_string(),
"{} missing managed-block marker",
target.name()
);
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();
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();
assert_eq!(parsed["mcpServers"]["other-mcp"]["command"], "x");
assert_eq!(parsed["unrelated"], 42);
assert!(parsed["mcpServers"]["ai-memory"].is_object());
}
#[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() {
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();
assert!(jv["mcp_servers"]["ai-memory"].is_object());
assert_eq!(
jv["mcp_servers"]["ai-memory"]["command"],
"/usr/local/bin/ai-memory"
);
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}");
}
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(()))
}
fn emit_snippet_isolated(target: Target) -> (PathBuf, String) {
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); (snippet_path, body)
}
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 hook",
] {
assert!(
body.contains(anchor),
"snippet for {harness} missing anchor `{anchor}`; body was:\n{body}",
);
}
}
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);
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() {
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() {
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(&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() {
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(&cfg, "{}\n");
run(
&args_for_apply(Target::ClaudeCode, cfg.clone()),
&mut env.output(),
)
.unwrap();
env.stderr.clear();
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);
}
fn args_no_config(target: Target) -> InstallArgs {
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() {
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() {
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() {
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()); assert!(val.is_object() && val.as_object().unwrap().is_empty());
}
#[test]
fn read_config_or_empty_handles_missing_file() {
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() {
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() {
let mut env = TestEnv::fresh();
let path = config_path(&env, "diff-source.json");
seed(&path, "{\n \"theme\": \"dark\"\n}\n");
run(&args_for(Target::Cursor, path.clone()), &mut env.output()).unwrap();
let stdout = env.stdout_str();
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() {
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() {
let mut env = TestEnv::fresh();
let path = config_path(&env, "settings.json");
seed(&path, r#"{"hooks":{}}"#);
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();
assert!(parsed.get("hooks").is_none());
}
#[test]
fn install_run_creates_missing_parent_directory() {
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() {
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() {
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();
#[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");
unsafe {
std::env::set_var("PATH", tmp.path());
}
let found = which_ai_memory();
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");
}
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() {
let v = claude_code_pretool_entry();
assert_eq!(PRETOOL_HOOK_MATCHER, "Bash|Edit|Write");
assert_eq!(v["matcher"], PRETOOL_HOOK_MATCHER);
assert_eq!(v["hooks"][0]["type"], "mcp_tool");
assert_eq!(v["hooks"][0]["tool"], PRETOOL_HOOK_TOOL_NAME);
assert_eq!(v["hooks"][0]["tool"], "memory_check_agent_action");
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();
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();
assert_eq!(arr.len(), 1);
let entry = &arr[0];
assert_eq!(entry["matcher"], PRETOOL_HOOK_MATCHER);
assert_eq!(entry["hooks"][0]["type"], "mcp_tool");
assert_eq!(entry["hooks"][0]["tool"], "memory_check_agent_action");
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");
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");
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]["tool"], "memory_check_agent_action");
}
#[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");
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}"
);
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");
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();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["matcher"], PRETOOL_HOOK_MATCHER);
assert_eq!(arr[0]["hooks"][0]["tool"], "memory_check_agent_action");
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"}"#,
);
run(&args_for_pretool_apply(path.clone()), &mut env.output()).unwrap();
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");
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");
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"));
assert!(stdout.contains("memory_check_agent_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());
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() {
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()
);
}
}