use crate::cli::commands::lifecycle;
use crate::cli::commands::{doctor_fix, doctor_rescue, doctor_restore};
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::DoctorArgs;
use crate::config;
use crate::error::OlError;
use crate::hooks;
#[derive(Clone)]
pub(crate) struct DoctorCheck {
pub pass: bool,
pub message: String,
}
impl DoctorCheck {
pub(crate) fn pass(message: impl Into<String>) -> Self {
Self {
pass: true,
message: message.into(),
}
}
pub(crate) fn fail(message: impl Into<String>) -> Self {
Self {
pass: false,
message: message.into(),
}
}
}
#[allow(dead_code)] pub(crate) struct DoctorReport {
pub checks: Vec<DoctorCheck>,
pub issues: Vec<String>,
pub daemon_alive: bool,
pub daemon_uptime_secs: Option<u64>,
pub port: u16,
}
#[allow(dead_code)] impl DoctorReport {
pub(crate) fn all_pass(&self) -> bool {
self.checks.iter().all(|c| c.pass)
}
pub(crate) fn fail_count(&self) -> usize {
self.checks.iter().filter(|c| !c.pass).count()
}
}
pub fn run_doctor(args: &DoctorArgs, output: &OutputConfig) -> Result<(), OlError> {
if args.trigger_panic {
panic!("openlatch crash-report validation panic");
}
if args.rescue && args.fix {
doctor_rescue::run(args, output, true)?;
return doctor_fix::run(args, output);
}
if args.rescue && args.restore {
doctor_rescue::run(args, output, false)?;
return doctor_restore::run(args, output);
}
if args.fix {
return doctor_fix::run(args, output);
}
if args.restore {
return doctor_restore::run(args, output);
}
if args.rescue {
return doctor_rescue::run(args, output, false);
}
crate::cli::header::print(output, &["doctor"]);
if output.format == OutputFormat::Human && !output.quiet {
eprintln!();
}
let report = run_all_checks(output)?;
print_diagnostic_results(&report, output);
Ok(())
}
pub(crate) fn run_all_checks(_output: &OutputConfig) -> Result<DoctorReport, OlError> {
let cfg = config::Config::load(None, None, false)?;
let ol_dir = config::openlatch_dir();
let mut issues: Vec<String> = Vec::new();
let mut checks: Vec<DoctorCheck> = Vec::new();
match hooks::detect_agent() {
Ok(agent) => {
let label = match &agent {
hooks::DetectedAgent::ClaudeCode { claude_dir, .. } => {
format!("Claude Code ({})", claude_dir.display())
}
};
checks.push(DoctorCheck::pass(format!("Agent detected: {label}")));
}
Err(e) => {
let msg = format!("Agent not found: {} ({})", e.message, e.code);
checks.push(DoctorCheck::fail(msg.clone()));
issues.push("No AI agent detected. Install Claude Code to use OpenLatch.".to_string());
}
}
let config_path = ol_dir.join("config.toml");
if config_path.exists() {
match config::Config::load(None, None, false) {
Ok(_) => {
checks.push(DoctorCheck::pass(format!(
"Config file: {}",
config_path.display()
)));
}
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Config file invalid: {} ({})",
e.message, e.code
)));
issues.push(format!(
"Config file '{}' has errors. Delete and re-run 'openlatch init'.",
config_path.display()
));
}
}
} else {
checks.push(DoctorCheck::fail(format!(
"Config file missing: {}",
config_path.display()
)));
issues.push("Config file missing. Run 'openlatch init' to create it.".to_string());
}
#[cfg(feature = "crash-report")]
{
let resolved = crate::crash_report::current_state(&ol_dir);
let label = match resolved.decided_by {
crate::crash_report::consent::DecidedBy::SentryDisabledEnv => {
"off (SENTRY_DISABLED env)"
}
crate::crash_report::consent::DecidedBy::NoBakedDsn => "off (no DSN baked)",
crate::crash_report::consent::DecidedBy::ConfigFile => {
if resolved.enabled() {
"on (config.toml)"
} else {
"off (config.toml)"
}
}
crate::crash_report::consent::DecidedBy::DefaultEnabled => "on (default)",
};
checks.push(DoctorCheck::pass(format!("Crash reporting: {label}")));
}
#[cfg(not(feature = "crash-report"))]
{
checks.push(DoctorCheck::pass(
"Crash reporting: not compiled in".to_string(),
));
}
let token_path = ol_dir.join("daemon.token");
let mut daemon_token: Option<String> = None;
if token_path.exists() {
match std::fs::read_to_string(&token_path) {
Ok(content) if !content.trim().is_empty() => {
daemon_token = Some(content.trim().to_string());
checks.push(DoctorCheck::pass(format!(
"Auth token: {} (valid)",
token_path.display()
)));
}
Ok(_) => {
checks.push(DoctorCheck::fail(format!(
"Auth token empty: {}",
token_path.display()
)));
issues.push("Auth token is empty. Run 'openlatch init' to regenerate.".to_string());
}
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Auth token unreadable: {} — {e}",
token_path.display()
)));
issues.push(format!(
"Cannot read token file '{}'. Check file permissions.",
token_path.display()
));
}
}
} else {
checks.push(DoctorCheck::fail(format!(
"Auth token missing: {}",
token_path.display()
)));
issues.push("Auth token missing. Run 'openlatch init' to generate one.".to_string());
}
let pid = lifecycle::read_pid_file();
let daemon_alive = pid.map(lifecycle::is_process_alive).unwrap_or(false);
if daemon_alive {
let url = format!("http://127.0.0.1:{}/health", cfg.port);
let reachable = reqwest::blocking::get(&url)
.map(|r| r.status().is_success())
.unwrap_or(false);
if reachable {
checks.push(DoctorCheck::pass(format!(
"Daemon: running on port {} (PID {})",
cfg.port,
pid.unwrap()
)));
} else {
checks.push(DoctorCheck::fail(format!(
"Daemon: process alive (PID {}) but /health unreachable on port {}",
pid.unwrap(),
cfg.port
)));
issues.push(format!(
"Daemon process exists but /health on port {} doesn't respond. Try 'openlatch restart'.",
cfg.port
));
}
} else {
checks.push(DoctorCheck::fail(format!(
"Daemon: not running (port {})",
cfg.port
)));
issues.push("Daemon is not running. Run 'openlatch start' to start it.".to_string());
}
let mut daemon_uptime_secs: Option<u64> = None;
if daemon_alive {
let metrics_url = format!("http://127.0.0.1:{}/metrics", cfg.port);
match reqwest::blocking::get(&metrics_url).and_then(|r| r.json::<serde_json::Value>()) {
Ok(m) => {
daemon_uptime_secs = m.get("uptime_secs").and_then(|v| v.as_u64());
let status = m
.get("cloud_status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let drops = m
.get("cloud_drop_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let api_url = m
.get("cloud_api_url")
.and_then(|v| v.as_str())
.unwrap_or("");
match status {
"connected" => {
checks.push(DoctorCheck::pass(format!("Cloud: connected ({api_url})")));
}
"network_error" => {
let msg =
format!("Cloud: network errors — {drops} event(s) dropped ({api_url})");
checks.push(DoctorCheck::fail(msg.clone()));
issues.push(format!(
"Daemon cannot reach cloud at {api_url}. Check OPENLATCH_API_URL and network connectivity."
));
}
"auth_error" => {
let msg = format!("Cloud: auth error — credential rejected ({api_url})");
checks.push(DoctorCheck::fail(msg));
issues.push(
"Run 'openlatch auth login' to refresh the cloud credential."
.to_string(),
);
}
"no_credential" => {
let msg = format!("Cloud: no credential — forwarding paused ({api_url})");
checks.push(DoctorCheck::fail(msg));
issues.push(
"Run 'openlatch auth login' to store an API key, or set \
[cloud] enabled = false in config.toml."
.to_string(),
);
}
"not_configured" => {
checks.push(DoctorCheck::pass(
"Cloud: disabled in daemon config".to_string(),
));
}
other => {
checks.push(DoctorCheck::fail(format!(
"Cloud: unknown status '{other}'"
)));
}
}
}
Err(_) => {
checks.push(DoctorCheck::fail(
"Cloud: could not read daemon /metrics — daemon may be unhealthy".to_string(),
));
}
}
} else if cfg.cloud.enabled {
match tokio::runtime::Runtime::new() {
Ok(rt) => {
let (pass, message) = rt.block_on(check_cloud(&cfg.cloud.api_url));
if pass {
checks.push(DoctorCheck::pass(message));
} else {
checks.push(DoctorCheck::fail(message.clone()));
if message.contains("not authenticated") {
issues.push("Run 'openlatch auth login' to enable cloud sync.".to_string());
} else {
issues.push(format!(
"Cloud unreachable or credential rejected at {}. Check network connectivity or re-run 'openlatch auth login'.",
cfg.cloud.api_url
));
}
}
}
Err(_) => {
checks.push(DoctorCheck::fail(
"Cloud: check skipped (runtime init failed)".to_string(),
));
}
}
} else {
checks.push(DoctorCheck::pass("Cloud: disabled in config".to_string()));
}
if let Ok(agent) = hooks::detect_agent() {
let settings_path = match &agent {
hooks::DetectedAgent::ClaudeCode { settings_path, .. } => settings_path.clone(),
};
if settings_path.exists() {
match std::fs::read_to_string(&settings_path) {
Ok(content) => {
let has_pre_tool_use =
content.contains("PreToolUse") && content.contains("_openlatch");
let has_user_prompt =
content.contains("UserPromptSubmit") && content.contains("_openlatch");
let has_stop = content.contains("Stop") && content.contains("_openlatch");
let mut missing: Vec<&str> = Vec::new();
if !has_pre_tool_use {
missing.push("PreToolUse");
}
if !has_user_prompt {
missing.push("UserPromptSubmit");
}
if !has_stop {
missing.push("Stop");
}
if missing.is_empty() {
checks.push(DoctorCheck::pass(format!(
"Hooks: all entries present in {}",
settings_path.display()
)));
} else {
for hook in &missing {
checks.push(DoctorCheck::fail(format!(
"Hooks: {hook} missing from {}",
settings_path.display()
)));
}
issues.push(format!(
"Missing hooks: {}. Run 'openlatch init' to fix hook installation.",
missing.join(", ")
));
}
}
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Hooks: cannot read {} — {e}",
settings_path.display()
)));
issues.push(format!(
"Cannot read settings file '{}'. Check permissions.",
settings_path.display()
));
}
}
check_hook_binding(
&settings_path,
daemon_token.as_deref(),
&mut checks,
&mut issues,
);
} else {
checks.push(DoctorCheck::fail(format!(
"Hooks: settings.json not found at {}",
settings_path.display()
)));
issues.push(
"settings.json not found. Run 'openlatch init' to install hooks.".to_string(),
);
}
}
check_supervision_state(&cfg, &mut checks, &mut issues);
check_fallback_activity(
&ol_dir,
daemon_alive,
daemon_uptime_secs,
&mut checks,
&mut issues,
);
Ok(DoctorReport {
checks,
issues,
daemon_alive,
daemon_uptime_secs,
port: cfg.port,
})
}
pub(crate) fn print_diagnostic_results(report: &DoctorReport, output: &OutputConfig) {
if output.format == OutputFormat::Json {
let checks_json: Vec<serde_json::Value> = report
.checks
.iter()
.map(|c| {
serde_json::json!({
"pass": c.pass,
"message": c.message,
})
})
.collect();
output.print_json(&serde_json::json!({
"checks": checks_json,
"issues": report.issues,
"issue_count": report.issues.len(),
}));
} else if !output.quiet {
for check in &report.checks {
if check.pass {
let mark = crate::cli::color::checkmark(output.color);
eprintln!("{mark} {}", check.message);
} else {
let cross = crate::cli::color::cross(output.color);
eprintln!("{cross} {}", check.message);
}
}
eprintln!();
if report.issues.is_empty() {
eprintln!("All checks passed.");
} else {
let n = report.issues.len();
eprintln!(
"{n} issue{} found. Run 'openlatch init' to fix hook installation.",
if n == 1 { "" } else { "s" }
);
}
}
}
async fn check_cloud(api_url: &str) -> (bool, String) {
use secrecy::ExposeSecret;
let store = crate::core::auth::KeyringCredentialStore::new();
let file_store = crate::cli::commands::auth::make_file_store();
let key = match crate::core::auth::retrieve_credential(
&store as &dyn crate::core::auth::CredentialStore,
&file_store as &dyn crate::core::auth::CredentialStore,
) {
Ok(k) => k,
Err(_) => return (false, "Cloud: not authenticated".to_string()),
};
let key_str = key.expose_secret().to_string();
let result = crate::cli::commands::auth::validate_online_full(&key_str, api_url).await;
if result.online {
let org = if result.org_name.is_empty() {
String::new()
} else {
format!(" — org {}", result.org_name)
};
(true, format!("Cloud: reachable ({api_url}){org}"))
} else {
(
false,
format!("Cloud: unreachable or credential rejected ({api_url})"),
)
}
}
fn check_hook_binding(
settings_path: &std::path::Path,
daemon_token: Option<&str>,
checks: &mut Vec<DoctorCheck>,
issues: &mut Vec<String>,
) {
let raw = match std::fs::read_to_string(settings_path) {
Ok(s) => s,
Err(_) => return,
};
let parsed = match crate::hooks::jsonc::parse_settings_value(&raw) {
Ok(v) => v,
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Hook config: cannot parse settings.json ({})",
e.code
)));
return;
}
};
let expected_bin = crate::hooks::resolve_hook_binary_path();
let commands = openlatch_hook_commands(&parsed);
if commands.is_empty() {
return;
}
let mut bin_missing: Vec<String> = Vec::new();
let mut bin_drifted: Vec<String> = Vec::new();
for cmd in &commands {
let Some(bin) = extract_quoted_binary(cmd) else {
continue;
};
let bin_path = std::path::PathBuf::from(&bin);
if !bin_path.exists() {
bin_missing.push(bin);
} else if bin_path != expected_bin {
bin_drifted.push(bin);
}
}
if bin_missing.is_empty() && bin_drifted.is_empty() {
checks.push(DoctorCheck::pass(format!(
"Hook binary: {} (referenced by settings.json)",
expected_bin.display()
)));
}
if !bin_missing.is_empty() {
checks.push(DoctorCheck::fail(format!(
"Hook binary missing: {}",
bin_missing.join(", ")
)));
issues.push(
"Hook command points at a binary that does not exist. Run 'openlatch init' to rewrite the hook command."
.to_string(),
);
}
if !bin_drifted.is_empty() {
checks.push(DoctorCheck::fail(format!(
"Hook binary drift: settings.json uses {}, current install is {}",
bin_drifted.join(", "),
expected_bin.display()
)));
issues.push(
"Hook command points at a stale binary. Run 'openlatch init' to re-link settings.json to the current install."
.to_string(),
);
}
if let Some(token) = daemon_token {
let settings_token = parsed
.get("env")
.and_then(|e| e.get("OPENLATCH_TOKEN"))
.and_then(|v| v.as_str());
match settings_token {
Some(t) if t == token => {
checks.push(DoctorCheck::pass(
"Hook token: settings.json env matches daemon.token".to_string(),
));
}
Some(_) => {
checks.push(DoctorCheck::fail(
"Hook token: settings.json OPENLATCH_TOKEN does not match daemon.token"
.to_string(),
));
issues.push(
"Hook subprocess will be rejected with 401. Run 'openlatch init' to re-sync the token."
.to_string(),
);
}
None => {
checks.push(DoctorCheck::fail(
"Hook token: OPENLATCH_TOKEN not set in settings.json env".to_string(),
));
issues.push(
"Hook subprocess will not receive OPENLATCH_TOKEN. Run 'openlatch init' to install it."
.to_string(),
);
}
}
}
}
fn openlatch_hook_commands(settings: &serde_json::Value) -> Vec<String> {
let Some(hooks) = settings.get("hooks").and_then(|v| v.as_object()) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
for entries in hooks.values().filter_map(|v| v.as_array()) {
for entry in entries {
if entry.get("_openlatch").and_then(|v| v.as_bool()) != Some(true) {
continue;
}
let Some(inner) = entry.get("hooks").and_then(|v| v.as_array()) else {
continue;
};
for h in inner {
if let Some(cmd) = h.get("command").and_then(|v| v.as_str()) {
out.push(cmd.to_string());
}
}
}
}
out
}
fn extract_quoted_binary(command: &str) -> Option<String> {
let start = command.find('"')? + 1;
let end = command[start..].find('"')? + start;
if start == end {
return None;
}
Some(command[start..end].to_string())
}
fn check_supervision_state(
cfg: &config::Config,
checks: &mut Vec<DoctorCheck>,
issues: &mut Vec<String>,
) {
use crate::supervision::{select_supervisor, SupervisionMode, SupervisorKind};
let configured_backend = match cfg.supervision.backend {
SupervisorKind::Launchd => "launchd",
SupervisorKind::Systemd => "systemd",
SupervisorKind::TaskScheduler => "task_scheduler",
SupervisorKind::None => "none",
};
match (&cfg.supervision.mode, select_supervisor()) {
(SupervisionMode::Active, Some(sup)) => match sup.status() {
Ok(s) if s.installed && s.running => {
checks.push(DoctorCheck::pass(format!(
"Supervision: active ({configured_backend}, {})",
s.description
)));
}
Ok(s) if s.installed => {
checks.push(DoctorCheck::fail(format!(
"Supervision: installed but supervisor not running ({configured_backend})"
)));
issues.push(
"Supervisor is registered but not running. Check OS logs or run \
'openlatch supervision install' to rebuild the artifact."
.to_string(),
);
}
Ok(_) => {
checks.push(DoctorCheck::fail(format!(
"Supervision: config says active but OS artifact is missing ({configured_backend})"
)));
issues.push(
"Supervisor drifted — OS artifact deleted while config still expects it. \
Run 'openlatch supervision install' (or 'openlatch doctor --fix')."
.to_string(),
);
}
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Supervision: cannot query supervisor ({}) — {}",
e.code, e.message
)));
}
},
(SupervisionMode::Deferred, _) => {
let reason = cfg
.supervision
.disabled_reason
.as_deref()
.unwrap_or("unknown");
checks.push(DoctorCheck::fail(format!(
"Supervision: deferred — {reason}"
)));
issues.push(
"Supervision is deferred. Run 'openlatch supervision install' once the \
underlying issue is resolved (headless session, missing systemd, etc)."
.to_string(),
);
}
(SupervisionMode::Disabled, _) => {
let reason = cfg
.supervision
.disabled_reason
.as_deref()
.unwrap_or("user_opt_out");
checks.push(DoctorCheck::pass(format!(
"Supervision: disabled ({reason})"
)));
}
(SupervisionMode::Active, None) => {
checks.push(DoctorCheck::fail(
"Supervision: config says active but no supervisor is available on this OS"
.to_string(),
));
issues.push(
"Supervisor unsupported on this OS. Run 'openlatch supervision disable' to \
clear the stale state."
.to_string(),
);
}
}
}
fn check_fallback_activity(
ol_dir: &std::path::Path,
daemon_alive: bool,
daemon_uptime_secs: Option<u64>,
checks: &mut Vec<DoctorCheck>,
issues: &mut Vec<String>,
) {
if !daemon_alive {
return;
}
let Some(uptime) = daemon_uptime_secs else {
return;
};
let fallback_path = ol_dir.join("logs").join("fallback.jsonl");
if !fallback_path.exists() {
checks.push(DoctorCheck::pass(
"Hook fallback log: no offline events recorded".to_string(),
));
return;
}
let meta = match std::fs::metadata(&fallback_path) {
Ok(m) => m,
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Hook fallback log: cannot stat {} — {e}",
fallback_path.display()
)));
return;
}
};
let mtime = match meta.modified() {
Ok(t) => t,
Err(_) => return,
};
let daemon_start = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(uptime))
.unwrap_or(std::time::UNIX_EPOCH);
if mtime > daemon_start {
let age_secs = mtime
.duration_since(daemon_start)
.map(|d| d.as_secs())
.unwrap_or(0);
checks.push(DoctorCheck::fail(format!(
"Hook fallback log: {} written {age_secs}s after daemon start — hook subprocess is not reaching the daemon",
fallback_path.display()
)));
issues.push(
"Hook binary is writing to fallback.jsonl while the daemon is up. \
Likely causes: token mismatch, wrong port, or stale settings.json. \
Run 'openlatch doctor' above for Hook token/binary checks, then 'openlatch init' to repair."
.to_string(),
);
} else {
checks.push(DoctorCheck::pass(format!(
"Hook fallback log: quiet since daemon start ({})",
fallback_path.display()
)));
}
}