Skip to main content

vtcode_config/
hooks.rs

1use anyhow::{Context, Result, ensure};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5/// Top-level configuration for automation hooks and lifecycle events
6#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[derive(Debug, Clone, Deserialize, Serialize, Default)]
8pub struct HooksConfig {
9    /// Configuration for lifecycle-based shell command execution
10    #[serde(default)]
11    pub lifecycle: LifecycleHooksConfig,
12}
13
14/// Configuration for hooks triggered during distinct agent lifecycle events.
15/// Each event supports a list of groups with optional matchers.
16#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
17#[derive(Debug, Clone, Deserialize, Serialize, Default)]
18pub struct LifecycleHooksConfig {
19    /// Commands to run immediately when an agent session begins
20    #[serde(default)]
21    pub session_start: Vec<HookGroupConfig>,
22
23    /// Commands to run when an agent session ends
24    #[serde(default)]
25    pub session_end: Vec<HookGroupConfig>,
26
27    /// Commands to run when the user submits a prompt (pre-processing)
28    #[serde(default)]
29    pub user_prompt_submit: Vec<HookGroupConfig>,
30
31    /// Commands to run immediately before a tool is executed
32    #[serde(default)]
33    pub pre_tool_use: Vec<HookGroupConfig>,
34
35    /// Commands to run immediately after a tool returns its output
36    #[serde(default)]
37    pub post_tool_use: Vec<HookGroupConfig>,
38
39    /// Commands to run when the agent indicates task completion (pre-exit)
40    #[serde(default)]
41    pub task_completion: Vec<HookGroupConfig>,
42
43    /// Commands to run after a task is finalized and session is closed
44    #[serde(default)]
45    pub task_completed: Vec<HookGroupConfig>,
46
47    /// Commands to run when a teammate agent remains idle
48    #[serde(default)]
49    pub teammate_idle: Vec<HookGroupConfig>,
50}
51
52impl LifecycleHooksConfig {
53    pub fn is_empty(&self) -> bool {
54        self.session_start.is_empty()
55            && self.session_end.is_empty()
56            && self.user_prompt_submit.is_empty()
57            && self.pre_tool_use.is_empty()
58            && self.post_tool_use.is_empty()
59            && self.task_completion.is_empty()
60            && self.task_completed.is_empty()
61            && self.teammate_idle.is_empty()
62    }
63}
64
65/// A group of hooks sharing a common execution matcher
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[derive(Debug, Clone, Deserialize, Serialize, Default)]
68pub struct HookGroupConfig {
69    /// Optional regex matcher to filter when this group runs.
70    /// Matched against context strings (e.g. tool name, project path).
71    #[serde(default)]
72    pub matcher: Option<String>,
73
74    /// List of hook commands to execute sequentially in this group
75    #[serde(default)]
76    pub hooks: Vec<HookCommandConfig>,
77}
78
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
81#[serde(rename_all = "snake_case")]
82#[derive(Default)]
83pub enum HookCommandKind {
84    #[default]
85    Command,
86}
87
88/// Configuration for a single shell command hook
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[derive(Debug, Clone, Deserialize, Serialize, Default)]
91pub struct HookCommandConfig {
92    /// Type of hook command (currently only 'command' is supported)
93    #[serde(default)]
94    #[serde(rename = "type")]
95    pub kind: HookCommandKind,
96
97    /// The shell command string to execute
98    #[serde(default)]
99    pub command: String,
100
101    /// Optional execution timeout in seconds
102    #[serde(default)]
103    pub timeout_seconds: Option<u64>,
104}
105
106impl HooksConfig {
107    pub fn validate(&self) -> Result<()> {
108        self.lifecycle
109            .validate()
110            .context("Invalid lifecycle hooks configuration")
111    }
112}
113
114impl LifecycleHooksConfig {
115    pub fn validate(&self) -> Result<()> {
116        validate_groups(&self.session_start, "session_start")?;
117        validate_groups(&self.session_end, "session_end")?;
118        validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
119        validate_groups(&self.pre_tool_use, "pre_tool_use")?;
120        validate_groups(&self.post_tool_use, "post_tool_use")?;
121        validate_groups(&self.task_completion, "task_completion")?;
122        validate_groups(&self.task_completed, "task_completed")?;
123        validate_groups(&self.teammate_idle, "teammate_idle")?;
124        Ok(())
125    }
126}
127
128fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
129    for (index, group) in groups.iter().enumerate() {
130        if let Some(pattern) = group.matcher.as_ref() {
131            validate_matcher(pattern).with_context(|| {
132                format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher")
133            })?;
134        }
135
136        ensure!(
137            !group.hooks.is_empty(),
138            "hooks.{context_name}[{index}] must define at least one hook command"
139        );
140
141        for (hook_index, hook) in group.hooks.iter().enumerate() {
142            ensure!(
143                matches!(hook.kind, HookCommandKind::Command),
144                "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
145            );
146
147            ensure!(
148                !hook.command.trim().is_empty(),
149                "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
150            );
151
152            if let Some(timeout) = hook.timeout_seconds {
153                ensure!(
154                    timeout > 0,
155                    "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
156                );
157            }
158        }
159    }
160
161    Ok(())
162}
163
164fn validate_matcher(pattern: &str) -> Result<()> {
165    let trimmed = pattern.trim();
166    if trimmed.is_empty() || trimmed == "*" {
167        return Ok(());
168    }
169
170    let regex_pattern = format!("^(?:{})$", trimmed);
171    Regex::new(&regex_pattern)
172        .with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
173    Ok(())
174}