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;
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(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>,
}
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 {
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,
};
if !rep.url_set {
return rep;
}
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) => {
match client.health().await {
Ok(health) => {
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 {
if let Ok(Some(doc)) = client.discover().await {
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 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();
}
}
}
Err(err) => rep.server_error = Some(err),
}
rep
}
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)
}
macro_rules! field {
($($arg:tt)*) => {
crate::ui::field_line(&format!($($arg)*))
};
}
fn print_declared_env(rep: &Report) {
for (i, var) in rep.declared_env.iter().enumerate() {
let label = if i == 0 {
"declared env "
} else {
" "
};
let mut note = String::new();
if var.empty {
note.push_str(", declared EMPTY, so the setting is off");
}
if var.shadows_shell {
note.push_str(if var.empty {
", and it hides the value set in this shell"
} else {
", overrides the value set in this shell"
});
}
field!("{label}: {} from {}{note}", var.name, var.file);
}
for var in &rep.ignored_env {
field!(
"settings : {} in {} is not a string, so it sets nothing",
var.name,
var.file
);
}
for file in &rep.unreadable_settings {
field!("settings : UNREADABLE — {file}");
}
if !rep.unreadable_settings.is_empty() {
field!(
" Claude Code cannot read it either, so nothing it \
declares is in effect for the hooks."
);
}
}
fn print_text(cfg: &ClientConfig, rep: &Report) {
crate::ui::title("recall status", &cfg.source_env);
anstream::println!();
field!("project : {}", rep.project);
let key_source = match rep.project_key_source {
KeySource::Declared => format!(
"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 => "from the git remote".to_string(),
KeySource::LocalPath => {
"from this checkout's path. With no git remote another machine will \
disagree, so set RECALL_PROJECT_KEY on both"
.to_string()
}
KeySource::DeclaredButRejected => {
"RECALL_PROJECT_KEY was SET BUT UNUSABLE and ignored. It must be \
non-empty, free of whitespace, and not under 'global:'"
.to_string()
}
};
let key_source = match declared_in(rep, "RECALL_PROJECT_KEY") {
Some(file) if declared_empty(rep, "RECALL_PROJECT_KEY") => format!(
"{key_source}; RECALL_PROJECT_KEY is declared empty in {file}, which reads as unset"
),
_ => key_source,
};
field!("project_key : {} ({key_source})", rep.project_key);
field!("memory dir : {}", rep.memory_dir);
field!("memory files : {} on disk", rep.memory_files);
field!(
"hooks wired : {}",
if rep.hooks_wired {
"yes"
} else {
"NO, run 'recall init' in this project"
}
);
print_declared_env(rep);
field!(
"global : {}",
match &rep.global_key {
None if rep.rejected_vars.contains(&"RECALL_GLOBAL_KEY") =>
"off, RECALL_GLOBAL_KEY was SET BUT EMPTY once trimmed, so it was ignored"
.to_string(),
None if declared_empty(rep, "RECALL_GLOBAL_KEY") => format!(
"off, RECALL_GLOBAL_KEY is declared empty in {}, which reads as unset",
declared_in(rep, "RECALL_GLOBAL_KEY").unwrap_or("a settings file")
),
None => "off (set RECALL_GLOBAL_KEY to share memories across projects)".to_string(),
Some(key) if rep.global_linked =>
format!("{key}, {} file(s), linked from MEMORY.md", rep.global_files),
Some(key) => format!(
"{key}, {} file(s), NOT linked from MEMORY.md yet (run 'recall pull')",
rep.global_files
),
}
);
field!(
"machine : {}",
match &rep.machine_key {
None if rep.rejected_vars.contains(&"RECALL_MACHINE_KEY") =>
"off, RECALL_MACHINE_KEY was SET BUT EMPTY once trimmed, so it was ignored"
.to_string(),
None if declared_empty(rep, "RECALL_MACHINE_KEY") => format!(
"off, RECALL_MACHINE_KEY is declared empty in {}, which reads as unset",
declared_in(rep, "RECALL_MACHINE_KEY").unwrap_or("a settings file")
),
None => "off (name this machine with recall connect)".to_string(),
Some(key) if rep.machine_files == 0 => format!("{key}, no files yet"),
Some(key) if rep.machine_linked => {
format!(
"{key}, {} file(s), linked from MEMORY.md",
rep.machine_files
)
}
Some(key) => format!(
"{key}, {} file(s), NOT linked from MEMORY.md yet (run 'recall pull')",
rep.machine_files
),
}
);
if let Some(d) = &rep.miscased_dir {
field!(
" ! '{}/' is not '{}/', so nothing under it syncs. On \
macOS the two are the same directory and on Linux they are not, so \
Recall refuses rather than file it somewhere you did not mean. \
Rename it to '{}'.",
d.found,
d.reserved,
d.reserved
);
}
let from_file = rep
.credentials_file
.as_deref()
.unwrap_or("the credentials file");
let from_config = rep.config_file.as_deref().unwrap_or("the config file");
field!(
"RECALL_URL : {}",
match rep.url_source {
Source::Unset => "(unset)".to_string(),
Source::Environment => cfg.url.clone(),
Source::CredentialsFile | Source::ConfigFile => {
format!("{} (from {from_config})", cfg.url)
}
}
);
field!(
"RECALL_TOKEN : {}",
match rep.token_source {
Source::Unset if rep.device.is_some() => {
"(unset, not needed: this machine signs its requests)".to_string()
}
Source::Unset => "(unset)".to_string(),
Source::Environment => match declared_in(rep, "RECALL_TOKEN") {
Some(file) => format!("set, by {file}"),
None => "set, in this shell".to_string(),
},
Source::CredentialsFile | Source::ConfigFile => {
format!("saved in {from_file}")
}
}
);
if let Some(d) = &rep.device {
field!(
"device : {} ({}{}), key in {}",
d.name,
d.scope,
if d.ephemeral { ", ephemeral" } else { "" },
d.key_file
);
} else if rep.authkey_set {
field!("device : none yet, RECALL_AUTHKEY enrols one at the next pull");
}
if let Some(err) = &rep.device_error {
field!("device : UNREADABLE ({err})");
}
for problem in &rep.config_problems {
field!("config : {problem} ({from_config})");
}
for o in &rep.overridden {
field!(
"config : {}={} overrides {} = {:?} in {from_config}",
o.variable,
o.environment,
o.setting,
o.config
);
}
if let Some(err) = &rep.credentials_error {
field!("credentials : UNREADABLE ({err})");
}
if rep.credentials_exposed {
field!("credentials : readable by other users, run chmod 600 {from_file}");
}
if !rep.url_set {
return;
}
if !rep.server_ok {
field!(
"server : UNREACHABLE ({})",
rep.server_error.as_deref().unwrap_or("unknown error")
);
return;
}
match (&rep.server_version, &rep.server_channel) {
(Some(version), Some(channel)) => {
field!("server : reachable ({version}, {channel} build)")
}
_ => field!(
"server : reachable (git_commit {})",
rep.git_commit.as_deref().unwrap_or("unknown")
),
}
field!("client : {}", rep.client_version);
match (&rep.device, rep.server_devices) {
(Some(d), _) if d.gone => field!(
"device : REFUSED by the server ({}), run recall connect",
d.check_error.as_deref().unwrap_or("unknown device")
),
(Some(d), _) if d.confirmed == Some(false) => field!(
"device : not confirmed ({})",
d.check_error.as_deref().unwrap_or("no answer")
),
(Some(_), _) => field!("device : confirmed by the server"),
(None, Some(true)) => {
field!("device : not enrolled, this machine uses the shared token")
}
_ => {}
}
match crate::doctor::worker_quiet(rep) {
Some(quiet) => field!(
"merge : stalled: the merge worker has not asked for work in {} minutes; \
is recall-worker running?",
quiet.whole_minutes()
),
None => field!(
"merge : {}",
match (rep.merge_ready, rep.merge_worker) {
(true, false) => "ready (claude CLI logged in)",
(true, true) => "ready (merge worker, claude CLI logged in)",
(false, false) => "not configured, so the server uses last-write-wins",
(false, true) => "waiting: the merge worker's claude CLI is not logged in",
}
),
}
if let Some(q) = &rep.merge_queue {
match &q.oldest_queued_at {
Some(since)
if crate::doctor::age_of(since)
.is_some_and(|age| age >= time::Duration::hours(1)) =>
{
field!(
"merge queue : {} waiting since {since}; is recall-worker running?",
q.queued
)
}
Some(since) => field!(
"merge queue : {} waiting, the oldest since {since}",
q.queued
),
None => field!("merge queue : nothing waiting"),
}
if q.failed > 0 {
field!("failed merges: {}; see GET /v1/jobs?state=failed", q.failed);
}
}
field!("synced files : {} on server", rep.synced_files);
}