use recall_hooks::config::Source;
use recall_hooks::{exit, ClientConfig};
use crate::project as proj;
use crate::status::{self, Report};
use crate::ui;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
Ok,
Warn,
Fail,
}
#[derive(Debug, serde::Serialize)]
pub struct Finding {
pub level: Level,
pub check: &'static str,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<String>,
}
fn ok(check: &'static str, detail: impl Into<String>) -> Finding {
Finding {
level: Level::Ok,
check,
detail: detail.into(),
fix: None,
}
}
fn warn(check: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Finding {
Finding {
level: Level::Warn,
check,
detail: detail.into(),
fix: Some(fix.into()),
}
}
fn fail(check: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Finding {
Finding {
level: Level::Fail,
check,
detail: detail.into(),
fix: Some(fix.into()),
}
}
const WHERE_TO_SET: &str =
"cloud environment: the \"Add/Edit cloud environment\" dialog; laptop: recall connect <url>";
pub(crate) fn findings(rep: &Report) -> Vec<Finding> {
let mut out = Vec::new();
if rep.url_set {
out.push(ok("RECALL_URL", source_detail(rep, rep.url_source)));
} else {
out.push(fail("RECALL_URL", "not set anywhere", WHERE_TO_SET));
}
if rep.token_set {
out.push(ok("RECALL_TOKEN", source_detail(rep, rep.token_source)));
} else {
out.push(fail("RECALL_TOKEN", "not set anywhere", WHERE_TO_SET));
}
credentials_findings(rep, &mut out);
if rep.url_set {
match (&rep.server_error, &rep.git_commit) {
(Some(err), _) => out.push(fail(
"server",
format!("unreachable — {err}"),
"check RECALL_URL, and that this environment is allowed to reach it \
(a cloud environment needs the domain under Allowed domains)",
)),
(None, Some(commit)) => out.push(ok("server", format!("answered, commit {commit}"))),
(None, None) => out.push(ok("server", "answered")),
}
}
if rep.server_ok && !rep.merge_ready {
out.push(warn(
"merge",
"server is up but not logged in to the Claude CLI, so conflicting \
edits fall back to last-write-wins",
"on the server: docker compose exec -it -u node recall-server claude setup-token",
));
}
match (rep.hooks_wired, rep.in_git_repo) {
(true, _) => out.push(ok("hooks", "wired in .claude/settings.json")),
(false, true) => out.push(fail(
"hooks",
"this project's .claude/settings.json has no Recall hooks",
"recall init",
)),
(false, false) => out.push(ok(
"hooks",
"not in a git repository — nothing here to wire",
)),
}
match (rep.remote_session, rep.remote_memory_dir_set) {
(true, false) => out.push(fail(
"CLAUDE_CODE_REMOTE_MEMORY_DIR",
"not set in a remote session, so Claude Code's auto-memory is off \
entirely and there is nothing for Recall to sync",
"set it on this cloud environment, and note it is not $HOME: /home/user/.claude",
)),
(true, true) => out.push(ok("CLAUDE_CODE_REMOTE_MEMORY_DIR", "set")),
(false, true) => out.push(ok(
"CLAUDE_CODE_REMOTE_MEMORY_DIR",
"set, so the memory root is this rather than ~/.claude",
)),
(false, false) => out.push(ok(
"CLAUDE_CODE_REMOTE_MEMORY_DIR",
"not needed outside a remote session",
)),
}
out.push(ok(
"memory dir",
format!("{} ({} files)", rep.memory_dir, rep.memory_files),
));
offbox_finding(rep, &mut out);
reserved_findings(rep, &mut out);
if let Some(mis) = &rep.miscased_dir {
out.push(fail(
"reserved directory",
format!(
"{}/ is spelled differently from {}/, so nothing under it syncs — \
and on a case-insensitive filesystem it looks identical",
mis.found, mis.reserved
),
format!("rename {}/ to {}/", mis.found, mis.reserved),
));
}
for var in &rep.rejected_vars {
out.push(fail(
"rejected value",
format!("{var} is set to a value Recall refused, so it did nothing"),
format!("recall status names the file behind {var}"),
));
}
for file in &rep.unreadable_settings {
out.push(fail(
"settings file",
format!("{file} is not readable JSON, so nothing it declares is in effect"),
"Claude Code cannot read it either — fix the JSON".to_string(),
));
}
for ig in &rep.ignored_env {
out.push(warn(
"ignored value",
format!(
"{} is declared in {} but its value is not a string, so it was skipped",
ig.name, ig.file
),
"quote the value".to_string(),
));
}
out
}
fn source_detail(rep: &Report, source: Source) -> String {
match source {
Source::CredentialsFile => format!(
"saved in {}",
rep.credentials_file
.as_deref()
.unwrap_or("the credentials file")
),
_ => "set".to_string(),
}
}
fn credentials_findings(rep: &Report, out: &mut Vec<Finding>) {
if rep.token_source == Source::Environment && !rep.remote_session {
let (detail, remove_from) = match rep.declared_env.iter().find(|d| d.name == "RECALL_TOKEN")
{
Some(d) => (
format!("RECALL_TOKEN is set in {}, in plain text", d.file),
d.file.clone(),
),
None => (
"RECALL_TOKEN comes from your shell, so every process started from it \
inherits the token"
.to_string(),
"your shell profile".to_string(),
),
};
out.push(warn(
"token storage",
detail,
format!(
"recall connect <url> saves it to ~/.recall/credentials.json, readable by \
you only; then remove RECALL_TOKEN from {remove_from}"
),
));
}
let config_file = rep
.config_file
.as_deref()
.unwrap_or("~/.recall/config.toml");
for problem in &rep.config_problems {
out.push(warn(
"config file",
problem.clone(),
format!("edit {config_file}"),
));
}
for o in &rep.overridden {
let from = rep
.declared_env
.iter()
.find(|d| d.name == o.variable)
.map(|d| d.file.clone())
.unwrap_or_else(|| "your shell profile".to_string());
out.push(warn(
"config overridden",
format!(
"{}={} wins over {} = {:?} in {config_file}",
o.variable, o.environment, o.setting, o.config
),
format!(
"remove {} from {from}, or change {} to match",
o.variable, o.setting
),
));
}
if let Some(err) = &rep.credentials_error {
out.push(warn(
"credentials file",
format!("{err} — so nothing in it is in effect"),
"move it aside and run recall connect again",
));
}
if rep.credentials_exposed {
let file = rep
.credentials_file
.as_deref()
.unwrap_or("~/.recall/credentials.json");
out.push(warn(
"credentials file",
format!("{file} is readable by other users"),
format!("chmod 600 {file}"),
));
}
}
const OFFBOX_STALE_AFTER: time::Duration = time::Duration::days(2);
fn offbox_finding(rep: &Report, out: &mut Vec<Finding>) {
let Some(stamp) = rep.last_offbox_at.as_deref() else {
return;
};
let Some(age) = age_of(stamp) else {
out.push(warn(
"off-box backup",
format!("the server reported a stamp this cannot read: {stamp}"),
"expected the API's timestamp format, e.g. 2026-09-22T00:17:03.000Z",
));
return;
};
if age > OFFBOX_STALE_AFTER {
out.push(warn(
"off-box backup",
format!(
"last verified copy was {} days ago ({stamp}) — the snapshots on \
the server are the only copies of anything newer",
age.whole_days()
),
"on the server: check the cron job's mail, then run \
RECALL_BACKUP_REMOTE=... ./deploy/backup-offbox.sh by hand",
));
} else {
out.push(ok("off-box backup", format!("verified {stamp}")));
}
}
fn age_of(stamp: &str) -> Option<time::Duration> {
let fmt = time::macros::format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let at = time::PrimitiveDateTime::parse(stamp, &fmt)
.ok()?
.assume_utc();
Some(time::OffsetDateTime::now_utc() - at)
}
fn reserved_findings(rep: &Report, out: &mut Vec<Finding>) {
let scopes: [(&'static str, &'static str, &Option<String>, usize, bool); 2] = [
(
"global scope",
"RECALL_GLOBAL_KEY",
&rep.global_key,
rep.global_files,
rep.global_linked,
),
(
"machine scope",
"RECALL_MACHINE_KEY",
&rep.machine_key,
rep.machine_files,
rep.machine_linked,
),
];
for (name, var, key, files, linked) in scopes {
match key {
None if files > 0 => out.push(warn(
name,
format!("off, but {files} file(s) are on disk under it and sync nowhere"),
format!("set {var}, or move the files out"),
)),
None => out.push(ok(name, "off")),
Some(k) if files > 0 && !linked => out.push(fail(
name,
format!(
"{k}: {files} file(s) synced, but MEMORY.md links none of them — \
Claude Code opens what MEMORY.md links and nothing else"
),
"recall pull".to_string(),
)),
Some(k) => out.push(ok(name, format!("{k} ({files} files)"))),
}
}
}
pub async fn run(as_json: bool) -> anyhow::Result<i32> {
let here = proj::resolve();
let cfg = here.config();
let rep = status::collect(&here, &cfg).await;
let found = findings(&rep);
if as_json {
println!("{}", serde_json::to_string_pretty(&found)?);
} else {
print_text(&cfg, &rep, &found);
}
Ok(verdict(&found))
}
pub(crate) fn verdict(found: &[Finding]) -> i32 {
if found.iter().any(|f| f.level == Level::Fail) {
exit::CONFIG
} else {
exit::OK
}
}
const SECTIONS: &[(&str, &[&str])] = &[
(
"Connection",
&[
"RECALL_URL",
"RECALL_TOKEN",
"server",
"merge",
"token storage",
"credentials file",
"config file",
"config overridden",
],
),
(
"This project",
&[
"hooks",
"memory dir",
"CLAUDE_CODE_REMOTE_MEMORY_DIR",
"settings file",
"ignored value",
"rejected value",
"reserved directory",
],
),
("Scopes", &["global scope", "machine scope"]),
("Backup", &["off-box backup"]),
];
const OTHER: &str = "Other";
fn section_of(check: &str) -> &'static str {
SECTIONS
.iter()
.find(|(_, checks)| checks.contains(&check))
.map(|(name, _)| *name)
.unwrap_or(OTHER)
}
fn tone_of(f: &Finding) -> ui::Tone {
match f.level {
Level::Fail => ui::Tone::Bad,
Level::Warn => ui::Tone::Warn,
Level::Ok
if f.detail == "off"
|| f.detail.starts_with("not needed")
|| f.detail.starts_with("not in a git repository") =>
{
ui::Tone::Quiet
}
Level::Ok => ui::Tone::Good,
}
}
fn print_text(cfg: &ClientConfig, rep: &Report, found: &[Finding]) {
let server = cfg
.url
.split_once("://")
.map(|(_, rest)| rest.trim_end_matches('/'))
.unwrap_or("no server");
ui::title("recall doctor", &format!("{} → {server}", cfg.source_env));
let width = found.iter().map(|f| f.check.len()).max().unwrap_or(0);
let names = SECTIONS.iter().map(|(n, _)| *n).chain([OTHER]);
for name in names {
let items: Vec<&Finding> = found
.iter()
.filter(|f| section_of(f.check) == name)
.collect();
if items.is_empty() {
continue;
}
let about = if name == "This project" {
rep.project_key.as_str()
} else {
""
};
ui::section(name, about);
for f in items {
ui::check(
tone_of(f),
f.check,
width,
&ui::tilde(&f.detail),
f.fix.as_deref().map(ui::tilde).as_deref(),
);
}
}
let fails = found.iter().filter(|f| f.level == Level::Fail).count();
let warns = found.iter().filter(|f| f.level == Level::Warn).count();
match (fails, warns) {
(0, 0) => ui::verdict(ui::Tone::Good, "Everything checks out."),
(0, w) => ui::verdict(
ui::Tone::Warn,
&format!("Nothing broken. {w} thing(s) worth a look."),
),
(f, _) => ui::verdict(
ui::Tone::Bad,
&format!(
"{f} problem(s). Recall is not syncing {}.",
if cfg.url.is_empty() {
"anything here"
} else {
"everything it looks like it is"
}
),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::status::{KeySource, MiscasedDir};
fn healthy() -> Report {
Report {
project: "/w/app".into(),
project_key: "acme/app".into(),
project_key_source: KeySource::Remote,
memory_dir: "/w/memory".into(),
memory_files: 4,
hooks_wired: true,
in_git_repo: true,
remote_session: false,
declared_env: Vec::new(),
ignored_env: Vec::new(),
unreadable_settings: Vec::new(),
global_key: None,
rejected_vars: Vec::new(),
global_files: 0,
machine_key: None,
machine_files: 0,
machine_linked: false,
miscased_dir: None,
global_linked: false,
remote_memory_dir_set: true,
url_set: true,
token_set: true,
url_source: Source::ConfigFile,
token_source: Source::CredentialsFile,
credentials_file: Some("/h/.recall/credentials.json".into()),
credentials_error: None,
credentials_exposed: false,
config_file: Some("/h/.recall/config.toml".into()),
machine_source: Source::Unset,
config_problems: Vec::new(),
overridden: Vec::new(),
server_ok: true,
server_error: None,
git_commit: Some("a1b2c3d".into()),
merge_ready: true,
synced_files: 4,
last_synced_at: None,
last_offbox_at: None,
}
}
fn find<'a>(found: &'a [Finding], check: &str) -> Option<&'a Finding> {
found.iter().find(|f| f.check == check)
}
#[test]
fn a_healthy_setup_exits_zero_and_flags_nothing() {
let found = findings(&healthy());
assert_eq!(verdict(&found), exit::OK);
assert!(
found.iter().all(|f| f.level == Level::Ok),
"{:?}",
found
.iter()
.filter(|f| f.level != Level::Ok)
.collect::<Vec<_>>()
);
}
#[test]
fn an_unconfigured_environment_fails_rather_than_looking_idle() {
let mut rep = healthy();
rep.url_set = false;
rep.token_set = false;
rep.server_ok = false;
rep.git_commit = None;
let found = findings(&rep);
assert_eq!(find(&found, "RECALL_URL").unwrap().level, Level::Fail);
assert_eq!(find(&found, "RECALL_TOKEN").unwrap().level, Level::Fail);
assert_eq!(verdict(&found), exit::CONFIG);
}
#[test]
fn the_server_is_not_reported_on_when_there_is_no_url() {
let mut rep = healthy();
rep.url_set = false;
rep.server_ok = false;
assert!(find(&findings(&rep), "server").is_none());
}
#[test]
fn a_shell_token_on_a_laptop_is_a_warning_that_names_the_fix() {
let mut rep = healthy();
rep.token_source = Source::Environment;
let found = findings(&rep);
let f = find(&found, "token storage").expect("a shell token is worth a word");
assert_eq!(f.level, Level::Warn);
assert!(f.detail.contains("shell"), "{}", f.detail);
assert!(f.fix.as_deref().unwrap().contains("recall connect"));
assert_eq!(
verdict(&found),
exit::OK,
"a warning never fails the command"
);
}
#[test]
fn a_token_in_the_environment_of_a_remote_session_is_fine() {
let mut rep = healthy();
rep.token_source = Source::Environment;
rep.remote_session = true;
assert!(find(&findings(&rep), "token storage").is_none());
}
#[test]
fn a_token_from_a_settings_file_names_the_file() {
let mut rep = healthy();
rep.token_source = Source::Environment;
rep.declared_env = vec![recall_hooks::declared_env::Declared {
name: "RECALL_TOKEN".into(),
file: "/w/app/.claude/settings.local.json".into(),
shadows_shell: false,
empty: false,
}];
let found = findings(&rep);
let f = find(&found, "token storage").unwrap();
assert!(f.detail.contains("settings.local.json"), "{}", f.detail);
assert!(f.fix.as_deref().unwrap().contains("settings.local.json"));
}
#[test]
fn a_credentials_file_others_can_read_is_a_warning_with_the_chmod() {
let mut rep = healthy();
rep.credentials_exposed = true;
let found = findings(&rep);
let f = find(&found, "credentials file").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.fix.as_deref().unwrap().starts_with("chmod 600"));
}
#[test]
fn an_environment_value_overriding_the_config_is_named() {
let mut rep = healthy();
rep.overridden = vec![crate::status::Override {
variable: "RECALL_SOURCE_ENV",
environment: "laptop".into(),
setting: "machine.name",
config: "jarvis".into(),
}];
let found = findings(&rep);
let f = find(&found, "config overridden").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(
f.detail.contains("laptop") && f.detail.contains("jarvis"),
"{}",
f.detail
);
assert!(f.fix.as_deref().unwrap().contains("RECALL_SOURCE_ENV"));
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn a_config_problem_is_a_warning_with_the_file_to_edit() {
let mut rep = healthy();
rep.config_problems = vec!["`machine.nmae` is not a setting Recall knows".into()];
let found = findings(&rep);
let f = find(&found, "config file").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.fix.as_deref().unwrap().contains("config.toml"));
}
#[test]
fn warnings_alone_never_fail_the_command() {
let mut rep = healthy();
rep.remote_memory_dir_set = false;
rep.merge_ready = false;
let found = findings(&rep);
assert!(found.iter().any(|f| f.level == Level::Warn));
assert!(found.iter().all(|f| f.level != Level::Fail));
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn files_under_a_scope_that_is_off_are_reported() {
let mut rep = healthy();
rep.global_files = 3;
let found = findings(&rep);
let f = find(&found, "global scope").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.detail.contains('3'), "{}", f.detail);
assert!(
f.fix.as_deref().unwrap().contains("RECALL_GLOBAL_KEY"),
"the fix has to name the variable that switches it on"
);
}
#[test]
fn a_scope_whose_files_memory_md_does_not_link_is_a_failure() {
let mut rep = healthy();
rep.machine_key = Some("machine:mbp".into());
rep.machine_files = 2;
rep.machine_linked = false;
let found = findings(&rep);
let f = find(&found, "machine scope").unwrap();
assert_eq!(f.level, Level::Fail);
assert!(f.detail.contains("MEMORY.md"), "{}", f.detail);
}
#[test]
fn a_linked_scope_with_files_is_fine() {
let mut rep = healthy();
rep.machine_key = Some("machine:mbp".into());
rep.machine_files = 2;
rep.machine_linked = true;
assert_eq!(
find(&findings(&rep), "machine scope").unwrap().level,
Level::Ok
);
}
#[test]
fn a_miscased_reserved_directory_fails() {
let mut rep = healthy();
rep.miscased_dir = Some(MiscasedDir {
found: "Global".into(),
reserved: "global",
});
let found = findings(&rep);
assert_eq!(
find(&found, "reserved directory").unwrap().level,
Level::Fail
);
assert_eq!(verdict(&found), exit::CONFIG);
}
#[test]
fn a_rejected_variable_fails_and_names_itself() {
let mut rep = healthy();
rep.rejected_vars = vec!["RECALL_PROJECT_KEY"];
let found = findings(&rep);
let f = find(&found, "rejected value").unwrap();
assert_eq!(f.level, Level::Fail);
assert!(f.detail.contains("RECALL_PROJECT_KEY"), "{}", f.detail);
}
#[test]
fn every_finding_that_is_not_ok_carries_a_fix() {
let mut rep = healthy();
rep.url_set = false;
rep.token_set = false;
rep.hooks_wired = false;
rep.remote_memory_dir_set = false;
rep.global_files = 3;
rep.machine_key = Some("machine:mbp".into());
rep.machine_files = 1;
rep.machine_linked = false;
rep.miscased_dir = Some(MiscasedDir {
found: "Global".into(),
reserved: "global",
});
rep.rejected_vars = vec!["RECALL_PROJECT_KEY"];
rep.unreadable_settings = vec![".claude/settings.json".into()];
for f in findings(&rep).iter().filter(|f| f.level != Level::Ok) {
assert!(
f.fix.as_deref().is_some_and(|s| !s.is_empty()),
"{} has no fix",
f.check
);
}
}
#[test]
fn an_unset_remote_memory_dir_fails_in_a_remote_session() {
let mut rep = healthy();
rep.remote_session = true;
rep.remote_memory_dir_set = false;
let found = findings(&rep);
let f = find(&found, "CLAUDE_CODE_REMOTE_MEMORY_DIR").unwrap();
assert_eq!(f.level, Level::Fail);
assert!(
f.fix.as_deref().unwrap().contains("/home/user/.claude"),
"{:?}",
f.fix
);
assert_eq!(verdict(&found), exit::CONFIG);
}
#[test]
fn an_unset_remote_memory_dir_is_fine_on_a_laptop() {
let mut rep = healthy();
rep.remote_session = false;
rep.remote_memory_dir_set = false;
let found = findings(&rep);
assert_eq!(
find(&found, "CLAUDE_CODE_REMOTE_MEMORY_DIR").unwrap().level,
Level::Ok
);
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn unwired_hooks_outside_a_git_repository_are_not_a_failure() {
let mut rep = healthy();
rep.in_git_repo = false;
rep.hooks_wired = false;
let found = findings(&rep);
assert_eq!(find(&found, "hooks").unwrap().level, Level::Ok);
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn unwired_hooks_inside_a_git_repository_still_fail() {
let mut rep = healthy();
rep.in_git_repo = true;
rep.hooks_wired = false;
let found = findings(&rep);
let f = find(&found, "hooks").unwrap();
assert_eq!(f.level, Level::Fail);
assert_eq!(f.fix.as_deref(), Some("recall init"));
assert_eq!(verdict(&found), exit::CONFIG);
}
#[test]
fn no_offbox_stamp_is_reported_as_nothing() {
let found = findings(&healthy());
assert!(find(&found, "off-box backup").is_none());
}
#[test]
fn a_recent_offbox_copy_is_fine() {
let mut rep = healthy();
rep.last_offbox_at = Some(stamp_days_ago(1));
let found = findings(&rep);
assert_eq!(find(&found, "off-box backup").unwrap().level, Level::Ok);
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn an_offbox_copy_that_stopped_is_reported_with_its_age() {
let mut rep = healthy();
rep.last_offbox_at = Some(stamp_days_ago(9));
let found = findings(&rep);
let f = find(&found, "off-box backup").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(
f.detail.contains('9'),
"the age has to be in it: {}",
f.detail
);
assert_eq!(verdict(&found), exit::OK);
}
#[test]
fn a_single_missed_run_is_not_worth_reporting() {
let mut rep = healthy();
rep.last_offbox_at = Some(stamp_days_ago(1));
assert_eq!(
find(&findings(&rep), "off-box backup").unwrap().level,
Level::Ok
);
}
#[test]
fn an_unreadable_stamp_says_so_rather_than_guessing() {
let mut rep = healthy();
rep.last_offbox_at = Some("yesterday-ish".into());
let found = findings(&rep);
let f = find(&found, "off-box backup").unwrap();
assert_eq!(f.level, Level::Warn);
assert!(f.detail.contains("yesterday-ish"), "{}", f.detail);
}
fn stamp_days_ago(days: i64) -> String {
let fmt = time::macros::format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
(time::OffsetDateTime::now_utc() - time::Duration::days(days))
.format(&fmt)
.unwrap()
}
}