use std::path::{Path, PathBuf};
use anyhow::Result;
use mati_core::scaffold::codex::CODEX_HOOK_SCRIPTS;
use mati_core::scaffold::settings::HOOK_SCRIPTS;
use crate::cli::daemon::{daemon_result, mati_root_for, DaemonResult};
#[derive(Debug)]
pub struct CheckItem {
pub label: &'static str,
pub status: CheckStatus,
pub hint: Option<String>,
}
#[derive(Debug)]
pub enum CheckStatus {
Pass(Option<String>),
Warn(String),
Fail(String),
}
impl CheckItem {
fn pass(label: &'static str, detail: Option<String>) -> Self {
Self {
label,
status: CheckStatus::Pass(detail),
hint: None,
}
}
fn warn(label: &'static str, msg: impl Into<String>, hint: impl Into<Option<String>>) -> Self {
Self {
label,
status: CheckStatus::Warn(msg.into()),
hint: hint.into(),
}
}
fn fail(label: &'static str, msg: impl Into<String>, hint: impl Into<Option<String>>) -> Self {
Self {
label,
status: CheckStatus::Fail(msg.into()),
hint: hint.into(),
}
}
pub fn is_fail(&self) -> bool {
matches!(self.status, CheckStatus::Fail(_))
}
}
pub async fn run() -> Result<()> {
let cwd = std::env::current_dir()?;
let items = run_silent(&cwd).await;
print_results(&items);
let failures = items.iter().filter(|i| i.is_fail()).count();
if failures > 0 {
std::process::exit(1);
}
Ok(())
}
pub async fn run_silent(cwd: &Path) -> Vec<CheckItem> {
let mut items = Vec::with_capacity(10);
items.push(check_git_repo(cwd));
let (store_item, mati_root_opt) = check_store(cwd);
items.push(store_item);
items.push(check_mati_on_path());
items.push(check_awk_float_math());
items.push(check_agent_host(cwd));
items.push(check_claude_hooks(cwd));
items.push(check_claude_config(cwd));
items.push(check_codex_hooks(cwd));
items.push(check_codex_config(cwd));
items.push(check_daemon(mati_root_opt).await);
items
}
fn check_git_repo(cwd: &Path) -> CheckItem {
match git2::Repository::discover(cwd) {
Ok(_) => CheckItem::pass("git repo", None),
Err(_) => CheckItem::fail(
"git repo",
"not a git repo",
Some("Layer 0 analysis and slug derivation require git".to_string()),
),
}
}
fn check_store(cwd: &Path) -> (CheckItem, Option<PathBuf>) {
let root = match mati_root_for(cwd) {
Ok(r) => r,
Err(e) => {
return (
CheckItem::fail(
"store",
format!("cannot derive store path: {e}"),
None::<String>,
),
None,
);
}
};
let db_path = root.join("knowledge.db");
if !db_path.exists() {
return (
CheckItem::fail(
"store",
"store not initialized",
Some("run: mati init".to_string()),
),
Some(root),
);
}
let detail = format!("{}", db_path.display());
(CheckItem::pass("store", Some(detail)), Some(root))
}
fn check_mati_on_path() -> CheckItem {
let result = std::process::Command::new("mati")
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
match result {
Ok(out) if out.status.success() => {
let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
CheckItem::pass("mati in PATH", Some(version))
}
Ok(_) => CheckItem::fail(
"mati in PATH",
"mati --version returned non-zero",
Some(
"hooks call mati get/log-hit/etc. and will silently pass everything through"
.to_string(),
),
),
Err(_) => CheckItem::fail(
"mati in PATH",
"mati not on PATH",
Some(
"hooks call mati get/log-hit/etc. and will silently pass everything through"
.to_string(),
),
),
}
}
fn check_awk_float_math() -> CheckItem {
let result = std::process::Command::new("sh")
.arg("-c")
.arg(r#"awk "BEGIN { exit !(0.7 >= 0.6) }" && echo ok"#)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
match result {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.trim() == "ok" {
CheckItem::pass("awk float math", None)
} else {
CheckItem::fail(
"awk float math",
"awk float math failed",
Some(
"pre-read hook decision matrix will silently allow all reads until fixed"
.to_string(),
),
)
}
}
Err(_) => CheckItem::fail(
"awk float math",
"awk not found",
Some(
"pre-read hook decision matrix requires awk — install gawk or ensure awk is on PATH"
.to_string(),
),
),
}
}
fn check_agent_host(cwd: &Path) -> CheckItem {
let has_claude = cwd.join(".claude").is_dir();
let has_codex = cwd.join(".codex").is_dir();
match (has_claude, has_codex) {
(false, false) => CheckItem::fail(
"agent host",
"no agent host wired — neither .claude/ nor .codex/ present",
Some("run: mati hooks --claude (or --codex)".to_string()),
),
(true, true) => CheckItem::pass("agent host", Some("claude + codex".to_string())),
(true, false) => CheckItem::pass("agent host", Some("claude".to_string())),
(false, true) => CheckItem::pass("agent host", Some("codex".to_string())),
}
}
fn check_claude_hooks(cwd: &Path) -> CheckItem {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
if !cwd.join(".claude").is_dir() {
return CheckItem::pass("claude hooks", Some("not configured".to_string()));
}
let hooks_dir = cwd.join(".claude").join("hooks");
let total = HOOK_SCRIPTS.len();
let mut missing: Vec<String> = Vec::new();
let mut not_exec: Vec<String> = Vec::new();
for (name, _) in HOOK_SCRIPTS {
let path = hooks_dir.join(name);
if !path.exists() {
missing.push(name.to_string());
continue;
}
#[cfg(unix)]
match std::fs::metadata(&path) {
Ok(meta) => {
let mode = meta.permissions().mode();
if mode & 0o111 == 0 {
not_exec.push(name.to_string());
}
}
Err(_) => {
missing.push(name.to_string());
}
}
}
if missing.is_empty() && not_exec.is_empty() {
return CheckItem::pass(
"claude hooks",
Some(format!("{total}/{total} installed, executable")),
);
}
let mut problems = Vec::new();
if !missing.is_empty() {
problems.push(format!("missing: {}", missing.join(", ")));
}
if !not_exec.is_empty() {
problems.push(format!("not executable: {}", not_exec.join(", ")));
}
CheckItem::fail(
"claude hooks",
format!(
"{}/{total} ok — {}",
total - missing.len() - not_exec.len(),
problems.join("; ")
),
Some("run: mati hooks --claude to reinstall".to_string()),
)
}
fn check_codex_hooks(cwd: &Path) -> CheckItem {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
if !cwd.join(".codex").is_dir() {
return CheckItem::pass("codex hooks", Some("not configured".to_string()));
}
let hooks_dir = cwd.join(".codex").join("hooks");
let total = CODEX_HOOK_SCRIPTS.len();
let mut missing: Vec<String> = Vec::new();
let mut not_exec: Vec<String> = Vec::new();
for (name, _) in CODEX_HOOK_SCRIPTS {
let path = hooks_dir.join(name);
if !path.exists() {
missing.push(name.to_string());
continue;
}
#[cfg(unix)]
match std::fs::metadata(&path) {
Ok(meta) => {
let mode = meta.permissions().mode();
if mode & 0o111 == 0 {
not_exec.push(name.to_string());
}
}
Err(_) => {
missing.push(name.to_string());
}
}
}
if missing.is_empty() && not_exec.is_empty() {
return CheckItem::pass(
"codex hooks",
Some(format!("{total}/{total} installed, executable")),
);
}
let mut problems = Vec::new();
if !missing.is_empty() {
problems.push(format!("missing: {}", missing.join(", ")));
}
if !not_exec.is_empty() {
problems.push(format!("not executable: {}", not_exec.join(", ")));
}
CheckItem::fail(
"codex hooks",
format!(
"{}/{total} ok — {}",
total - missing.len() - not_exec.len(),
problems.join("; ")
),
Some("run: mati hooks --codex to reinstall".to_string()),
)
}
fn check_claude_config(cwd: &Path) -> CheckItem {
if !cwd.join(".claude").is_dir() {
return CheckItem::pass("claude config", Some("not configured".to_string()));
}
let path = cwd.join(".claude").join("settings.json");
if !path.exists() {
return CheckItem::fail(
"claude config",
"settings.json not found",
Some("run: mati hooks --claude".to_string()),
);
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
return CheckItem::fail(
"claude config",
format!("cannot read settings.json: {e}"),
None::<String>,
);
}
};
let json: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
return CheckItem::fail(
"claude config",
format!("invalid JSON: {e}"),
Some("run: mati hooks --claude to regenerate settings.json".to_string()),
);
}
};
let mut absent_hooks: Vec<&str> = Vec::new();
for (name, _) in HOOK_SCRIPTS {
let hook_path = format!(".claude/hooks/{name}");
if !content.contains(&hook_path) {
absent_hooks.push(name);
}
}
let mcp_ok = json.get("mcpServers").and_then(|s| s.get("mati")).is_some();
if absent_hooks.is_empty() && mcp_ok {
return CheckItem::pass(
"claude config",
Some(format!(
"{} hooks + mcpServers registered",
HOOK_SCRIPTS.len()
)),
);
}
let mut problems = Vec::new();
if !absent_hooks.is_empty() {
problems.push(format!("hooks absent: {}", absent_hooks.join(", ")));
}
if !mcp_ok {
problems.push("mcpServers.mati missing".to_string());
}
CheckItem::fail(
"claude config",
problems.join("; "),
Some("run: mati hooks --claude to regenerate settings.json".to_string()),
)
}
fn check_codex_config(cwd: &Path) -> CheckItem {
if !cwd.join(".codex").is_dir() {
return CheckItem::pass("codex config", Some("not configured".to_string()));
}
let codex_dir = cwd.join(".codex");
let mut problems: Vec<String> = Vec::new();
match std::fs::read_to_string(codex_dir.join("hooks.json")) {
Ok(content) => {
if serde_json::from_str::<serde_json::Value>(&content).is_err() {
problems.push("hooks.json: invalid JSON".to_string());
} else {
let absent: Vec<&str> = CODEX_HOOK_SCRIPTS
.iter()
.map(|(n, _)| *n)
.filter(|n| !content.contains(&format!(".codex/hooks/{n}")))
.collect();
if !absent.is_empty() {
problems.push(format!("hooks.json: scripts absent: {}", absent.join(", ")));
}
}
}
Err(_) => problems.push("hooks.json: not found".to_string()),
}
match std::fs::read_to_string(codex_dir.join("config.toml")) {
Ok(content) => {
if !content.contains("mcp_servers.mati") {
problems.push("config.toml: mcp_servers.mati missing".to_string());
}
}
Err(_) => problems.push("config.toml: not found".to_string()),
}
if problems.is_empty() {
return CheckItem::pass(
"codex config",
Some("hooks.json + mcp_servers.mati registered".to_string()),
);
}
CheckItem::fail(
"codex config",
problems.join("; "),
Some("run: mati hooks --codex to regenerate".to_string()),
)
}
async fn check_daemon(mati_root_opt: Option<PathBuf>) -> CheckItem {
let root = match mati_root_opt {
Some(r) => r,
None => {
return CheckItem::warn("daemon", "skipped (store not initialized)", None::<String>);
}
};
let t0 = std::time::Instant::now();
let result = daemon_result(&root, "ping", serde_json::json!({})).await;
let latency = t0.elapsed();
match result {
DaemonResult::Ok(_) => {
let ms = latency.as_secs_f64() * 1000.0;
CheckItem::pass("daemon", Some(format!("ping {ms:.1}ms")))
}
DaemonResult::NotRunning => CheckItem::pass(
"daemon",
Some("not running (OK — auto-starts on demand; ~150ms on first call)".to_string()),
),
DaemonResult::StaleSocket => CheckItem::warn(
"daemon",
"stale socket detected",
Some("run: mati daemon stop to clean up".to_string()),
),
DaemonResult::Unresponsive => CheckItem::fail(
"daemon",
"daemon unresponsive — may hold store lock",
Some("fix: mati daemon stop && mati daemon start".to_string()),
),
}
}
fn print_results(items: &[CheckItem]) {
println!("\nmati check\n");
for item in items {
print_item(item);
}
println!();
let failures = items.iter().filter(|i| i.is_fail()).count();
let warnings = items
.iter()
.filter(|i| matches!(i.status, CheckStatus::Warn(_)))
.count();
if failures == 0 && warnings == 0 {
println!(" All checks passed. Hook enforcement is active.");
} else if failures == 0 {
println!(
" {} warning(s). Hook enforcement is active but degraded.",
warnings
);
} else {
println!(
" {} blocker(s) found. Hook enforcement is NOT active.",
failures
);
}
println!();
}
fn print_item(item: &CheckItem) {
let label = format!(" {:<15}", item.label);
match &item.status {
CheckStatus::Pass(extra) => {
let extra_str = extra.as_deref().unwrap_or("");
if extra_str.is_empty() {
println!("{label} ok");
} else {
println!("{label} ok ({extra_str})");
}
}
CheckStatus::Warn(msg) => {
println!("{label} warn {msg}");
if let Some(hint) = &item.hint {
for line in hint.lines() {
println!("{:19} {line}", "");
}
}
}
CheckStatus::Fail(msg) => {
println!("{label} FAIL {msg}");
if let Some(hint) = &item.hint {
for line in hint.lines() {
println!("{:19} {line}", "");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn check_awk_float_math_passes() {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(r#"awk "BEGIN { exit !(0.7 >= 0.6) }" && echo ok"#)
.output();
match out {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
assert_eq!(
stdout.trim(),
"ok",
"awk should exit 0 for 0.7 >= 0.6; got: {stdout:?}"
);
}
Err(e) => {
eprintln!("awk not available on this machine, skipping test: {e}");
}
}
}
#[test]
fn check_awk_fail_produces_non_ok_output() {
let result = std::process::Command::new("sh")
.arg("-c")
.arg(r#"awk "BEGIN { exit !(0.5 >= 0.6) }" && echo ok || echo fail"#)
.output();
if let Ok(out) = result {
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout.trim(),
"fail",
"expected fail output for 0.5 >= 0.6; got: {stdout:?}"
);
}
}
#[test]
fn check_hook_executable_detection_catches_missing_bit() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let hooks_dir = dir.path().join(".claude").join("hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
for (name, content) in HOOK_SCRIPTS {
let path = hooks_dir.join(name);
std::fs::write(&path, content).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
if let Some((first_name, _)) = HOOK_SCRIPTS.first() {
let first_path = hooks_dir.join(first_name);
let mut perms = std::fs::metadata(&first_path).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&first_path, perms).unwrap();
}
let item = check_claude_hooks(dir.path());
assert!(
item.is_fail(),
"expected FAIL when first hook is not executable: {:?}",
item.status
);
}
#[test]
fn check_hook_passes_when_all_installed_and_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let hooks_dir = dir.path().join(".claude").join("hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
for (name, content) in HOOK_SCRIPTS {
let path = hooks_dir.join(name);
std::fs::write(&path, content).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
let item = check_claude_hooks(dir.path());
assert!(
!item.is_fail(),
"expected PASS when all hooks are present and executable: {:?}",
item.status
);
}
#[test]
fn check_settings_json_valid_passes() {
let dir = TempDir::new().unwrap();
let claude_dir = dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
let hook_entries: Vec<String> = HOOK_SCRIPTS
.iter()
.map(|(name, _)| format!("\".claude/hooks/{name}\""))
.collect();
let hooks_array = hook_entries.join(", ");
let json = format!(
r#"{{
"hooks": {{ "PreToolUse": [{}] }},
"mcpServers": {{ "mati": {{ "command": "mati", "args": ["serve"] }} }}
}}"#,
hooks_array
);
std::fs::write(claude_dir.join("settings.json"), &json).unwrap();
let item = check_claude_config(dir.path());
assert!(
!item.is_fail(),
"expected PASS for valid settings.json: {:?}",
item.status
);
}
#[test]
fn check_settings_json_missing_hook_fails() {
let dir = TempDir::new().unwrap();
let claude_dir = dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
let first_hook = HOOK_SCRIPTS
.first()
.map(|(n, _)| *n)
.unwrap_or("pre-read.sh");
let json = format!(
r#"{{
"hooks": {{ "PreToolUse": [".claude/hooks/{first_hook}"] }},
"mcpServers": {{ "mati": {{ "command": "mati", "args": ["serve"] }} }}
}}"#
);
std::fs::write(claude_dir.join("settings.json"), &json).unwrap();
let item = check_claude_config(dir.path());
if HOOK_SCRIPTS.len() > 1 {
assert!(
item.is_fail(),
"expected FAIL when hooks are missing from settings.json: {:?}",
item.status
);
}
}
#[test]
fn check_settings_json_missing_mcp_fails() {
let dir = TempDir::new().unwrap();
let claude_dir = dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
let hook_entries: Vec<String> = HOOK_SCRIPTS
.iter()
.map(|(name, _)| format!("\".claude/hooks/{name}\""))
.collect();
let hooks_array = hook_entries.join(", ");
let json = format!(r#"{{ "hooks": {{ "PreToolUse": [{}] }} }}"#, hooks_array);
std::fs::write(claude_dir.join("settings.json"), &json).unwrap();
let item = check_claude_config(dir.path());
assert!(
item.is_fail(),
"expected FAIL when mcpServers.mati is absent: {:?}",
item.status
);
}
#[test]
fn check_agent_host_fails_when_neither_wired() {
let dir = TempDir::new().unwrap();
let item = check_agent_host(dir.path());
assert!(
item.is_fail(),
"neither .claude/ nor .codex/ should FAIL: {:?}",
item.status
);
}
#[test]
fn check_agent_host_passes_with_one_host() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".codex")).unwrap();
let item = check_agent_host(dir.path());
assert!(
!item.is_fail(),
"one wired host (.codex/) should pass: {:?}",
item.status
);
}
#[test]
fn check_claude_hooks_not_configured_passes() {
let dir = TempDir::new().unwrap();
let item = check_claude_hooks(dir.path());
assert!(
!item.is_fail(),
"absent .claude/ should be 'not configured', not FAIL: {:?}",
item.status
);
}
#[test]
fn check_codex_hooks_not_configured_passes() {
let dir = TempDir::new().unwrap();
let item = check_codex_hooks(dir.path());
assert!(
!item.is_fail(),
"absent .codex/ should be 'not configured', not FAIL: {:?}",
item.status
);
}
#[test]
fn check_codex_hooks_missing_scripts_fails() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".codex").join("hooks")).unwrap();
let item = check_codex_hooks(dir.path());
assert!(
item.is_fail(),
"missing codex hook scripts should FAIL: {:?}",
item.status
);
}
#[test]
fn check_codex_hooks_complete_passes() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let hooks_dir = dir.path().join(".codex").join("hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
for (name, content) in CODEX_HOOK_SCRIPTS {
let path = hooks_dir.join(name);
std::fs::write(&path, content).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
let item = check_codex_hooks(dir.path());
assert!(
!item.is_fail(),
"all codex hooks present + executable should pass: {:?}",
item.status
);
}
fn write_codex_hooks_json(codex_dir: &std::path::Path) {
let refs: String = CODEX_HOOK_SCRIPTS
.iter()
.map(|(n, _)| format!("\"bash .codex/hooks/{n}\""))
.collect::<Vec<_>>()
.join(", ");
std::fs::write(
codex_dir.join("hooks.json"),
format!("{{ \"hooks\": [{refs}] }}"),
)
.unwrap();
}
#[test]
fn check_codex_config_complete_passes() {
let dir = TempDir::new().unwrap();
let codex_dir = dir.path().join(".codex");
std::fs::create_dir_all(&codex_dir).unwrap();
write_codex_hooks_json(&codex_dir);
std::fs::write(
codex_dir.join("config.toml"),
"[mcp_servers.mati]\ncommand = \"mati\"\n",
)
.unwrap();
let item = check_codex_config(dir.path());
assert!(
!item.is_fail(),
"complete codex config should pass: {:?}",
item.status
);
}
#[test]
fn check_codex_config_missing_mcp_fails() {
let dir = TempDir::new().unwrap();
let codex_dir = dir.path().join(".codex");
std::fs::create_dir_all(&codex_dir).unwrap();
write_codex_hooks_json(&codex_dir);
std::fs::write(codex_dir.join("config.toml"), "[other]\nx = 1\n").unwrap();
let item = check_codex_config(dir.path());
assert!(
item.is_fail(),
"missing mcp_servers.mati should FAIL: {:?}",
item.status
);
}
#[test]
fn check_codex_config_missing_hooks_json_fails() {
let dir = TempDir::new().unwrap();
let codex_dir = dir.path().join(".codex");
std::fs::create_dir_all(&codex_dir).unwrap();
std::fs::write(
codex_dir.join("config.toml"),
"[mcp_servers.mati]\ncommand = \"mati\"\n",
)
.unwrap();
let item = check_codex_config(dir.path());
assert!(
item.is_fail(),
"missing hooks.json should FAIL: {:?}",
item.status
);
}
}