use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use serde_json::{Map, Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Host {
ClaudeDesktop,
Cursor,
Continue_,
Zed,
ClaudeCode,
GeminiCli,
}
impl Host {
pub(crate) const fn all() -> &'static [Host] {
&[
Host::ClaudeDesktop,
Host::Cursor,
Host::Continue_,
Host::Zed,
Host::ClaudeCode,
Host::GeminiCli,
]
}
pub(crate) const fn slug(self) -> &'static str {
match self {
Host::ClaudeDesktop => "claude-desktop",
Host::Cursor => "cursor",
Host::Continue_ => "continue",
Host::Zed => "zed",
Host::ClaudeCode => "claude-code",
Host::GeminiCli => "gemini-cli",
}
}
pub(crate) const fn display(self) -> &'static str {
match self {
Host::ClaudeDesktop => "Claude Desktop",
Host::Cursor => "Cursor",
Host::Continue_ => "Continue",
Host::Zed => "Zed",
Host::ClaudeCode => "Claude Code",
Host::GeminiCli => "Gemini CLI",
}
}
pub(crate) fn parse(s: &str) -> Option<Host> {
match s.to_ascii_lowercase().as_str() {
"claude-desktop" | "claude_desktop" => Some(Host::ClaudeDesktop),
"cursor" => Some(Host::Cursor),
"continue" => Some(Host::Continue_),
"zed" => Some(Host::Zed),
"claude-code" | "claude_code" | "claude" => Some(Host::ClaudeCode),
"gemini-cli" | "gemini_cli" | "gemini" => Some(Host::GeminiCli),
_ => None,
}
}
pub(crate) fn config_path(self) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match self {
Host::ClaudeDesktop => {
if cfg!(target_os = "macos") {
Some(
home.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json"),
)
} else if cfg!(target_os = "windows") {
dirs::config_dir().map(|d| d.join("Claude").join("claude_desktop_config.json"))
} else {
Some(
home.join(".config")
.join("Claude")
.join("claude_desktop_config.json"),
)
}
}
Host::Cursor => Some(home.join(".cursor").join("mcp.json")),
Host::Continue_ => Some(home.join(".continue").join("config.json")),
Host::Zed => {
if cfg!(target_os = "macos") {
Some(
home.join("Library")
.join("Application Support")
.join("Zed")
.join("settings.json"),
)
} else {
Some(home.join(".config").join("zed").join("settings.json"))
}
}
Host::ClaudeCode => Some(home.join(".claude.json")),
Host::GeminiCli => Some(home.join(".gemini").join("settings.json")),
}
}
pub(crate) fn hooks_path(self) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match self {
Host::ClaudeCode => Some(home.join(".claude").join("settings.json")),
_ => None,
}
}
pub(crate) fn system_prompt_path(self) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match self {
Host::ClaudeCode => Some(home.join(".claude").join("CLAUDE.md")),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy)]
enum Schema {
McpServersTopLevel,
ZedNested,
}
const fn schema_of(h: Host) -> Schema {
match h {
Host::ClaudeDesktop
| Host::Cursor
| Host::Continue_
| Host::ClaudeCode
| Host::GeminiCli => Schema::McpServersTopLevel,
Host::Zed => Schema::ZedNested,
}
}
#[derive(clap::Args, Debug)]
#[command(after_long_help = "\
Examples:
mnem integrate # interactive; detect + prompt
mnem integrate --all # wire every detected host, no prompts
mnem integrate claude-desktop cursor # wire these two, non-interactive
mnem integrate --show claude-desktop # print JSON for copy-paste
mnem integrate --check # report wired state, mutate nothing
mnem integrate --undo claude-desktop # remove mnem from one host
mnem integrate --all --dry-run # diff mode; write nothing
")]
pub(crate) struct Args {
pub hosts: Vec<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub check: bool,
#[arg(long, value_name = "HOST")]
pub show: Option<String>,
#[arg(long, value_name = "HOST")]
pub undo: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long, value_name = "PATH")]
pub target_repo: Option<PathBuf>,
#[arg(long = "system-prompt")]
pub system_prompt: bool,
#[arg(long = "with-hooks")]
pub with_hooks: bool,
#[arg(long = "with-system-prompt")]
pub with_system_prompt: bool,
}
const SYSTEM_PROMPT: &str = include_str!("../../../docs/system-prompt.md");
pub(crate) fn run(args: Args) -> Result<()> {
if args.system_prompt {
print!("{SYSTEM_PROMPT}");
return Ok(());
}
if let Some(slug) = args.show.as_deref() {
let host = Host::parse(slug).ok_or_else(|| {
let known = Host::all()
.iter()
.map(|h| h.slug())
.collect::<Vec<_>>()
.join(", ");
anyhow!("unknown host: {slug}. Known: {known}")
})?;
let target = resolve_target(args.target_repo.as_deref())?;
let snippet = snippet_for(host, &target);
println!("# host: {}", host.display());
if let Some(p) = host.config_path() {
println!("# config: {}", p.display());
}
println!("{snippet}");
return Ok(());
}
if args.check {
return do_check();
}
if let Some(slug) = args.undo.as_deref() {
if slug == "all" || args.all {
let mut failures: Vec<(Host, anyhow::Error)> = Vec::new();
for h in Host::all() {
if let Err(e) = do_undo(*h, args.dry_run) {
failures.push((*h, e));
}
}
if !failures.is_empty() {
for (h, e) in &failures {
eprintln!("undo {}: {e}", h.slug());
}
anyhow::bail!(
"{} of {} hosts failed to undo",
failures.len(),
Host::all().len()
);
}
return Ok(());
} else {
let host = Host::parse(slug).ok_or_else(|| {
let known = Host::all()
.iter()
.map(|h| h.slug())
.collect::<Vec<_>>()
.join(", ");
anyhow!("unknown host: {slug}. Known: {known}")
})?;
do_undo(host, args.dry_run)?;
}
return Ok(());
}
let target = resolve_target(args.target_repo.as_deref())?;
let selected: Vec<Host> = if args.all {
Host::all()
.iter()
.filter(|h| {
h.config_path()
.is_some_and(|p| p.parent().is_some_and(Path::exists))
})
.copied()
.collect()
} else if !args.hosts.is_empty() {
let mut out = Vec::new();
for s in &args.hosts {
out.push(Host::parse(s).ok_or_else(|| {
let known = Host::all()
.iter()
.map(|h| h.slug())
.collect::<Vec<_>>()
.join(", ");
anyhow!("unknown host: {s}. Known: {known}")
})?);
}
out
} else {
interactive_select()?
};
if selected.is_empty() {
println!("no hosts selected");
return Ok(());
}
let stamp = timestamp();
println!("Writing configs (backing up with .bak-{stamp}):");
for host in selected {
match do_wire(host, &target, &stamp, args.dry_run) {
Ok(WireOutcome::Wrote) => {
println!(" ok {} wired -> {}", host.display(), target.display());
}
Ok(WireOutcome::DryRun(diff)) => {
println!(" -- {} (dry-run)\n{diff}", host.display());
}
Ok(WireOutcome::AlreadyWired) => {
println!(
" = {} already wired -> {}",
host.display(),
target.display()
);
}
Err(e) => {
println!(" ! {} {e}", host.display());
}
}
if args.with_hooks && host.hooks_path().is_some() {
match do_wire_hooks(host, &stamp, args.dry_run) {
Ok(WireOutcome::Wrote) => {
println!(" ok {} hooks wired", host.display());
}
Ok(WireOutcome::DryRun(diff)) => {
println!(" -- {} hooks (dry-run)\n{diff}", host.display());
}
Ok(WireOutcome::AlreadyWired) => {
println!(" = {} hooks already wired", host.display());
}
Err(e) => {
println!(" ! {} hooks: {e}", host.display());
}
}
}
if args.with_system_prompt && host.system_prompt_path().is_some() {
match do_wire_system_prompt(host, &stamp, args.dry_run) {
Ok(WireOutcome::Wrote) => {
println!(" ok {} system prompt wired", host.display());
}
Ok(WireOutcome::DryRun(diff)) => {
println!(" -- {} system prompt (dry-run)\n{diff}", host.display());
}
Ok(WireOutcome::AlreadyWired) => {
println!(" = {} system prompt already wired", host.display());
}
Err(e) => {
println!(" ! {} system prompt: {e}", host.display());
}
}
}
}
println!();
println!("Next steps:");
println!(" 1. Restart each agent host you wired.");
println!(" 2. Verify: mnem doctor");
match (args.with_hooks, args.with_system_prompt) {
(true, true) => {
}
(true, false) => {
println!(
" 3. Recommended: also write the LLM system prompt to your host's project rules:"
);
println!(
" a) Auto-write (Claude Code today): mnem integrate --with-system-prompt"
);
println!(
" b) Copy-paste into UI panel (others): mnem integrate --system-prompt | clip"
);
}
(false, true) => {
println!(" 3. Recommended: also add a guaranteed before-prompt memory hook:");
println!(" mnem integrate --with-hooks");
}
(false, false) => {
println!(" 3. (Recommended) Add the recommended LLM system prompt:");
println!(" mnem integrate --with-system-prompt (auto-write, Claude Code)");
println!(" mnem integrate --system-prompt | clip (copy-paste, all hosts)");
println!(" 4. (Recommended) Add a guaranteed before-prompt memory hook:");
println!(" mnem integrate --with-hooks (Claude Code today)");
}
}
#[cfg(not(feature = "bundled-embedder"))]
{
println!();
println!("Note: this `mnem` binary was built without `--features bundled-embedder`.");
println!(" Semantic `mnem retrieve --text` will return zero hits until you configure");
println!(" an embedder. Two paths:");
println!(
" a) Reinstall with the bundled MiniLM: cargo install mnem-cli --features bundled-embedder"
);
println!(
" b) Configure your own provider: see docs/guide/getting-started.md#switching-to-a-custom-embedder-later"
);
}
println!();
println!("Run `mnem integrate` again any time to re-sync.");
Ok(())
}
fn interactive_select() -> Result<Vec<Host>> {
use dialoguer::{MultiSelect, theme::ColorfulTheme};
let entries: Vec<(Host, bool, String)> = Host::all()
.iter()
.map(|h| {
let detected = h
.config_path()
.is_some_and(|p| p.parent().is_some_and(Path::exists));
let label = if let Some(p) = h.config_path() {
let show = p
.parent()
.map_or_else(|| p.display().to_string(), |d| d.display().to_string());
if detected {
format!("{} (at {show})", h.display())
} else {
format!("{} (not found)", h.display())
}
} else {
format!("{} (unsupported on this OS)", h.display())
};
(*h, detected, label)
})
.collect();
println!("mnem integrate - wire mnem into agent hosts\n");
for (_, detected, label) in &entries {
let prefix = if *detected { "[x]" } else { "[ ]" };
println!(" {prefix} {label}");
}
println!();
let items: Vec<&str> = entries.iter().map(|(_, _, s)| s.as_str()).collect();
let defaults: Vec<bool> = entries.iter().map(|(_, d, _)| *d).collect();
let picks = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Which to wire? (space to toggle, enter to confirm)")
.items(&items)
.defaults(&defaults)
.interact()
.context("interactive prompt failed")?;
Ok(picks.into_iter().map(|i| entries[i].0).collect())
}
enum WireOutcome {
Wrote,
DryRun(String),
AlreadyWired,
}
fn do_wire(host: Host, target: &Path, stamp: &str, dry_run: bool) -> Result<WireOutcome> {
let path = host
.config_path()
.ok_or_else(|| anyhow!("unsupported on this OS"))?;
let mut root = if path.exists() {
let s = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
if s.trim().is_empty() {
Value::Object(Map::new())
} else {
serde_json::from_str::<Value>(&s)
.with_context(|| format!("parsing {}", path.display()))?
}
} else {
Value::Object(Map::new())
};
let changed = match schema_of(host) {
Schema::McpServersTopLevel => set_top_level(&mut root, target),
Schema::ZedNested => set_zed_nested(&mut root, target),
};
if !changed {
return Ok(WireOutcome::AlreadyWired);
}
let new_text = serde_json::to_string_pretty(&root).context("serialising merged config")?;
if dry_run {
return Ok(WireOutcome::DryRun(indent(&new_text, " ")));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
if path.exists() {
let bak = path.with_extension(format!(
"{}.bak-{stamp}",
path.extension().and_then(|s| s.to_str()).unwrap_or("json")
));
fs::copy(&path, &bak).with_context(|| format!("backing up to {}", bak.display()))?;
}
atomic_write(&path, &new_text)?;
Ok(WireOutcome::Wrote)
}
const SYSTEM_PROMPT_MARKER_START: &str = "<!-- mnem-system-prompt:v1:start -->";
const SYSTEM_PROMPT_MARKER_END: &str = "<!-- mnem-system-prompt:v1:end -->";
fn do_wire_system_prompt(host: Host, stamp: &str, dry_run: bool) -> Result<WireOutcome> {
let Some(path) = host.system_prompt_path() else {
return Ok(WireOutcome::AlreadyWired);
};
let existing = if path.exists() {
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?
} else {
String::new()
};
let new_content = merge_system_prompt(&existing, SYSTEM_PROMPT);
if new_content == existing {
return Ok(WireOutcome::AlreadyWired);
}
if dry_run {
return Ok(WireOutcome::DryRun(format!(
" (writing mnem-managed section to {} - \
{} bytes total, {} bytes changed)",
path.display(),
new_content.len(),
new_content.len().abs_diff(existing.len())
)));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
if path.exists() {
let bak = path.with_extension(format!(
"{}.bak-{stamp}",
path.extension().and_then(|s| s.to_str()).unwrap_or("md")
));
fs::copy(&path, &bak).with_context(|| format!("backing up to {}", bak.display()))?;
}
atomic_write(&path, &new_content)?;
Ok(WireOutcome::Wrote)
}
fn merge_system_prompt(existing: &str, prompt: &str) -> String {
let prompt_block = format!(
"{}\n{}\n{}\n",
SYSTEM_PROMPT_MARKER_START,
prompt.trim_end(),
SYSTEM_PROMPT_MARKER_END
);
if let (Some(start), Some(end)) = (
existing.find(SYSTEM_PROMPT_MARKER_START),
existing.find(SYSTEM_PROMPT_MARKER_END),
) && end > start
{
let end_inclusive = end + SYSTEM_PROMPT_MARKER_END.len();
let mut tail_start = end_inclusive;
if existing.as_bytes().get(tail_start) == Some(&b'\n') {
tail_start += 1;
}
return format!(
"{}{}{}",
&existing[..start],
&prompt_block,
&existing[tail_start..]
);
}
if existing.is_empty() {
return prompt_block;
}
let needs_separator = !existing.ends_with("\n\n");
let separator = if existing.ends_with('\n') {
"\n"
} else {
"\n\n"
};
if needs_separator {
format!("{existing}{separator}{prompt_block}")
} else {
format!("{existing}{prompt_block}")
}
}
fn remove_system_prompt(existing: &str) -> String {
if let (Some(start), Some(end)) = (
existing.find(SYSTEM_PROMPT_MARKER_START),
existing.find(SYSTEM_PROMPT_MARKER_END),
) && end > start
{
let end_inclusive = end + SYSTEM_PROMPT_MARKER_END.len();
let mut tail_start = end_inclusive;
if existing.as_bytes().get(tail_start) == Some(&b'\n') {
tail_start += 1;
}
let mut head_end = start;
while head_end > 0 {
let ch = existing.as_bytes()[head_end - 1];
if ch == b'\n' || ch == b' ' || ch == b'\r' || ch == b'\t' {
head_end -= 1;
} else {
break;
}
}
let mut out = String::with_capacity(existing.len());
out.push_str(&existing[..head_end]);
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&existing[tail_start..]);
return out;
}
existing.to_string()
}
fn do_wire_hooks(host: Host, stamp: &str, dry_run: bool) -> Result<WireOutcome> {
let Some(path) = host.hooks_path() else {
return Ok(WireOutcome::AlreadyWired);
};
let mut root = if path.exists() {
let s = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
if s.trim().is_empty() {
Value::Object(Map::new())
} else {
serde_json::from_str::<Value>(&s)
.with_context(|| format!("parsing {}", path.display()))?
}
} else {
Value::Object(Map::new())
};
let changed = set_user_prompt_hook(&mut root);
if !changed {
return Ok(WireOutcome::AlreadyWired);
}
let new_text = serde_json::to_string_pretty(&root).context("serialising hooks config")?;
if dry_run {
return Ok(WireOutcome::DryRun(indent(&new_text, " ")));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
if path.exists() {
let bak = path.with_extension(format!(
"{}.bak-{stamp}",
path.extension().and_then(|s| s.to_str()).unwrap_or("json")
));
fs::copy(&path, &bak).with_context(|| format!("backing up to {}", bak.display()))?;
}
atomic_write(&path, &new_text)?;
Ok(WireOutcome::Wrote)
}
fn set_user_prompt_hook(root: &mut Value) -> bool {
ensure_object(root);
let obj = root.as_object_mut().expect("ensured object");
let hooks = obj
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
if !hooks.is_object() {
*hooks = Value::Object(Map::new());
}
let hooks_map = hooks.as_object_mut().expect("object");
let entries = hooks_map
.entry("UserPromptSubmit")
.or_insert_with(|| Value::Array(Vec::new()));
if !entries.is_array() {
*entries = Value::Array(Vec::new());
}
let arr = entries.as_array_mut().expect("array");
let new_val = user_prompt_hook_value();
for entry in arr.iter_mut() {
if entry_is_mnem_hook(entry) {
if entry == &new_val {
return false;
}
*entry = new_val;
return true;
}
}
arr.push(new_val);
true
}
fn remove_user_prompt_hook(root: &mut Value) -> bool {
let Some(obj) = root.as_object_mut() else {
return false;
};
let Some(hooks) = obj.get_mut("hooks") else {
return false;
};
let Some(hooks_map) = hooks.as_object_mut() else {
return false;
};
let Some(entries) = hooks_map.get_mut("UserPromptSubmit") else {
return false;
};
let Some(arr) = entries.as_array_mut() else {
return false;
};
let before = arr.len();
arr.retain(|e| !entry_is_mnem_hook(e));
arr.len() != before
}
fn entry_is_mnem_hook(entry: &Value) -> bool {
entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|inner| {
inner.iter().any(|h| {
h.get("command").and_then(Value::as_str).is_some_and(|c| {
let stripped: String =
c.chars().filter(|ch| *ch != '"' && *ch != '\'').collect();
stripped.contains("mnem retrieve") || stripped.contains("mnem.exe retrieve")
})
})
})
}
fn do_undo(host: Host, dry_run: bool) -> Result<()> {
let path = match host.config_path() {
Some(p) => p,
None => {
println!(" - {} unsupported on this OS", host.display());
return Ok(());
}
};
let mcp_changed = if path.exists() {
let s = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let mut root: Value = if s.trim().is_empty() {
Value::Object(Map::new())
} else {
serde_json::from_str(&s).with_context(|| format!("parsing {}", path.display()))?
};
let changed = match schema_of(host) {
Schema::McpServersTopLevel => remove_top_level(&mut root),
Schema::ZedNested => remove_zed_nested(&mut root),
};
if changed {
let new_text = serde_json::to_string_pretty(&root).context("serialising config")?;
if dry_run {
println!(
" -- {} (dry-run)\n{}",
host.display(),
indent(&new_text, " ")
);
} else {
atomic_write(&path, &new_text)?;
println!(" ok {} removed mnem entry", host.display());
}
}
changed
} else {
false
};
let prompt_changed = if let Some(pp) = host.system_prompt_path()
&& pp.exists()
{
let s = fs::read_to_string(&pp).with_context(|| format!("reading {}", pp.display()))?;
let new_s = remove_system_prompt(&s);
if new_s == s {
false
} else {
if dry_run {
println!(
" -- {} system prompt (dry-run)\n (would shrink to {} bytes)",
host.display(),
new_s.len()
);
} else if new_s.trim().is_empty() {
fs::remove_file(&pp).with_context(|| format!("removing empty {}", pp.display()))?;
println!(
" ok {} removed mnem system-prompt file ({} now empty)",
host.display(),
pp.display()
);
} else {
atomic_write(&pp, &new_s)?;
println!(
" ok {} removed mnem system-prompt section",
host.display()
);
}
true
}
} else {
false
};
let hooks_changed = if let Some(hp) = host.hooks_path()
&& hp.exists()
{
let s = fs::read_to_string(&hp).with_context(|| format!("reading {}", hp.display()))?;
let mut root: Value = if s.trim().is_empty() {
Value::Object(Map::new())
} else {
serde_json::from_str(&s).with_context(|| format!("parsing {}", hp.display()))?
};
let changed = remove_user_prompt_hook(&mut root);
if changed {
let new_text = serde_json::to_string_pretty(&root).context("serialising hooks")?;
if dry_run {
println!(
" -- {} hooks (dry-run)\n{}",
host.display(),
indent(&new_text, " ")
);
} else {
atomic_write(&hp, &new_text)?;
println!(" ok {} removed mnem hook entry", host.display());
}
}
changed
} else {
false
};
if !mcp_changed && !hooks_changed && !prompt_changed {
println!(" - {} no mnem entry", host.display());
}
Ok(())
}
fn do_check() -> Result<()> {
for host in Host::all() {
let line = match host.config_path() {
None => format!(" - {:<18} unsupported on this OS", host.display()),
Some(path) if !path.exists() => {
format!(" - {:<18} not wired ({})", host.display(), path.display())
}
Some(path) => {
let s = fs::read_to_string(&path)?;
let root: Value = if s.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&s).unwrap_or(Value::Null)
};
let wired = match schema_of(*host) {
Schema::McpServersTopLevel => has_top_level(&root),
Schema::ZedNested => has_zed_nested(&root),
};
if wired {
format!(" ok {:<18} wired ({})", host.display(), path.display())
} else {
format!(
" - {:<18} config exists, no mnem entry ({})",
host.display(),
path.display()
)
}
}
};
println!("{line}");
}
Ok(())
}
fn resolve_mnem_mcp_command() -> String {
if let Ok(here) = std::env::current_exe()
&& let Some(dir) = here.parent()
{
let candidate = if cfg!(target_os = "windows") {
dir.join("mnem-mcp.exe")
} else {
dir.join("mnem-mcp")
};
if candidate.exists() {
return candidate.to_string_lossy().into_owned();
}
}
"mnem-mcp".to_string()
}
fn resolve_mnem_command() -> String {
if let Ok(here) = std::env::current_exe()
&& let Some(dir) = here.parent()
{
let candidate = if cfg!(target_os = "windows") {
dir.join("mnem.exe")
} else {
dir.join("mnem")
};
if candidate.exists() {
return candidate.to_string_lossy().into_owned();
}
}
"mnem".to_string()
}
fn pre_prompt_hook_command(mnem_bin: &str) -> String {
if cfg!(target_os = "windows") {
format!(
"powershell -NoProfile -Command \"$j = ($input | Out-String | ConvertFrom-Json); \
if ($j.prompt) {{ & '{}' retrieve --text $j.prompt --budget 2000 2>$null }}\"",
mnem_bin.replace('\'', "''")
)
} else {
format!(
"bash -c 'p=$(jq -r .prompt 2>/dev/null); \
if [ -n \"$p\" ] && [ \"$p\" != \"null\" ]; then \
\"{}\" retrieve --text \"$p\" --budget 2000 2>/dev/null; fi'",
mnem_bin.replace('"', "\\\"")
)
}
}
fn user_prompt_hook_value() -> Value {
let cmd = pre_prompt_hook_command(&resolve_mnem_command());
json!({
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": cmd
}
]
})
}
fn mnem_server_value(target: &Path) -> Value {
json!({
"command": resolve_mnem_mcp_command(),
"args": ["--repo", target.to_string_lossy()]
})
}
fn zed_server_value(target: &Path) -> Value {
json!({
"command": {
"path": resolve_mnem_mcp_command(),
"args": ["--repo", target.to_string_lossy()]
}
})
}
fn set_top_level(root: &mut Value, target: &Path) -> bool {
ensure_object(root);
let obj = root.as_object_mut().expect("ensured above");
let servers = obj
.entry("mcpServers")
.or_insert_with(|| Value::Object(Map::new()));
if !servers.is_object() {
*servers = Value::Object(Map::new());
}
let servers_map = servers.as_object_mut().expect("object");
let new_val = mnem_server_value(target);
let was = servers_map.get("mnem");
if was == Some(&new_val) {
return false;
}
servers_map.insert("mnem".to_string(), new_val);
true
}
fn remove_top_level(root: &mut Value) -> bool {
let Some(obj) = root.as_object_mut() else {
return false;
};
let Some(servers) = obj.get_mut("mcpServers") else {
return false;
};
let Some(map) = servers.as_object_mut() else {
return false;
};
map.remove("mnem").is_some()
}
fn has_top_level(root: &Value) -> bool {
root.get("mcpServers")
.and_then(Value::as_object)
.is_some_and(|m| m.contains_key("mnem"))
}
fn set_zed_nested(root: &mut Value, target: &Path) -> bool {
ensure_object(root);
let obj = root.as_object_mut().expect("ensured");
let exp = obj
.entry("experimental")
.or_insert_with(|| Value::Object(Map::new()));
if !exp.is_object() {
*exp = Value::Object(Map::new());
}
let ctx = exp
.as_object_mut()
.expect("object")
.entry("context_servers")
.or_insert_with(|| Value::Object(Map::new()));
if !ctx.is_object() {
*ctx = Value::Object(Map::new());
}
let ctx_map = ctx.as_object_mut().expect("object");
let new_val = zed_server_value(target);
if ctx_map.get("mnem") == Some(&new_val) {
return false;
}
ctx_map.insert("mnem".to_string(), new_val);
true
}
fn remove_zed_nested(root: &mut Value) -> bool {
let Some(exp) = root.as_object_mut().and_then(|o| o.get_mut("experimental")) else {
return false;
};
let Some(ctx) = exp
.as_object_mut()
.and_then(|o| o.get_mut("context_servers"))
else {
return false;
};
ctx.as_object_mut()
.is_some_and(|m| m.remove("mnem").is_some())
}
fn has_zed_nested(root: &Value) -> bool {
root.get("experimental")
.and_then(|e| e.get("context_servers"))
.and_then(Value::as_object)
.is_some_and(|m| m.contains_key("mnem"))
}
fn ensure_object(v: &mut Value) {
if !v.is_object() {
*v = Value::Object(Map::new());
}
}
fn snippet_for(host: Host, target: &Path) -> String {
let v = match schema_of(host) {
Schema::McpServersTopLevel => json!({"mcpServers": {"mnem": mnem_server_value(target)}}),
Schema::ZedNested => {
json!({"experimental": {"context_servers": {"mnem": zed_server_value(target)}}})
}
};
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "<encode failure>".into())
}
fn resolve_target(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = explicit {
return Ok(p.to_path_buf());
}
match crate::repo::locate_data_dir(None) {
Ok(p) => Ok(p),
Err(_) => {
let cwd = std::env::current_dir().context("cwd unreadable")?;
Ok(cwd.join(".mnem"))
}
}
}
fn atomic_write(path: &Path, contents: &str) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("{} has no parent", path.display()))?;
let tmp = parent.join(format!(
".mnem-tmp-{}",
std::process::id() as u64 ^ now_millis()
));
{
let mut f =
fs::File::create(&tmp).with_context(|| format!("creating tmp {}", tmp.display()))?;
f.write_all(contents.as_bytes())
.with_context(|| format!("writing tmp {}", tmp.display()))?;
f.sync_all()
.with_context(|| format!("fsync {}", tmp.display()))?;
}
fs::rename(&tmp, path)
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
const MILLIS_PER_SECOND: u64 = 1_000;
fn timestamp() -> String {
let now = now_millis() / MILLIS_PER_SECOND;
format!("{now}")
}
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn indent(s: &str, pad: &str) -> String {
s.lines()
.map(|line| format!("{pad}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
#[allow(dead_code)]
pub(crate) fn format_snippet(host: Host, target: &Path) -> String {
snippet_for(host, target)
}
pub(crate) fn wired_status() -> Vec<(Host, Option<PathBuf>, bool)> {
Host::all()
.iter()
.map(|h| {
let path = h.config_path();
let wired = path
.as_ref()
.and_then(|p| fs::read_to_string(p).ok())
.is_some_and(|s| {
let root: Value = if s.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&s).unwrap_or(Value::Null)
};
match schema_of(*h) {
Schema::McpServersTopLevel => has_top_level(&root),
Schema::ZedNested => has_zed_nested(&root),
}
});
(*h, path, wired)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tmp_path() -> PathBuf {
let id = format!(
"mnem-integrate-test-{}-{}",
std::process::id(),
now_millis()
);
std::env::temp_dir().join(id)
}
#[test]
fn host_slugs_are_stable() {
assert_eq!(Host::ClaudeDesktop.slug(), "claude-desktop");
assert_eq!(Host::Cursor.slug(), "cursor");
assert_eq!(Host::Continue_.slug(), "continue");
assert_eq!(Host::Zed.slug(), "zed");
}
#[test]
fn parse_accepts_aliases() {
assert_eq!(Host::parse("claude-desktop"), Some(Host::ClaudeDesktop));
assert_eq!(Host::parse("claude_desktop"), Some(Host::ClaudeDesktop));
assert_eq!(Host::parse("CURSOR"), Some(Host::Cursor));
assert_eq!(Host::parse("garbage"), None);
}
fn assert_is_mnem_mcp_command(v: &Value) {
let s = v
.as_str()
.unwrap_or_else(|| panic!("command must be a string; got {v:?}"));
let ok = s == "mnem-mcp"
|| s.ends_with("mnem-mcp")
|| s.ends_with("mnem-mcp.exe")
|| s.ends_with("mnem-mcp\\")
|| s.ends_with("mnem-mcp.exe\\");
assert!(
ok,
"command must be `mnem-mcp` or absolute path to it; got `{s}`"
);
}
#[test]
fn set_top_level_into_empty_object() {
let mut v = json!({});
let changed = set_top_level(&mut v, Path::new("/r"));
assert!(changed);
assert_is_mnem_mcp_command(&v["mcpServers"]["mnem"]["command"]);
}
#[test]
fn set_top_level_preserves_other_servers() {
let mut v = json!({
"mcpServers": {"other": {"command": "other-mcp"}}
});
set_top_level(&mut v, Path::new("/r"));
assert_eq!(v["mcpServers"]["other"]["command"], json!("other-mcp"));
assert_is_mnem_mcp_command(&v["mcpServers"]["mnem"]["command"]);
}
#[test]
fn set_top_level_idempotent_when_already_wired() {
let mut v = json!({});
assert!(set_top_level(&mut v, Path::new("/r")));
assert!(!set_top_level(&mut v, Path::new("/r")));
}
#[test]
fn set_top_level_overwrites_stale_mnem_entry() {
let mut v = json!({
"mcpServers": {"mnem": {"command": "mnem-mcp", "args": ["--repo", "/old"]}}
});
let changed = set_top_level(&mut v, Path::new("/new"));
assert!(changed);
assert_eq!(v["mcpServers"]["mnem"]["args"][1], json!("/new"));
}
#[test]
fn remove_top_level_is_clean() {
let mut v = json!({
"mcpServers": {"mnem": {}, "other": {"command": "x"}}
});
assert!(remove_top_level(&mut v));
assert!(v["mcpServers"]["mnem"].is_null());
assert_eq!(v["mcpServers"]["other"]["command"], json!("x"));
}
#[test]
fn remove_top_level_when_absent() {
let mut v = json!({"mcpServers": {"other": {}}});
assert!(!remove_top_level(&mut v));
}
#[test]
fn zed_nested_round_trip() {
let mut v = json!({});
assert!(set_zed_nested(&mut v, Path::new("/r")));
assert!(has_zed_nested(&v));
assert!(remove_zed_nested(&mut v));
assert!(!has_zed_nested(&v));
}
#[test]
fn zed_nested_preserves_other_experimental_keys() {
let mut v = json!({"experimental": {"feature_x": true}});
set_zed_nested(&mut v, Path::new("/r"));
assert_eq!(v["experimental"]["feature_x"], json!(true));
assert!(has_zed_nested(&v));
}
#[test]
fn non_object_root_is_replaced_cleanly() {
let mut v = json!([1, 2, 3]);
assert!(set_top_level(&mut v, Path::new("/r")));
assert!(v.is_object());
assert!(has_top_level(&v));
}
#[test]
fn snippet_for_top_level_is_valid_json() {
let s = snippet_for(Host::ClaudeDesktop, Path::new("/r"));
let v: Value = serde_json::from_str(&s).expect("valid json");
assert_is_mnem_mcp_command(&v["mcpServers"]["mnem"]["command"]);
}
#[test]
fn snippet_for_zed_uses_experimental_context_servers() {
let s = snippet_for(Host::Zed, Path::new("/r"));
let v: Value = serde_json::from_str(&s).expect("valid json");
assert_is_mnem_mcp_command(
&v["experimental"]["context_servers"]["mnem"]["command"]["path"],
);
}
#[test]
fn parse_accepts_new_host_aliases() {
assert_eq!(Host::parse("claude-code"), Some(Host::ClaudeCode));
assert_eq!(Host::parse("claude_code"), Some(Host::ClaudeCode));
assert_eq!(Host::parse("CLAUDE-CODE"), Some(Host::ClaudeCode));
assert_eq!(Host::parse("gemini-cli"), Some(Host::GeminiCli));
assert_eq!(Host::parse("gemini"), Some(Host::GeminiCli));
assert_eq!(Host::parse("claude"), Some(Host::ClaudeCode));
}
#[test]
fn all_hosts_includes_new_entries() {
let slugs: Vec<_> = Host::all().iter().map(|h| h.slug()).collect();
assert!(slugs.contains(&"claude-code"));
assert!(slugs.contains(&"gemini-cli"));
assert!(slugs.contains(&"claude-desktop"));
assert!(slugs.contains(&"cursor"));
assert!(slugs.contains(&"continue"));
assert!(slugs.contains(&"zed"));
}
#[test]
fn claude_code_uses_top_level_mcp_servers_schema() {
let mut v = json!({});
let changed = set_top_level(&mut v, Path::new("/r"));
assert!(changed);
assert!(v["mcpServers"]["mnem"].is_object());
}
#[test]
fn claude_code_hooks_path_resolves() {
assert!(Host::ClaudeCode.hooks_path().is_some());
assert!(Host::Cursor.hooks_path().is_none());
assert!(Host::ClaudeDesktop.hooks_path().is_none());
assert!(Host::GeminiCli.hooks_path().is_none());
}
#[test]
fn snippet_for_claude_code_emits_top_level_shape() {
let s = snippet_for(Host::ClaudeCode, Path::new("/r"));
let v: Value = serde_json::from_str(&s).expect("valid json");
assert_is_mnem_mcp_command(&v["mcpServers"]["mnem"]["command"]);
}
#[test]
fn snippet_for_gemini_cli_emits_top_level_shape() {
let s = snippet_for(Host::GeminiCli, Path::new("/r"));
let v: Value = serde_json::from_str(&s).expect("valid json");
assert_is_mnem_mcp_command(&v["mcpServers"]["mnem"]["command"]);
}
#[test]
fn system_prompt_constant_is_non_empty_and_mentions_mnem_retrieve() {
assert!(SYSTEM_PROMPT.contains("mnem_retrieve"));
assert!(SYSTEM_PROMPT.contains("mnem_resolve_or_create"));
assert!(SYSTEM_PROMPT.contains("Entity:Person"));
assert!(
SYSTEM_PROMPT.len() > 1000,
"system prompt suspiciously small"
);
}
#[test]
fn pre_prompt_hook_command_mentions_mnem_retrieve() {
let cmd = pre_prompt_hook_command("mnem");
assert!(
cmd.contains("retrieve"),
"hook command must invoke `retrieve`: {cmd}"
);
assert!(
cmd.contains("mnem"),
"hook command must reference the mnem binary: {cmd}"
);
assert!(
cmd.contains("--budget 2000"),
"hook command must pass a budget: {cmd}"
);
}
#[test]
fn user_prompt_hook_value_round_trip_is_idempotent() {
let mut root = json!({});
let first = set_user_prompt_hook(&mut root);
let second = set_user_prompt_hook(&mut root);
assert!(first, "first set must report a change");
assert!(!second, "second set with same value must be no-op");
}
#[test]
fn user_prompt_hook_preserves_unrelated_hooks() {
let mut root = json!({
"hooks": {
"UserPromptSubmit": [
{ "matcher": "/foo", "hooks": [
{ "type": "command", "command": "echo other" }
] }
]
}
});
assert!(set_user_prompt_hook(&mut root));
let arr = root["hooks"]["UserPromptSubmit"].as_array().unwrap();
assert_eq!(arr.len(), 2, "expected pre-existing entry + mnem entry");
assert!(
arr.iter().any(|e| e["hooks"][0]["command"] == "echo other"),
"unrelated hook entry was clobbered"
);
}
#[test]
fn user_prompt_hook_removal_round_trip() {
let mut root = json!({});
assert!(set_user_prompt_hook(&mut root));
assert!(remove_user_prompt_hook(&mut root));
assert!(!remove_user_prompt_hook(&mut root));
}
#[test]
fn merge_system_prompt_into_empty_file_creates_marker_bracketed_block() {
let out = merge_system_prompt("", "PROMPT BODY");
assert!(out.contains(SYSTEM_PROMPT_MARKER_START));
assert!(out.contains(SYSTEM_PROMPT_MARKER_END));
assert!(out.contains("PROMPT BODY"));
assert!(out.starts_with(SYSTEM_PROMPT_MARKER_START));
}
#[test]
fn merge_system_prompt_appends_to_non_marker_existing_content() {
let existing = "# My project\n\nSome rules I wrote myself.\n";
let out = merge_system_prompt(existing, "PROMPT BODY");
assert!(out.starts_with(existing));
assert!(out.contains(SYSTEM_PROMPT_MARKER_START));
assert!(out.contains("PROMPT BODY"));
assert!(out.contains(SYSTEM_PROMPT_MARKER_END));
}
#[test]
fn merge_system_prompt_replaces_existing_marker_block_idempotently() {
let existing = format!(
"# My project\n\n{SYSTEM_PROMPT_MARKER_START}\nOLD PROMPT\n{SYSTEM_PROMPT_MARKER_END}\n\n## After mnem section\n"
);
let out = merge_system_prompt(&existing, "NEW PROMPT");
assert!(out.contains("NEW PROMPT"));
assert!(!out.contains("OLD PROMPT"));
assert!(out.starts_with("# My project"));
assert!(out.contains("## After mnem section"));
let again = merge_system_prompt(&out, "NEW PROMPT");
assert_eq!(
again, out,
"second merge with same prompt should be a no-op"
);
}
#[test]
fn remove_system_prompt_strips_only_the_marker_block() {
let existing = format!(
"# My project\n\n{SYSTEM_PROMPT_MARKER_START}\nMNEM PROMPT BODY\n{SYSTEM_PROMPT_MARKER_END}\n\n## After mnem section\n"
);
let out = remove_system_prompt(&existing);
assert!(!out.contains("MNEM PROMPT BODY"));
assert!(!out.contains(SYSTEM_PROMPT_MARKER_START));
assert!(!out.contains(SYSTEM_PROMPT_MARKER_END));
assert!(out.contains("# My project"));
assert!(out.contains("## After mnem section"));
}
#[test]
fn remove_system_prompt_no_op_when_no_markers() {
let existing = "Just user content.\n";
let out = remove_system_prompt(existing);
assert_eq!(out, existing);
}
#[test]
fn host_system_prompt_path_only_set_for_claude_code() {
assert!(Host::ClaudeCode.system_prompt_path().is_some());
assert!(Host::ClaudeDesktop.system_prompt_path().is_none());
assert!(Host::Cursor.system_prompt_path().is_none());
assert!(Host::Continue_.system_prompt_path().is_none());
assert!(Host::Zed.system_prompt_path().is_none());
assert!(Host::GeminiCli.system_prompt_path().is_none());
}
#[test]
fn entry_is_mnem_hook_recognises_round_trip_value() {
let v = user_prompt_hook_value();
assert!(entry_is_mnem_hook(&v));
let other = json!({
"matcher": "/foo",
"hooks": [{ "type": "command", "command": "do_something_else.sh" }]
});
assert!(!entry_is_mnem_hook(&other));
}
#[test]
fn resolve_mnem_mcp_command_falls_back_to_bare_name_in_test_env() {
let cmd = resolve_mnem_mcp_command();
let ok = cmd == "mnem-mcp" || cmd.ends_with("mnem-mcp") || cmd.ends_with("mnem-mcp.exe");
assert!(ok, "resolver returned unexpected value: {cmd}");
}
#[test]
fn atomic_write_creates_file_and_replaces() {
let path = tmp_path();
atomic_write(&path, "first").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "first");
atomic_write(&path, "second").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "second");
fs::remove_file(&path).ok();
}
}