1use anyhow::{Context, Result, ensure};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5pub(crate) const LIFECYCLE_HOOK_EVENTS: &[&str] = &[
10 "session_start",
11 "session_end",
12 "subagent_start",
13 "subagent_stop",
14 "user_prompt_submit",
15 "pre_tool_use",
16 "post_tool_use",
17 "permission_request",
18 "pre_compact",
19 "stop",
20 "task_completion",
21 "task_completed",
22 "notification",
23];
24
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
28pub struct HooksConfig {
29 #[serde(default)]
31 pub lifecycle: LifecycleHooksConfig,
32}
33
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
37#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
38pub struct LifecycleHooksConfig {
39 #[serde(default)]
41 pub quiet_success_output: bool,
42
43 #[serde(default)]
45 pub session_start: Vec<HookGroupConfig>,
46
47 #[serde(default)]
49 pub session_end: Vec<HookGroupConfig>,
50
51 #[serde(default)]
53 pub subagent_start: Vec<HookGroupConfig>,
54
55 #[serde(default)]
57 pub subagent_stop: Vec<HookGroupConfig>,
58
59 #[serde(default)]
61 pub user_prompt_submit: Vec<HookGroupConfig>,
62
63 #[serde(default)]
65 pub pre_tool_use: Vec<HookGroupConfig>,
66
67 #[serde(default)]
69 pub post_tool_use: Vec<HookGroupConfig>,
70
71 #[serde(default)]
73 pub permission_request: Vec<HookGroupConfig>,
74
75 #[serde(default)]
77 pub pre_compact: Vec<HookGroupConfig>,
78
79 #[serde(default)]
81 pub stop: Vec<HookGroupConfig>,
82
83 #[serde(default)]
85 pub task_completion: Vec<HookGroupConfig>,
86
87 #[serde(default)]
89 pub task_completed: Vec<HookGroupConfig>,
90
91 #[serde(default)]
93 pub notification: Vec<HookGroupConfig>,
94}
95
96impl LifecycleHooksConfig {
97 pub fn is_empty(&self) -> bool {
98 self.session_start.is_empty()
99 && self.session_end.is_empty()
100 && self.subagent_start.is_empty()
101 && self.subagent_stop.is_empty()
102 && self.user_prompt_submit.is_empty()
103 && self.pre_tool_use.is_empty()
104 && self.post_tool_use.is_empty()
105 && self.permission_request.is_empty()
106 && self.pre_compact.is_empty()
107 && self.stop.is_empty()
108 && self.task_completion.is_empty()
109 && self.task_completed.is_empty()
110 && self.notification.is_empty()
111 }
112
113 pub fn normalized(&self) -> Self {
114 let mut normalized = self.clone();
115 normalized.stop.extend(self.task_completion.clone());
116 normalized.stop.extend(self.task_completed.clone());
117 normalized.task_completion.clear();
118 normalized.task_completed.clear();
119 normalized
120 }
121
122 pub(crate) fn groups_for_event(&self, event: &str) -> &[HookGroupConfig] {
126 match event {
127 "session_start" => &self.session_start,
128 "session_end" => &self.session_end,
129 "subagent_start" => &self.subagent_start,
130 "subagent_stop" => &self.subagent_stop,
131 "user_prompt_submit" => &self.user_prompt_submit,
132 "pre_tool_use" => &self.pre_tool_use,
133 "post_tool_use" => &self.post_tool_use,
134 "permission_request" => &self.permission_request,
135 "pre_compact" => &self.pre_compact,
136 "stop" => &self.stop,
137 "task_completion" => &self.task_completion,
138 "task_completed" => &self.task_completed,
139 "notification" => &self.notification,
140 _ => &[],
141 }
142 }
143}
144
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
148pub struct HookGroupConfig {
149 #[serde(default)]
152 pub matcher: Option<String>,
153
154 #[serde(default)]
156 pub hooks: Vec<HookCommandConfig>,
157}
158
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
161#[serde(rename_all = "snake_case")]
162#[derive(Default)]
163pub enum HookCommandKind {
164 #[default]
165 Command,
166 #[serde(other)]
168 Unknown,
169}
170
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
174pub struct HookCommandConfig {
175 #[serde(default)]
177 #[serde(rename = "type")]
178 pub kind: HookCommandKind,
179
180 #[serde(default)]
182 pub command: String,
183
184 #[serde(default)]
186 pub timeout_seconds: Option<u64>,
187}
188
189impl HooksConfig {
190 pub fn validate(&self) -> Result<()> {
191 self.lifecycle.validate().context("Invalid lifecycle hooks configuration")
192 }
193}
194
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct WorkspaceHookCommand {
205 pub event: String,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub matcher: Option<String>,
212 pub command: String,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub timeout_seconds: Option<u64>,
217}
218
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
230pub struct WorkspaceLifecycleHooks {
231 pub commands: Vec<WorkspaceHookCommand>,
233}
234
235impl WorkspaceLifecycleHooks {
236 pub fn is_empty(&self) -> bool {
237 self.commands.is_empty()
238 }
239}
240
241impl LifecycleHooksConfig {
242 fn validate(&self) -> Result<()> {
243 validate_groups(&self.session_start, "session_start")?;
244 validate_groups(&self.session_end, "session_end")?;
245 validate_groups(&self.subagent_start, "subagent_start")?;
246 validate_groups(&self.subagent_stop, "subagent_stop")?;
247 validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
248 validate_groups(&self.pre_tool_use, "pre_tool_use")?;
249 validate_groups(&self.post_tool_use, "post_tool_use")?;
250 validate_groups(&self.permission_request, "permission_request")?;
251 validate_groups(&self.pre_compact, "pre_compact")?;
252 validate_groups(&self.stop, "stop")?;
253 validate_groups(&self.task_completion, "task_completion")?;
254 validate_groups(&self.task_completed, "task_completed")?;
255 validate_groups(&self.notification, "notification")?;
256 Ok(())
257 }
258}
259
260fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
261 for (index, group) in groups.iter().enumerate() {
262 if let Some(pattern) = group.matcher.as_ref() {
263 validate_matcher(pattern)
264 .with_context(|| format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher"))?;
265 }
266
267 ensure!(!group.hooks.is_empty(), "hooks.{context_name}[{index}] must define at least one hook command");
268
269 for (hook_index, hook) in group.hooks.iter().enumerate() {
270 ensure!(
271 matches!(hook.kind, HookCommandKind::Command),
272 "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
273 );
274
275 ensure!(
276 !hook.command.trim().is_empty(),
277 "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
278 );
279
280 if let Some(timeout) = hook.timeout_seconds {
281 ensure!(
282 timeout > 0,
283 "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
284 );
285 }
286 }
287 }
288
289 Ok(())
290}
291
292fn validate_matcher(pattern: &str) -> Result<()> {
293 let trimmed = pattern.trim();
294 if trimmed.is_empty() || trimmed == "*" {
295 return Ok(());
296 }
297
298 let regex_pattern = format!("^(?:{trimmed})$");
299 Regex::new(®ex_pattern).with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
300 Ok(())
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn sample_group() -> HookGroupConfig {
308 HookGroupConfig {
309 matcher: None,
310 hooks: vec![HookCommandConfig {
311 kind: HookCommandKind::Command,
312 command: "echo ok".to_string(),
313 timeout_seconds: None,
314 }],
315 }
316 }
317
318 #[test]
319 fn permission_request_and_stop_validate() {
320 let config = LifecycleHooksConfig {
321 permission_request: vec![sample_group()],
322 stop: vec![sample_group()],
323 ..Default::default()
324 };
325
326 config.validate().expect("hooks validate");
327 }
328
329 #[test]
330 fn task_aliases_normalize_into_stop() {
331 let config = LifecycleHooksConfig {
332 task_completion: vec![sample_group()],
333 task_completed: vec![sample_group()],
334 ..Default::default()
335 };
336
337 let normalized = config.normalized();
338 assert_eq!(normalized.stop.len(), 2);
339 assert!(normalized.task_completion.is_empty());
340 assert!(normalized.task_completed.is_empty());
341 }
342}