use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HookEvent {
SessionStart,
PreToolUse,
PostToolUse,
UserPromptSubmit,
Stop,
SubagentStop,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
pub event: HookEvent,
#[serde(default)]
pub matcher: Option<String>,
pub command: String,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
}
fn default_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookDecision {
Allow,
Warn(String),
Block(String),
}
impl HookDecision {
pub fn from_exit_code(code: i32, stderr: String) -> Self {
match code {
0 => Self::Allow,
1 => Self::Warn(stderr),
_ => Self::Block(stderr),
}
}
pub fn is_blocking(&self) -> bool {
matches!(self, Self::Block(_))
}
}
#[derive(Debug, Default)]
pub struct HookRegistry {
by_event: HashMap<HookEvent, Vec<HookConfig>>,
}
impl HookRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn from_configs(configs: impl IntoIterator<Item = HookConfig>) -> Self {
let mut reg = Self::new();
for cfg in configs {
reg.register(cfg);
}
reg
}
pub fn register(&mut self, cfg: HookConfig) {
self.by_event.entry(cfg.event).or_default().push(cfg);
}
pub fn hooks_for(&self, event: HookEvent) -> &[HookConfig] {
self.by_event.get(&event).map_or(&[], |v| v.as_slice())
}
pub fn len(&self) -> usize {
self.by_event.values().map(Vec::len).sum()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn run(&self, event: HookEvent, context: &str, cwd: &Path) -> HookDecision {
let mut warnings = Vec::new();
for cfg in self.hooks_for(event) {
if let Some(m) = cfg.matcher.as_deref() {
if !context.contains(m) {
continue;
}
}
match run_single(cfg, cwd) {
HookDecision::Allow => {}
HookDecision::Warn(msg) => warnings.push(msg),
block @ HookDecision::Block(_) => return block,
}
}
if warnings.is_empty() {
HookDecision::Allow
} else {
HookDecision::Warn(warnings.join("\n"))
}
}
}
fn run_single(cfg: &HookConfig, cwd: &Path) -> HookDecision {
let output = match Command::new("sh").arg("-c").arg(&cfg.command).current_dir(cwd).output() {
Ok(o) => o,
Err(e) => {
return HookDecision::Warn(format!("hook '{}' failed to spawn: {e}", cfg.command));
}
};
let code = output.status.code().unwrap_or(0);
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
HookDecision::from_exit_code(code, stderr)
}
#[cfg(test)]
mod tests;