use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::core::paths::FrameworkPaths;
use super::discover::{TRUSTY_MEMORY_DEFAULT_ADDR, TRUSTY_SEARCH_DEFAULT_ADDR, discover_addr};
#[path = "doctor_output_style.rs"]
mod doctor_output_style;
use doctor_output_style::{
check_output_style, check_output_style_legacy_ids, check_output_style_staleness,
};
#[path = "doctor_fs_checks.rs"]
mod doctor_fs_checks;
use doctor_fs_checks::{check_agents, check_instructions, check_skill_source, check_skills};
#[path = "doctor_deploy_validate.rs"]
mod doctor_deploy_validate;
use doctor_deploy_validate::check_deployment_completeness;
#[path = "doctor_staleness.rs"]
mod doctor_staleness;
use doctor_staleness::{check_legacy_instruction_sources, check_skill_staleness};
#[path = "doctor_agent_skills.rs"]
mod doctor_agent_skills;
use doctor_agent_skills::check_agent_skills;
#[path = "doctor_hooks_hygiene.rs"]
mod doctor_hooks_hygiene;
use doctor_hooks_hygiene::check_hooks_hygiene;
#[path = "doctor_tcc.rs"]
mod doctor_tcc;
use doctor_tcc::check_tcc_taint;
#[path = "doctor_scaffold_tracking.rs"]
mod doctor_scaffold_tracking;
use doctor_scaffold_tracking::check_scaffold_tracking;
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
const EXPECTED_SEARCH_INDEX: &str = "trusty-mpm";
pub async fn run_doctor(
project_dir: Option<&Path>,
repos_root: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> DoctorReport {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let paths = match project_dir {
Some(dir) => FrameworkPaths::for_managed_workspace(dir),
None => FrameworkPaths::default(),
};
let skills_root: &Path = project_dir.unwrap_or(&home);
let (agent_skills, agent_skills_prose_hints) = check_agent_skills(&paths);
let mut checks = vec![
check_instructions(project_dir),
check_agents(&paths),
check_skills(skills_root),
check_skill_source(&paths),
check_output_style(project_dir, &home),
check_output_style_staleness(project_dir, &home),
check_output_style_legacy_ids(project_dir, &home),
check_deployment_completeness(&paths),
check_skill_staleness(&paths),
check_legacy_instruction_sources(&home),
agent_skills,
agent_skills_prose_hints,
];
checks.push(check_memory(&home).await);
checks.push(check_search(&home).await);
checks.push(check_worktrees(repos_root, active_workspace_paths).await);
checks.push(check_gh_account().await);
checks.push(check_oauth_token_config());
let (hooks_contamination, hooks_foreign_conflict) =
check_hooks_hygiene(project_dir, active_workspace_paths);
checks.push(hooks_contamination);
checks.push(hooks_foreign_conflict);
checks.push(check_tcc_taint());
checks.push(check_scaffold_tracking(project_dir));
DoctorReport::from_checks(checks)
}
fn check_oauth_token_config() -> DoctorCheck {
let relocated = crate::core::trusty_tools_config::managed_claude_config_dir().is_some();
let status = crate::core::oauth_token::compute_status();
build_oauth_token_check(relocated, status.stored_token_present, status.env_var_set)
}
fn build_oauth_token_check(
relocated: bool,
stored_token_present: bool,
env_var_set: bool,
) -> DoctorCheck {
let token_available = stored_token_present || env_var_set;
if relocated && !token_available {
DoctorCheck::new(
"oauth_token",
CheckStatus::Warn,
"managed sessions relocate CLAUDE_CONFIG_DIR but no CLAUDE_CODE_OAUTH_TOKEN is \
configured — a `/login` inside a managed session may loop between \"successful\" \
and \"not logged in\" (issue #2246). Note an ambient ANTHROPIC_API_KEY does NOT \
help: managed spawns strip it. Run `claude setup-token` then `tm auth set-token`",
)
} else {
DoctorCheck::new(
"oauth_token",
CheckStatus::Ok,
"managed-session auth looks configured",
)
}
}
async fn check_gh_account() -> DoctorCheck {
let (active, accounts) = tokio::task::spawn_blocking(|| {
let active = crate::core::gh_account::active_gh_account();
let accounts = crate::core::gh_account::logged_in_gh_accounts();
(active, accounts)
})
.await
.unwrap_or((None, Vec::new()));
build_gh_account_check(active, accounts)
}
fn build_gh_account_check(active: Option<String>, accounts: Vec<String>) -> DoctorCheck {
if accounts.len() > 1 {
let list = accounts.join(", ");
let active_str = active.as_deref().unwrap_or("unknown");
return DoctorCheck::new(
"gh_account",
CheckStatus::Warn,
format!(
"{} github.com accounts logged in ({list}); active is `{active_str}`. \
`gh` (and `gh pr merge --admin`) uses the ACTIVE account — if merges \
fail on permissions, run `gh auth switch` to the repo owner.",
accounts.len()
),
);
}
match active {
Some(login) => DoctorCheck::new(
"gh_account",
CheckStatus::Ok,
format!("active gh account: `{login}`"),
),
None if accounts.is_empty() => DoctorCheck::new(
"gh_account",
CheckStatus::Warn,
"gh is not authenticated (no github.com account) — `gh` calls will fail; \
run `gh auth login`"
.to_string(),
),
None => DoctorCheck::new(
"gh_account",
CheckStatus::Warn,
format!(
"gh authenticated ({}) but no active account is set — run `gh auth switch`",
accounts.join(", ")
),
),
}
}
async fn check_worktrees(
repos_root: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> DoctorCheck {
let Some(root) = repos_root else {
return DoctorCheck::new(
"worktrees",
CheckStatus::Ok,
"no managed workspace root configured — worktree scan skipped",
);
};
if !root.is_dir() {
return DoctorCheck::new(
"worktrees",
CheckStatus::Ok,
format!("{} does not exist — no worktrees to check", root.display()),
);
}
let root = root.to_path_buf();
let active_set: std::collections::HashSet<PathBuf> = active_workspace_paths
.iter()
.map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
.collect();
let orphans = tokio::task::spawn_blocking(move || {
crate::session_manager::prune::find_orphaned_worktrees(&root, &active_set)
})
.await
.unwrap_or_else(|e| {
tracing::error!("doctor: worktree scan panicked: {e}");
Vec::new()
});
if orphans.is_empty() {
DoctorCheck::new("worktrees", CheckStatus::Ok, "no orphaned worktrees found")
} else {
DoctorCheck::new(
"worktrees",
CheckStatus::Warn,
format!(
"{} orphaned worktree dir(s) found — run `tm session prune --worktrees` to remove",
orphans.len()
),
)
}
}
async fn check_memory(home: &Path) -> DoctorCheck {
let dir = home.join(".trusty-memory");
let default = TRUSTY_MEMORY_DEFAULT_ADDR
.parse()
.expect("static default is valid");
let env = std::env::var("TRUSTY_MEMORY_ADDR").ok();
let addr = discover_addr(&dir, default, env.as_deref()).await;
match http_get_ok(&format!("http://{addr}/health")).await {
Ok(true) => DoctorCheck::new(
"memory",
CheckStatus::Ok,
format!("trusty-memory healthy at {addr}"),
),
Ok(false) => DoctorCheck::new(
"memory",
CheckStatus::Fail,
format!("trusty-memory at {addr} returned a non-2xx status"),
),
Err(e) => DoctorCheck::new(
"memory",
CheckStatus::Fail,
format!("trusty-memory unreachable at {addr}: {e}"),
),
}
}
async fn check_search(home: &Path) -> DoctorCheck {
let dir = home.join(".trusty-search");
let default = TRUSTY_SEARCH_DEFAULT_ADDR
.parse()
.expect("static default is valid");
let env = std::env::var("TRUSTY_SEARCH_ADDR").ok();
let addr = discover_addr(&dir, default, env.as_deref()).await;
match http_get_ok(&format!("http://{addr}/health")).await {
Ok(true) => {}
Ok(false) => {
return DoctorCheck::new(
"search",
CheckStatus::Fail,
format!("trusty-search at {addr} returned a non-2xx status"),
);
}
Err(e) => {
return DoctorCheck::new(
"search",
CheckStatus::Fail,
format!("trusty-search unreachable at {addr}: {e}"),
);
}
}
match http_get_json(&format!("http://{addr}/indexes")).await {
Ok(body) if index_present(&body, EXPECTED_SEARCH_INDEX) => DoctorCheck::new(
"search",
CheckStatus::Ok,
format!("trusty-search healthy at {addr}, `{EXPECTED_SEARCH_INDEX}` index present"),
),
Ok(_) => DoctorCheck::new(
"search",
CheckStatus::Warn,
format!(
"trusty-search healthy at {addr} but the `{EXPECTED_SEARCH_INDEX}` index is missing"
),
),
Err(e) => DoctorCheck::new(
"search",
CheckStatus::Warn,
format!("trusty-search healthy at {addr} but listing indexes failed: {e}"),
),
}
}
fn index_present(body: &serde_json::Value, name: &str) -> bool {
let array = body
.as_array()
.or_else(|| body.get("indexes").and_then(|v| v.as_array()));
let Some(array) = array else {
return false;
};
array.iter().any(|entry| {
if entry.as_str() == Some(name) {
return true;
}
["id", "name", "index_id"]
.iter()
.any(|key| entry.get(key).and_then(|v| v.as_str()) == Some(name))
})
}
async fn http_get_ok(url: &str) -> anyhow::Result<bool> {
let client = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
let resp = client.get(url).send().await?;
Ok(resp.status().is_success())
}
async fn http_get_json(url: &str) -> anyhow::Result<serde_json::Value> {
let client = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
let body = client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body)
}
#[cfg(test)]
#[path = "doctor_tests.rs"]
mod tests;