Skip to main content

systemprompt_models/services/
hooks.rs

1//! Hook configuration: lifecycle events, matchers, and actions.
2//!
3//! [`HookEvent`] enumerates the agent lifecycle points a hook can bind to;
4//! [`HookEventsConfig`] groups the [`HookMatcher`]/[`HookAction`] bindings per
5//! event and validates them via [`HookEventsConfig::validate`].
6//! [`DiskHookConfig`] is the per-hook on-disk descriptor.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15use systemprompt_identifiers::HookId;
16
17use crate::errors::{ConfigValidationError, ParseEnumError};
18
19pub const HOOK_CONFIG_FILENAME: &str = "config.yaml";
20
21const fn default_true() -> bool {
22    true
23}
24
25fn default_version() -> String {
26    "1.0.0".to_owned()
27}
28
29fn default_matcher() -> String {
30    "*".to_owned()
31}
32
33fn default_hook_id() -> HookId {
34    HookId::new("")
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "PascalCase")]
39pub enum HookEvent {
40    PreToolUse,
41    PostToolUse,
42    PostToolUseFailure,
43    SessionStart,
44    SessionEnd,
45    UserPromptSubmit,
46    Notification,
47    Stop,
48    SubagentStart,
49    SubagentStop,
50}
51
52impl HookEvent {
53    pub const ALL_VARIANTS: &'static [Self] = &[
54        Self::PreToolUse,
55        Self::PostToolUse,
56        Self::PostToolUseFailure,
57        Self::SessionStart,
58        Self::SessionEnd,
59        Self::UserPromptSubmit,
60        Self::Notification,
61        Self::Stop,
62        Self::SubagentStart,
63        Self::SubagentStop,
64    ];
65
66    pub const fn as_str(&self) -> &'static str {
67        match self {
68            Self::PreToolUse => "PreToolUse",
69            Self::PostToolUse => "PostToolUse",
70            Self::PostToolUseFailure => "PostToolUseFailure",
71            Self::SessionStart => "SessionStart",
72            Self::SessionEnd => "SessionEnd",
73            Self::UserPromptSubmit => "UserPromptSubmit",
74            Self::Notification => "Notification",
75            Self::Stop => "Stop",
76            Self::SubagentStart => "SubagentStart",
77            Self::SubagentStop => "SubagentStop",
78        }
79    }
80}
81
82impl fmt::Display for HookEvent {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        write!(f, "{}", self.as_str())
85    }
86}
87
88impl FromStr for HookEvent {
89    type Err = ParseEnumError;
90
91    fn from_str(s: &str) -> Result<Self, Self::Err> {
92        match s {
93            "PreToolUse" => Ok(Self::PreToolUse),
94            "PostToolUse" => Ok(Self::PostToolUse),
95            "PostToolUseFailure" => Ok(Self::PostToolUseFailure),
96            "SessionStart" => Ok(Self::SessionStart),
97            "SessionEnd" => Ok(Self::SessionEnd),
98            "UserPromptSubmit" => Ok(Self::UserPromptSubmit),
99            "Notification" => Ok(Self::Notification),
100            "Stop" => Ok(Self::Stop),
101            "SubagentStart" => Ok(Self::SubagentStart),
102            "SubagentStop" => Ok(Self::SubagentStop),
103            _ => Err(ParseEnumError::new("hook_event", s)),
104        }
105    }
106}
107
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum HookCategory {
111    System,
112    #[default]
113    Custom,
114}
115
116impl HookCategory {
117    pub const fn as_str(&self) -> &'static str {
118        match self {
119            Self::System => "system",
120            Self::Custom => "custom",
121        }
122    }
123}
124
125impl fmt::Display for HookCategory {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(f, "{}", self.as_str())
128    }
129}
130
131impl FromStr for HookCategory {
132    type Err = ParseEnumError;
133
134    fn from_str(s: &str) -> Result<Self, Self::Err> {
135        match s {
136            "system" => Ok(Self::System),
137            "custom" => Ok(Self::Custom),
138            _ => Err(ParseEnumError::new("hook_category", s)),
139        }
140    }
141}
142
143#[derive(Debug, Clone, Deserialize)]
144pub struct DiskHookConfig {
145    #[serde(default = "default_hook_id")]
146    pub id: HookId,
147    #[serde(default)]
148    pub name: String,
149    #[serde(default)]
150    pub description: String,
151    #[serde(default = "default_version")]
152    pub version: String,
153    #[serde(default = "default_true")]
154    pub enabled: bool,
155    pub event: HookEvent,
156    #[serde(default = "default_matcher")]
157    pub matcher: String,
158    #[serde(default)]
159    pub command: String,
160    #[serde(default, rename = "async")]
161    pub is_async: bool,
162    #[serde(default)]
163    pub category: HookCategory,
164    #[serde(default)]
165    pub tags: Vec<String>,
166    #[serde(default)]
167    pub visible_to: Vec<String>,
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
171#[serde(rename_all = "PascalCase")]
172pub struct HookEventsConfig {
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub pre_tool_use: Vec<HookMatcher>,
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub post_tool_use: Vec<HookMatcher>,
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub post_tool_use_failure: Vec<HookMatcher>,
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub session_start: Vec<HookMatcher>,
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub session_end: Vec<HookMatcher>,
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub user_prompt_submit: Vec<HookMatcher>,
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub notification: Vec<HookMatcher>,
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub stop: Vec<HookMatcher>,
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub subagent_start: Vec<HookMatcher>,
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub subagent_stop: Vec<HookMatcher>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct HookMatcher {
197    pub matcher: String,
198    pub hooks: Vec<HookAction>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct HookAction {
203    #[serde(rename = "type")]
204    pub hook_type: HookType,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub command: Option<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub prompt: Option<String>,
209    #[serde(default, rename = "async")]
210    pub r#async: bool,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub timeout: Option<u32>,
213    #[serde(skip_serializing_if = "Option::is_none", rename = "statusMessage")]
214    pub status_message: Option<String>,
215}
216
217#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum HookType {
220    Command,
221    Prompt,
222    Agent,
223}
224
225impl HookEventsConfig {
226    pub const fn is_empty(&self) -> bool {
227        self.pre_tool_use.is_empty()
228            && self.post_tool_use.is_empty()
229            && self.post_tool_use_failure.is_empty()
230            && self.session_start.is_empty()
231            && self.session_end.is_empty()
232            && self.user_prompt_submit.is_empty()
233            && self.notification.is_empty()
234            && self.stop.is_empty()
235            && self.subagent_start.is_empty()
236            && self.subagent_stop.is_empty()
237    }
238
239    pub fn matchers_for_event(&self, event: HookEvent) -> &[HookMatcher] {
240        match event {
241            HookEvent::PreToolUse => &self.pre_tool_use,
242            HookEvent::PostToolUse => &self.post_tool_use,
243            HookEvent::PostToolUseFailure => &self.post_tool_use_failure,
244            HookEvent::SessionStart => &self.session_start,
245            HookEvent::SessionEnd => &self.session_end,
246            HookEvent::UserPromptSubmit => &self.user_prompt_submit,
247            HookEvent::Notification => &self.notification,
248            HookEvent::Stop => &self.stop,
249            HookEvent::SubagentStart => &self.subagent_start,
250            HookEvent::SubagentStop => &self.subagent_stop,
251        }
252    }
253
254    pub fn validate(&self) -> Result<(), ConfigValidationError> {
255        for event in HookEvent::ALL_VARIANTS {
256            for matcher in self.matchers_for_event(*event) {
257                for action in &matcher.hooks {
258                    match action.hook_type {
259                        HookType::Command => {
260                            if action.command.is_none() {
261                                return Err(ConfigValidationError::required(format!(
262                                    "Hook matcher '{}': command hook requires a 'command' field",
263                                    matcher.matcher
264                                )));
265                            }
266                        },
267                        HookType::Prompt => {
268                            if action.prompt.is_none() {
269                                return Err(ConfigValidationError::required(format!(
270                                    "Hook matcher '{}': prompt hook requires a 'prompt' field",
271                                    matcher.matcher
272                                )));
273                            }
274                        },
275                        HookType::Agent => {},
276                    }
277                }
278            }
279        }
280
281        Ok(())
282    }
283}