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    /// A third-party query was queued for the owner's review (#446).
19    Escalation,
20    /// A peer asked to join this machine's hive and is waiting for the owner to
21    /// decide (#434). Only fires for `join_policy: ask` — the other policies
22    /// admit without anyone needing to be told.
23    HiveJoinRequest,
24}
25
26impl HookEvent {
27    pub fn from_section(s: &str) -> Option<Self> {
28        match s {
29            "hooks.on_session_start" => Some(Self::SessionStart),
30            "hooks.on_status_change" => Some(Self::StatusChange),
31            "hooks.on_needs_input" => Some(Self::NeedsInput),
32            "hooks.on_finished" => Some(Self::Finished),
33            "hooks.on_budget_warning" => Some(Self::BudgetWarning),
34            "hooks.on_budget_exceeded" => Some(Self::BudgetExceeded),
35            "hooks.on_idle" => Some(Self::Idle),
36            "hooks.on_context_high" => Some(Self::ContextHigh),
37            "hooks.on_conflict_detected" => Some(Self::ConflictDetected),
38            "hooks.on_escalation" => Some(Self::Escalation),
39            "hooks.on_hive_join_request" => Some(Self::HiveJoinRequest),
40            _ => None,
41        }
42    }
43
44    pub fn name(&self) -> &'static str {
45        match self {
46            Self::SessionStart => "on_session_start",
47            Self::StatusChange => "on_status_change",
48            Self::NeedsInput => "on_needs_input",
49            Self::Finished => "on_finished",
50            Self::BudgetWarning => "on_budget_warning",
51            Self::BudgetExceeded => "on_budget_exceeded",
52            Self::Idle => "on_idle",
53            Self::ContextHigh => "on_context_high",
54            Self::ConflictDetected => "on_conflict_detected",
55            Self::Escalation => "on_escalation",
56            Self::HiveJoinRequest => "on_hive_join_request",
57        }
58    }
59}
60
61/// Registry of all configured hooks.
62#[derive(Debug, Clone, Default)]
63pub struct HookRegistry {
64    hooks: HashMap<HookEvent, Vec<String>>,
65}
66
67impl HookRegistry {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    pub fn add(&mut self, event: HookEvent, command: String) {
73        self.hooks.entry(event).or_default().push(command);
74    }
75
76    #[allow(dead_code)]
77    pub fn is_empty(&self) -> bool {
78        self.hooks.is_empty()
79    }
80
81    /// Fire all hooks for an event with session context.
82    pub fn fire(&self, event: HookEvent, session: &ClaudeSession) {
83        let Some(commands) = self.hooks.get(&event) else {
84            return;
85        };
86
87        for template in commands {
88            let cmd = expand_template(template, session);
89
90            crate::logger::log("DEBUG", &format!("hook {}: {}", event.name(), cmd));
91
92            // Spawn async — don't block the TUI
93            let _ = Command::new("sh")
94                .args(["-c", &cmd])
95                .stdin(std::process::Stdio::null())
96                .stdout(std::process::Stdio::null())
97                .stderr(std::process::Stdio::null())
98                .spawn();
99        }
100    }
101
102    /// Fire hooks with just a status string (for events without a specific session, like StatusChange).
103    pub fn fire_with_status(
104        &self,
105        event: HookEvent,
106        session: &ClaudeSession,
107        old_status: &str,
108        new_status: &str,
109    ) {
110        let Some(commands) = self.hooks.get(&event) else {
111            return;
112        };
113
114        for template in commands {
115            let cmd = expand_template(template, session)
116                .replace("{old_status}", old_status)
117                .replace("{new_status}", new_status);
118
119            crate::logger::log("DEBUG", &format!("hook {}: {}", event.name(), cmd));
120
121            let _ = Command::new("sh")
122                .args(["-c", &cmd])
123                .stdin(std::process::Stdio::null())
124                .stdout(std::process::Stdio::null())
125                .stderr(std::process::Stdio::null())
126                .spawn();
127        }
128    }
129
130    /// Fire hooks for an event that has no session behind it.
131    ///
132    /// `fire` and `fire_with_status` both expand a `ClaudeSession` into the
133    /// command template. Some events are not about a session at all — a
134    /// third-party query queued for review (#446) belongs to a grant, not a
135    /// PID — so this passes context as environment variables instead and
136    /// expands no template placeholders.
137    ///
138    /// Spawned and dropped, never waited on, exactly as `fire` does: the
139    /// caller here is a `query serve` connection thread answering a request,
140    /// and a slow hook must not hold the response open.
141    pub fn fire_env(&self, event: HookEvent, vars: &[(&str, &str)]) {
142        let Some(commands) = self.hooks.get(&event) else {
143            return;
144        };
145
146        for cmd in commands {
147            crate::logger::log("DEBUG", &format!("hook {}: {}", event.name(), cmd));
148
149            let mut c = Command::new("sh");
150            c.args(["-c", cmd])
151                .stdin(std::process::Stdio::null())
152                .stdout(std::process::Stdio::null())
153                .stderr(std::process::Stdio::null());
154            for (k, v) in vars {
155                c.env(k, v);
156            }
157            let _ = c.spawn();
158        }
159    }
160
161    /// List all configured hooks (for `claudectl --hooks`).
162    pub fn print_list(&self) {
163        if self.hooks.is_empty() {
164            println!("No hooks configured.");
165            println!();
166            println!("Add hooks in ~/.config/claudectl/config.toml:");
167            println!();
168            println!("  [hooks.on_needs_input]");
169            println!("  run = \"say 'Claude needs input'\"");
170            return;
171        }
172
173        println!("Configured hooks:");
174        println!();
175        for (event, commands) in &self.hooks {
176            for cmd in commands {
177                let display = if cmd.len() > 60 {
178                    format!("{}...", crate::session::truncate_str(cmd, 57))
179                } else {
180                    cmd.clone()
181                };
182                println!("  {:<22} {}", event.name(), display);
183            }
184        }
185    }
186}
187
188/// Replace template placeholders with session data.
189fn expand_template(template: &str, session: &ClaudeSession) -> String {
190    template
191        .replace("{pid}", &session.pid.to_string())
192        .replace("{project}", session.display_name())
193        .replace("{status}", &session.status.to_string())
194        .replace("{cost}", &format!("{:.2}", session.cost_usd))
195        .replace("{model}", &session.model)
196        .replace("{cwd}", &session.cwd)
197        .replace("{tokens_in}", &session.total_input_tokens.to_string())
198        .replace("{tokens_out}", &session.total_output_tokens.to_string())
199        .replace("{elapsed}", &session.format_elapsed())
200        .replace("{session_id}", &session.session_id)
201        .replace(
202            "{context_pct}",
203            &format!("{:.0}", session.context_percent()),
204        )
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::session::{ClaudeSession, RawSession, TelemetryStatus};
211
212    fn make_session() -> ClaudeSession {
213        let raw = RawSession {
214            pid: 12345,
215            session_id: "abc-def-123".into(),
216            cwd: "/Users/test/projects/my-app".into(),
217            started_at: 0,
218        };
219        let mut s = ClaudeSession::from_raw(raw);
220        s.model = "opus-4.6".into();
221        s.cost_usd = 3.45;
222        s.total_input_tokens = 500_000;
223        s.total_output_tokens = 50_000;
224        s.telemetry_status = TelemetryStatus::Available;
225        s.usage_metrics_available = true;
226        s
227    }
228
229    #[test]
230    fn test_expand_template() {
231        let s = make_session();
232        let result = expand_template("echo {pid} {project} ${cost}", &s);
233        assert_eq!(result, "echo 12345 my-app $3.45");
234    }
235
236    #[test]
237    fn test_expand_all_vars() {
238        let s = make_session();
239        let result = expand_template(
240            "{pid}|{project}|{status}|{cost}|{model}|{cwd}|{tokens_in}|{tokens_out}|{session_id}",
241            &s,
242        );
243        assert!(result.contains("12345"));
244        assert!(result.contains("my-app"));
245        assert!(result.contains("opus-4.6"));
246        assert!(result.contains("/Users/test/projects/my-app"));
247        assert!(result.contains("500000"));
248        assert!(result.contains("50000"));
249        assert!(result.contains("abc-def-123"));
250    }
251
252    #[test]
253    fn test_hook_event_from_section() {
254        assert_eq!(
255            HookEvent::from_section("hooks.on_needs_input"),
256            Some(HookEvent::NeedsInput)
257        );
258        assert_eq!(
259            HookEvent::from_section("hooks.on_finished"),
260            Some(HookEvent::Finished)
261        );
262        assert_eq!(
263            HookEvent::from_section("hooks.on_context_high"),
264            Some(HookEvent::ContextHigh)
265        );
266        assert_eq!(HookEvent::from_section("hooks.unknown"), None);
267        assert_eq!(HookEvent::from_section("defaults"), None);
268    }
269
270    #[test]
271    fn test_registry_add_and_fire() {
272        let mut reg = HookRegistry::new();
273        assert!(reg.is_empty());
274        reg.add(HookEvent::NeedsInput, "echo test".into());
275        assert!(!reg.is_empty());
276    }
277
278    #[test]
279    fn test_expand_context_pct() {
280        let mut s = make_session();
281        s.context_tokens = 150_000;
282        s.context_max = 200_000;
283        let result = expand_template("context at {context_pct}%", &s);
284        assert_eq!(result, "context at 75%");
285    }
286
287    #[test]
288    fn fire_env_passes_the_variables_and_expands_no_placeholders() {
289        // `on_escalation` has no session behind it, so the contract is
290        // environment variables rather than `{placeholder}` expansion. This is
291        // the documented interface in docs/access.md, so it is tested rather
292        // than asserted.
293        let dir = std::env::temp_dir().join(format!(
294            "claudectl-hookenv-{}-{}",
295            std::process::id(),
296            std::time::SystemTime::now()
297                .duration_since(std::time::UNIX_EPOCH)
298                .map(|d| d.as_nanos())
299                .unwrap_or(0)
300        ));
301        std::fs::create_dir_all(&dir).unwrap();
302        let out = dir.join("fired.txt");
303
304        let mut r = HookRegistry::new();
305        r.add(
306            HookEvent::Escalation,
307            format!(
308                "printf '%s|%s|%s' \"$CLAUDECTL_ESCALATION_ID\" \"$CLAUDECTL_ESCALATION_GRANT\" \"{{project}}\" > {}",
309                out.display()
310            ),
311        );
312        r.fire_env(
313            HookEvent::Escalation,
314            &[
315                ("CLAUDECTL_ESCALATION_ID", "esc_abcdef123456"),
316                ("CLAUDECTL_ESCALATION_GRANT", "gr_test01"),
317            ],
318        );
319
320        // Spawned and not waited on, which is the point — so poll briefly.
321        let mut body = String::new();
322        for _ in 0..200 {
323            if let Ok(b) = std::fs::read_to_string(&out) {
324                if !b.is_empty() {
325                    body = b;
326                    break;
327                }
328            }
329            std::thread::sleep(std::time::Duration::from_millis(10));
330        }
331        std::fs::remove_dir_all(&dir).ok();
332
333        assert_eq!(
334            body, "esc_abcdef123456|gr_test01|{project}",
335            "env vars did not arrive, or a placeholder was expanded"
336        );
337    }
338
339    #[test]
340    fn fire_env_on_an_unconfigured_event_runs_nothing() {
341        // The common case: an owner who configured no `on_escalation` pays one
342        // map lookup.
343        let r = HookRegistry::new();
344        r.fire_env(HookEvent::Escalation, &[("X", "y")]);
345        assert!(r.is_empty());
346    }
347
348    #[test]
349    fn the_escalation_event_round_trips_through_its_section_name() {
350        assert_eq!(
351            HookEvent::from_section("hooks.on_escalation"),
352            Some(HookEvent::Escalation)
353        );
354        assert_eq!(HookEvent::Escalation.name(), "on_escalation");
355    }
356}