use anyhow::{Context as _, Result};
use pushkin_core::waivers::WaiverSet;
use serde_json::Value;
use crate::agents::Agent;
use super::init::install_claude;
use super::{CLAUDE_SETTINGS, PUSHKIN_MARKER};
pub fn run_with_repair(repair: bool) -> Result<i32> {
if repair {
super::init::migrate_legacy_footprint()?;
}
println!("pushkin doctor: {}", super::daemon::health_line());
for finding in waiver_lint_findings() {
println!("pushkin doctor: {finding}");
}
let reports = collect_reports(host_os());
let floor = check_lefthook();
let shim = check_git_shim();
let resolvability = check_adapter_resolvability(&reports);
for (_, check) in &reports {
if let Some(info) = &check.info {
println!("pushkin doctor: {info}");
}
}
if let Some(info) = &floor.info {
println!("pushkin doctor: {info}");
}
if let Some(info) = &shim.info {
println!("pushkin doctor: {info}");
}
let healthy = reports.iter().all(|(_, check)| check.findings.is_empty())
&& floor.findings.is_empty()
&& shim.findings.is_empty()
&& resolvability.findings.is_empty();
if healthy {
println!("pushkin doctor: hooks healthy.");
return Ok(0);
}
for (_, check) in &reports {
for finding in &check.findings {
println!("pushkin doctor: {finding}");
}
}
for finding in &floor.findings {
println!("pushkin doctor: {finding}");
}
for finding in &shim.findings {
println!("pushkin doctor: {finding}");
}
for finding in &resolvability.findings {
println!("pushkin doctor: {finding}");
}
if !repair {
println!("pushkin doctor: run `pushkin doctor --repair` to fix.");
return Ok(1);
}
if reports.iter().any(|(_, check)| check.unreadable) || floor.unreadable || shim.unreadable {
println!(
"pushkin doctor: refusing to repair unreadable state — fix or delete \
the file named above yourself, then re-run. Nothing was modified."
);
return Ok(1);
}
repair_floor(&floor)?;
repair_git_shim(&shim)?;
repair_reports(&reports)
}
fn check_git_shim() -> AgentCheck {
let mut check = AgentCheck::default();
let Some(git_dir) = super::git::git_dir() else {
check.info = Some("git shim: not a git repository — not checked".to_owned());
return check;
};
let target = git_dir.join("hooks").join("pre-commit");
let text = match std::fs::read_to_string(&target) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
check.info = Some("git shim: not installed — not checked".to_owned());
return check;
}
Err(error) => {
check.unreadable = true;
check.findings.push(format!(
"git shim: cannot read {}: {error}",
target.display()
));
return check;
}
Ok(text) => text,
};
if !super::init::is_pushkin_shim(&text) {
check.info = Some("git shim: present but not pushkin's — not checked".to_owned());
return check;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&target) {
if meta.permissions().mode() & 0o111 == 0 {
check.findings.push(
"git shim: the pre-commit hook is not executable, so git never runs \
it; repair rewrites the shim with the executable bit restored"
.to_owned(),
);
}
}
}
if !pushkin_on_path() {
check.findings.push(
"git shim: the hook runs `pushkin` from PATH but no `pushkin` is resolvable \
there — the pre-commit gate will fail open until the binary is installed \
(`cargo install --path crates/pushkin-cli`)"
.to_owned(),
);
check.path_advisory = true;
}
check
}
fn repair_git_shim(shim: &AgentCheck) -> Result<()> {
let repairable = shim.findings.len() > usize::from(shim.path_advisory);
if !repairable {
return Ok(());
}
super::init::install_git_shim()?;
println!("pushkin doctor: repaired — native git shim regenerated.");
Ok(())
}
fn repair_floor(floor: &AgentCheck) -> Result<()> {
let repairable = floor.findings.len() > usize::from(floor.path_advisory);
if !repairable {
if floor.path_advisory {
println!(
"pushkin doctor: the lefthook floor file is current; the PATH \
finding is advisory — install pushkin (`cargo install --path \
crates/pushkin-cli`). Nothing was rewritten."
);
}
return Ok(());
}
let path = std::path::Path::new(super::init::LEFTHOOK_FILE);
if !path.exists() {
return Ok(());
}
let text = std::fs::read_to_string(path).context("cannot read lefthook.yml")?;
if floor_generation(&text, super::init::LEFTHOOK_MARKER).is_some() {
super::init::install_lefthook()?;
println!("pushkin doctor: repaired — lefthook floor regenerated.");
} else {
let scrubbed = super::init::without_pushkin_comment_lines(&text, true);
std::fs::write(path, scrubbed).context("cannot write lefthook.yml")?;
println!(
"pushkin doctor: repaired — orphaned pushkin marker comments removed; \
the file carries no pushkin block, so nothing was installed."
);
}
Ok(())
}
fn check_adapter_resolvability(reports: &[(Agent, AgentCheck)]) -> AgentCheck {
let mut check = AgentCheck::default();
let any_installed = reports.iter().any(|(_, report)| report.info.is_none());
if any_installed && !pushkin_on_path() {
check.findings.push(unresolvable_binary_finding());
}
check
}
#[must_use]
pub fn sweep_findings() -> Vec<String> {
let reports = collect_reports(host_os());
let resolvability = check_adapter_resolvability(&reports);
reports
.into_iter()
.flat_map(|(_, check)| check.findings)
.chain(check_lefthook().findings)
.chain(check_git_shim().findings)
.chain(resolvability.findings)
.collect()
}
fn waiver_lint_findings() -> Vec<String> {
match WaiverSet::load(std::path::Path::new(super::waive::WAIVERS_FILE)) {
Ok(set) => set.lint_now(),
Err(error) => vec![format!(
"waivers file rejected ({error}) — no waivers are being honored \
until it parses"
)],
}
}
#[derive(Default)]
struct AgentCheck {
findings: Vec<String>,
info: Option<String>,
unreadable: bool,
path_advisory: bool,
}
fn collect_reports(os: HostOs) -> Vec<(Agent, AgentCheck)> {
Agent::ALL
.into_iter()
.map(|agent| {
let check = match agent {
Agent::Claude => check_claude(),
Agent::Codex => check_codex(os),
Agent::Auggie => check_auggie(),
Agent::Hermes => check_hermes(),
Agent::Opencode => check_opencode(),
};
(agent, check)
})
.collect()
}
fn repair_reports(reports: &[(Agent, AgentCheck)]) -> Result<i32> {
for (agent, check) in reports {
if check.findings.is_empty() {
continue;
}
match agent {
Agent::Claude => {
install_claude()?;
println!("pushkin doctor: repaired — pushkin entries reinstalled.");
}
Agent::Codex | Agent::Auggie | Agent::Hermes | Agent::Opencode => {
println!(
"pushkin doctor: repair not implemented for {name} in Phase 2 — \
rerun `pushkin init --agent {name}`.",
name = agent.as_str()
);
}
}
}
Ok(0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HostOs {
Windows,
Other,
}
impl HostOs {
fn parse(name: &str) -> Self {
match name {
"windows" => HostOs::Windows,
_ => HostOs::Other,
}
}
}
fn host_os() -> HostOs {
let name =
std::env::var("PUSHKIN_DOCTOR_OS").unwrap_or_else(|_| std::env::consts::OS.to_owned());
HostOs::parse(&name)
}
fn check_claude() -> AgentCheck {
let mut check = AgentCheck::default();
match std::fs::read_to_string(CLAUDE_SETTINGS) {
Err(_) => check
.findings
.push(format!("{CLAUDE_SETTINGS} missing — no hook installed")),
Ok(text) => match serde_json::from_str::<Value>(&text) {
Err(error) => {
check.unreadable = true;
check
.findings
.push(format!("{CLAUDE_SETTINGS} is not valid JSON: {error}"));
}
Ok(settings) => {
for event in ["PreToolUse", "Stop"] {
match pushkin_entry(&settings, event) {
None => check
.findings
.push(claude_missing_finding(&settings, event)),
Some(entry) => {
if !command_is_current(entry) {
let detail = if entry_has_absolute_command(entry) {
" — it embeds an absolute binary path, which goes \
stale when the binary moves"
} else {
""
};
check.findings.push(format!(
"{event} hook command is stale (does not invoke \
`hook claude` via the portable `pushkin` command){detail}"
));
}
}
}
}
}
},
}
check
}
fn claude_missing_finding(settings: &Value, event: &str) -> String {
if legacy_entry(settings, event).is_some() {
format!(
"{event} hook entry carries a legacy pushkin marker — \
repair migrates it to {PUSHKIN_MARKER}"
)
} else {
format!("{event} hook entry missing or not pushkin's")
}
}
fn check_codex(os: HostOs) -> AgentCheck {
let mut check = AgentCheck::default();
let rules_path = ".codex/rules/pushkin.rules";
let rules_present = std::path::Path::new(rules_path).exists();
let entry = marked_entry_state(".codex/hooks.json", "hook codex");
if !rules_present && matches!(entry, EntryState::FileAbsent | EntryState::Missing) {
check.info = Some("codex: not installed — not checked".to_owned());
return check;
}
match entry {
EntryState::Unreadable(finding) => {
check.unreadable = true;
check.findings.push(finding);
}
EntryState::FileAbsent | EntryState::Missing => check.findings.push(
"codex: PreToolUse hook entry missing or not pushkin's (.codex/hooks.json)".to_owned(),
),
EntryState::Stale => check.findings.push(
"codex: PreToolUse hook command is stale (does not invoke `hook codex` \
on this binary)"
.to_owned(),
),
EntryState::Current => {}
}
if !rules_present {
check.findings.push(format!(
"codex: {rules_path} missing — execpolicy floor absent"
));
}
if os == HostOs::Windows {
check.findings.push(
"codex: hooks are unavailable on Windows (openai/codex#17478) — the \
execpolicy rules floor still applies; the PreToolUse gate will not fire"
.to_owned(),
);
}
check
}
fn check_auggie() -> AgentCheck {
let mut check = AgentCheck::default();
let script_path = ".augment/hooks/pushkin.sh";
let script_present = std::path::Path::new(script_path).exists();
let entry = marked_entry_state(".augment/settings.json", "pushkin.sh");
if !script_present && matches!(entry, EntryState::FileAbsent | EntryState::Missing) {
check.info = Some("auggie: not installed — not checked".to_owned());
return check;
}
match entry {
EntryState::Unreadable(finding) => {
check.unreadable = true;
check.findings.push(finding);
}
EntryState::FileAbsent | EntryState::Missing => check.findings.push(
"auggie: PreToolUse hook entry missing or not pushkin's (.augment/settings.json)"
.to_owned(),
),
EntryState::Stale => check
.findings
.push("auggie: hook command does not invoke the pushkin.sh wrapper — stale".to_owned()),
EntryState::Current => {}
}
if !script_present {
check.findings.push(format!(
"auggie: {script_path} missing — hook script absent"
));
} else if let Ok(script) = std::fs::read_to_string(script_path) {
if script.lines().any(|line| {
line.starts_with("exec ") && command_has_absolute_path(&line["exec ".len()..])
}) {
check.findings.push(format!(
"auggie: {script_path} is stale — it execs an absolute binary \
path, which breaks when the binary moves; repair regenerates it"
));
}
}
check
}
fn check_hermes() -> AgentCheck {
let mut check = AgentCheck::default();
let hermes_home = std::env::var("HERMES_HOME")
.unwrap_or_else(|_| format!("{}/.hermes", std::env::var("HOME").unwrap_or_default()));
let plugin_dir = format!("{hermes_home}/plugins/pushkin-gate");
if !std::path::Path::new(&plugin_dir).exists() {
check.info = Some("hermes: not installed — not checked".to_owned());
return check;
}
for file in ["plugin.yaml", "__init__.py"] {
if !std::path::Path::new(&format!("{plugin_dir}/{file}")).exists() {
check
.findings
.push(format!("hermes: {file} missing from {plugin_dir}"));
}
}
check
}
fn check_opencode() -> AgentCheck {
let mut check = AgentCheck::default();
let path = ".opencode/plugin/pushkin.ts";
match std::fs::read_to_string(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
check.info = Some("opencode: not installed — not checked".to_owned());
}
Err(error) => {
check.unreadable = true;
check
.findings
.push(format!("opencode: cannot read {path}: {error}"));
}
Ok(text) => {
if !text.contains(PUSHKIN_MARKER) || !text.contains("hook opencode") {
check.findings.push(format!(
"opencode: {path} is not pushkin's current plugin (marker or \
hook relay missing) — hand-edited or stale"
));
}
}
}
check
}
#[derive(Clone, Copy)]
enum FloorGeneration {
V1,
V2,
Current,
}
impl FloorGeneration {
fn stale_finding(self, file: &str) -> Option<String> {
match self {
Self::V1 => Some(format!(
"lefthook: {file} carries a stale v1 pushkin floor (an absolute binary path \
and/or a whole-repo Stop sweep); repair regenerates it and preserves \
other commands"
)),
Self::V2 => Some(format!(
"lefthook: {file} carries a stale v2 pushkin floor — the command is clean, \
but it has no fail-open guard, so it blocks teammates who have not \
installed pushkin; repair regenerates it and preserves other commands"
)),
Self::Current => None,
}
}
}
fn floor_generation(text: &str, marker: &str) -> Option<FloorGeneration> {
if text.contains(marker) {
return Some(FloorGeneration::Current);
}
if (text.contains("pushkin:begin pushkin-v2") || text.contains("marker: pushkin-v2"))
&& has_pushkin_entry(text)
{
return Some(FloorGeneration::V2);
}
has_pushkin_entry(text).then_some(FloorGeneration::V1)
}
fn has_pushkin_entry(text: &str) -> bool {
let mut lines = text.lines().skip_while(|line| line.trim() != "pushkin:");
if lines.next().is_none() {
return false;
}
lines
.take_while(|line| line.starts_with(' ') || line.trim().is_empty())
.any(|line| {
let trimmed = line.trim();
trimmed.starts_with("run:") && trimmed.contains("pushkin")
})
}
fn check_lefthook() -> AgentCheck {
use super::init::{LEFTHOOK_FILE, LEFTHOOK_MARKER};
let mut check = AgentCheck::default();
let text = match std::fs::read_to_string(LEFTHOOK_FILE) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
check.info = Some("lefthook: not installed — not checked".to_owned());
return check;
}
Err(error) => {
check.unreadable = true;
check
.findings
.push(format!("lefthook: cannot read {LEFTHOOK_FILE}: {error}"));
return check;
}
Ok(text) => text,
};
let Some(generation) = floor_generation(&text, LEFTHOOK_MARKER) else {
check.info = Some("lefthook: present but no pushkin block — not checked".to_owned());
if super::init::has_stale_pushkin_comments(&text) {
check.findings.push(format!(
"lefthook: {LEFTHOOK_FILE} carries orphaned pushkin marker comments but \
no pushkin block; repair removes the orphans, preserves other \
commands, and installs nothing"
));
}
return check;
};
if let Some(finding) = generation.stale_finding(LEFTHOOK_FILE) {
check.findings.push(finding);
} else if super::init::has_stale_pushkin_comments(&text) {
check.findings.push(format!(
"lefthook: {LEFTHOOK_FILE} carries orphaned pushkin marker comments outside \
the current block (litter from an upgrade by a pre-fix binary); repair \
removes them and preserves other commands"
));
}
if !pushkin_on_path() {
check.findings.push(
"lefthook: the floor runs `pushkin` from PATH but no `pushkin` is resolvable \
there — the pre-commit gate will fail to launch. Install it on PATH \
(`cargo install --path crates/pushkin-cli`) or add the binary's directory \
to PATH"
.to_owned(),
);
check.path_advisory = true;
}
check
}
fn pushkin_on_path() -> bool {
let Ok(path) = std::env::var("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| {
let candidate = dir.join("pushkin");
candidate.is_file() || candidate.with_extension("exe").is_file()
})
}
fn command_has_absolute_path(command: &str) -> bool {
let absolute = command.starts_with('/') || command.starts_with('\\') || command.contains(":\\");
absolute && !command.contains("pushkin.sh")
}
fn unresolvable_binary_finding() -> String {
"adapter hooks are installed but no `pushkin` is resolvable on PATH — \
the gate will fail to launch and writes will go ungated. Install it on \
PATH (`cargo install --path crates/pushkin-cli`) or add the binary's \
directory to PATH"
.to_owned()
}
enum EntryState {
FileAbsent,
Unreadable(String),
Missing,
Stale,
Current,
}
fn marked_entry_state(path: &str, needle: &str) -> EntryState {
match std::fs::read_to_string(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => EntryState::FileAbsent,
Err(error) => EntryState::Unreadable(format!("cannot read {path}: {error}")),
Ok(text) => match serde_json::from_str::<Value>(&text) {
Err(error) => EntryState::Unreadable(format!("{path} is not valid JSON: {error}")),
Ok(settings) => match pushkin_entry(&settings, "PreToolUse") {
None => EntryState::Missing,
Some(entry) if entry_has_absolute_command(entry) => EntryState::Stale,
Some(entry) if entry_invokes(entry, needle) => EntryState::Current,
Some(_) => EntryState::Stale,
},
},
}
}
fn pushkin_entry<'a>(settings: &'a Value, event: &str) -> Option<&'a Value> {
settings
.get("hooks")
.and_then(|hooks| hooks.get(event))
.and_then(Value::as_array)
.and_then(|entries| {
entries
.iter()
.find(|entry| entry.get("_pushkin").and_then(Value::as_str) == Some(PUSHKIN_MARKER))
})
}
fn legacy_entry<'a>(settings: &'a Value, event: &str) -> Option<&'a Value> {
settings
.get("hooks")
.and_then(|hooks| hooks.get(event))
.and_then(Value::as_array)
.and_then(|entries| entries.iter().find(|entry| marker_is_legacy(entry)))
}
fn marker_is_legacy(entry: &Value) -> bool {
entry
.get("_pushkin")
.is_some_and(|marker| marker.as_str() != Some(PUSHKIN_MARKER))
|| entry.get(pushkin_core::legacy::MARKER_KEY).is_some()
}
fn command_is_current(entry: &Value) -> bool {
entry_invokes(entry, "hook claude") && !entry_has_absolute_command(entry)
}
fn entry_has_absolute_command(entry: &Value) -> bool {
entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hooks| {
hooks.iter().any(|hook| {
hook.get("command")
.and_then(Value::as_str)
.is_some_and(command_has_absolute_path)
})
})
}
fn entry_invokes(entry: &Value, needle: &str) -> bool {
entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hooks| {
hooks.iter().all(|hook| {
hook.get("command")
.and_then(Value::as_str)
.is_some_and(|command| command.contains(needle))
})
})
}
#[cfg(test)]
mod tests {
use super::{marker_is_legacy, HostOs};
use serde_json::json;
#[test]
fn host_os_parses_the_consts_os_domain() {
assert_eq!(HostOs::parse("windows"), HostOs::Windows);
assert_eq!(HostOs::parse("macos"), HostOs::Other);
assert_eq!(HostOs::parse("linux"), HostOs::Other);
assert_eq!(HostOs::parse("Windows"), HostOs::Other);
}
#[test]
fn marker_is_legacy_only_for_foreign_pushkin_versions() {
assert!(marker_is_legacy(&json!({ "_pushkin": "pushkin-v0" })));
assert!(!marker_is_legacy(&json!({ "_pushkin": "pushkin-v1" })));
assert!(!marker_is_legacy(&json!({ "matcher": "Bash" })));
}
}