Skip to main content

starpod_hooks/
input.rs

1//! Hook input types — the data passed to hook callbacks.
2
3use crate::permissions::PermissionUpdate;
4use serde::{Deserialize, Serialize};
5
6/// Base fields shared by all hook inputs.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct BaseHookInput {
9    pub session_id: String,
10    pub transcript_path: String,
11    pub cwd: String,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub permission_mode: Option<String>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub agent_id: Option<String>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub agent_type: Option<String>,
18}
19
20/// Union of all hook input types, tagged by event name.
21///
22/// Each variant carries the [`BaseHookInput`] plus event-specific fields.
23///
24/// # Example
25///
26/// ```
27/// use starpod_hooks::{HookInput, BaseHookInput};
28///
29/// let input = HookInput::UserPromptSubmit {
30///     base: BaseHookInput {
31///         session_id: "sess-1".into(),
32///         transcript_path: String::new(),
33///         cwd: "/tmp".into(),
34///         permission_mode: None,
35///         agent_id: None,
36///         agent_type: None,
37///     },
38///     prompt: "Hello!".into(),
39/// };
40/// assert_eq!(input.event_name(), "UserPromptSubmit");
41/// assert!(input.tool_name().is_none());
42/// ```
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(tag = "hook_event_name")]
45pub enum HookInput {
46    PreToolUse {
47        #[serde(flatten)]
48        base: BaseHookInput,
49        tool_name: String,
50        tool_input: serde_json::Value,
51        tool_use_id: String,
52    },
53
54    PostToolUse {
55        #[serde(flatten)]
56        base: BaseHookInput,
57        tool_name: String,
58        tool_input: serde_json::Value,
59        tool_response: serde_json::Value,
60        tool_use_id: String,
61    },
62
63    PostToolUseFailure {
64        #[serde(flatten)]
65        base: BaseHookInput,
66        tool_name: String,
67        tool_input: serde_json::Value,
68        tool_use_id: String,
69        error: String,
70        #[serde(skip_serializing_if = "Option::is_none")]
71        is_interrupt: Option<bool>,
72    },
73
74    Notification {
75        #[serde(flatten)]
76        base: BaseHookInput,
77        message: String,
78        #[serde(skip_serializing_if = "Option::is_none")]
79        title: Option<String>,
80        notification_type: String,
81    },
82
83    UserPromptSubmit {
84        #[serde(flatten)]
85        base: BaseHookInput,
86        prompt: String,
87    },
88
89    SessionStart {
90        #[serde(flatten)]
91        base: BaseHookInput,
92        source: SessionStartSource,
93        #[serde(skip_serializing_if = "Option::is_none")]
94        model: Option<String>,
95    },
96
97    SessionEnd {
98        #[serde(flatten)]
99        base: BaseHookInput,
100        reason: String,
101    },
102
103    Stop {
104        #[serde(flatten)]
105        base: BaseHookInput,
106        stop_hook_active: bool,
107        #[serde(skip_serializing_if = "Option::is_none")]
108        last_assistant_message: Option<String>,
109    },
110
111    SubagentStart {
112        #[serde(flatten)]
113        base: BaseHookInput,
114        agent_id: String,
115        agent_type: String,
116    },
117
118    SubagentStop {
119        #[serde(flatten)]
120        base: BaseHookInput,
121        stop_hook_active: bool,
122        agent_id: String,
123        agent_transcript_path: String,
124        agent_type: String,
125        #[serde(skip_serializing_if = "Option::is_none")]
126        last_assistant_message: Option<String>,
127    },
128
129    PreCompact {
130        #[serde(flatten)]
131        base: BaseHookInput,
132        trigger: CompactTriggerType,
133        custom_instructions: Option<String>,
134    },
135
136    PermissionRequest {
137        #[serde(flatten)]
138        base: BaseHookInput,
139        tool_name: String,
140        tool_input: serde_json::Value,
141        #[serde(skip_serializing_if = "Option::is_none")]
142        permission_suggestions: Option<Vec<PermissionUpdate>>,
143    },
144
145    Setup {
146        #[serde(flatten)]
147        base: BaseHookInput,
148        trigger: SetupTrigger,
149    },
150
151    TeammateIdle {
152        #[serde(flatten)]
153        base: BaseHookInput,
154        teammate_name: String,
155        team_name: String,
156    },
157
158    TaskCompleted {
159        #[serde(flatten)]
160        base: BaseHookInput,
161        task_id: String,
162        task_subject: String,
163        #[serde(skip_serializing_if = "Option::is_none")]
164        task_description: Option<String>,
165        #[serde(skip_serializing_if = "Option::is_none")]
166        teammate_name: Option<String>,
167        #[serde(skip_serializing_if = "Option::is_none")]
168        team_name: Option<String>,
169    },
170
171    ConfigChange {
172        #[serde(flatten)]
173        base: BaseHookInput,
174        source: ConfigChangeSource,
175        #[serde(skip_serializing_if = "Option::is_none")]
176        file_path: Option<String>,
177    },
178
179    WorktreeCreate {
180        #[serde(flatten)]
181        base: BaseHookInput,
182        name: String,
183    },
184
185    WorktreeRemove {
186        #[serde(flatten)]
187        base: BaseHookInput,
188        worktree_path: String,
189    },
190}
191
192impl HookInput {
193    /// Returns the tool name if this is a tool-related hook.
194    pub fn tool_name(&self) -> Option<&str> {
195        match self {
196            HookInput::PreToolUse { tool_name, .. }
197            | HookInput::PostToolUse { tool_name, .. }
198            | HookInput::PostToolUseFailure { tool_name, .. }
199            | HookInput::PermissionRequest { tool_name, .. } => Some(tool_name),
200            _ => None,
201        }
202    }
203
204    /// Returns the hook event name as a string.
205    pub fn event_name(&self) -> &str {
206        match self {
207            HookInput::PreToolUse { .. } => "PreToolUse",
208            HookInput::PostToolUse { .. } => "PostToolUse",
209            HookInput::PostToolUseFailure { .. } => "PostToolUseFailure",
210            HookInput::Notification { .. } => "Notification",
211            HookInput::UserPromptSubmit { .. } => "UserPromptSubmit",
212            HookInput::SessionStart { .. } => "SessionStart",
213            HookInput::SessionEnd { .. } => "SessionEnd",
214            HookInput::Stop { .. } => "Stop",
215            HookInput::SubagentStart { .. } => "SubagentStart",
216            HookInput::SubagentStop { .. } => "SubagentStop",
217            HookInput::PreCompact { .. } => "PreCompact",
218            HookInput::PermissionRequest { .. } => "PermissionRequest",
219            HookInput::Setup { .. } => "Setup",
220            HookInput::TeammateIdle { .. } => "TeammateIdle",
221            HookInput::TaskCompleted { .. } => "TaskCompleted",
222            HookInput::ConfigChange { .. } => "ConfigChange",
223            HookInput::WorktreeCreate { .. } => "WorktreeCreate",
224            HookInput::WorktreeRemove { .. } => "WorktreeRemove",
225        }
226    }
227
228    /// Returns a reference to the base input fields.
229    pub fn base(&self) -> &BaseHookInput {
230        match self {
231            HookInput::PreToolUse { base, .. }
232            | HookInput::PostToolUse { base, .. }
233            | HookInput::PostToolUseFailure { base, .. }
234            | HookInput::Notification { base, .. }
235            | HookInput::UserPromptSubmit { base, .. }
236            | HookInput::SessionStart { base, .. }
237            | HookInput::SessionEnd { base, .. }
238            | HookInput::Stop { base, .. }
239            | HookInput::SubagentStart { base, .. }
240            | HookInput::SubagentStop { base, .. }
241            | HookInput::PreCompact { base, .. }
242            | HookInput::PermissionRequest { base, .. }
243            | HookInput::Setup { base, .. }
244            | HookInput::TeammateIdle { base, .. }
245            | HookInput::TaskCompleted { base, .. }
246            | HookInput::ConfigChange { base, .. }
247            | HookInput::WorktreeCreate { base, .. }
248            | HookInput::WorktreeRemove { base, .. } => base,
249        }
250    }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(rename_all = "lowercase")]
255pub enum SessionStartSource {
256    Startup,
257    Resume,
258    Clear,
259    Compact,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(rename_all = "lowercase")]
264pub enum CompactTriggerType {
265    Manual,
266    Auto,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270#[serde(rename_all = "lowercase")]
271pub enum SetupTrigger {
272    /// First-time initialization (BOOTSTRAP.md).
273    Init,
274    /// Routine maintenance.
275    Maintenance,
276    /// Server boot (BOOT.md) — fires on every server start.
277    Boot,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(rename_all = "snake_case")]
282pub enum ConfigChangeSource {
283    UserSettings,
284    ProjectSettings,
285    LocalSettings,
286    PolicySettings,
287    Skills,
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn base() -> BaseHookInput {
295        BaseHookInput {
296            session_id: "sess-1".into(),
297            transcript_path: "/tmp/transcript.json".into(),
298            cwd: "/projects/test".into(),
299            permission_mode: Some("default".into()),
300            agent_id: None,
301            agent_type: None,
302        }
303    }
304
305    #[test]
306    fn tool_name_returns_some_for_tool_events() {
307        let input = HookInput::PreToolUse {
308            base: base(),
309            tool_name: "Bash".into(),
310            tool_input: serde_json::json!({"command": "ls"}),
311            tool_use_id: "tu-1".into(),
312        };
313        assert_eq!(input.tool_name(), Some("Bash"));
314    }
315
316    #[test]
317    fn tool_name_returns_none_for_non_tool_events() {
318        let input = HookInput::SessionStart {
319            base: base(),
320            source: SessionStartSource::Startup,
321            model: Some("claude-haiku-4-5".into()),
322        };
323        assert_eq!(input.tool_name(), None);
324    }
325
326    #[test]
327    fn event_name_matches_variant() {
328        let input = HookInput::PostToolUseFailure {
329            base: base(),
330            tool_name: "Write".into(),
331            tool_input: serde_json::json!({}),
332            tool_use_id: "tu-2".into(),
333            error: "permission denied".into(),
334            is_interrupt: Some(false),
335        };
336        assert_eq!(input.event_name(), "PostToolUseFailure");
337    }
338
339    #[test]
340    fn base_accessor_returns_correct_fields() {
341        let b = base();
342        let input = HookInput::Stop {
343            base: b.clone(),
344            stop_hook_active: true,
345            last_assistant_message: None,
346        };
347        assert_eq!(input.base().session_id, "sess-1");
348        assert_eq!(input.base().cwd, "/projects/test");
349    }
350
351    #[test]
352    fn serde_roundtrip_tagged() {
353        let input = HookInput::UserPromptSubmit {
354            base: base(),
355            prompt: "hello world".into(),
356        };
357        let json = serde_json::to_string(&input).unwrap();
358        assert!(json.contains("\"hook_event_name\":\"UserPromptSubmit\""));
359        let back: HookInput = serde_json::from_str(&json).unwrap();
360        assert_eq!(back.event_name(), "UserPromptSubmit");
361    }
362
363    #[test]
364    fn session_start_source_serde() {
365        let src = SessionStartSource::Resume;
366        let json = serde_json::to_string(&src).unwrap();
367        assert_eq!(json, "\"resume\"");
368        let back: SessionStartSource = serde_json::from_str(&json).unwrap();
369        assert_eq!(back, SessionStartSource::Resume);
370    }
371
372    #[test]
373    fn config_change_source_serde() {
374        let src = ConfigChangeSource::ProjectSettings;
375        let json = serde_json::to_string(&src).unwrap();
376        assert_eq!(json, "\"project_settings\"");
377    }
378
379    #[test]
380    fn all_event_names_covered() {
381        // Ensure event_name() returns non-empty for every variant
382        let inputs = vec![
383            HookInput::PreToolUse {
384                base: base(),
385                tool_name: "t".into(),
386                tool_input: serde_json::json!(null),
387                tool_use_id: "id".into(),
388            },
389            HookInput::PostToolUse {
390                base: base(),
391                tool_name: "t".into(),
392                tool_input: serde_json::json!(null),
393                tool_response: serde_json::json!(null),
394                tool_use_id: "id".into(),
395            },
396            HookInput::PostToolUseFailure {
397                base: base(),
398                tool_name: "t".into(),
399                tool_input: serde_json::json!(null),
400                tool_use_id: "id".into(),
401                error: "e".into(),
402                is_interrupt: None,
403            },
404            HookInput::Notification {
405                base: base(),
406                message: "m".into(),
407                title: None,
408                notification_type: "info".into(),
409            },
410            HookInput::UserPromptSubmit {
411                base: base(),
412                prompt: "p".into(),
413            },
414            HookInput::SessionStart {
415                base: base(),
416                source: SessionStartSource::Startup,
417                model: None,
418            },
419            HookInput::SessionEnd {
420                base: base(),
421                reason: "done".into(),
422            },
423            HookInput::Stop {
424                base: base(),
425                stop_hook_active: false,
426                last_assistant_message: None,
427            },
428            HookInput::SubagentStart {
429                base: base(),
430                agent_id: "a".into(),
431                agent_type: "general".into(),
432            },
433            HookInput::SubagentStop {
434                base: base(),
435                stop_hook_active: false,
436                agent_id: "a".into(),
437                agent_transcript_path: "/t".into(),
438                agent_type: "general".into(),
439                last_assistant_message: None,
440            },
441            HookInput::PreCompact {
442                base: base(),
443                trigger: CompactTriggerType::Auto,
444                custom_instructions: None,
445            },
446            HookInput::PermissionRequest {
447                base: base(),
448                tool_name: "t".into(),
449                tool_input: serde_json::json!(null),
450                permission_suggestions: None,
451            },
452            HookInput::Setup {
453                base: base(),
454                trigger: SetupTrigger::Init,
455            },
456            HookInput::TeammateIdle {
457                base: base(),
458                teammate_name: "n".into(),
459                team_name: "t".into(),
460            },
461            HookInput::TaskCompleted {
462                base: base(),
463                task_id: "1".into(),
464                task_subject: "s".into(),
465                task_description: None,
466                teammate_name: None,
467                team_name: None,
468            },
469            HookInput::ConfigChange {
470                base: base(),
471                source: ConfigChangeSource::Skills,
472                file_path: None,
473            },
474            HookInput::WorktreeCreate {
475                base: base(),
476                name: "wt".into(),
477            },
478            HookInput::WorktreeRemove {
479                base: base(),
480                worktree_path: "/wt".into(),
481            },
482        ];
483
484        for input in &inputs {
485            assert!(!input.event_name().is_empty());
486            // base should always be accessible
487            assert!(!input.base().session_id.is_empty());
488        }
489        assert_eq!(inputs.len(), 18, "should cover all 18 HookInput variants");
490    }
491}