claudectl-core 0.69.0

Foundational types and IO primitives for claudectl. Shared across the TUI, the binary, and future crates.
Documentation
use std::collections::HashMap;
use std::process::Command;

use crate::session::ClaudeSession;

/// Event types that can trigger hooks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookEvent {
    SessionStart,
    StatusChange,
    NeedsInput,
    Finished,
    BudgetWarning,
    BudgetExceeded,
    Idle,
    ContextHigh,
    ConflictDetected,
    /// A third-party query was queued for the owner's review (#446).
    Escalation,
    /// A peer asked to join this machine's hive and is waiting for the owner to
    /// decide (#434). Only fires for `join_policy: ask` — the other policies
    /// admit without anyone needing to be told.
    HiveJoinRequest,
}

impl HookEvent {
    pub fn from_section(s: &str) -> Option<Self> {
        match s {
            "hooks.on_session_start" => Some(Self::SessionStart),
            "hooks.on_status_change" => Some(Self::StatusChange),
            "hooks.on_needs_input" => Some(Self::NeedsInput),
            "hooks.on_finished" => Some(Self::Finished),
            "hooks.on_budget_warning" => Some(Self::BudgetWarning),
            "hooks.on_budget_exceeded" => Some(Self::BudgetExceeded),
            "hooks.on_idle" => Some(Self::Idle),
            "hooks.on_context_high" => Some(Self::ContextHigh),
            "hooks.on_conflict_detected" => Some(Self::ConflictDetected),
            "hooks.on_escalation" => Some(Self::Escalation),
            "hooks.on_hive_join_request" => Some(Self::HiveJoinRequest),
            _ => None,
        }
    }

    pub fn name(&self) -> &'static str {
        match self {
            Self::SessionStart => "on_session_start",
            Self::StatusChange => "on_status_change",
            Self::NeedsInput => "on_needs_input",
            Self::Finished => "on_finished",
            Self::BudgetWarning => "on_budget_warning",
            Self::BudgetExceeded => "on_budget_exceeded",
            Self::Idle => "on_idle",
            Self::ContextHigh => "on_context_high",
            Self::ConflictDetected => "on_conflict_detected",
            Self::Escalation => "on_escalation",
            Self::HiveJoinRequest => "on_hive_join_request",
        }
    }
}

/// Registry of all configured hooks.
#[derive(Debug, Clone, Default)]
pub struct HookRegistry {
    hooks: HashMap<HookEvent, Vec<String>>,
}

impl HookRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn add(&mut self, event: HookEvent, command: String) {
        self.hooks.entry(event).or_default().push(command);
    }

    #[allow(dead_code)]
    pub fn is_empty(&self) -> bool {
        self.hooks.is_empty()
    }

    /// Fire all hooks for an event with session context.
    pub fn fire(&self, event: HookEvent, session: &ClaudeSession) {
        let Some(commands) = self.hooks.get(&event) else {
            return;
        };

        for template in commands {
            let cmd = expand_template(template, session);

            crate::logger::log("DEBUG", &format!("hook {}: {}", event.name(), cmd));

            // Spawn async — don't block the TUI
            let _ = Command::new("sh")
                .args(["-c", &cmd])
                .stdin(std::process::Stdio::null())
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .spawn();
        }
    }

    /// Fire hooks with just a status string (for events without a specific session, like StatusChange).
    pub fn fire_with_status(
        &self,
        event: HookEvent,
        session: &ClaudeSession,
        old_status: &str,
        new_status: &str,
    ) {
        let Some(commands) = self.hooks.get(&event) else {
            return;
        };

        for template in commands {
            let cmd = expand_template(template, session)
                .replace("{old_status}", old_status)
                .replace("{new_status}", new_status);

            crate::logger::log("DEBUG", &format!("hook {}: {}", event.name(), cmd));

            let _ = Command::new("sh")
                .args(["-c", &cmd])
                .stdin(std::process::Stdio::null())
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .spawn();
        }
    }

    /// Fire hooks for an event that has no session behind it.
    ///
    /// `fire` and `fire_with_status` both expand a `ClaudeSession` into the
    /// command template. Some events are not about a session at all — a
    /// third-party query queued for review (#446) belongs to a grant, not a
    /// PID — so this passes context as environment variables instead and
    /// expands no template placeholders.
    ///
    /// Spawned and dropped, never waited on, exactly as `fire` does: the
    /// caller here is a `query serve` connection thread answering a request,
    /// and a slow hook must not hold the response open.
    pub fn fire_env(&self, event: HookEvent, vars: &[(&str, &str)]) {
        let Some(commands) = self.hooks.get(&event) else {
            return;
        };

        for cmd in commands {
            crate::logger::log("DEBUG", &format!("hook {}: {}", event.name(), cmd));

            let mut c = Command::new("sh");
            c.args(["-c", cmd])
                .stdin(std::process::Stdio::null())
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null());
            for (k, v) in vars {
                c.env(k, v);
            }
            let _ = c.spawn();
        }
    }

    /// List all configured hooks (for `claudectl --hooks`).
    pub fn print_list(&self) {
        if self.hooks.is_empty() {
            println!("No hooks configured.");
            println!();
            println!("Add hooks in ~/.config/claudectl/config.toml:");
            println!();
            println!("  [hooks.on_needs_input]");
            println!("  run = \"say 'Claude needs input'\"");
            return;
        }

        println!("Configured hooks:");
        println!();
        for (event, commands) in &self.hooks {
            for cmd in commands {
                let display = if cmd.len() > 60 {
                    format!("{}...", crate::session::truncate_str(cmd, 57))
                } else {
                    cmd.clone()
                };
                println!("  {:<22} {}", event.name(), display);
            }
        }
    }
}

