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}
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#[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 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 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 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 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
147fn 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}