1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "PascalCase")]
12pub enum HookEventKind {
13 PrePrompt,
15 PreTool,
17 PostTool,
19 PermissionRequest,
21 SessionStart,
23 SessionEnd,
25 Compact,
27 ModeSwitch,
29 ModelSwitch,
31 Error,
33 Other,
35}
36
37impl HookEventKind {
38 pub fn parse(s: &str) -> Option<Self> {
40 match s.trim() {
41 "PrePrompt" | "pre_prompt" | "UserPromptSubmit" => Some(Self::PrePrompt),
42 "PreTool" | "PreToolUse" | "pre_tool" | "preToolUse" => Some(Self::PreTool),
43 "PostTool" | "PostToolUse" | "post_tool" | "postToolUse" => Some(Self::PostTool),
44 "PermissionRequest" | "permission_request" => Some(Self::PermissionRequest),
45 "SessionStart" | "OnSessionStart" | "session_start" | "startup" => {
46 Some(Self::SessionStart)
47 }
48 "SessionEnd" | "OnSessionEnd" | "session_end" => Some(Self::SessionEnd),
49 "Compact" | "OnCompact" | "PreCompact" | "PostCompact" | "compact" => {
50 Some(Self::Compact)
51 }
52 "ModeSwitch" | "OnModeSwitch" | "mode_switch" => Some(Self::ModeSwitch),
53 "ModelSwitch" | "OnModelSwitch" | "model_switch" => Some(Self::ModelSwitch),
54 "Error" | "OnError" | "error" => Some(Self::Error),
55 "Other" | "other" => Some(Self::Other),
56 _ => None,
57 }
58 }
59
60 pub fn default_merge_mode(self) -> MergeMode {
62 match self {
63 Self::PreTool => MergeMode::PreTool,
64 Self::PostTool => MergeMode::PostTool,
65 Self::PermissionRequest => MergeMode::PermissionRequest,
66 _ => MergeMode::InjectOnly,
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct HookEvent {
74 pub kind: HookEventKind,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub tool: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub args: Option<Value>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub result: Option<String>,
85 #[serde(default)]
87 pub meta: Value,
88}
89
90impl Default for HookEvent {
91 fn default() -> Self {
92 Self::unit(HookEventKind::Other)
93 }
94}
95
96impl HookEvent {
97 pub fn pre_tool(tool: impl Into<String>, args: Value) -> Self {
99 Self {
100 kind: HookEventKind::PreTool,
101 tool: Some(tool.into()),
102 args: Some(args),
103 result: None,
104 meta: Value::Null,
105 }
106 }
107
108 pub fn post_tool(tool: impl Into<String>, result: impl Into<String>) -> Self {
110 Self {
111 kind: HookEventKind::PostTool,
112 tool: Some(tool.into()),
113 args: None,
114 result: Some(result.into()),
115 meta: Value::Null,
116 }
117 }
118
119 pub fn permission_request(tool: impl Into<String>, args: Value) -> Self {
121 Self {
122 kind: HookEventKind::PermissionRequest,
123 tool: Some(tool.into()),
124 args: Some(args),
125 result: None,
126 meta: Value::Null,
127 }
128 }
129
130 pub fn unit(kind: HookEventKind) -> Self {
132 Self {
133 kind,
134 tool: None,
135 args: None,
136 result: None,
137 meta: Value::Null,
138 }
139 }
140
141 pub fn with_meta(mut self, meta: Value) -> Self {
143 self.meta = meta;
144 self
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ContextChannel {
152 PrePrompt,
154 ToolPreface,
156 UiNotice,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(tag = "type", rename_all = "snake_case")]
163pub enum HookOutcome {
164 Continue,
166 AdditionalContext {
168 text: String,
170 #[serde(default = "default_pre_prompt_channel")]
172 channel: ContextChannel,
173 },
174 Deny {
176 reason: String,
178 },
179 MutateArgs {
181 args: Value,
183 },
184 Allow,
186 Ask,
188 ReplaceResult {
190 text: String,
192 },
193}
194
195fn default_pre_prompt_channel() -> ContextChannel {
196 ContextChannel::PrePrompt
197}
198
199impl HookOutcome {
200 pub fn context(text: impl Into<String>) -> Self {
202 Self::AdditionalContext {
203 text: text.into(),
204 channel: ContextChannel::PrePrompt,
205 }
206 }
207
208 pub fn ui_notice(text: impl Into<String>) -> Self {
210 Self::AdditionalContext {
211 text: text.into(),
212 channel: ContextChannel::UiNotice,
213 }
214 }
215
216 pub fn deny(reason: impl Into<String>) -> Self {
218 Self::Deny {
219 reason: reason.into(),
220 }
221 }
222
223 pub fn mutate_args(args: Value) -> Self {
225 Self::MutateArgs { args }
226 }
227
228 pub fn replace_result(text: impl Into<String>) -> Self {
230 Self::ReplaceResult { text: text.into() }
231 }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum MergeMode {
237 #[default]
239 PreTool,
240 PostTool,
242 PermissionRequest,
244 InjectOnly,
246}
247
248#[derive(Debug, Clone, Default, PartialEq)]
250pub struct PreToolEffect {
251 pub deny: Option<String>,
253 pub args: Option<Value>,
255 pub contexts: Vec<(ContextChannel, String)>,
257}
258
259impl PreToolEffect {
260 pub fn is_denied(&self) -> bool {
262 self.deny.is_some()
263 }
264
265 pub fn final_args<'a>(&'a self, original: &'a Value) -> &'a Value {
267 self.args.as_ref().unwrap_or(original)
268 }
269
270 pub fn into_final_args(self, original: Value) -> Result<Value, String> {
272 if let Some(reason) = self.deny {
273 return Err(reason);
274 }
275 Ok(self.args.unwrap_or(original))
276 }
277}
278
279#[derive(Debug, Clone, Default, PartialEq)]
281pub struct PostToolEffect {
282 pub replace_result: Option<String>,
284 pub block_feedback: Option<String>,
286 pub contexts: Vec<(ContextChannel, String)>,
288}
289
290impl PostToolEffect {
291 pub fn effective_result<'a>(&'a self, original: &'a str) -> &'a str {
295 if let Some(r) = self.replace_result.as_deref() {
296 return r;
297 }
298 if let Some(b) = self.block_feedback.as_deref() {
299 return b;
300 }
301 original
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Default)]
307pub enum PermissionDecision {
308 #[default]
310 Unspecified,
311 Allow,
313 Deny(String),
315 Ask,
317}
318
319#[derive(Debug, Clone, Default, PartialEq)]
321pub struct PermissionEffect {
322 pub decision: PermissionDecision,
324 pub contexts: Vec<(ContextChannel, String)>,
326}
327
328#[derive(Debug, Clone, Default, PartialEq)]
330pub struct InjectEffect {
331 pub contexts: Vec<(ContextChannel, String)>,
333}
334
335#[derive(Debug, Clone, PartialEq)]
337pub enum MergedEffect {
338 PreTool(PreToolEffect),
340 PostTool(PostToolEffect),
342 Permission(PermissionEffect),
344 Inject(InjectEffect),
346}
347
348impl MergedEffect {
349 pub fn contexts(&self) -> &[(ContextChannel, String)] {
351 match self {
352 Self::PreTool(e) => &e.contexts,
353 Self::PostTool(e) => &e.contexts,
354 Self::Permission(e) => &e.contexts,
355 Self::Inject(e) => &e.contexts,
356 }
357 }
358
359 pub fn as_pre_tool(&self) -> Option<&PreToolEffect> {
361 match self {
362 Self::PreTool(e) => Some(e),
363 _ => None,
364 }
365 }
366
367 pub fn as_post_tool(&self) -> Option<&PostToolEffect> {
369 match self {
370 Self::PostTool(e) => Some(e),
371 _ => None,
372 }
373 }
374
375 pub fn as_permission(&self) -> Option<&PermissionEffect> {
377 match self {
378 Self::Permission(e) => Some(e),
379 _ => None,
380 }
381 }
382}
383
384pub fn merge_outcomes(mode: MergeMode, outcomes: &[HookOutcome]) -> MergedEffect {
401 match mode {
402 MergeMode::PreTool => MergedEffect::PreTool(merge_pre_tool(outcomes)),
403 MergeMode::PostTool => MergedEffect::PostTool(merge_post_tool(outcomes)),
404 MergeMode::PermissionRequest => MergedEffect::Permission(merge_permission(outcomes)),
405 MergeMode::InjectOnly => MergedEffect::Inject(merge_inject(outcomes)),
406 }
407}
408
409pub fn merge_pre_tool(outcomes: &[HookOutcome]) -> PreToolEffect {
411 let mut effect = PreToolEffect::default();
412 for o in outcomes {
413 match o {
414 HookOutcome::Deny { reason } => {
415 effect.deny = Some(reason.clone());
416 break;
417 }
418 HookOutcome::MutateArgs { args } => {
419 effect.args = Some(args.clone());
420 }
421 HookOutcome::AdditionalContext { text, channel } => {
422 effect.contexts.push((*channel, text.clone()));
423 }
424 HookOutcome::Continue
425 | HookOutcome::Allow
426 | HookOutcome::Ask
427 | HookOutcome::ReplaceResult { .. } => {}
428 }
429 }
430 effect
431}
432
433pub fn merge_post_tool(outcomes: &[HookOutcome]) -> PostToolEffect {
435 let mut effect = PostToolEffect::default();
436 for o in outcomes {
437 match o {
438 HookOutcome::Deny { reason } => {
439 effect.block_feedback = Some(reason.clone());
440 }
441 HookOutcome::ReplaceResult { text } => {
442 effect.replace_result = Some(text.clone());
443 }
444 HookOutcome::AdditionalContext { text, channel } => {
445 effect.contexts.push((*channel, text.clone()));
446 }
447 HookOutcome::Continue
448 | HookOutcome::MutateArgs { .. }
449 | HookOutcome::Allow
450 | HookOutcome::Ask => {}
451 }
452 }
453 effect
454}
455
456pub fn merge_permission(outcomes: &[HookOutcome]) -> PermissionEffect {
460 let mut effect = PermissionEffect::default();
461 let mut saw_allow = false;
462 let mut saw_ask = false;
463 for o in outcomes {
464 match o {
465 HookOutcome::Deny { reason } => {
466 effect.decision = PermissionDecision::Deny(reason.clone());
467 break;
469 }
470 HookOutcome::Allow => saw_allow = true,
471 HookOutcome::Ask => saw_ask = true,
472 HookOutcome::AdditionalContext { text, channel } => {
473 effect.contexts.push((*channel, text.clone()));
474 }
475 _ => {}
476 }
477 }
478 if matches!(effect.decision, PermissionDecision::Unspecified) {
479 if saw_allow {
480 effect.decision = PermissionDecision::Allow;
481 } else if saw_ask {
482 effect.decision = PermissionDecision::Ask;
483 }
484 }
485 effect
486}
487
488pub fn merge_inject(outcomes: &[HookOutcome]) -> InjectEffect {
490 let mut effect = InjectEffect::default();
491 for o in outcomes {
492 if let HookOutcome::AdditionalContext { text, channel } = o {
493 effect.contexts.push((*channel, text.clone()));
494 }
495 }
496 effect
497}
498
499pub fn apply_pre_tool_args(original: &Value, effect: &PreToolEffect) -> Value {
501 effect
502 .args
503 .clone()
504 .unwrap_or_else(|| original.clone())
505}
506
507pub fn contexts_for_channel(
509 contexts: &[(ContextChannel, String)],
510 channel: ContextChannel,
511) -> Vec<&str> {
512 contexts
513 .iter()
514 .filter(|(c, _)| *c == channel)
515 .map(|(_, t)| t.as_str())
516 .collect()
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use serde_json::json;
523
524 #[test]
525 fn parse_claude_aliases() {
526 assert_eq!(
527 HookEventKind::parse("PreToolUse"),
528 Some(HookEventKind::PreTool)
529 );
530 assert_eq!(
531 HookEventKind::parse("PostToolUse"),
532 Some(HookEventKind::PostTool)
533 );
534 assert_eq!(
535 HookEventKind::parse("SessionStart"),
536 Some(HookEventKind::SessionStart)
537 );
538 assert_eq!(HookEventKind::parse("UserPromptSubmit"), Some(HookEventKind::PrePrompt));
539 assert_eq!(HookEventKind::parse("nope"), None);
540 }
541
542 #[test]
543 fn default_merge_mode_mapping() {
544 assert_eq!(
545 HookEventKind::PreTool.default_merge_mode(),
546 MergeMode::PreTool
547 );
548 assert_eq!(
549 HookEventKind::SessionStart.default_merge_mode(),
550 MergeMode::InjectOnly
551 );
552 }
553
554 #[test]
555 fn pre_tool_deny_short_circuits() {
556 let outcomes = [
557 HookOutcome::mutate_args(json!({"command": "echo a"})),
558 HookOutcome::deny("nope"),
559 HookOutcome::mutate_args(json!({"command": "echo b"})),
560 ];
561 let e = merge_pre_tool(&outcomes);
562 assert_eq!(e.deny.as_deref(), Some("nope"));
563 assert_eq!(e.args, Some(json!({"command": "echo a"})));
564 assert!(e.is_denied());
565 assert!(e.into_final_args(json!({})).is_err());
566 }
567
568 #[test]
569 fn pre_tool_mutate_chains() {
570 let outcomes = [
571 HookOutcome::mutate_args(json!({"command": "git status"})),
572 HookOutcome::mutate_args(json!({"command": "rtk git status"})),
573 HookOutcome::context("note"),
574 ];
575 let e = merge_pre_tool(&outcomes);
576 assert!(!e.is_denied());
577 assert_eq!(e.args, Some(json!({"command": "rtk git status"})));
578 assert_eq!(e.contexts.len(), 1);
579 assert_eq!(
580 apply_pre_tool_args(&json!({"command": "raw"}), &e),
581 json!({"command": "rtk git status"})
582 );
583 let owned = e
584 .clone()
585 .into_final_args(json!({"command": "raw"}));
586 assert!(matches!(owned, Ok(v) if v == json!({"command": "rtk git status"})));
587 }
588
589 #[test]
590 fn pre_tool_no_mutate_keeps_original_ref() {
591 let e = PreToolEffect::default();
592 let original = json!({"a": 1});
593 assert_eq!(e.final_args(&original), &original);
594 }
595
596 #[test]
597 fn post_tool_replace_last_wins() {
598 let outcomes = [
599 HookOutcome::replace_result("first"),
600 HookOutcome::replace_result("second"),
601 HookOutcome::context("ctx"),
602 ];
603 let e = merge_post_tool(&outcomes);
604 assert_eq!(e.replace_result.as_deref(), Some("second"));
605 assert_eq!(e.effective_result("orig"), "second");
606 assert_eq!(e.contexts.len(), 1);
607 }
608
609 #[test]
610 fn post_tool_deny_becomes_block_feedback() {
611 let outcomes = [HookOutcome::deny("needs review")];
612 let e = merge_post_tool(&outcomes);
613 assert_eq!(e.block_feedback.as_deref(), Some("needs review"));
614 assert_eq!(e.effective_result("orig"), "needs review");
615 }
616
617 #[test]
618 fn post_tool_replace_beats_block_feedback() {
619 let outcomes = [
620 HookOutcome::deny("block"),
621 HookOutcome::replace_result("replaced"),
622 ];
623 let e = merge_post_tool(&outcomes);
624 assert_eq!(e.effective_result("orig"), "replaced");
625 }
626
627 #[test]
628 fn permission_deny_beats_allow() {
629 let outcomes = [
630 HookOutcome::Allow,
631 HookOutcome::deny("policy"),
632 HookOutcome::Ask,
633 ];
634 let e = merge_permission(&outcomes);
635 assert_eq!(e.decision, PermissionDecision::Deny("policy".into()));
636 }
637
638 #[test]
639 fn permission_allow_over_ask() {
640 let outcomes = [HookOutcome::Ask, HookOutcome::Allow];
641 let e = merge_permission(&outcomes);
642 assert_eq!(e.decision, PermissionDecision::Allow);
643 }
644
645 #[test]
646 fn permission_ask_only() {
647 let e = merge_permission(&[HookOutcome::Ask]);
648 assert_eq!(e.decision, PermissionDecision::Ask);
649 }
650
651 #[test]
652 fn permission_unspecified() {
653 let e = merge_permission(&[HookOutcome::Continue]);
654 assert_eq!(e.decision, PermissionDecision::Unspecified);
655 }
656
657 #[test]
658 fn inject_filters_non_context() {
659 let e = merge_inject(&[
660 HookOutcome::Continue,
661 HookOutcome::deny("x"),
662 HookOutcome::ui_notice("ui"),
663 HookOutcome::context("model"),
664 ]);
665 assert_eq!(e.contexts.len(), 2);
666 assert_eq!(
667 contexts_for_channel(&e.contexts, ContextChannel::UiNotice),
668 vec!["ui"]
669 );
670 assert_eq!(
671 contexts_for_channel(&e.contexts, ContextChannel::PrePrompt),
672 vec!["model"]
673 );
674 }
675
676 #[test]
677 fn merge_outcomes_dispatch() {
678 let m = merge_outcomes(MergeMode::PreTool, &[HookOutcome::deny("d")]);
679 assert!(m.as_pre_tool().is_some_and(|e| e.is_denied()));
680 let m = merge_outcomes(MergeMode::PostTool, &[HookOutcome::replace_result("r")]);
681 assert!(m.as_post_tool().is_some_and(|e| e.replace_result.as_deref() == Some("r")));
682 let m = merge_outcomes(MergeMode::PermissionRequest, &[HookOutcome::Allow]);
683 assert!(m
684 .as_permission()
685 .is_some_and(|e| e.decision == PermissionDecision::Allow));
686 }
687
688 #[test]
689 fn event_builders_and_serde() {
690 let ev = HookEvent::pre_tool("shell", json!({"command": "ls"}))
691 .with_meta(json!({"session": "s1"}));
692 let Ok(s) = serde_json::to_string(&ev) else {
693 panic!("serialize failed");
694 };
695 let Ok(back) = serde_json::from_str::<HookEvent>(&s) else {
696 panic!("deserialize failed");
697 };
698 assert_eq!(back.kind, HookEventKind::PreTool);
699 assert_eq!(back.tool.as_deref(), Some("shell"));
700 assert_eq!(back.meta.get("session").and_then(|v| v.as_str()), Some("s1"));
701
702 let p = HookEvent::permission_request("shell", json!({}));
703 assert_eq!(p.kind, HookEventKind::PermissionRequest);
704
705 let post = HookEvent::post_tool("shell", "ok");
706 assert_eq!(post.result.as_deref(), Some("ok"));
707 }
708
709 #[test]
710 fn outcome_serde_roundtrip() {
711 let outcomes = [
712 HookOutcome::Continue,
713 HookOutcome::context("c"),
714 HookOutcome::deny("d"),
715 HookOutcome::mutate_args(json!({"x": 1})),
716 HookOutcome::Allow,
717 HookOutcome::Ask,
718 HookOutcome::replace_result("r"),
719 ];
720 for o in &outcomes {
721 let Ok(s) = serde_json::to_string(o) else {
722 panic!("ser");
723 };
724 let Ok(back) = serde_json::from_str::<HookOutcome>(&s) else {
725 panic!("de {s}");
726 };
727 assert_eq!(&back, o);
728 }
729 }
730}