use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::config;
use crate::error::OlError;
use crate::hooks;
pub fn run_doctor(output: &OutputConfig) -> Result<(), OlError> {
if output.format == OutputFormat::Human && !output.quiet {
eprintln!("OpenLatch Doctor");
eprintln!();
}
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());
}
let token_path = ol_dir.join("daemon.token");
if token_path.exists() {
match std::fs::read_to_string(&token_path) {
Ok(content) if !content.trim().is_empty() => {
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());
}
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()
));
}
}
} 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(),
);
}
}
if output.format == OutputFormat::Json {
let checks_json: Vec<serde_json::Value> = checks
.iter()
.map(|c| {
serde_json::json!({
"pass": c.pass,
"message": c.message,
})
})
.collect();
output.print_json(&serde_json::json!({
"checks": checks_json,
"issues": issues,
"issue_count": issues.len(),
}));
} else if !output.quiet {
for check in &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 issues.is_empty() {
eprintln!("All checks passed.");
} else {
let n = issues.len();
eprintln!(
"{n} issue{} found. Run 'openlatch init' to fix hook installation.",
if n == 1 { "" } else { "s" }
);
}
}
Ok(())
}
struct DoctorCheck {
pass: bool,
message: String,
}
impl DoctorCheck {
fn pass(message: impl Into<String>) -> Self {
Self {
pass: true,
message: message.into(),
}
}
fn fail(message: impl Into<String>) -> Self {
Self {
pass: false,
message: message.into(),
}
}
}