Skip to main content

claudectl_core/
hooks.rs

1use std::collections::HashMap;
2use std::process::Command;
3
4use crate::session::ClaudeSession;
5
6/// Event types that can trigger hooks.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum HookEvent {
9    SessionStart,
10    StatusChange,
11    NeedsInput,
12    Finished,
13    BudgetWarning,
14    BudgetExceeded,
15    Idle,
16    ContextHigh,
17    ConflictDetected,
18}
19
20impl HookEvent {
21    pub fn from_section(s: &str) -> Option<Self> {
22        match s {
23            "hooks.on_session_start" => Some(Self::SessionStart),
24            "hooks.on_status_change" => Some(Self::StatusChange),
25            "hooks.on_needs_input" => Some(Self::NeedsInput),
26            "hooks.on_finished" => Some(Self::Finished),
27            "hooks.on_budget_warning" => Some(Self::BudgetWarning),
28            "hooks.on_budget_exceeded" => Some(Self::BudgetExceeded),
29            "hooks.on_idle" => Some(Self::Idle),
30            "hooks.on_context_high" => Some(Self::ContextHigh),
31            "hooks.on_conflict_detected" => Some(Self::ConflictDetected),
32            _ => None,
33        }
34    }
35
36    pub fn name(&self) -> &'static str {
37        match self {
38            Self::SessionStart => "on_session_start",
39            Self::StatusChange => "on_status_change",
40            Self::NeedsInput => "on_needs_input",
41            Self::Finished => "on_finished",
42            Self::BudgetWarning => "on_budget_warning",
43            Self::BudgetExceeded => "on_budget_exceeded",
44            Self::Idle => "on_idle",
45            Self::ContextHigh => "on_context_high",
46            Self::ConflictDetected => "on_conflict_detected",
47        }
48    }
49}
50
51/// Registry of all configured hooks.
52#[derive(Debug, Clone, Default)]
53pub struct HookRegistry {
54    hooks: HashMap<HookEvent, Vec<String>>,
55}
56
57impl HookRegistry {
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    pub fn add(&mut self, event: HookEvent, command: String) {
63        self.hooks.entry(event).or_default().push(command);
64    }
65
66    #[allow(dead_code)]
67    pub fn is_empty(&self) -> bool {
68        self.hooks.is_empty()
69    }
70
71    /// Fire all hooks for an event with session context.
72    pub fn fire(&self, event: HookEvent, session: &ClaudeSession) {
73        let Some(commands) = self.hooks.get(&event) else {
74            return;
75        };
76
77        for template in commands {
78            let cmd = expand_template(template, session);
79
80            crate::logger::log("DEBUG", &format!("hook {}: {}", event.name(), cmd));
81
82            // Spawn async — don't block the TUI
83            let _ = Command::new("sh")
84                .args(["-c", &cmd])
85                .stdin(std::process::Stdio::null())
86                .stdout(std::process::Stdio::null())
87                .stderr(std::process::Stdio::null())
88                .spawn();
89        }
90    }
91
92    /// Fire hooks with just a status string (for events without a specific session, like StatusChange).
93    pub fn fire_with_status(
94        &self,
95        event: HookEvent,
96        session: &ClaudeSession,
97        old_status: &str,
98        new_status: &str,
99    ) {
100        let Some(commands) = self.hooks.get(&event) else {
101            return;
102        };
103
104        for template in commands {
105            let cmd = expand_template(template, session)
106                .replace("{old_status}", old_status)
107                .replace("{new_status}", new_status);
108
109            crate::logger::log("DEBUG", &format!("hook {}: {}", event.name(), cmd));
110
111            let _ = Command::new("sh")
112                .args(["-c", &cmd])
113                .stdin(std::process::Stdio::null())
114                .stdout(std::process::Stdio::null())
115                .stderr(std::process::Stdio::null())
116                .spawn();
117        }
118    }
119
120    /// List all configured hooks (for `claudectl --hooks`).
121    pub fn print_list(&self) {
122        if self.hooks.is_empty() {
123            println!("No hooks configured.");
124            println!();
125            println!("Add hooks in ~/.config/claudectl/config.toml:");
126            println!();
127            println!("  [hooks.on_needs_input]");
128            println!("  run = \"say 'Claude needs input'\"");
129            return;
130        }
131
132        println!("Configured hooks:");
133        println!();
134        for (event, commands) in &self.hooks {
135            for cmd in commands {
136                let display = if cmd.len() > 60 {
137                    format!("{}...", crate::session::truncate_str(cmd, 57))
138                } else {
139                    cmd.clone()
140                };
141                println!("  {:<22} {}", event.name(), display);
142            }
143        }
144    }
145}
146
147/// Replace template placeholders with session data.
148fn expand_template(template: &str, session: &ClaudeSession) -> String {
149    template
150        .replace("{pid}", &session.pid.to_string())
151        .replace("{project}", session.display_name())
152        .replace("{status}", &session.status.to_string())
153        .replace("{cost}", &format!("{:.2}", session.cost_usd))
154        .replace("{model}", &session.model)
155        .replace("{cwd}", &session.cwd)
156        .replace("{tokens_in}", &session.total_input_tokens.to_string())
157        .replace("{tokens_out}", &session.total_output_tokens.to_string())
158        .replace("{elapsed}", &session.format_elapsed())
159        .replace("{session_id}", &session.session_id)
160        .replace(
161            "{context_pct}",
162            &format!("{:.0}", session.context_percent()),
163        )
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::session::{ClaudeSession, RawSession, TelemetryStatus};
170
171    fn make_session() -> ClaudeSession {
172        let raw = RawSession {
173            pid: 12345,
174            session_id: "abc-def-123".into(),
175            cwd: "/Users/test/projects/my-app".into(),
176            started_at: 0,
177        };
178        let mut s = ClaudeSession::from_raw(raw);
179        s.model = "opus-4.6".into();
180        s.cost_usd = 3.45;
181        s.total_input_tokens = 500_000;
182        s.total_output_tokens = 50_000;
183        s.telemetry_status = TelemetryStatus::Available;
184        s.usage_metrics_available = true;
185        s
186    }
187
188    #[test]
189    fn test_expand_template() {
190        let s = make_session();
191        let result = expand_template("echo {pid} {project} ${cost}", &s);
192        assert_eq!(result, "echo 12345 my-app $3.45");
193    }
194
195    #[test]
196    fn test_expand_all_vars() {
197        let s = make_session();
198        let result = expand_template(
199            "{pid}|{project}|{status}|{cost}|{model}|{cwd}|{tokens_in}|{tokens_out}|{session_id}",
200            &s,
201        );
202        assert!(result.contains("12345"));
203        assert!(result.contains("my-app"));
204        assert!(result.contains("opus-4.6"));
205        assert!(result.contains("/Users/test/projects/my-app"));
206        assert!(result.contains("500000"));
207        assert!(result.contains("50000"));
208        assert!(result.contains("abc-def-123"));
209    }
210
211    #[test]
212    fn test_hook_event_from_section() {
213        assert_eq!(
214            HookEvent::from_section("hooks.on_needs_input"),
215            Some(HookEvent::NeedsInput)
216        );
217        assert_eq!(
218            HookEvent::from_section("hooks.on_finished"),
219            Some(HookEvent::Finished)
220        );
221        assert_eq!(
222            HookEvent::from_section("hooks.on_context_high"),
223            Some(HookEvent::ContextHigh)
224        );
225        assert_eq!(HookEvent::from_section("hooks.unknown"), None);
226        assert_eq!(HookEvent::from_section("defaults"), None);
227    }
228
229    #[test]
230    fn test_registry_add_and_fire() {
231        let mut reg = HookRegistry::new();
232        assert!(reg.is_empty());
233        reg.add(HookEvent::NeedsInput, "echo test".into());
234        assert!(!reg.is_empty());
235    }
236
237    #[test]
238    fn test_expand_context_pct() {
239        let mut s = make_session();
240        s.context_tokens = 150_000;
241        s.context_max = 200_000;
242        let result = expand_template("context at {context_pct}%", &s);
243        assert_eq!(result, "context at 75%");
244    }
245}