返回 CodeWhale
hooks.rs
根目录 / crates / tui / src / commands / groups / core / hooks.rs
1 //! `/hooks` slash command — read-only listing of configured
2 //! lifecycle hooks (#460 MVP).
3 //!
4 //! The full picker / persisted enable-disable surface in #460 is
5 //! still M-sized. This MVP gives the user a no-typing view of what's
6 //! actually configured in `~/.codewhale/config.toml`'s `[hooks]`
7 //! table — the most-asked question once hooks start firing.
8
9 use crate::commands::traits::{CommandInfo, RegisterCommand};
10 use crate::hooks::HookEvent;
11 use crate::localization::MessageId;
12 use crate::tui::app::App;
13
14 use super::CommandResult;
15
16 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
17 name: "hooks",
18 aliases: &["hook", "gouzi"],
19 usage: "/hooks [list|events]",
20 description_id: MessageId::CmdHooksDescription,
21 };
22
23 pub(in crate::commands) struct HooksCmd;
24
25 impl RegisterCommand for HooksCmd {
26 fn info() -> &'static CommandInfo {
27 &COMMAND_INFO
28 }
29
30 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
31 hooks(app, arg)
32 }
33 }
34
35 /// Top-level dispatch for `/hooks`. Subcommands:
36 ///
37 /// * `/hooks` — same as `/hooks list`.
38 /// * `/hooks list` — show every configured hook grouped by event,
39 /// noting whether the global `[hooks].enabled` flag suppresses
40 /// them.
41 /// * `/hooks events` — list every supported `HookEvent` value the
42 /// user can target in `[[hooks.hooks]]` entries. Useful for
43 /// discovery — without this, the only way to learn the event
44 /// names is to read source.
45 pub fn hooks(app: &App, arg: Option<&str>) -> CommandResult {
46 let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase();
47 match sub.as_str() {
48 "" | "list" | "ls" | "show" => list(app),
49 "events" | "event" | "list-events" => events(),
50 other => CommandResult::error(format!(
51 "unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`."
52 )),
53 }
54 }
55
56 fn events() -> CommandResult {
57 let mut out = String::new();
58 out.push_str(
59 "Available hook events (use one of these as `event = \"...\"` in your `[[hooks.hooks]]` entry).\n\
60 Hooks are a TUI runtime feature: `codewhale exec`, the CLI dispatcher, the app-server,\n\
61 and the workflow tool do not fire them.\n\n",
62 );
63 // Order matters — group lifecycle events first, then per-tool,
64 // then situational. Stays stable across releases so users can
65 // grep on it.
66 let ordered = [
67 (HookEvent::SessionStart, "fires once when the TUI launches"),
68 (HookEvent::SessionEnd, "fires once on graceful shutdown"),
69 (
70 HookEvent::TurnEnd,
71 "fires after a turn completes (observer-only)",
72 ),
73 (
74 HookEvent::MessageSubmit,
75 "fires before model dispatch; can transform or block submitted text",
76 ),
77 (
78 HookEvent::ToolCallBefore,
79 "fires before each tool call; can allow/deny/ask, rewrite input, add context",
80 ),
81 (
82 HookEvent::ToolCallAfter,
83 "fires after each tool call (observer-only)",
84 ),
85 (
86 HookEvent::ModeChange,
87 "fires on Plan/Act/Operate transitions",
88 ),
89 (
90 HookEvent::OnError,
91 "fires on transport / capacity / tool errors",
92 ),
93 (
94 HookEvent::SubagentSpawn,
95 "fires when a sub-agent starts (observer-only)",
96 ),
97 (
98 HookEvent::SubagentComplete,
99 "fires when a sub-agent completes, fails, or is cancelled (observer-only)",
100 ),
101 (
102 HookEvent::ShellEnv,
103 "fires before each exec_shell; stdout KEY=VALUE lines are merged into its environment",
104 ),
105 ];
106 for (event, desc) in ordered {
107 out.push_str(&format!(" - `{}` — {desc}\n", event_label(event)));
108 }
109 out.push_str(
110 "\nOnly `message_submit`, `tool_call_before`, and `shell_env` can steer a turn.\n\
111 Observer-only means the *result* is ignored — the command still runs\n\
112 and can have any external side effect.\n\n\
113 Full contract: docs/HOOKS.md\n",
114 );
115 CommandResult::message(out.trim_end().to_string())
116 }
117
118 fn list(app: &App) -> CommandResult {
119 let config = app.hooks.config();
120 if config.hooks.is_empty() && config.problems.is_empty() {
121 return CommandResult::message(
122 "No hooks configured. Add a `[[hooks.hooks]]` entry to `~/.codewhale/config.toml` to define one.",
123 );
124 }
125 if config.hooks.is_empty() {
126 let mut out =
127 String::from("No runnable hooks. Every configured entry was rejected at load:\n\n");
128 out.push_str(&render_problems(&config.problems));
129 return CommandResult::message(out.trim_end().to_string());
130 }
131
132 let mut out = String::new();
133 out.push_str(&format!(
134 "{} configured hook(s) (global enabled: {}):\n\n",
135 config.hooks.len(),
136 if config.enabled {
137 "yes"
138 } else {
139 "no — all hooks suppressed"
140 }
141 ));
142
143 let mut by_event: std::collections::BTreeMap<&str, Vec<&crate::hooks::Hook>> =
144 std::collections::BTreeMap::new();
145 for hook in &config.hooks {
146 by_event
147 .entry(event_label(hook.event))
148 .or_default()
149 .push(hook);
150 }
151
152 for (event, hooks) in by_event {
153 out.push_str(&format!("### {event}\n"));
154 for hook in hooks {
155 // `name` is operator-supplied and otherwise unbounded: a name made
156 // of ANSI escapes would repaint this listing, and a long one would
157 // push the rest of the row off screen.
158 let label = crate::hooks::sanitize_hook_label(hook.name.as_deref());
159 // `[bg]` describes actual scheduling: `shell_env` ignores the flag
160 // and always runs in the foreground, so it is not labelled.
161 let bg = if hook.background && hook.event.honors_background() {
162 " [bg, submitted not awaited]"
163 } else {
164 ""
165 };
166 let timeout = render_timeout(config, hook);
167 let condition = match &hook.condition {
168 None | Some(crate::hooks::HookCondition::Always) => String::new(),
169 Some(c) => format!(" if {}", condition_summary(c)),
170 };
171 let cmd_preview = preview_command(&hook.command, 60);
172 out.push_str(&format!(
173 " - {label}{bg} (timeout {timeout}){condition}\n $ {cmd_preview}\n",
174 ));
175 }
176 out.push('\n');
177 }
178
179 if !config.problems.is_empty() {
180 out.push_str("### configuration problems\n");
181 out.push_str(&render_problems(&config.problems));
182 out.push('\n');
183 }
184
185 if !config.enabled {
186 out.push_str(
187 "Hooks are globally disabled — set `[hooks].enabled = true` in `config.toml` to fire them.\n",
188 );
189 }
190
191 CommandResult::message(out.trim_end().to_string())
192 }
193
194 /// The timeout this hook will actually run with, plus where it came from.
195 ///
196 /// `[hooks].default_timeout_secs` *replaces* every hook's own `timeout_secs`
197 /// (see `HooksConfig::effective_timeout_secs`). Rendering `hook.timeout_secs`
198 /// unconditionally made `/hooks list` report a budget no hook would ever run
199 /// with — a listing that disagrees with the runtime is worse than no listing.
200 fn render_timeout(config: &crate::hooks::HooksConfig, hook: &crate::hooks::Hook) -> String {
201 let effective = config.effective_timeout_secs(hook);
202 if config.timeout_is_overridden() {
203 format!("{effective}s — `[hooks].default_timeout_secs` override")
204 } else {
205 format!("{effective}s")
206 }
207 }
208
209 /// Render load-time problems. Only the hook's own `name`, its event, and a
210 /// fixed explanation are shown — never the command line, stdin payload, or
211 /// any resolved filesystem path.
212 fn render_problems(problems: &[crate::hooks::HookConfigProblem]) -> String {
213 let mut out = String::new();
214 for problem in problems {
215 out.push_str(&format!(" - {}\n", problem.summary()));
216 }
217 out
218 }
219
220 fn event_label(event: HookEvent) -> &'static str {
221 match event {
222 HookEvent::SessionStart => "session_start",
223 HookEvent::SessionEnd => "session_end",
224 HookEvent::MessageSubmit => "message_submit",
225 HookEvent::ToolCallBefore => "tool_call_before",
226 HookEvent::ToolCallAfter => "tool_call_after",
227 HookEvent::ModeChange => "mode_change",
228 HookEvent::OnError => "on_error",
229 HookEvent::TurnEnd => "turn_end",
230 HookEvent::SubagentSpawn => "subagent_spawn",
231 HookEvent::SubagentComplete => "subagent_complete",
232 HookEvent::ShellEnv => "shell_env",
233 }
234 }
235
236 fn condition_summary(condition: &crate::hooks::HookCondition) -> String {
237 match condition {
238 crate::hooks::HookCondition::Always => "always".to_string(),
239 crate::hooks::HookCondition::ToolName { name } => {
240 format!("tool_name=`{}`", condition_value(name))
241 }
242 crate::hooks::HookCondition::ToolCategory { category } => {
243 format!("tool_category=`{}`", condition_value(category))
244 }
245 crate::hooks::HookCondition::Mode { mode } => {
246 format!("mode=`{}`", condition_value(mode))
247 }
248 crate::hooks::HookCondition::ExitCode { code } => format!("exit_code={code}"),
249 crate::hooks::HookCondition::All { conditions } => format!(
250 "all of [{}]",
251 conditions
252 .iter()
253 .map(condition_summary)
254 .collect::<Vec<_>>()
255 .join(", ")
256 ),
257 crate::hooks::HookCondition::Any { conditions } => format!(
258 "any of [{}]",
259 conditions
260 .iter()
261 .map(condition_summary)
262 .collect::<Vec<_>>()
263 .join(", ")
264 ),
265 }
266 }
267
268 /// Single-line preview of the shell command, capped at `max_chars`.
269 ///
270 /// Filtering newlines is not enough on its own: the command is operator text
271 /// and can contain escape sequences, so it goes through the shared sanitizer
272 /// before the cap.
273 fn preview_command(command: &str, max_chars: usize) -> String {
274 let single_line = crate::hooks::sanitize_hook_line(command, usize::MAX);
275 if single_line.chars().count() <= max_chars {
276 return single_line;
277 }
278 let mut out: String = single_line
279 .chars()
280 .take(max_chars.saturating_sub(1))
281 .collect();
282 out.push('…');
283 out
284 }
285
286 /// Operator-supplied strings inside a rendered condition.
287 ///
288 /// `tool_name`, `tool_category`, and `mode` come from config verbatim, so the
289 /// same bound-and-de-fang rule that governs hook names governs these.
290 fn condition_value(value: &str) -> String {
291 crate::hooks::sanitize_hook_line(value, crate::hooks::HOOK_LABEL_MAX_CHARS)
292 }
293
294 #[cfg(test)]
295 mod tests {
296 use super::*;
297 use crate::hooks::{Hook, HookCondition};
298
299 #[test]
300 fn preview_command_truncates_to_cap() {
301 let cmd = "x".repeat(200);
302 assert_eq!(preview_command(&cmd, 10).chars().count(), 10);
303 assert!(preview_command(&cmd, 10).ends_with('…'));
304 }
305
306 #[test]
307 fn preview_command_strips_newlines() {
308 assert_eq!(
309 preview_command("line one\nline two", 50),
310 "line one line two"
311 );
312 }
313
314 /// A hook command is operator text. Filtering `\n` kept the row from
315 /// splitting, but left every other control character — including the CSI
316 /// introducer — free to repaint the terminal from inside `/hooks list`.
317 #[test]
318 fn preview_command_defangs_control_characters() {
319 let preview = preview_command("echo \u{1b}[2Jhi\r\tthere\u{7}", 100);
320 assert!(!preview.contains('\u{1b}'), "{preview:?}");
321 assert!(!preview.contains('\r'), "{preview:?}");
322 assert!(!preview.contains('\u{7}'), "{preview:?}");
323 assert!(!preview.contains('\t'), "{preview:?}");
324 assert!(preview.contains("there"), "{preview:?}");
325 }
326
327 #[test]
328 fn hook_labels_are_bounded_and_defanged() {
329 let noisy = format!("\u{1b}[31mgate\n{}", "x".repeat(500));
330 let label = crate::hooks::sanitize_hook_label(Some(&noisy));
331 assert!(!label.contains('\u{1b}'), "{label}");
332 assert!(!label.contains('\n'), "{label}");
333 assert!(
334 label.chars().count() <= crate::hooks::HOOK_LABEL_MAX_CHARS + 16,
335 "{} chars",
336 label.chars().count()
337 );
338 // A name that is only whitespace, or absent, still renders something.
339 assert_eq!(crate::hooks::sanitize_hook_label(Some(" ")), "(unnamed)");
340 assert_eq!(crate::hooks::sanitize_hook_label(None), "(unnamed)");
341 }
342
343 /// Condition values are config strings too, and they were being
344 /// interpolated raw into the same row the label was being sanitized in.
345 #[test]
346 fn condition_summary_defangs_operator_supplied_values() {
347 let rendered = condition_summary(&HookCondition::ToolName {
348 name: format!("\u{1b}[2Jexec_shell{}", "y".repeat(500)),
349 });
350 assert!(!rendered.contains('\u{1b}'), "{rendered}");
351 assert!(
352 rendered.chars().count() <= crate::hooks::HOOK_LABEL_MAX_CHARS + 32,
353 "{} chars",
354 rendered.chars().count()
355 );
356 }
357
358 /// `default_timeout_secs = 0` is rejected at load, so the listing must
359 /// report the per-hook budget the runtime will actually apply and must not
360 /// credit the provenance to a setting that was ignored — while the
361 /// rejection itself still shows up in the problems section.
362 #[test]
363 fn listed_timeout_ignores_a_rejected_zero_override() {
364 let hook = Hook::new(HookEvent::SessionStart, "echo hi").with_timeout(90);
365 let zeroed = crate::hooks::HooksConfig {
366 enabled: true,
367 hooks: vec![hook.clone()],
368 default_timeout_secs: Some(0),
369 ..crate::hooks::HooksConfig::default()
370 };
371 assert_eq!(render_timeout(&zeroed, &hook), "90s");
372
373 let problems = zeroed.validate();
374 let rendered = render_problems(&problems);
375 assert!(rendered.contains("`[hooks]` setting"), "{rendered}");
376 assert!(rendered.contains("default_timeout_secs = 0"), "{rendered}");
377 }
378
379 #[test]
380 fn preview_command_keeps_short_input_intact() {
381 assert_eq!(preview_command("echo hi", 50), "echo hi");
382 }
383
384 #[test]
385 fn condition_summary_renders_all_variants() {
386 assert_eq!(condition_summary(&HookCondition::Always), "always");
387 assert_eq!(
388 condition_summary(&HookCondition::ToolName {
389 name: "exec_shell".into()
390 }),
391 "tool_name=`exec_shell`"
392 );
393 assert_eq!(
394 condition_summary(&HookCondition::ToolCategory {
395 category: "shell".into()
396 }),
397 "tool_category=`shell`"
398 );
399 assert_eq!(
400 condition_summary(&HookCondition::Mode {
401 mode: "yolo".into()
402 }),
403 "mode=`yolo`"
404 );
405 assert_eq!(
406 condition_summary(&HookCondition::ExitCode { code: 1 }),
407 "exit_code=1"
408 );
409 assert_eq!(
410 condition_summary(&HookCondition::All {
411 conditions: vec![
412 HookCondition::ToolName {
413 name: "exec_shell".into()
414 },
415 HookCondition::Mode {
416 mode: "yolo".into()
417 }
418 ]
419 }),
420 "all of [tool_name=`exec_shell`, mode=`yolo`]"
421 );
422 }
423
424 #[test]
425 fn events_subcommand_lists_every_event_variant_in_documented_order() {
426 let result = events();
427 let body = result.message.expect("non-empty body");
428 let positions: Vec<(usize, &str)> = [
429 "session_start",
430 "session_end",
431 "turn_end",
432 "message_submit",
433 "tool_call_before",
434 "tool_call_after",
435 "mode_change",
436 "on_error",
437 "subagent_spawn",
438 "subagent_complete",
439 "shell_env",
440 ]
441 .iter()
442 .map(|name| {
443 (
444 body.find(name).unwrap_or_else(|| {
445 panic!("event `{name}` missing from /hooks events output:\n{body}")
446 }),
447 *name,
448 )
449 })
450 .collect();
451 // Documented order is lifecycle → tool-call → situational.
452 // Each subsequent position must be greater than the previous.
453 for window in positions.windows(2) {
454 let (a_pos, a_name) = window[0];
455 let (b_pos, b_name) = window[1];
456 assert!(
457 a_pos < b_pos,
458 "expected `{a_name}` before `{b_name}` in events listing"
459 );
460 }
461 // Each event line includes the descriptive blurb.
462 assert!(body.contains("fires once when the TUI launches"));
463 // `tool_call_before` has been a steering hook since #3026; the listing
464 // must not keep advertising it as read-only.
465 assert!(
466 !body.contains("read-only observer"),
467 "stale read-only wording in events listing:\n{body}"
468 );
469 assert!(body.contains("can allow/deny/ask"));
470 assert!(body.contains("docs/HOOKS.md"));
471 }
472
473 #[test]
474 fn event_label_covers_every_variant() {
475 // Compile-time `match` exhaustiveness; this just sanity-checks
476 // the rendered strings stay stable.
477 assert_eq!(event_label(HookEvent::SessionStart), "session_start");
478 assert_eq!(event_label(HookEvent::SessionEnd), "session_end");
479 assert_eq!(event_label(HookEvent::ToolCallBefore), "tool_call_before");
480 assert_eq!(event_label(HookEvent::ToolCallAfter), "tool_call_after");
481 assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit");
482 assert_eq!(event_label(HookEvent::ModeChange), "mode_change");
483 assert_eq!(event_label(HookEvent::OnError), "on_error");
484 assert_eq!(event_label(HookEvent::TurnEnd), "turn_end");
485 assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn");
486 assert_eq!(
487 event_label(HookEvent::SubagentComplete),
488 "subagent_complete"
489 );
490 }
491
492 #[test]
493 fn list_renders_hooks_grouped_by_event_and_notes_disabled_state() {
494 // We test the formatter directly via a synthetic HooksConfig
495 // because `App` is heavyweight to spin up here. The actual
496 // `list(&App)` path is exercised once we hand the real
497 // config in via `app.hooks.config()`; the formatter logic is
498 // unit-tested standalone below.
499 let cfg = crate::hooks::HooksConfig {
500 enabled: false,
501 hooks: vec![
502 Hook::new(HookEvent::SessionStart, "echo started").with_name("greet"),
503 Hook::new(HookEvent::ToolCallAfter, "notify-send done")
504 .with_condition(HookCondition::ToolName {
505 name: "exec_shell".into(),
506 })
507 .with_name("notify"),
508 ],
509 ..crate::hooks::HooksConfig::default()
510 };
511
512 // Synthesize the expected sections by re-running the same
513 // formatter logic against the BTreeMap grouping.
514 let mut by_event: std::collections::BTreeMap<&str, Vec<&Hook>> =
515 std::collections::BTreeMap::new();
516 for h in &cfg.hooks {
517 by_event.entry(event_label(h.event)).or_default().push(h);
518 }
519 let events: Vec<&&str> = by_event.keys().collect();
520 // BTreeMap sorts alphabetically — `session_start` before `tool_call_after`.
521 assert_eq!(events, vec![&"session_start", &"tool_call_after"]);
522 }
523
524 #[test]
525 fn events_listing_states_scope_and_the_steering_allowlist() {
526 let body = events().message.expect("non-empty body");
527 // Scope truth: this is a TUI runtime feature.
528 assert!(body.contains("TUI runtime feature"), "{body}");
529 assert!(body.contains("codewhale exec"), "{body}");
530 // Steering allowlist, matching docs/HOOKS.md.
531 assert!(body.contains("`message_submit`, `tool_call_before`, and `shell_env`"));
532 // And the honest caveat about what observer-only does not mean.
533 assert!(body.contains("can have any external side effect"), "{body}");
534 }
535
536 #[test]
537 fn events_listing_covers_every_runtime_event() {
538 let body = events().message.expect("non-empty body");
539 for event in crate::hooks::ALL_HOOK_EVENTS {
540 assert!(
541 body.contains(event.as_str()),
542 "event `{}` missing from /hooks events",
543 event.as_str()
544 );
545 }
546 }
547
548 #[test]
549 fn listed_timeout_is_the_one_the_runtime_will_apply() {
550 let hook = Hook::new(HookEvent::SessionStart, "echo hi").with_timeout(90);
551
552 let per_hook = crate::hooks::HooksConfig {
553 enabled: true,
554 hooks: vec![hook.clone()],
555 ..crate::hooks::HooksConfig::default()
556 };
557 assert_eq!(render_timeout(&per_hook, &hook), "90s");
558
559 // With the global override set, the runtime uses 5s — the listing must
560 // say 5s, and say why.
561 let overridden = crate::hooks::HooksConfig {
562 enabled: true,
563 hooks: vec![hook.clone()],
564 default_timeout_secs: Some(5),
565 ..crate::hooks::HooksConfig::default()
566 };
567 let rendered = render_timeout(&overridden, &hook);
568 assert!(rendered.starts_with("5s"), "{rendered}");
569 assert!(!rendered.contains("90"), "{rendered}");
570 assert!(rendered.contains("default_timeout_secs"), "{rendered}");
571 }
572
573 #[test]
574 fn rendered_problems_name_the_hook_without_leaking_the_command() {
575 let problems = vec![
576 crate::hooks::HookConfigProblem {
577 name: Some("gate".to_string()),
578 event: Some(HookEvent::SessionStart),
579 detail: "condition can never match".to_string(),
580 rejected: true,
581 },
582 crate::hooks::HookConfigProblem {
583 name: None,
584 event: Some(HookEvent::ShellEnv),
585 detail: "`background = true` is not honored".to_string(),
586 rejected: false,
587 },
588 ];
589 let rendered = render_problems(&problems);
590 assert!(rendered.contains("rejected: `session_start` hook `gate`"));
591 assert!(rendered.contains("warning: `shell_env` hook `(unnamed)`"));
592 // Only the hook name, event, and fixed detail — nothing else.
593 assert!(!rendered.contains('$'), "{rendered}");
594 assert!(!rendered.contains('/'), "{rendered}");
595 }
596 }
597
597 lines RUST