use recall_hooks::declared_env::{Declared, Ignored};
use recall_hooks::{
claude, client::Client, 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 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,
#[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 miscased_global: Option<String>,
pub global_linked: bool,
pub url_set: bool,
pub token_set: 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>,
pub merge_ready: bool,
pub synced_files: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_synced_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)
}
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),
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),
miscased_global: std::fs::read_dir(&memory_dir).ok().and_then(|entries| {
entries
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.find(|name| scope::miscased_global_dir(name).is_some())
}),
global_linked: std::fs::read(memory_dir.join("MEMORY.md"))
.map(|b| recall_hooks::global_index_is_linked(&b))
.unwrap_or(false),
url_set: !cfg.url.is_empty(),
token_set: !cfg.token.is_empty(),
server_ok: false,
server_error: None,
git_commit: None,
merge_ready: false,
synced_files: 0,
last_synced_at: None,
};
if !rep.url_set {
return rep;
}
match Client::new(&cfg.url, &cfg.token) {
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);
if !health.last_sync_at.is_empty() {
rep.last_synced_at = Some(health.last_sync_at);
}
}
Err(err) => rep.server_error = Some(err.to_string()),
}
if rep.token_set {
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.to_string()),
}
rep
}
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 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 {
" — this overrides the value set in this shell"
});
}
println!("{label}: {} from {}{note}", var.name, var.file);
}
for var in &rep.ignored_env {
println!(
"settings : {} in {} is not a string, so it sets nothing",
var.name, var.file
);
}
for file in &rep.unreadable_settings {
println!("settings : UNREADABLE — {file}");
}
if !rep.unreadable_settings.is_empty() {
println!(
" Claude Code cannot read it either, so nothing it \
declares is in effect for the hooks."
);
}
}
fn print_text(cfg: &ClientConfig, rep: &Report) {
println!("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 — no git remote, so another machine will \
disagree; 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,
};
println!("project_key : {} ({key_source})", rep.project_key);
println!("memory dir : {}", rep.memory_dir);
println!("memory files : {} on disk", rep.memory_files);
println!(
"hooks wired : {}",
if rep.hooks_wired {
"yes"
} else {
"NO — run 'recall init' in this project"
}
);
print_declared_env(rep);
println!(
"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
),
}
);
if let Some(dir) = &rep.miscased_global {
println!(
" ! '{dir}/' 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 '{}'.",
scope::GLOBAL_DIR,
scope::GLOBAL_DIR
);
}
println!(
"RECALL_URL : {}",
if cfg.url.is_empty() {
"(unset)"
} else {
&cfg.url
}
);
println!(
"RECALL_TOKEN : {}",
if cfg.token.is_empty() {
"(unset)"
} else {
"set"
}
);
if !rep.url_set {
return;
}
if !rep.server_ok {
println!(
"server : UNREACHABLE ({})",
rep.server_error.as_deref().unwrap_or("unknown error")
);
return;
}
println!(
"server : reachable (git_commit {})",
rep.git_commit.as_deref().unwrap_or("unknown")
);
println!(
"merge : {}",
if rep.merge_ready {
"ready (claude CLI logged in)"
} else {
"not configured — server falls back to last-write-wins"
}
);
println!("synced files : {} on server", rep.synced_files);
}