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 {
25 self.major == other.major
26 }
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30pub enum HookPhase {
31 PreRun,
32 PostRun,
33 PreSessionStart,
34 PostSessionStart,
35 PreTurn,
36 PostTurn,
37 PreToolUse,
39 PostToolUse,
41}
42
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44pub struct HookContext {
45 pub phase: HookPhase,
46 pub thread_id: Option<String>,
47 pub turn_id: Option<String>,
48 pub cwd: Option<String>,
49 pub model: Option<String>,
50 pub main_status: Option<String>,
51 pub correlation_id: String,
52 pub ts_ms: i64,
53 pub metadata: Value,
54 pub tool_name: Option<String>,
56 pub tool_input: Option<Value>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub enum HookAttachment {
62 AtPath {
63 path: String,
64 placeholder: Option<String>,
65 },
66 ImageUrl {
67 url: String,
68 },
69 LocalImage {
70 path: String,
71 },
72 Skill {
73 name: String,
74 path: String,
75 },
76}
77
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
79pub struct HookPatch {
80 pub prompt_override: Option<String>,
81 pub model_override: Option<String>,
82 pub add_attachments: Vec<HookAttachment>,
83 pub metadata_delta: Value,
84}
85
86impl Default for HookPatch {
87 fn default() -> Self {
88 Self {
89 prompt_override: None,
90 model_override: None,
91 add_attachments: Vec::new(),
92 metadata_delta: Value::Null,
93 }
94 }
95}
96
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100pub struct BlockReason {
101 pub hook_name: String,
102 pub phase: HookPhase,
103 pub message: String,
104}
105
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub enum HookAction {
108 Noop,
109 Mutate(HookPatch),
110 Block(BlockReason),
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
115pub enum HookIssueClass {
116 Validation,
117 Execution,
118 Timeout,
119 Internal,
120}
121
122#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
123pub struct HookIssue {
124 pub hook_name: String,
125 pub phase: HookPhase,
126 pub class: HookIssueClass,
127 pub message: String,
128}
129
130#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
131pub struct HookReport {
132 pub issues: Vec<HookIssue>,
133}
134
135impl HookReport {
136 pub fn push(&mut self, issue: HookIssue) {
137 self.issues.push(issue);
138 }
139
140 pub fn is_clean(&self) -> bool {
141 self.issues.is_empty()
142 }
143}
144
145pub trait PreHook: Send + Sync + 'static {
146 fn name(&self) -> &'static str;
147 fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<HookAction, HookIssue>>;
148}
149
150pub trait PostHook: Send + Sync + 'static {
151 fn name(&self) -> &'static str;
152 fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<(), HookIssue>>;
153}
154
155#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
159pub struct HookMatcher {
160 pub phases: Vec<HookPhase>,
162 pub tool_name: Option<String>,
164 pub cwd_prefix: Option<String>,
166}
167
168impl HookMatcher {
169 pub fn phases(phases: impl Into<Vec<HookPhase>>) -> Self {
172 Self {
173 phases: phases.into(),
174 ..Self::default()
175 }
176 }
177
178 pub fn with_tool_name(mut self, name: impl Into<String>) -> Self {
181 self.tool_name = Some(name.into());
182 self
183 }
184
185 pub fn with_cwd_prefix(mut self, prefix: impl Into<String>) -> Self {
188 self.cwd_prefix = Some(prefix.into());
189 self
190 }
191
192 pub fn matches(&self, ctx: &HookContext) -> bool {
197 let phase_ok = self.phases.is_empty() || self.phases.contains(&ctx.phase);
198 let tool_ok = self
199 .tool_name
200 .as_deref()
201 .is_none_or(|name| ctx.tool_name.as_deref() == Some(name));
202 let cwd_ok = self.cwd_prefix.as_deref().is_none_or(|prefix| {
203 ctx.cwd.as_deref().is_some_and(|cwd| {
204 cwd == prefix || (cwd.starts_with(prefix) && cwd[prefix.len()..].starts_with('/'))
207 })
208 });
209 phase_ok && tool_ok && cwd_ok
210 }
211}
212
213pub struct FilteredPreHook<H: PreHook> {
217 inner: H,
218 matcher: HookMatcher,
219}
220
221impl<H: PreHook> FilteredPreHook<H> {
222 pub fn new(hook: H, matcher: HookMatcher) -> Self {
225 Self {
226 inner: hook,
227 matcher,
228 }
229 }
230}
231
232impl<H: PreHook> PreHook for FilteredPreHook<H> {
233 fn name(&self) -> &'static str {
234 self.inner.name()
235 }
236
237 fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<HookAction, HookIssue>> {
238 Box::pin(async move {
239 if self.matcher.matches(ctx) {
240 self.inner.call(ctx).await
241 } else {
242 Ok(HookAction::Noop)
243 }
244 })
245 }
246}
247
248pub struct FilteredPostHook<H: PostHook> {
252 inner: H,
253 matcher: HookMatcher,
254}
255
256impl<H: PostHook> FilteredPostHook<H> {
257 pub fn new(hook: H, matcher: HookMatcher) -> Self {
260 Self {
261 inner: hook,
262 matcher,
263 }
264 }
265}
266
267impl<H: PostHook> PostHook for FilteredPostHook<H> {
268 fn name(&self) -> &'static str {
269 self.inner.name()
270 }
271
272 fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<(), HookIssue>> {
273 Box::pin(async move {
274 if self.matcher.matches(ctx) {
275 self.inner.call(ctx).await
276 } else {
277 Ok(())
278 }
279 })
280 }
281}
282
283#[cfg(test)]
284mod tests;