use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::hooks::HookEvent;
use crate::localization::MessageId;
use crate::tui::app::App;
use super::CommandResult;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "hooks",
aliases: &["hook", "gouzi"],
usage: "/hooks [list|events]",
description_id: MessageId::CmdHooksDescription,
};
pub(in crate::commands) struct HooksCmd;
impl RegisterCommand for HooksCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
hooks(app, arg)
}
}
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),
"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\
Hooks are a TUI runtime feature: `codewhale exec`, the CLI dispatcher, the app-server,\n\
and the workflow tool do not fire them.\n\n",
);
let ordered = [
(HookEvent::SessionStart, "fires once when the TUI launches"),
(HookEvent::SessionEnd, "fires once on graceful shutdown"),
(
HookEvent::TurnEnd,
"fires after a turn completes (observer-only)",
),
(
HookEvent::MessageSubmit,
"fires before model dispatch; can transform or block submitted text",
),
(
HookEvent::ToolCallBefore,
"fires before each tool call; can allow/deny/ask, rewrite input, add context",
),
(
HookEvent::ToolCallAfter,
"fires after each tool call (observer-only)",
),
(
HookEvent::ModeChange,
"fires on Plan/Act/Operate transitions",
),
(
HookEvent::OnError,
"fires on transport / capacity / tool errors",
),
(
HookEvent::SubagentSpawn,
"fires when a sub-agent starts (observer-only)",
),
(
HookEvent::SubagentComplete,
"fires when a sub-agent completes, fails, or is cancelled (observer-only)",
),
(
HookEvent::ShellEnv,
"fires before each exec_shell; stdout KEY=VALUE lines are merged into its environment",
),
];
for (event, desc) in ordered {
out.push_str(&format!(" - `{}` — {desc}\n", event_label(event)));
}
out.push_str(
"\nOnly `message_submit`, `tool_call_before`, and `shell_env` can steer a turn.\n\
Observer-only means the *result* is ignored — the command still runs\n\
and can have any external side effect.\n\n\
Full contract: docs/HOOKS.md\n",
);
CommandResult::message(out.trim_end().to_string())
}
fn list(app: &App) -> CommandResult {
let config = app.hooks.config();
if config.hooks.is_empty() && config.problems.is_empty() {
return CommandResult::message(
"No hooks configured. Add a `[[hooks.hooks]]` entry to `~/.codewhale/config.toml` to define one.",
);
}
if config.hooks.is_empty() {
let mut out =
String::from("No runnable hooks. Every configured entry was rejected at load:\n\n");
out.push_str(&render_problems(&config.problems));
return CommandResult::message(out.trim_end().to_string());
}
let mut out = String::new();
out.push_str(&format!(
"{} configured hook(s) (global enabled: {}):\n\n",
config.hooks.len(),
if config.enabled {
"yes"
} else {
"no — all hooks suppressed"
}
));
let mut by_event: std::collections::BTreeMap<&str, Vec<&crate::hooks::Hook>> =
std::collections::BTreeMap::new();
for hook in &config.hooks {
by_event
.entry(event_label(hook.event))
.or_default()
.push(hook);
}
for (event, hooks) in by_event {
out.push_str(&format!("### {event}\n"));
for hook in hooks {
let label = crate::hooks::sanitize_hook_label(hook.name.as_deref());
let bg = if hook.background && hook.event.honors_background() {
" [bg, submitted not awaited]"
} else {
""
};
let timeout = render_timeout(config, hook);
let condition = match &hook.condition {
None | Some(crate::hooks::HookCondition::Always) => String::new(),
Some(c) => format!(" if {}", condition_summary(c)),
};
let cmd_preview = preview_command(&hook.command, 60);
out.push_str(&format!(
" - {label}{bg} (timeout {timeout}){condition}\n $ {cmd_preview}\n",
));
}
out.push('\n');
}
if !config.problems.is_empty() {
out.push_str("### configuration problems\n");
out.push_str(&render_problems(&config.problems));
out.push('\n');
}
if !config.enabled {
out.push_str(
"Hooks are globally disabled — set `[hooks].enabled = true` in `config.toml` to fire them.\n",
);
}
CommandResult::message(out.trim_end().to_string())
}
fn render_timeout(config: &crate::hooks::HooksConfig, hook: &crate::hooks::Hook) -> String {
let effective = config.effective_timeout_secs(hook);
if config.timeout_is_overridden() {
format!("{effective}s — `[hooks].default_timeout_secs` override")
} else {
format!("{effective}s")
}
}
fn render_problems(problems: &[crate::hooks::HookConfigProblem]) -> String {
let mut out = String::new();
for problem in problems {
out.push_str(&format!(" - {}\n", problem.summary()));
}
out
}
fn event_label(event: HookEvent) -> &'static str {
match event {
HookEvent::SessionStart => "session_start",
HookEvent::SessionEnd => "session_end",
HookEvent::MessageSubmit => "message_submit",
HookEvent::ToolCallBefore => "tool_call_before",
HookEvent::ToolCallAfter => "tool_call_after",
HookEvent::ModeChange => "mode_change",
HookEvent::OnError => "on_error",
HookEvent::TurnEnd => "turn_end",
HookEvent::SubagentSpawn => "subagent_spawn",
HookEvent::SubagentComplete => "subagent_complete",
HookEvent::ShellEnv => "shell_env",
}
}
fn condition_summary(condition: &crate::hooks::HookCondition) -> String {
match condition {
crate::hooks::HookCondition::Always => "always".to_string(),
crate::hooks::HookCondition::ToolName { name } => {
format!("tool_name=`{}`", condition_value(name))
}
crate::hooks::HookCondition::ToolCategory { category } => {
format!("tool_category=`{}`", condition_value(category))
}
crate::hooks::HookCondition::Mode { mode } => {
format!("mode=`{}`", condition_value(mode))
}
crate::hooks::HookCondition::ExitCode { code } => format!("exit_code={code}"),
crate::hooks::HookCondition::All { conditions } => format!(
"all of [{}]",
conditions
.iter()
.map(condition_summary)
.collect::<Vec<_>>()
.join(", ")
),
crate::hooks::HookCondition::Any { conditions } => format!(
"any of [{}]",
conditions
.iter()
.map(condition_summary)
.collect::<Vec<_>>()
.join(", ")
),
}
}
fn preview_command(command: &str, max_chars: usize) -> String {
let single_line = crate::hooks::sanitize_hook_line(command, usize::MAX);
if single_line.chars().count() <= max_chars {
return single_line;
}
let mut out: String = single_line
.chars()
.take(max_chars.saturating_sub(1))
.collect();
out.push('…');
out
}
fn condition_value(value: &str) -> String {
crate::hooks::sanitize_hook_line(value, crate::hooks::HOOK_LABEL_MAX_CHARS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::{Hook, HookCondition};
#[test]
fn preview_command_truncates_to_cap() {
let cmd = "x".repeat(200);
assert_eq!(preview_command(&cmd, 10).chars().count(), 10);
assert!(preview_command(&cmd, 10).ends_with('…'));
}
#[test]
fn preview_command_strips_newlines() {
assert_eq!(
preview_command("line one\nline two", 50),
"line one line two"
);
}
#[test]
fn preview_command_defangs_control_characters() {
let preview = preview_command("echo \u{1b}[2Jhi\r\tthere\u{7}", 100);
assert!(!preview.contains('\u{1b}'), "{preview:?}");
assert!(!preview.contains('\r'), "{preview:?}");
assert!(!preview.contains('\u{7}'), "{preview:?}");
assert!(!preview.contains('\t'), "{preview:?}");
assert!(preview.contains("there"), "{preview:?}");
}
#[test]
fn hook_labels_are_bounded_and_defanged() {
let noisy = format!("\u{1b}[31mgate\n{}", "x".repeat(500));
let label = crate::hooks::sanitize_hook_label(Some(&noisy));
assert!(!label.contains('\u{1b}'), "{label}");
assert!(!label.contains('\n'), "{label}");
assert!(
label.chars().count() <= crate::hooks::HOOK_LABEL_MAX_CHARS + 16,
"{} chars",
label.chars().count()
);
assert_eq!(crate::hooks::sanitize_hook_label(Some(" ")), "(unnamed)");
assert_eq!(crate::hooks::sanitize_hook_label(None), "(unnamed)");
}
#[test]
fn condition_summary_defangs_operator_supplied_values() {
let rendered = condition_summary(&HookCondition::ToolName {
name: format!("\u{1b}[2Jexec_shell{}", "y".repeat(500)),
});
assert!(!rendered.contains('\u{1b}'), "{rendered}");
assert!(
rendered.chars().count() <= crate::hooks::HOOK_LABEL_MAX_CHARS + 32,
"{} chars",
rendered.chars().count()
);
}
#[test]
fn listed_timeout_ignores_a_rejected_zero_override() {
let hook = Hook::new(HookEvent::SessionStart, "echo hi").with_timeout(90);
let zeroed = crate::hooks::HooksConfig {
enabled: true,
hooks: vec![hook.clone()],
default_timeout_secs: Some(0),
..crate::hooks::HooksConfig::default()
};
assert_eq!(render_timeout(&zeroed, &hook), "90s");
let problems = zeroed.validate();
let rendered = render_problems(&problems);
assert!(rendered.contains("`[hooks]` setting"), "{rendered}");
assert!(rendered.contains("default_timeout_secs = 0"), "{rendered}");
}
#[test]
fn preview_command_keeps_short_input_intact() {
assert_eq!(preview_command("echo hi", 50), "echo hi");
}
#[test]
fn condition_summary_renders_all_variants() {
assert_eq!(condition_summary(&HookCondition::Always), "always");
assert_eq!(
condition_summary(&HookCondition::ToolName {
name: "exec_shell".into()
}),
"tool_name=`exec_shell`"
);
assert_eq!(
condition_summary(&HookCondition::ToolCategory {
category: "shell".into()
}),
"tool_category=`shell`"
);
assert_eq!(
condition_summary(&HookCondition::Mode {
mode: "yolo".into()
}),
"mode=`yolo`"
);
assert_eq!(
condition_summary(&HookCondition::ExitCode { code: 1 }),
"exit_code=1"
);
assert_eq!(
condition_summary(&HookCondition::All {
conditions: vec![
HookCondition::ToolName {
name: "exec_shell".into()
},
HookCondition::Mode {
mode: "yolo".into()
}
]
}),
"all of [tool_name=`exec_shell`, mode=`yolo`]"
);
}
#[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",
"turn_end",
"message_submit",
"tool_call_before",
"tool_call_after",
"mode_change",
"on_error",
"subagent_spawn",
"subagent_complete",
"shell_env",
]
.iter()
.map(|name| {
(
body.find(name).unwrap_or_else(|| {
panic!("event `{name}` missing from /hooks events output:\n{body}")
}),
*name,
)
})
.collect();
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"
);
}
assert!(body.contains("fires once when the TUI launches"));
assert!(
!body.contains("read-only observer"),
"stale read-only wording in events listing:\n{body}"
);
assert!(body.contains("can allow/deny/ask"));
assert!(body.contains("docs/HOOKS.md"));
}
#[test]
fn event_label_covers_every_variant() {
assert_eq!(event_label(HookEvent::SessionStart), "session_start");
assert_eq!(event_label(HookEvent::SessionEnd), "session_end");
assert_eq!(event_label(HookEvent::ToolCallBefore), "tool_call_before");
assert_eq!(event_label(HookEvent::ToolCallAfter), "tool_call_after");
assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit");
assert_eq!(event_label(HookEvent::ModeChange), "mode_change");
assert_eq!(event_label(HookEvent::OnError), "on_error");
assert_eq!(event_label(HookEvent::TurnEnd), "turn_end");
assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn");
assert_eq!(
event_label(HookEvent::SubagentComplete),
"subagent_complete"
);
}
#[test]
fn list_renders_hooks_grouped_by_event_and_notes_disabled_state() {
let cfg = crate::hooks::HooksConfig {
enabled: false,
hooks: vec![
Hook::new(HookEvent::SessionStart, "echo started").with_name("greet"),
Hook::new(HookEvent::ToolCallAfter, "notify-send done")
.with_condition(HookCondition::ToolName {
name: "exec_shell".into(),
})
.with_name("notify"),
],
..crate::hooks::HooksConfig::default()
};
let mut by_event: std::collections::BTreeMap<&str, Vec<&Hook>> =
std::collections::BTreeMap::new();
for h in &cfg.hooks {
by_event.entry(event_label(h.event)).or_default().push(h);
}
let events: Vec<&&str> = by_event.keys().collect();
assert_eq!(events, vec![&"session_start", &"tool_call_after"]);
}
#[test]
fn events_listing_states_scope_and_the_steering_allowlist() {
let body = events().message.expect("non-empty body");
assert!(body.contains("TUI runtime feature"), "{body}");
assert!(body.contains("codewhale exec"), "{body}");
assert!(body.contains("`message_submit`, `tool_call_before`, and `shell_env`"));
assert!(body.contains("can have any external side effect"), "{body}");
}
#[test]
fn events_listing_covers_every_runtime_event() {
let body = events().message.expect("non-empty body");
for event in crate::hooks::ALL_HOOK_EVENTS {
assert!(
body.contains(event.as_str()),
"event `{}` missing from /hooks events",
event.as_str()
);
}
}
#[test]
fn listed_timeout_is_the_one_the_runtime_will_apply() {
let hook = Hook::new(HookEvent::SessionStart, "echo hi").with_timeout(90);
let per_hook = crate::hooks::HooksConfig {
enabled: true,
hooks: vec![hook.clone()],
..crate::hooks::HooksConfig::default()
};
assert_eq!(render_timeout(&per_hook, &hook), "90s");
let overridden = crate::hooks::HooksConfig {
enabled: true,
hooks: vec![hook.clone()],
default_timeout_secs: Some(5),
..crate::hooks::HooksConfig::default()
};
let rendered = render_timeout(&overridden, &hook);
assert!(rendered.starts_with("5s"), "{rendered}");
assert!(!rendered.contains("90"), "{rendered}");
assert!(rendered.contains("default_timeout_secs"), "{rendered}");
}
#[test]
fn rendered_problems_name_the_hook_without_leaking_the_command() {
let problems = vec![
crate::hooks::HookConfigProblem {
name: Some("gate".to_string()),
event: Some(HookEvent::SessionStart),
detail: "condition can never match".to_string(),
rejected: true,
},
crate::hooks::HookConfigProblem {
name: None,
event: Some(HookEvent::ShellEnv),
detail: "`background = true` is not honored".to_string(),
rejected: false,
},
];
let rendered = render_problems(&problems);
assert!(rendered.contains("rejected: `session_start` hook `gate`"));
assert!(rendered.contains("warning: `shell_env` hook `(unnamed)`"));
assert!(!rendered.contains('$'), "{rendered}");
assert!(!rendered.contains('/'), "{rendered}");
}
}