use recall_hooks::config::Source;
use recall_hooks::declared_env::{Declared, Ignored};
use recall_hooks::{claude, config, exit, project, scope, settings, state, ClientConfig};
use crate::project as proj;
use crate::ui::{self, Tone};
pub(crate) fn known_vars() -> Vec<&'static str> {
config::VARS
.iter()
.chain(claude::VARS.iter())
.copied()
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum KeySource {
Declared,
Remote,
LocalPath,
DeclaredButRejected,
}
#[derive(serde::Serialize)]
pub struct MiscasedDir {
pub found: String,
pub reserved: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Override {
pub variable: &'static str,
pub environment: String,
pub setting: &'static str,
pub config: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DeviceReport {
pub id: String,
pub name: String,
pub scope: String,
pub ephemeral: bool,
pub key_storage: &'static str,
pub key_file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub confirmed: Option<bool>,
pub gone: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub check_error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
pub struct AuditReport {
pub file: String,
pub checkpoints: usize,
pub unchecked: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub newest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_log: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extends: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unproven: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unchecked_since: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_proven_at: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub refused: bool,
#[serde(skip_serializing_if = "is_zero")]
pub dropped: u64,
#[serde(skip_serializing_if = "is_zero")]
pub unanswered: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub inconsistent: Option<recall_hooks::audit::Inconsistency>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unsaved: Option<String>,
}
fn is_zero(n: &u64) -> bool {
*n == 0
}
impl AuditReport {
pub fn saved(&self) -> usize {
self.checkpoints + self.unchecked
}
fn read(&mut self, witness: &recall_hooks::audit::Witness) {
match witness.load() {
Ok(saved) => {
self.checkpoints = saved.checkpoints.len();
self.unchecked = saved.unchecked.len();
self.newest = saved.newest().map(|c| c.header());
self.unchecked_since = saved.unchecked_since;
self.last_proven_at = saved.last_proven_at;
self.dropped = saved.dropped;
self.unanswered = saved.unanswered;
if saved.inconsistent.is_some() {
self.inconsistent = saved.inconsistent;
}
}
Err(e) => self.file_error = Some(e.to_string()),
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Deadlines {
pub server: std::time::Duration,
pub audit: std::time::Duration,
}
const DEADLINES: Deadlines = Deadlines {
server: std::time::Duration::from_secs(90),
audit: std::time::Duration::from_secs(20),
};
#[derive(serde::Serialize)]
pub struct Report {
pub project: String,
pub project_key: String,
pub project_key_source: KeySource,
pub memory_dir: String,
pub memory_files: usize,
pub hooks_wired: bool,
pub in_git_repo: bool,
pub remote_session: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub declared_env: Vec<Declared>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ignored_env: Vec<Ignored>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unreadable_settings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub global_key: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub rejected_vars: Vec<&'static str>,
pub global_files: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub machine_key: Option<String>,
pub machine_files: usize,
pub machine_linked: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub miscased_dir: Option<MiscasedDir>,
pub global_linked: bool,
pub remote_memory_dir_set: bool,
pub url_set: bool,
pub token_set: bool,
pub url_source: Source,
pub token_source: Source,
#[serde(skip_serializing_if = "Option::is_none")]
pub credentials_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credentials_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_file: Option<String>,
pub machine_source: Source,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub config_problems: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub overridden: Vec<Override>,
pub credentials_exposed: bool,
pub auth: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<DeviceReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_error: Option<String>,
pub device_file_exposed: bool,
pub authkey_set: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_devices: Option<bool>,
pub server_ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_channel: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub server_protocols: Vec<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_client: Option<String>,
pub client_version: String,
pub merge_ready: bool,
pub merge_worker: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge_queue: Option<recall_wire::QueueStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge_worker_seen_at: Option<String>,
pub synced_files: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_synced_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_offbox_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audit: Option<AuditReport>,
#[serde(skip)]
pub health: Option<recall_wire::Health>,
#[serde(skip)]
pub discovery: Option<recall_wire::discovery::Discovery>,
}
pub async fn run(as_json: bool) -> anyhow::Result<i32> {
let here = proj::resolve();
let cfg = here.config();
let rep = collect(&here, &cfg).await;
if as_json {
println!("{}", serde_json::to_string_pretty(&rep)?);
} else {
print_text(&cfg, &rep);
}
Ok(exit::OK)
}
pub(crate) async fn collect(here: &proj::Resolved, cfg: &ClientConfig) -> Report {
collect_within(here, cfg, DEADLINES).await
}
pub(crate) async fn collect_within(
here: &proj::Resolved,
cfg: &ClientConfig,
deadlines: Deadlines,
) -> Report {
collect_asking(here, cfg, deadlines, Asks::Everything).await
}
pub(crate) async fn collect_for_review(
here: &proj::Resolved,
cfg: &ClientConfig,
server: std::time::Duration,
) -> Report {
let deadlines = Deadlines {
server,
audit: std::time::Duration::ZERO,
};
collect_asking(here, cfg, deadlines, Asks::WhatTheServerIs).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Asks {
Everything,
WhatTheServerIs,
}
async fn collect_asking(
here: &proj::Resolved,
cfg: &ClientConfig,
deadlines: Deadlines,
asks: Asks,
) -> Report {
let root = &here.root;
let root_str = root.to_string_lossy().to_string();
let remote = proj::remote();
let memory_dir = here.memory_dir();
let mut rep = Report {
project: root_str.clone(),
project_key: here.project_key(cfg, &remote),
project_key_source: key_source(cfg, &remote),
memory_dir: memory_dir.display().to_string(),
memory_files: state::list_memory_files(&memory_dir)
.map(|f| f.len())
.unwrap_or(0),
hooks_wired: std::fs::read(root.join(".claude").join("settings.json"))
.map(|b| settings::is_wired(&b))
.unwrap_or(false),
in_git_repo: proj::git_root().is_some(),
remote_session: proj::remote_session(),
declared_env: here.env.declared(&known_vars()),
ignored_env: here.env.ignored(&known_vars()),
unreadable_settings: here.env.unreadable().to_vec(),
global_key: cfg.global_key.clone(),
rejected_vars: cfg.rejected_vars.clone(),
global_files: state::list_memory_files(&memory_dir.join(scope::GLOBAL_DIR))
.map(|f| f.len())
.unwrap_or(0),
machine_key: cfg.machine_key.clone(),
machine_files: state::list_memory_files(&memory_dir.join(scope::MACHINE_DIR))
.map(|f| f.len())
.unwrap_or(0),
machine_linked: std::fs::read(memory_dir.join("MEMORY.md"))
.map(|b| recall_hooks::machine_index_is_linked(&b))
.unwrap_or(false),
miscased_dir: std::fs::read_dir(&memory_dir).ok().and_then(|entries| {
entries
.flatten()
.filter(|e| e.path().is_dir())
.find_map(|e| {
let found = e.file_name().to_string_lossy().into_owned();
scope::miscased_reserved_dir(&found).map(|(_, reserved)| MiscasedDir {
found: found.clone(),
reserved,
})
})
}),
global_linked: std::fs::read(memory_dir.join("MEMORY.md"))
.map(|b| recall_hooks::global_index_is_linked(&b))
.unwrap_or(false),
remote_memory_dir_set: claude::Env::from_lookup(here.env.lookup())
.remote_memory_dir
.is_some_and(|d| !d.is_empty()),
url_set: !cfg.url.is_empty(),
token_set: !cfg.token.is_empty(),
url_source: cfg.url_source,
token_source: cfg.token_source,
credentials_file: cfg
.credentials_file
.as_ref()
.map(|p| p.display().to_string()),
credentials_error: cfg.credentials_error.clone(),
credentials_exposed: cfg
.credentials_file
.as_deref()
.is_some_and(recall_hooks::home::readable_by_others),
config_file: cfg.config_file.as_ref().map(|p| p.display().to_string()),
auth: if cfg.device_error.is_some() {
"none"
} else if cfg.device.is_some() {
"device"
} else if !cfg.token.is_empty() {
"bearer"
} else {
"none"
},
device: cfg.device.as_ref().map(|d| DeviceReport {
id: d.device_id.clone(),
name: d.name.clone(),
scope: d.scope.clone(),
ephemeral: d.ephemeral,
key_storage: "file",
key_file: cfg
.device_file
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default(),
confirmed: None,
gone: false,
check_error: None,
}),
device_file: cfg.device_file.as_ref().map(|p| p.display().to_string()),
device_error: cfg.device_error.clone(),
device_file_exposed: cfg
.device_file
.as_deref()
.is_some_and(recall_hooks::home::readable_by_others),
authkey_set: cfg.authkey.is_some(),
server_devices: None,
machine_source: cfg.machine_source,
config_problems: cfg.config_problems.clone(),
overridden: overrides(here, cfg),
server_ok: false,
server_error: None,
git_commit: None,
server_version: None,
server_channel: None,
server_protocols: Vec::new(),
min_client: None,
client_version: recall_wire::discovery::version(),
merge_ready: false,
merge_worker: false,
merge_queue: None,
merge_worker_seen_at: None,
synced_files: 0,
last_synced_at: None,
last_offbox_at: None,
audit: None,
health: None,
discovery: None,
};
if !rep.url_set {
return rep;
}
let witness = cfg
.audit_file
.as_ref()
.filter(|_| asks == Asks::Everything)
.map(|file| recall_hooks::audit::Witness::new(file, &cfg.url));
if let Some(witness) = &witness {
let mut audit = AuditReport {
file: witness.file().display().to_string(),
..Default::default()
};
audit.read(witness);
rep.audit = Some(audit);
}
let (client, usable) = match cfg.client() {
Ok(client) => (Ok(client), true),
Err(e) if e.is_device() => {
rep.device_error.get_or_insert_with(|| e.to_string());
rep.auth = "none";
let anonymous = recall_hooks::client::Client::new(&cfg.url, "");
(anonymous.map_err(|e| e.to_string()), false)
}
Err(e) => (Err(e.to_string()), false),
};
match client {
Ok(client) => {
let asked = ask_server(&mut rep, &client, usable, asks);
let finished = tokio::time::timeout(deadlines.server, asked).await.is_ok();
if !finished && !rep.server_ok {
rep.server_error.get_or_insert(format!(
"no answer within {} seconds in all",
deadlines.server.as_secs()
));
}
let credential = usable && (rep.token_set || rep.device.is_some());
if let (Some(witness), Some(audit)) = (&witness, rep.audit.as_mut()) {
audit.read(witness);
if credential && audit.server_log != Some(false) && audit.file_error.is_none() {
witness_check(witness, &client, audit, deadlines.audit).await;
}
}
}
Err(err) => rep.server_error = Some(err),
}
rep
}
async fn ask_server(
rep: &mut Report,
client: &recall_hooks::client::Client,
usable: bool,
asks: Asks,
) {
match client.health().await {
Ok(health) => {
rep.health = Some(health.clone());
rep.server_ok = true;
rep.git_commit = Some(health.git_commit);
rep.merge_ready = health.merge.claude_cli.logged_in.unwrap_or(false);
rep.merge_worker = health.merge.worker.is_some();
rep.merge_queue = health.merge.queue;
rep.merge_worker_seen_at = health
.merge
.worker
.map(|w| w.last_claim_at.unwrap_or_else(|| health.started_at.clone()));
if !health.last_sync_at.is_empty() {
rep.last_synced_at = Some(health.last_sync_at);
}
if !health.last_offbox_at.is_empty() {
rep.last_offbox_at = Some(health.last_offbox_at);
}
}
Err(err) => rep.server_error = Some(err.to_string()),
}
if rep.server_ok {
let doc = client.discover().await;
if let Some(audit) = rep.audit.as_mut() {
audit.server_log = match &doc {
Ok(Some(doc)) => Some(doc.audit().is_some()),
Ok(None) => Some(false),
Err(_) => None,
};
}
if let Ok(Some(doc)) = doc {
rep.discovery = Some(doc.clone());
rep.server_devices = Some(
doc.accepts(recall_wire::discovery::AUTH_DEVICE_SIG) && doc.devices().is_some(),
);
rep.server_version = Some(doc.server.version);
rep.server_channel = Some(doc.server.build.channel);
rep.server_protocols = doc.protocol.supported;
rep.min_client = Some(doc.min_client);
}
}
if asks == Asks::WhatTheServerIs {
return;
}
if rep.server_ok && usable {
if let Some(device) = rep.device.as_mut() {
match client.me().await {
Ok(me) => {
device.confirmed = Some(true);
device.name = me.name;
device.scope = me.scope;
device.ephemeral = me.ephemeral;
}
Err(e) => {
device.confirmed = Some(false);
device.gone = e.device_gone();
device.check_error = Some(e.reason());
}
}
}
}
if usable && (rep.token_set || rep.device.is_some()) {
if let Ok(resp) = client.pull(&rep.project_key).await {
rep.synced_files = resp.files.iter().filter(|f| !f.deleted).count();
}
}
}
async fn witness_check(
witness: &recall_hooks::audit::Witness,
client: &recall_hooks::client::Client,
audit: &mut AuditReport,
deadline: std::time::Duration,
) {
use recall_hooks::audit::{CheckError, Witnessed};
let mut found = None;
match witness.check(client, deadline).await {
Ok(Witnessed::Extends { .. }) => audit.extends = Some(true),
Ok(Witnessed::Inconsistent { finding, unsaved }) => {
audit.extends = Some(false);
audit.unsaved = unsaved;
found = Some(finding);
}
Err(e) if e.unreadable() => audit.file_error = Some(e.to_string()),
Err(e) if e.no_log() => audit.server_log = Some(false),
Err(e) if e.refused() => {
audit.refused = true;
audit.error = Some(e.to_string());
}
Err(e @ (CheckError::File(_) | CheckError::Deadline(_) | CheckError::Moved)) => {
audit.error = Some(e.to_string())
}
Err(e) if e.unanswered() => audit.error = Some(e.to_string()),
Err(e) => audit.unproven = Some(e.to_string()),
}
audit.read(witness);
if audit.inconsistent.is_none() {
audit.inconsistent = found;
}
}
pub(crate) fn overrides(here: &proj::Resolved, cfg: &ClientConfig) -> Vec<Override> {
let env = |name: &str| here.env.get(name).filter(|v| !v.trim().is_empty());
let mut out = Vec::new();
if let (Some(url), Some(saved)) = (env("RECALL_URL"), cfg.saved_server.as_deref()) {
if recall_hooks::home::normalize_url(&url) != recall_hooks::home::normalize_url(saved) {
out.push(Override {
variable: "RECALL_URL",
environment: url,
setting: "server",
config: saved.to_string(),
});
}
}
let saved_name = cfg
.saved_machine_name
.as_deref()
.and_then(recall_hooks::home::machine_name);
if let Some(name) = saved_name.as_deref() {
if let Some(raw) = env("RECALL_MACHINE_KEY") {
if scope::machine_key(&raw) != scope::machine_key(name) {
out.push(Override {
variable: "RECALL_MACHINE_KEY",
environment: raw,
setting: "machine.name",
config: name.to_string(),
});
}
}
if let Some(label) = env("RECALL_SOURCE_ENV") {
if label.trim() != name {
out.push(Override {
variable: "RECALL_SOURCE_ENV",
environment: label,
setting: "machine.name",
config: name.to_string(),
});
}
}
}
out
}
fn key_source(cfg: &ClientConfig, remote: &str) -> KeySource {
if cfg.project_key.is_some() {
return KeySource::Declared;
}
if cfg.rejected_vars.contains(&"RECALL_PROJECT_KEY") {
return KeySource::DeclaredButRejected;
}
if project::key_from_remote(remote).is_some() {
KeySource::Remote
} else {
KeySource::LocalPath
}
}
fn declared_in<'a>(rep: &'a Report, name: &str) -> Option<&'a str> {
rep.declared_env
.iter()
.find(|var| var.name == name)
.map(|var| var.file.as_str())
}
fn declared_empty(rep: &Report, name: &str) -> bool {
rep.declared_env
.iter()
.any(|var| var.name == name && var.empty)
}
fn origin(rep: &Report, name: &str) -> String {
match declared_in(rep, name) {
Some(file) => format!("set by {file}"),
None if rep.remote_session => "set in the environment".to_string(),
None => "set in this shell".to_string(),
}
}
fn connect_hint(rep: &Report, url: &str) -> String {
match (rep.remote_session, url.is_empty()) {
(true, _) => "set RECALL_URL and RECALL_AUTHKEY on the cloud environment".to_string(),
(false, true) => "recall connect https://your-recall-host".to_string(),
(false, false) => format!("recall connect {url}"),
}
}
fn files(n: usize) -> String {
match n {
1 => "1 file".to_string(),
n => format!("{n} files"),
}
}
const SHORT: usize = 12;
pub(crate) fn short_checkpoint(header: &str) -> String {
match header.split_once(' ') {
Some((size, root)) if root.chars().count() > SHORT => {
format!("{size} {}…", root.chars().take(SHORT).collect::<String>())
}
_ => header.to_string(),
}
}
pub(crate) fn short_commit(commit: &str) -> String {
commit.chars().take(SHORT).collect()
}
pub(crate) fn about(cfg: &ClientConfig) -> String {
let server = cfg
.url
.split_once("://")
.map(|(_, rest)| rest.trim_end_matches('/'))
.unwrap_or("no server");
format!("{} → {server}", cfg.source_env)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Group {
Connection,
Project,
Scopes,
History,
}
impl Group {
const ALL: [Group; 4] = [
Group::Connection,
Group::Project,
Group::Scopes,
Group::History,
];
fn name(self) -> &'static str {
match self {
Group::Connection => "Connection",
Group::Project => "This project",
Group::Scopes => "Scopes",
Group::History => "History",
}
}
}
#[derive(Debug)]
struct Line {
group: Group,
tone: Tone,
label: &'static str,
detail: String,
fix: Option<String>,
under: Option<String>,
}
impl Line {
fn new(group: Group, tone: Tone, label: &'static str, detail: String) -> Line {
Line {
group,
tone,
label,
detail,
fix: None,
under: None,
}
}
fn good(group: Group, label: &'static str, detail: impl Into<String>) -> Line {
Line::new(group, Tone::Good, label, detail.into())
}
fn quiet(group: Group, label: &'static str, detail: impl Into<String>) -> Line {
Line::new(group, Tone::Quiet, label, detail.into())
}
fn warn(
group: Group,
label: &'static str,
detail: impl Into<String>,
fix: impl Into<String>,
) -> Line {
Line {
fix: Some(fix.into()),
..Line::new(group, Tone::Warn, label, detail.into())
}
}
fn bad(
group: Group,
label: &'static str,
detail: impl Into<String>,
fix: impl Into<String>,
) -> Line {
Line {
fix: Some(fix.into()),
..Line::new(group, Tone::Bad, label, detail.into())
}
}
fn under(mut self, text: impl Into<String>) -> Line {
self.under = Some(text.into());
self
}
}
fn lines(cfg: &ClientConfig, rep: &Report) -> Vec<Line> {
let mut out = Vec::new();
connection_lines(cfg, rep, &mut out);
project_lines(rep, &mut out);
scope_lines(rep, &mut out);
if let Some(audit) = &rep.audit {
out.push(audit_line(audit, rep.server_ok));
}
out
}
fn connection_lines(cfg: &ClientConfig, rep: &Report, out: &mut Vec<Line>) {
use Group::Connection as C;
let config_file = rep.config_file.as_deref().unwrap_or("the config file");
let credentials_file = rep
.credentials_file
.as_deref()
.unwrap_or("the credentials file");
out.push(match rep.url_source {
Source::Unset => Line::bad(
C,
"RECALL_URL",
"not set, so nothing syncs",
connect_hint(rep, ""),
),
Source::Environment => Line::good(
C,
"RECALL_URL",
format!("{}, {}", cfg.url, origin(rep, "RECALL_URL")),
),
Source::CredentialsFile | Source::ConfigFile => Line::good(
C,
"RECALL_URL",
format!("{}, saved in {config_file}", cfg.url),
),
});
out.push(match rep.token_source {
Source::Unset if rep.device.is_some() => Line::quiet(
C,
"RECALL_TOKEN",
"not needed, this machine signs its requests",
),
Source::Unset if rep.authkey_set => Line::quiet(
C,
"RECALL_TOKEN",
"not needed, RECALL_AUTHKEY enrols this session as a device",
),
Source::Unset => Line::bad(C, "RECALL_TOKEN", "not set", connect_hint(rep, &cfg.url)),
Source::Environment => Line::good(C, "RECALL_TOKEN", origin(rep, "RECALL_TOKEN")),
Source::CredentialsFile | Source::ConfigFile => {
Line::good(C, "RECALL_TOKEN", format!("saved in {credentials_file}"))
}
});
if rep.url_set && !rep.server_ok {
let fix = if rep.remote_session {
"add the server's domain under the cloud environment's Allowed domains".to_string()
} else {
format!("curl -sS {}/health", cfg.url.trim_end_matches('/'))
};
out.push(Line::bad(
C,
"server",
format!(
"unreachable: {}",
rep.server_error.as_deref().unwrap_or("unknown error")
),
fix,
));
}
if rep.server_ok {
out.push(Line::good(
C,
"server",
match (&rep.server_version, rep.server_channel.as_deref()) {
(Some(v), Some("release") | None) => format!("answered, {v}"),
(Some(v), Some(channel)) => format!("answered, {v} ({channel} build)"),
(None, _) => format!(
"answered, commit {}",
short_commit(rep.git_commit.as_deref().unwrap_or("unknown"))
),
},
));
let parse = recall_wire::discovery::Version::parse;
let too_old = rep.min_client.as_deref().filter(|min| {
matches!(
(parse(&rep.client_version), parse(min)),
(Some(mine), Some(min)) if mine < min
)
});
out.push(match too_old {
Some(min) => Line::bad(
C,
"client",
format!(
"{}, and the server needs at least {min}",
rep.client_version
),
"brew upgrade recall, or npm install -g @pimlabs/recall",
),
None => Line::good(C, "client", rep.client_version.clone()),
});
}
device_lines(rep, out);
if let Some(err) = &rep.credentials_error {
out.push(Line::warn(
C,
"credentials",
format!("{err}, so nothing in it is in effect"),
format!("move {credentials_file} aside and run recall connect again"),
));
}
if rep.credentials_exposed {
out.push(Line::warn(
C,
"credentials",
format!("{credentials_file} is readable by other users"),
format!("chmod 600 {credentials_file}"),
));
}
for problem in &rep.config_problems {
out.push(Line::warn(
C,
"config",
format!("{problem}, in {config_file}"),
format!("edit {config_file}"),
));
}
for o in &rep.overridden {
let from = declared_in(rep, o.variable).unwrap_or("your shell profile");
out.push(Line::warn(
C,
"config",
format!(
"{}={} overrides {} = {:?} in {config_file}",
o.variable, o.environment, o.setting, o.config
),
format!(
"remove {} from {from}, or change {} to match",
o.variable, o.setting
),
));
}
if rep.server_ok {
merge_lines(rep, out);
}
}
fn device_lines(rep: &Report, out: &mut Vec<Line>) {
use Group::Connection as C;
let key_file = rep.device_file.as_deref().unwrap_or("~/.recall/device.key");
if let Some(err) = &rep.device_error {
out.push(Line::bad(
C,
"device",
format!("{err}, so this machine sends nothing to the server"),
format!("move {key_file} aside, then recall connect to enrol again"),
));
}
if rep.device_file_exposed {
out.push(Line::warn(
C,
"device",
format!("{key_file} is readable by other users"),
format!("chmod 600 {key_file}"),
));
}
let Some(d) = &rep.device else {
if rep.authkey_set {
out.push(Line::quiet(
C,
"device",
"none yet, RECALL_AUTHKEY enrols one at the next pull",
));
} else if rep.server_devices == Some(true) && rep.token_set {
out.push(Line::warn(
C,
"device",
"not enrolled, so this machine uses the shared RECALL_TOKEN",
if rep.remote_session {
"on an admin device: recall authkey create --tag cloud --expires 90d, then \
set RECALL_AUTHKEY on the cloud environment and remove RECALL_TOKEN"
} else {
"recall connect"
},
));
}
return;
};
let what = format!(
"{} ({}{})",
d.name,
d.scope,
if d.ephemeral { ", ephemeral" } else { "" }
);
let why = d.check_error.as_deref().unwrap_or("no answer");
out.push(match d.confirmed {
Some(false) if d.gone => Line::bad(
C,
"device",
format!("{what} is refused by the server: {why}"),
if rep.remote_session && rep.authkey_set {
"start a new session, which enrols again with RECALL_AUTHKEY"
} else {
"recall connect"
},
),
Some(false) => Line::warn(
C,
"device",
format!("{what}, not confirmed by the server: {why}"),
"recall status again; if it persists, recall connect",
),
Some(true) => Line::good(
C,
"device",
format!("{what}, confirmed by the server, key in {}", d.key_file),
),
None => Line::good(C, "device", format!("{what}, key in {}", d.key_file)),
});
}
fn merge_lines(rep: &Report, out: &mut Vec<Line>) {
use Group::Connection as C;
const WORKER_LOGS: &str = "on the server: docker logs recall-worker, and check it is running";
const LOG_IN: &str = "on the server: docker exec -it -u node";
out.push(match crate::doctor::worker_quiet(rep) {
Some(quiet) => Line::warn(
C,
"merge",
format!(
"stalled: the merge worker has not asked for work in {} minutes",
quiet.whole_minutes()
),
WORKER_LOGS,
),
None => match (rep.merge_ready, rep.merge_worker) {
(true, false) => Line::good(C, "merge", "ready, the server's claude CLI is logged in"),
(true, true) => Line::good(
C,
"merge",
"ready, through the merge worker, whose claude CLI is logged in",
),
(false, false) => Line::warn(
C,
"merge",
"not configured, so conflicting edits use last-write-wins",
format!("{LOG_IN} recall-server claude setup-token"),
),
(false, true) => Line::warn(
C,
"merge",
"waiting: the merge worker's claude CLI is not logged in",
format!("{LOG_IN} recall-worker claude setup-token"),
),
},
});
let Some(q) = &rep.merge_queue else {
return;
};
let waiting = |since: &str| format!("{} waiting, the oldest since {since}", q.queued);
out.push(match q.oldest_queued_at.as_deref() {
Some(since)
if crate::doctor::age_of(since).is_some_and(|age| age >= time::Duration::hours(1)) =>
{
Line::warn(C, "merge queue", waiting(since), WORKER_LOGS)
}
Some(since) => Line::good(C, "merge queue", waiting(since)),
None => Line::good(C, "merge queue", "nothing waiting"),
});
if q.failed > 0 {
out.push(Line::warn(
C,
"failed merges",
format!(
"{}; for each, the newest push stands and the merge is kept in its job",
q.failed
),
"GET /v1/jobs?state=failed, then POST /v1/jobs/{id}/retry, with the operator token",
));
}
}
fn project_lines(rep: &Report, out: &mut Vec<Line>) {
use Group::Project as P;
out.push(if rep.in_git_repo {
Line::good(P, "root", rep.project.clone())
} else {
Line::quiet(P, "root", format!("{}, not a git repository", rep.project))
});
let key = &rep.project_key;
let mut key_line = match rep.project_key_source {
KeySource::Declared => Line::good(
P,
"key",
format!(
"{key}, declared in RECALL_PROJECT_KEY, {}",
match declared_in(rep, "RECALL_PROJECT_KEY") {
Some(file) => format!("set by {file}"),
None => "set in this shell".to_string(),
}
),
),
KeySource::Remote => Line::good(P, "key", format!("{key}, from the git remote")),
KeySource::LocalPath if rep.in_git_repo => Line::warn(
P,
"key",
format!(
"{key}, from this checkout's path: with no git remote, another machine \
derives a different one"
),
"git remote add origin <url>, or declare RECALL_PROJECT_KEY in .claude/settings.json",
),
KeySource::LocalPath => Line::quiet(P, "key", format!("{key}, from this directory's path")),
KeySource::DeclaredButRejected => Line::bad(
P,
"key",
format!("{key}, derived: RECALL_PROJECT_KEY is set but unusable, so it was ignored"),
"make RECALL_PROJECT_KEY non-empty, free of whitespace, and not under 'global:'",
),
};
if let Some(file) = declared_in(rep, "RECALL_PROJECT_KEY") {
if declared_empty(rep, "RECALL_PROJECT_KEY") {
key_line.detail.push_str(&format!(
"; RECALL_PROJECT_KEY is declared empty in {file}, which reads as unset"
));
}
}
out.push(key_line);
out.push(match (rep.hooks_wired, rep.in_git_repo) {
(true, _) => Line::good(P, "hooks", "wired in .claude/settings.json"),
(false, true) => Line::bad(
P,
"hooks",
"not wired, so nothing here syncs",
"recall init",
),
(false, false) => Line::quiet(P, "hooks", "nothing to wire outside a git repository"),
});
out.push(
match rep.memory_files {
0 => Line::quiet(P, "memory", "no files yet"),
n => Line::good(P, "memory", format!("{} on disk", files(n))),
}
.under(rep.memory_dir.clone()),
);
if rep.server_ok && rep.auth != "none" {
out.push(Line::good(
P,
"synced",
format!("{} on the server", files(rep.synced_files)),
));
}
if rep.remote_session {
out.push(if rep.remote_memory_dir_set {
Line::good(P, "remote memory", "CLAUDE_CODE_REMOTE_MEMORY_DIR is set")
} else {
Line::bad(
P,
"remote memory",
"CLAUDE_CODE_REMOTE_MEMORY_DIR is not set, so auto-memory is off in this \
remote session",
"set it to /home/user/.claude on the cloud environment (not $HOME)",
)
});
}
for var in &rep.declared_env {
let mut detail = format!("{} from {}", var.name, var.file);
if var.empty {
detail.push_str(", declared empty, so the setting is off");
}
if var.shadows_shell {
detail.push_str(if var.empty {
", and it hides the value set in this shell"
} else {
", overrides the value set in this shell"
});
}
out.push(if var.empty {
let fix = format!(
"give {} a value in {}, or remove it there",
var.name, var.file
);
Line::warn(P, "declared", detail, fix)
} else {
Line::good(P, "declared", detail)
});
}
for var in &rep.ignored_env {
out.push(Line::warn(
P,
"declared",
format!(
"{} in {} is not a string, so it sets nothing",
var.name, var.file
),
format!("quote its value in {}", var.file),
));
}
for file in &rep.unreadable_settings {
out.push(Line::bad(
P,
"settings",
format!(
"{file} is not readable JSON, so nothing it declares is in effect; Claude \
Code cannot read it either"
),
format!("fix the JSON in {file}"),
));
}
}
fn scope_lines(rep: &Report, out: &mut Vec<Line>) {
use Group::Scopes as S;
let scopes = [
(
"global",
"RECALL_GLOBAL_KEY",
scope::GLOBAL_DIR,
&rep.global_key,
rep.global_files,
rep.global_linked,
"set RECALL_GLOBAL_KEY to share memories across projects",
),
(
"machine",
"RECALL_MACHINE_KEY",
scope::MACHINE_DIR,
&rep.machine_key,
rep.machine_files,
rep.machine_linked,
"name this machine with recall connect",
),
];
for (label, var, dir, key, count, linked, when_off) in scopes {
out.push(match key {
None if rep.rejected_vars.contains(&var) => Line::bad(
S,
label,
format!("off: {var} is set but empty once trimmed, so it was ignored"),
format!("give {var} a value, or unset it"),
),
None if declared_empty(rep, var) => Line::quiet(
S,
label,
format!(
"off, because {} declares {var} empty",
declared_in(rep, var).unwrap_or("a settings file")
),
),
None if count > 0 => Line::warn(
S,
label,
format!("off, but {} under {dir}/ sync nowhere", files(count)),
format!("set {var}, or move the files out of {dir}/"),
),
None => Line::quiet(S, label, format!("off; {when_off}")),
Some(key) if count == 0 => Line::good(S, label, format!("{key}, no files yet")),
Some(key) if linked => Line::good(
S,
label,
format!("{key}, {} linked from MEMORY.md", files(count)),
),
Some(key) => Line::bad(
S,
label,
format!(
"{key}, {}, but MEMORY.md links none of them, so Claude Code never reads \
them",
files(count)
),
"recall pull",
),
});
}
if let Some(d) = &rep.miscased_dir {
out.push(Line::bad(
S,
"directory",
format!(
"{}/ is not {}/, so nothing under it syncs. On macOS the two are one directory \
and on Linux they are not, so Recall files it nowhere",
d.found, d.reserved
),
format!(
"rename {}/ to {}/ in the memory directory",
d.found, d.reserved
),
));
}
}
fn audit_line(audit: &AuditReport, server_ok: bool) -> Line {
use Group::History as H;
const LABEL: &str = "audit log";
if let Some(found) = &audit.inconsistent {
return Line::bad(
H,
LABEL,
format!(
"rewritten: the server's log no longer extends a checkpoint saved here (found \
{}): {}{}",
found.found_at,
found.detail,
if audit.unsaved.is_some() {
", and this could not be saved to audit.json"
} else {
""
}
),
crate::audit::AFTER_A_REWRITE,
);
}
if let Some(err) = &audit.file_error {
return Line::bad(
H,
LABEL,
format!("{err}; it may hold the only record of a rewrite"),
format!(
"look at {} first; move it aside only once you know what it held",
audit.file
),
);
}
if audit.dropped > 0 {
return Line::bad(
H,
LABEL,
format!(
"{} checkpoint(s) dropped unchecked, a gap a rewrite could hide in",
audit.dropped
),
"recall audit verify",
);
}
if let Some(why) = &audit.unproven {
return Line::bad(
H,
LABEL,
format!("not proven: the server answered without a proof: {why}"),
"recall audit verify",
);
}
if audit.extends == Some(true) {
let newest = audit.newest.as_deref().map(short_checkpoint);
return Line::good(
H,
LABEL,
format!(
"extends every checkpoint saved here ({} kept, newest {})",
audit.checkpoints,
newest.as_deref().unwrap_or("none")
),
);
}
if let Some(err) = &audit.error {
let why = if server_ok || audit.refused {
err.as_str()
} else {
"the server did not answer"
};
let detail = format!("not checked ({why}); {} checkpoint(s) saved", audit.saved());
return match (audit.refused, audit.saved()) {
(true, _) => Line::warn(
H,
LABEL,
detail,
"recall connect, if the server no longer accepts this device",
),
(false, 0) => Line::quiet(H, LABEL, detail),
(false, _) => Line::warn(
H,
LABEL,
detail,
"recall audit verify, once the server answers",
),
};
}
if audit.server_log == Some(false) && audit.saved() == 0 {
return Line::quiet(H, LABEL, "not kept by this server (older than 0.4.2)");
}
if audit.saved() > 0 {
return Line::warn(
H,
LABEL,
format!("{} checkpoint(s) saved, not checked", audit.saved()),
"recall audit verify",
);
}
Line::quiet(H, LABEL, "nothing saved yet")
}
fn print_text(cfg: &ClientConfig, rep: &Report) {
ui::title("recall status", &about(cfg));
let lines = lines(cfg, rep);
let width = lines.iter().map(|l| l.label.len()).max().unwrap_or(0);
for group in Group::ALL {
let items: Vec<&Line> = lines.iter().filter(|l| l.group == group).collect();
if items.is_empty() {
continue;
}
let about = match group {
Group::Project => rep.project_key.as_str(),
_ => "",
};
ui::section(group.name(), about);
for l in items {
ui::check_fitted(
l.tone,
l.label,
width,
&ui::tilde(&l.detail),
l.fix.as_deref().map(ui::tilde).as_deref(),
);
if let Some(under) = &l.under {
anstream::println!("{}{}", " ".repeat(width + 6), ui::dim(&ui::tilde(under)));
}
}
}
let count = |tone| lines.iter().filter(|l| l.tone == tone).count();
match (count(Tone::Bad), count(Tone::Warn)) {
(0, 0) if !rep.in_git_repo => ui::verdict(
Tone::Good,
"Connected. Outside a git repository there is nothing here to sync.",
),
(0, 0) if rep.server_ok && rep.auth != "none" => ui::verdict(
Tone::Good,
&format!(
"{} syncs: {} here, {} on the server.",
rep.project_key,
files(rep.memory_files),
rep.synced_files
),
),
(0, 0) => ui::verdict(
Tone::Good,
&format!("{} is set up to sync.", rep.project_key),
),
(0, w) => ui::verdict(
Tone::Warn,
&format!(
"Nothing broken. {w} {} worth a look.",
if w == 1 { "thing" } else { "things" }
),
),
(b, _) => ui::verdict(
Tone::Bad,
&format!(
"{b} {}. {}",
if b == 1 { "problem" } else { "problems" },
if rep.url_set {
"Some of it is not syncing."
} else {
"Nothing syncs here."
}
),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn the_audit_check_has_a_budget_of_its_own() {
let root = recall_wire::audit::merkle::hash_leaf(b"x");
let header = recall_hooks::audit::Checkpoint { size: 1, root }.header();
let answer = recall_wire::AuditCheckpoint::parse_header_value(&header).unwrap();
let app = axum::Router::new()
.route(
"/health",
axum::routing::get(|| async {
tokio::time::sleep(Duration::from_secs(60)).await;
"late"
}),
)
.route(
recall_wire::audit::CHECKPOINT_PATH,
axum::routing::get(move || {
let answer = answer.clone();
async move { axum::Json(answer) }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await });
let rep = collect_from(url).await;
assert!(!rep.server_ok, "the rest ran out of time");
let audit = rep.audit.unwrap();
assert_eq!(audit.extends, Some(true), "{audit:?}");
assert_eq!(audit.checkpoints, 1);
}
async fn collect_from(url: String) -> Report {
let dir = tempfile::tempdir().unwrap();
let here = proj::resolve_at(dir.path().to_path_buf());
let cfg = ClientConfig {
url,
token: "t".into(),
audit_file: Some(dir.path().join("audit.json")),
..Default::default()
};
let deadlines = Deadlines {
server: Duration::from_millis(300),
audit: Duration::from_secs(10),
};
collect_within(&here, &cfg, deadlines).await
}
#[test]
fn every_problem_line_says_what_to_run() {
let healthy = crate::doctor::tests::healthy;
let cfg = ClientConfig::default();
let mut reports = Vec::new();
let mut rep = healthy();
rep.url_set = false;
rep.url_source = Source::Unset;
rep.token_set = false;
rep.token_source = Source::Unset;
rep.hooks_wired = false;
rep.project_key_source = KeySource::DeclaredButRejected;
rep.rejected_vars = vec!["RECALL_PROJECT_KEY", "RECALL_GLOBAL_KEY"];
rep.unreadable_settings = vec!["/w/app/.claude/settings.json".into()];
rep.miscased_dir = Some(MiscasedDir {
found: "Global".into(),
reserved: "global",
});
reports.push(rep);
let mut rep = healthy();
rep.server_ok = false;
rep.server_error = Some("timed out".into());
rep.project_key_source = KeySource::LocalPath;
rep.credentials_exposed = true;
rep.credentials_error = Some("bad TOML".into());
rep.config_problems = vec!["unknown key 'sever'".into()];
rep.machine_key = Some("machine:jarvis".into());
rep.machine_files = 2;
rep.global_files = 1;
rep.device_error = Some("device.key is damaged".into());
rep.device_file_exposed = true;
rep.remote_session = true;
rep.remote_memory_dir_set = false;
rep.audit.as_mut().unwrap().dropped = 2;
reports.push(rep);
let mut rep = healthy();
rep.merge_ready = false;
rep.min_client = Some("9.0.0".into());
rep.server_devices = Some(true);
rep.merge_queue = Some(recall_wire::QueueStatus {
queued: 3,
oldest_queued_at: Some("2020-01-01T00:00:00.000Z".into()),
failed: 1,
..Default::default()
});
rep.audit = Some(AuditReport {
error: Some("forbidden".into()),
refused: true,
checkpoints: 1,
..Default::default()
});
reports.push(rep);
let mut problems = 0;
for rep in &reports {
for line in lines(&cfg, rep) {
if matches!(line.tone, Tone::Bad | Tone::Warn) {
problems += 1;
assert!(line.fix.is_some(), "a problem with no fix: {line:?}");
}
}
}
assert!(problems >= 20, "{problems}");
}
#[test]
fn a_healthy_report_has_nothing_to_act_on() {
let rep = crate::doctor::tests::healthy();
let lines = lines(&ClientConfig::default(), &rep);
assert!(
lines
.iter()
.all(|l| matches!(l.tone, Tone::Good | Tone::Quiet) && l.fix.is_none()),
"{lines:#?}"
);
let audit = lines.iter().find(|l| l.label == "audit log").unwrap();
assert!(
audit.detail.contains("newest 1042 CsUYapGGPo4d…"),
"the root is cut short: {audit:?}"
);
}
#[test]
fn a_checkpoint_and_a_commit_are_cut_short_for_reading() {
assert_eq!(
short_checkpoint("1042 CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I="),
"1042 CsUYapGGPo4d…"
);
assert_eq!(short_checkpoint("7 short"), "7 short");
assert_eq!(
short_commit("0fa9e05aa1b2c3d4e5f60718293a4b5c6d7e8f90"),
"0fa9e05aa1b2"
);
assert_eq!(short_commit("a1b2c3d"), "a1b2c3d");
}
#[tokio::test]
async fn a_refused_credential_is_flagged() {
let app = axum::Router::new().route(
recall_wire::audit::CHECKPOINT_PATH,
axum::routing::get(|| async {
(
axum::http::StatusCode::FORBIDDEN,
r#"{"error":"forbidden"}"#,
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await });
let audit = collect_from(url).await.audit.unwrap();
assert!(audit.refused, "{audit:?}");
assert!(
audit.error.is_some() && audit.unproven.is_none(),
"{audit:?}"
);
}
}