use std::path::Path;
use std::process::ExitCode;
use klasp_core::{
AgentSurface, CheckSourceConfig, ConfigV1, InstallContext, KlaspError, GATE_SCHEMA_VERSION,
};
use serde_json::Value;
use crate::cli::DoctorArgs;
use crate::cmd::install::resolve_repo_root;
use crate::registry::SurfaceRegistry;
pub(crate) struct Counters {
pub(crate) fails: usize,
pub(crate) warns: usize,
}
impl Counters {
pub(crate) fn new() -> Self {
Self { fails: 0, warns: 0 }
}
pub(crate) fn ok(&self, msg: &str) {
println!("OK {msg}");
}
pub(crate) fn warn(&mut self, msg: &str) {
self.warns += 1;
println!("WARN {msg}");
}
pub(crate) fn fail(&mut self, msg: &str) {
self.fails += 1;
println!("FAIL {msg}");
}
pub(crate) fn info(msg: &str) {
println!("INFO {msg}");
}
}
pub fn run(_args: &DoctorArgs) -> ExitCode {
let repo_root = match resolve_repo_root(None) {
Ok(r) => r,
Err(e) => {
eprintln!("klasp doctor: {e:#}");
return ExitCode::FAILURE;
}
};
let mut c = Counters::new();
let config = check_config(&repo_root, &mut c);
check_surfaces(&repo_root, config.as_ref(), &mut c);
if let Some(cfg) = config {
check_paths(&cfg, &mut c);
}
if c.fails > 0 || c.warns > 0 {
eprintln!("doctor: {} FAIL, {} WARN", c.fails, c.warns);
} else {
eprintln!("doctor: all checks passed");
}
if c.fails > 0 {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
pub(crate) fn check_config(repo_root: &Path, c: &mut Counters) -> Option<ConfigV1> {
match ConfigV1::load(repo_root) {
Ok(cfg) => {
c.ok("config: klasp.toml loaded OK");
Some(cfg)
}
Err(KlaspError::ConfigNotFound { searched }) => {
let paths: Vec<String> = searched.iter().map(|p| p.display().to_string()).collect();
c.fail(&format!(
"config: klasp.toml not found (searched: {})",
paths.join(", ")
));
None
}
Err(KlaspError::ConfigVersion { found, supported }) => {
c.fail(&format!(
"config: version mismatch — file declares version = {found}, but this klasp understands version = {supported}"
));
None
}
Err(KlaspError::ConfigParse(e)) => {
c.fail(&format!("config: klasp.toml parse error: {e}"));
None
}
Err(KlaspError::Io { path, source }) => {
c.fail(&format!(
"config: I/O error reading {}: {source}",
path.display()
));
None
}
Err(
e @ (KlaspError::Protocol(_) | KlaspError::Install(_) | KlaspError::CheckSource(_)),
) => {
c.fail(&format!("config: unexpected error: {e}"));
None
}
}
}
pub(crate) fn check_surfaces(repo_root: &Path, config: Option<&ConfigV1>, c: &mut Counters) {
let registry = SurfaceRegistry::default();
let mut active = 0usize;
let declared: Option<&[String]> = config.map(|cfg| cfg.gate.agents.as_slice());
for surface in registry.iter() {
let agent_id = surface.agent_id();
let is_declared = match declared {
Some(agents) => agents.iter().any(|a| a == agent_id),
None => surface.detect(repo_root),
};
if !is_declared {
Counters::info(&format!("{agent_id}: not in [gate].agents, skipping"));
continue;
}
active += 1;
check_hook(repo_root, surface, c);
check_settings(repo_root, surface, c);
}
if active == 0 {
c.warn(
"no agent surfaces declared in [gate].agents; run `klasp install --force` if needed",
);
}
let codex_declared = declared
.map(|agents| agents.iter().any(|a| a == "codex"))
.unwrap_or(false);
if !codex_declared && repo_root.join("AGENTS.md").is_file() {
Counters::info(
"AGENTS.md present but \"codex\" not in [gate].agents \
— run `klasp install --agent codex` to enable codex gate coverage",
);
}
}
fn check_hook(repo_root: &Path, surface: &dyn AgentSurface, c: &mut Counters) {
let agent_id = surface.agent_id();
let hook_path = surface.hook_path(repo_root);
let actual = match std::fs::read_to_string(&hook_path) {
Ok(s) => s,
Err(_) => {
c.fail(&format!(
"hook[{agent_id}]: {} not found; re-run `klasp install`",
hook_path.display()
));
return;
}
};
let ctx = InstallContext {
repo_root: repo_root.to_path_buf(),
dry_run: false,
force: false,
schema_version: GATE_SCHEMA_VERSION,
};
let expected = surface.render_hook_script(&ctx);
if actual == expected {
c.ok(&format!(
"hook[{agent_id}]: current (schema v{GATE_SCHEMA_VERSION})"
));
} else {
c.fail(&format!(
"hook[{agent_id}]: schema drift detected (re-run `klasp install`)"
));
}
}
fn check_settings(repo_root: &Path, surface: &dyn AgentSurface, c: &mut Counters) {
let agent_id = surface.agent_id();
if agent_id != klasp_agents_claude::ClaudeCodeSurface::AGENT_ID {
return;
}
let settings_path = surface.settings_path(repo_root);
let raw = match std::fs::read_to_string(&settings_path) {
Ok(s) => s,
Err(_) => {
c.fail(&format!(
"settings[{agent_id}]: {} not found; re-run `klasp install`",
settings_path.display()
));
return;
}
};
let root: Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
c.fail(&format!(
"settings[{agent_id}]: failed to parse {} as JSON: {e}",
settings_path.display()
));
return;
}
};
let hook_command = klasp_agents_claude::ClaudeCodeSurface::HOOK_COMMAND;
let has_entry = root
.get("hooks")
.and_then(|h| h.get("PreToolUse"))
.and_then(Value::as_array)
.is_some_and(|arr| {
arr.iter().any(|matcher_entry| {
matcher_entry.get("matcher").and_then(Value::as_str) == Some("Bash")
&& matcher_entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|inner| {
inner.iter().any(|hook| {
hook.get("command").and_then(Value::as_str) == Some(hook_command)
})
})
})
});
if has_entry {
c.ok(&format!("settings[{agent_id}]: hook entry present"));
} else {
c.fail(&format!(
"settings[{agent_id}]: klasp hook entry missing; re-run `klasp install`"
));
}
}
pub(crate) fn check_paths(config: &ConfigV1, c: &mut Counters) {
if config.checks.is_empty() {
Counters::info("no checks declared in klasp.toml — add [[checks]] blocks to gate agents");
return;
}
for check in &config.checks {
match &check.source {
CheckSourceConfig::Shell { command } => match extract_argv0(command) {
Some(argv0) => match which::which(argv0) {
Ok(_) => c.ok(&format!("path[{}]: `{argv0}` found in PATH", check.name)),
Err(_) => c.warn(&format!(
"path[{}]: `{argv0}` not found in PATH (command: `{command}`)",
check.name
)),
},
None => c.warn(&format!(
"path[{}]: could not determine executable from command `{command}`",
check.name
)),
},
CheckSourceConfig::PreCommit { .. } => match which::which("pre-commit") {
Ok(_) => c.ok(&format!("path[{}]: `pre-commit` found in PATH", check.name)),
Err(_) => c.warn(&format!(
"path[{}]: `pre-commit` not on PATH; check `{}` will fail to run \
— install with `pip install pre-commit` or via your package manager",
check.name, check.name
)),
},
CheckSourceConfig::Fallow { .. } => check_recipe_argv0(c, &check.name, "fallow"),
CheckSourceConfig::Pytest { .. } => check_recipe_argv0(c, &check.name, "pytest"),
CheckSourceConfig::Cargo { .. } => check_recipe_argv0(c, &check.name, "cargo"),
CheckSourceConfig::Plugin { name, .. } => {
let binary = format!("{}{}", klasp_core::KLASP_PLUGIN_BIN_PREFIX, name);
match which::which(&binary) {
Ok(_) => c.ok(&format!("path[{}]: `{binary}` found in PATH", check.name)),
Err(_) => c.warn(&format!(
"path[{}]: plugin binary `{binary}` not found in PATH \
(install it to enable this check)",
check.name
)),
}
}
}
}
}
fn check_recipe_argv0(c: &mut Counters, check_name: &str, argv0: &str) {
match which::which(argv0) {
Ok(_) => c.ok(&format!("path[{check_name}]: `{argv0}` found in PATH")),
Err(_) => c.warn(&format!(
"path[{check_name}]: `{argv0}` not found in PATH (recipe: {argv0})"
)),
}
}
pub(crate) fn extract_argv0(command: &str) -> Option<&str> {
command
.split_ascii_whitespace()
.find(|token| !token.contains('='))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_argv0_simple_command() {
assert_eq!(extract_argv0("ruff check ."), Some("ruff"));
}
#[test]
fn extract_argv0_skips_env_prefix() {
assert_eq!(extract_argv0("PYTHONPATH=. pytest -q"), Some("pytest"));
}
#[test]
fn extract_argv0_skips_multiple_env_prefixes() {
assert_eq!(
extract_argv0("FOO=1 BAR=2 cargo test --workspace"),
Some("cargo")
);
}
#[test]
fn extract_argv0_empty_command() {
assert_eq!(extract_argv0(""), None);
}
#[test]
fn extract_argv0_only_env_assignments() {
assert_eq!(extract_argv0("FOO=1 BAR=2"), None);
}
}