1use std::future::Future;
2use std::pin::Pin;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7pub type HookFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
10pub struct PluginContractVersion {
11 pub major: u16,
12 pub minor: u16,
13}
14
15impl PluginContractVersion {
16 pub const CURRENT: Self = Self { major: 1, minor: 0 };
17
18 pub const fn new(major: u16, minor: u16) -> Self {
19 Self { major, minor }
20 }
21
22 pub const fn is_compatible_with(self, other: Self) -> bool {
23 self.major == other.major
24 }
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub enum HookPhase {
29 PreRun,
30 PostRun,
31 PreSessionStart,
32 PostSessionStart,
33 PreTurn,
34 PostTurn,
35 PreToolUse,
37 PostToolUse,
39}
40
41#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42pub struct HookContext {
43 pub phase: HookPhase,
44 pub thread_id: Option<String>,
45 pub turn_id: Option<String>,
46 pub cwd: Option<String>,
47 pub model: Option<String>,
48 pub main_status: Option<String>,
49 pub correlation_id: String,
50 pub ts_ms: i64,
51 pub metadata: Value,
52 pub tool_name: Option<String>,
54 pub tool_input: Option<Value>,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub enum HookAttachment {
60 AtPath {
61 path: String,
62 placeholder: Option<String>,
63 },
64 ImageUrl {
65 url: String,
66 },
67 LocalImage {
68 path: String,
69 },
70 Skill {
71 name: String,
72 path: String,
73 },
74}
75
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77pub struct HookPatch {
78 pub prompt_override: Option<String>,
79 pub model_override: Option<String>,
80 pub add_attachments: Vec<HookAttachment>,
81 pub metadata_delta: Value,
82}
83
84impl Default for HookPatch {
85 fn default() -> Self {
86 Self {
87 prompt_override: None,
88 model_override: None,
89 add_attachments: Vec::new(),
90 metadata_delta: Value::Null,
91 }
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub struct BlockReason {
99 pub hook_name: String,
100 pub phase: HookPhase,
101 pub message: String,
102}
103
104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
105pub enum HookAction {
106 Noop,
107 Mutate(HookPatch),
108 Block(BlockReason),
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub enum HookIssueClass {
114 Validation,
115 Execution,
116 Timeout,
117 Internal,
118}
119
120#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
121pub struct HookIssue {
122 pub hook_name: String,
123 pub phase: HookPhase,
124 pub class: HookIssueClass,
125 pub message: String,
126}
127
128#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
129pub struct HookReport {
130 pub issues: Vec<HookIssue>,
131}
132
133impl HookReport {
134 pub fn push(&mut self, issue: HookIssue) {
135 self.issues.push(issue);
136 }
137
138 pub fn is_clean(&self) -> bool {
139 self.issues.is_empty()
140 }
141}
142
143pub trait PreHook: Send + Sync + 'static {
144 fn name(&self) -> &'static str;
145 fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<HookAction, HookIssue>>;
146}
147
148pub trait PostHook: Send + Sync + 'static {
149 fn name(&self) -> &'static str;
150 fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<(), HookIssue>>;
151}
152
153#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct HookMatcher {
158 pub phases: Vec<HookPhase>,
160 pub tool_name: Option<String>,
162 pub cwd_prefix: Option<String>,
164}
165
166impl HookMatcher {
167 pub fn phases(phases: impl Into<Vec<HookPhase>>) -> Self {
170 Self {
171 phases: phases.into(),
172 ..Self::default()
173 }
174 }
175
176 pub fn with_tool_name(mut self, name: impl Into<String>) -> Self {
179 self.tool_name = Some(name.into());
180 self
181 }
182
183 pub fn with_cwd_prefix(mut self, prefix: impl Into<String>) -> Self {
186 self.cwd_prefix = Some(prefix.into());
187 self
188 }
189
190 pub fn matches(&self, ctx: &HookContext) -> bool {
195 let phase_ok = self.phases.is_empty() || self.phases.contains(&ctx.phase);
196 let tool_ok = self
197 .tool_name
198 .as_deref()
199 .is_none_or(|name| ctx.tool_name.as_deref() == Some(name));
200 let cwd_ok = self.cwd_prefix.as_deref().is_none_or(|prefix| {
201 ctx.cwd.as_deref().is_some_and(|cwd| {
202 cwd == prefix || (cwd.starts_with(prefix) && cwd[prefix.len()..].starts_with('/'))
205 })
206 });
207 phase_ok && tool_ok && cwd_ok
208 }
209}
210
211pub struct FilteredPreHook<H: PreHook> {
215 inner: H,
216 matcher: HookMatcher,
217}
218
219impl<H: PreHook> FilteredPreHook<H> {
220 pub fn new(hook: H, matcher: HookMatcher) -> Self {
223 Self {
224 inner: hook,
225 matcher,
226 }
227 }
228}
229
230impl<H: PreHook> PreHook for FilteredPreHook<H> {
231 fn name(&self) -> &'static str {
232 self.inner.name()
233 }
234
235 fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<HookAction, HookIssue>> {
236 Box::pin(async move {
237 if self.matcher.matches(ctx) {
238 self.inner.call(ctx).await
239 } else {
240 Ok(HookAction::Noop)
241 }
242 })
243 }
244}
245
246pub struct FilteredPostHook<H: PostHook> {
250 inner: H,
251 matcher: HookMatcher,
252}
253
254impl<H: PostHook> FilteredPostHook<H> {
255 pub fn new(hook: H, matcher: HookMatcher) -> Self {
258 Self {
259 inner: hook,
260 matcher,
261 }
262 }
263}
264
265impl<H: PostHook> PostHook for FilteredPostHook<H> {
266 fn name(&self) -> &'static str {
267 self.inner.name()
268 }
269
270 fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<(), HookIssue>> {
271 Box::pin(async move {
272 if self.matcher.matches(ctx) {
273 self.inner.call(ctx).await
274 } else {
275 Ok(())
276 }
277 })
278 }
279}
280
281#[cfg(test)]
282mod tests;