1use std::collections::HashMap;
2use std::process::Command;
3
4use crate::session::ClaudeSession;
5
6#[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 Escalation,
20 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#[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 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 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 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 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 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
188fn 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 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 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 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}