/// Replace template placeholders with session data.
fn expand_template(template: &str, session: &ClaudeSession) -> String {
    template
        .replace("{pid}", &session.pid.to_string())
        .replace("{project}", session.display_name())
        .replace("{status}", &session.status.to_string())
        .replace("{cost}", &format!("{:.2}", session.cost_usd))
        .replace("{model}", &session.model)
        .replace("{cwd}", &session.cwd)
        .replace("{tokens_in}", &session.total_input_tokens.to_string())
        .replace("{tokens_out}", &session.total_output_tokens.to_string())
        .replace("{elapsed}", &session.format_elapsed())
        .replace("{session_id}", &session.session_id)
        .replace(
            "{context_pct}",
            &format!("{:.0}", session.context_percent()),
        )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::session::{ClaudeSession, RawSession, TelemetryStatus};

    fn make_session() -> ClaudeSession {
        let raw = RawSession {
            pid: 12345,
            session_id: "abc-def-123".into(),
            cwd: "/Users/test/projects/my-app".into(),
            started_at: 0,
        };
        let mut s = ClaudeSession::from_raw(raw);
        s.model = "opus-4.6".into();
        s.cost_usd = 3.45;
        s.total_input_tokens = 500_000;
        s.total_output_tokens = 50_000;
        s.telemetry_status = TelemetryStatus::Available;
        s.usage_metrics_available = true;
        s
    }

    #[test]
    fn test_expand_template() {
        let s = make_session();
        let result = expand_template("echo {pid} {project} ${cost}", &s);
        assert_eq!(result, "echo 12345 my-app $3.45");
    }

    #[test]
    fn test_expand_all_vars() {
        let s = make_session();
        let result = expand_template(
            "{pid}|{project}|{status}|{cost}|{model}|{cwd}|{tokens_in}|{tokens_out}|{session_id}",
            &s,
        );
        assert!(result.contains("12345"));
        assert!(result.contains("my-app"));
        assert!(result.contains("opus-4.6"));
        assert!(result.contains("/Users/test/projects/my-app"));
        assert!(result.contains("500000"));
        assert!(result.contains("50000"));
        assert!(result.contains("abc-def-123"));
    }

    #[test]
    fn test_hook_event_from_section() {
        assert_eq!(
            HookEvent::from_section("hooks.on_needs_input"),
            Some(HookEvent::NeedsInput)
        );
        assert_eq!(
            HookEvent::from_section("hooks.on_finished"),
            Some(HookEvent::Finished)
        );
        assert_eq!(
            HookEvent::from_section("hooks.on_context_high"),
            Some(HookEvent::ContextHigh)
        );
        assert_eq!(HookEvent::from_section("hooks.unknown"), None);
        assert_eq!(HookEvent::from_section("defaults"), None);
    }

    #[test]
    fn test_registry_add_and_fire() {
        let mut reg = HookRegistry::new();
        assert!(reg.is_empty());
        reg.add(HookEvent::NeedsInput, "echo test".into());
        assert!(!reg.is_empty());
    }

    #[test]
    fn test_expand_context_pct() {
        let mut s = make_session();
        s.context_tokens = 150_000;
        s.context_max = 200_000;
        let result = expand_template("context at {context_pct}%", &s);
        assert_eq!(result, "context at 75%");
    }

    #[test]
    fn fire_env_passes_the_variables_and_expands_no_placeholders() {
        // `on_escalation` has no session behind it, so the contract is
        // environment variables rather than `{placeholder}` expansion. This is
        // the documented interface in docs/access.md, so it is tested rather
        // than asserted.
        let dir = std::env::temp_dir().join(format!(
            "claudectl-hookenv-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&dir).unwrap();
        let out = dir.join("fired.txt");

        let mut r = HookRegistry::new();
        r.add(
            HookEvent::Escalation,
            format!(
                "printf '%s|%s|%s' \"$CLAUDECTL_ESCALATION_ID\" \"$CLAUDECTL_ESCALATION_GRANT\" \"{{project}}\" > {}",
                out.display()
            ),
        );
        r.fire_env(
            HookEvent::Escalation,
            &[
                ("CLAUDECTL_ESCALATION_ID", "esc_abcdef123456"),
                ("CLAUDECTL_ESCALATION_GRANT", "gr_test01"),
            ],
        );

        // Spawned and not waited on, which is the point — so poll briefly.
        let mut body = String::new();
        for _ in 0..200 {
            if let Ok(b) = std::fs::read_to_string(&out) {
                if !b.is_empty() {
                    body = b;
                    break;
                }
            }
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
        std::fs::remove_dir_all(&dir).ok();

        assert_eq!(
            body, "esc_abcdef123456|gr_test01|{project}",
            "env vars did not arrive, or a placeholder was expanded"
        );
    }

    #[test]
    fn fire_env_on_an_unconfigured_event_runs_nothing() {
        // The common case: an owner who configured no `on_escalation` pays one
        // map lookup.
        let r = HookRegistry::new();
        r.fire_env(HookEvent::Escalation, &[("X", "y")]);
        assert!(r.is_empty());
    }

    #[test]
    fn the_escalation_event_round_trips_through_its_section_name() {
        assert_eq!(
            HookEvent::from_section("hooks.on_escalation"),
            Some(HookEvent::Escalation)
        );
        assert_eq!(HookEvent::Escalation.name(), "on_escalation");
    }
}