feat(hooks): /hooks events subcommand for discovery (#460 polish)

The shipped `/hooks list` told users WHAT was configured but
not WHAT they could configure. Without this, the only way to
learn the supported `HookEvent` values is to grep source — not
ideal when most users just want to wire up a notification on
session_end.

Adds `/hooks events` (aliases `event` / `list-events`) which
prints every `HookEvent` variant alongside a short descriptive
blurb (when it fires, current observability-vs-mutation status).
Ordered lifecycle → per-tool → situational so the listing reads
naturally and stays stable across releases.

Updates `CommandInfo::usage` to `/hooks [list|events]` so the
fuzzy autocomplete shows the new subcommand.

Tests:
  1 new test (`events_subcommand_lists_every_event_variant_in_documented_order`)
  pins the order, the per-event descriptive blurb format, and
  exhaustive variant coverage. The existing 6 hooks tests pass
  unchanged.
This commit is contained in:
Hunter Bown
2026-05-03 06:51:27 -05:00
parent 14931566b5
commit 8ed1cb4e68
3 changed files with 92 additions and 4 deletions
+5
View File
@@ -246,6 +246,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
count. Mirrored under `storage.{spillover,stash}` in the
JSON output so `deepseek doctor --json` keeps a stable
schema.
- **`/hooks events` subcommand** (#460 polish) — lists every
supported `HookEvent` value with a short blurb so users can
discover which events to target in `[[hooks.hooks]]` entries
without reading source. Ordered lifecycle → per-tool →
situational, stable across releases.
- **RLM tool family** (#512) — `rlm` tool cards map to
`ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm"
wording cleaned out of docs / comments / tests.
+86 -3
View File
@@ -13,18 +13,63 @@ use super::CommandResult;
/// Top-level dispatch for `/hooks`. Subcommands:
///
/// * `/hooks` — same as `/hooks list`.
/// * `/hooks list` — show every configured hook grouped by event,
/// * `/hooks` — same as `/hooks list`.
/// * `/hooks list` — show every configured hook grouped by event,
/// noting whether the global `[hooks].enabled` flag suppresses
/// them.
/// * `/hooks events` — list every supported `HookEvent` value the
/// user can target in `[[hooks.hooks]]` entries. Useful for
/// discovery — without this, the only way to learn the event
/// names is to read source.
pub fn hooks(app: &App, arg: Option<&str>) -> CommandResult {
let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase();
match sub.as_str() {
"" | "list" | "ls" | "show" => list(app),
other => CommandResult::error(format!("unknown subcommand `{other}`. Try `/hooks list`.")),
"events" | "event" | "list-events" => events(),
other => CommandResult::error(format!(
"unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`."
)),
}
}
fn events() -> CommandResult {
let mut out = String::new();
out.push_str(
"Available hook events (use one of these as `event = \"...\"` in your `[[hooks.hooks]]` entry):\n\n",
);
// Order matters — group lifecycle events first, then per-tool,
// then situational. Stays stable across releases so users can
// grep on it.
let ordered = [
(HookEvent::SessionStart, "fires once when the TUI launches"),
(HookEvent::SessionEnd, "fires once on graceful shutdown"),
(
HookEvent::MessageSubmit,
"fires when the user submits a turn (before model dispatch)",
),
(
HookEvent::ToolCallBefore,
"fires before each tool call (read-only observer for now)",
),
(
HookEvent::ToolCallAfter,
"fires after each tool call (read-only observer for now)",
),
(
HookEvent::ModeChange,
"fires on Plan/Agent/Yolo transitions",
),
(
HookEvent::OnError,
"fires on transport / capacity / tool errors",
),
];
for (event, desc) in ordered {
out.push_str(&format!(" - `{}` — {desc}\n", event_label(event)));
}
CommandResult::message(out.trim_end().to_string())
}
fn list(app: &App) -> CommandResult {
let config = app.hooks.config();
if config.hooks.is_empty() {
@@ -203,6 +248,44 @@ mod tests {
);
}
#[test]
fn events_subcommand_lists_every_event_variant_in_documented_order() {
let result = events();
let body = result.message.expect("non-empty body");
let positions: Vec<(usize, &str)> = [
"session_start",
"session_end",
"message_submit",
"tool_call_before",
"tool_call_after",
"mode_change",
"on_error",
]
.iter()
.map(|name| {
(
body.find(name).unwrap_or_else(|| {
panic!("event `{name}` missing from /hooks events output:\n{body}")
}),
*name,
)
})
.collect();
// Documented order is lifecycle → tool-call → situational.
// Each subsequent position must be greater than the previous.
for window in positions.windows(2) {
let (a_pos, a_name) = window[0];
let (b_pos, b_name) = window[1];
assert!(
a_pos < b_pos,
"expected `{a_name}` before `{b_name}` in events listing"
);
}
// Each event line includes the descriptive blurb.
assert!(body.contains("fires once when the TUI launches"));
assert!(body.contains("read-only observer"));
}
#[test]
fn event_label_covers_every_variant() {
// Compile-time `match` exhaustiveness; this just sanity-checks
+1 -1
View File
@@ -182,7 +182,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "hooks",
aliases: &["hook"],
usage: "/hooks [list]",
usage: "/hooks [list|events]",
description_id: MessageId::CmdHooksDescription,
},
CommandInfo {