use crate::paths;
use anyhow::Result;
use std::path::Path;
pub fn run(dir: &Path) -> Result<i32> {
use crate::ui::{amber, bold, cyan, dim, green, red};
println!();
println!("{}", bold("Termaxa doctor"));
println!("{}", dim("──────────────────────────────────────────"));
let version = env!("CARGO_PKG_VERSION");
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "(unknown)".into());
println!("{} termaxa {}", green("✓"), version);
println!(" {}", dim(&exe));
println!();
println!("{}", bold("Policy"));
let resolved = paths::resolve_readonly(dir).ok();
let mut problems: Vec<String> = Vec::new();
match &resolved {
Some(p) => {
let pf = p.policy_file();
println!("{} {}", green("✓"), pf.display());
match crate::policy::Policy::load(&pf) {
Ok(pol) => {
println!(
" {} rule(s), default {}",
pol.rules.len(),
crate::ui::decision(&pol.default.to_string())
);
}
Err(e) => {
println!("{} policy will not parse: {}", red("✗"), e);
problems.push("fix .termaxa/policy.yaml (it does not parse)".into());
}
}
}
None => {
println!(
"{} no .termaxa/policy.yaml in this directory or any parent",
amber("!")
);
println!(
" {} works anyway (built-in starter policy), but {} and {} need one.",
cyan("termaxa check"),
cyan("run"),
cyan("hook")
);
problems.push("run `termaxa init` to create a policy".into());
}
}
println!();
println!("{}", bold("Agents"));
let claude_settings = dir.join(".claude").join("settings.json");
let cursor_hooks = dir.join(".cursor").join("hooks.json");
let codex_hooks = dir.join(".codex").join("hooks.json");
let copilot_hooks = dir.join(".github").join("hooks").join("hooks.json");
let mut any_agent = false;
let claude_present = dir.join(".claude").exists() || crate::init::which("claude");
if claude_present {
any_agent = true;
let wired = hook_configured(&claude_settings);
report_agent(
"Claude Code",
wired,
"termaxa init --claude-code",
&mut problems,
);
}
let cursor_present = dir.join(".cursor").exists() || crate::init::which("cursor");
if cursor_present {
any_agent = true;
let wired = hook_configured(&cursor_hooks);
report_agent("Cursor", wired, "termaxa init --cursor", &mut problems);
if wired {
println!(
" {}",
dim("restart Cursor after wiring — it caches hook config at startup")
);
}
}
if crate::init::which("codex") {
any_agent = true;
let wired = hook_configured(&codex_hooks);
report_agent("Codex CLI", wired, "termaxa init --codex", &mut problems);
println!(
" {}",
dim("dialect built, not yet verified end-to-end (issue #10)")
);
}
if crate::init::which("copilot") || crate::init::which("gh") {
let wired = hook_configured(&copilot_hooks);
if wired || crate::init::which("copilot") {
any_agent = true;
report_agent(
"Copilot CLI",
wired,
"termaxa init --copilot",
&mut problems,
);
println!(
" {}",
dim("dialect built, not yet verified end-to-end (issue #10)")
);
}
}
if !any_agent {
println!("{} no agent harness detected in this directory", dim("·"));
println!(
" {}",
dim("that's fine — `termaxa check` and `termaxa run` work standalone")
);
}
println!();
println!("{}", bold("Preview support"));
for (tool, what) in [
("git", "force-push previews and git backups"),
("psql", "Postgres blast radius"),
("pg_dump", "Postgres backups"),
("terraform", "plan previews"),
] {
if crate::init::which(tool) {
println!("{} {:<11}{}", green("✓"), tool, dim(what));
} else {
println!(
"{} {:<11}{}",
dim("·"),
tool,
dim(&format!("{} unavailable", what))
);
}
}
println!();
println!("{}", bold("State"));
match &resolved {
Some(p) => {
let exists = p.state_dir.exists();
if exists {
println!("{} {}", green("✓"), p.state_dir.display());
} else {
println!("{} {}", dim("·"), p.state_dir.display());
println!(" {}", dim("not created yet — nothing has run here"));
}
let logfile = p.log_file();
if logfile.is_file() {
let (entries, hooks) = count_log(&logfile);
println!(" {} audit entries ({} from hooks)", entries, hooks);
if hooks == 0 && any_agent {
println!(
" {} no hook entries yet — the gate has not seen an agent command",
amber("!")
);
println!(
" {}",
dim("if your agent has run commands since wiring, suspect dialect drift:")
);
println!(
" {}",
dim("set TERMAXA_HOOK_DEBUG=1 and check what arrives")
);
}
} else {
println!(" {}", dim("no audit log yet — nothing has been evaluated"));
}
}
None => println!("{}", dim("· (no project — state lives per-project)")),
}
println!();
println!("{}", dim("──────────────────────────────────────────"));
if problems.is_empty() {
println!("{} {}", green("✓"), bold("Everything checks out."));
println!(
" {}",
dim("proof is in the log: run your agent, then `termaxa report`")
);
println!();
Ok(0)
} else {
println!("{} {} to fix:", amber("!"), problems.len());
for p in &problems {
println!(" · {}", p);
}
println!();
Ok(1)
}
}
fn report_agent(name: &str, wired: bool, fix: &str, problems: &mut Vec<String>) {
use crate::ui::{amber, cyan, dim, green};
if wired {
println!("{} {:<13}{}", green("✓"), name, dim("hook configured"));
} else {
println!(
"{} {:<13}{}",
amber("!"),
name,
dim("detected, hook NOT configured")
);
println!(" {}", cyan(fix));
problems.push(format!("wire {} — `{}`", name, fix));
}
}
fn count_log(path: &Path) -> (usize, usize) {
let Ok(raw) = std::fs::read_to_string(path) else {
return (0, 0);
};
let mut total = 0usize;
let mut hooks = 0usize;
for line in raw.lines().filter(|l| !l.trim().is_empty()) {
match serde_json::from_str::<crate::audit::AuditEntry>(line) {
Ok(e) => {
total += 1;
if e.source == "hook" {
hooks += 1;
}
}
Err(_) => total += 1, }
}
(total, hooks)
}
fn hook_configured(path: &Path) -> bool {
std::fs::read_to_string(path)
.map(|s| s.contains("termaxa hook") || s.contains("termaxa\\\" hook"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn count_log_reads_without_creating_and_tolerates_junk() {
let dir = std::env::temp_dir().join(format!("tmx-doc-log-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let missing = dir.join("nope.jsonl");
assert_eq!(count_log(&missing), (0, 0));
assert!(!missing.exists(), "count_log must not create the log file");
let f = dir.join("audit.jsonl");
let hook_line = r#"{"ts_ms":1,"ts":"t","source":"hook","command":"rm -rf .","decision":"deny","matched_rule":null,"reason":"r","signals":[],"escalated":false,"approved":null,"exit_code":null,"cwd":"/x"}"#;
let check_line = r#"{"ts_ms":2,"ts":"t","source":"check","command":"ls","decision":"allow","matched_rule":null,"reason":"r","signals":[],"escalated":false,"approved":null,"exit_code":null,"cwd":"/x"}"#;
std::fs::write(
&f,
format!("{hook_line}\n{check_line}\nnot json at all\n\n"),
)
.unwrap();
let (total, hooks) = count_log(&f);
assert_eq!(total, 3, "junk lines still count as entries that happened");
assert_eq!(hooks, 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hook_configured_detects_plain_and_absolute_forms() {
let dir = std::env::temp_dir().join(format!("tmx-doctor-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let plain = dir.join("plain.json");
let mut f = std::fs::File::create(&plain).unwrap();
writeln!(
f,
r#"{{"hooks":{{"PreToolUse":[{{"command":"termaxa hook"}}]}}}}"#
)
.unwrap();
assert!(hook_configured(&plain));
let abs = dir.join("abs.json");
let mut f = std::fs::File::create(&abs).unwrap();
writeln!(
f,
r#"{{"hooks":{{"beforeShellExecution":[{{"command":"C:\\Users\\x\\.cargo\\bin\\termaxa hook"}}]}}}}"#
)
.unwrap();
assert!(hook_configured(&abs));
let empty = dir.join("empty.json");
std::fs::write(&empty, "{}").unwrap();
assert!(!hook_configured(&empty));
assert!(!hook_configured(&dir.join("does-not-exist.json")));
let _ = std::fs::remove_dir_all(&dir);
}
}