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    /// Suppress plain stdout from successful hooks unless they emit structured fields
20    #[serde(default)]
21    pub quiet_success_output: bool,
22
23    /// Commands to run immediately when an agent session begins
24    #[serde(default)]
25    pub session_start: Vec<HookGroupConfig>,
26
27    /// Commands to run when an agent session ends
28    #[serde(default)]
29    pub session_end: Vec<HookGroupConfig>,
30
31    /// Commands to run when a delegated subagent starts
32    #[serde(default)]
33    pub subagent_start: Vec<HookGroupConfig>,
34
35    /// Commands to run when a delegated subagent stops
36    #[serde(default)]
37    pub subagent_stop: Vec<HookGroupConfig>,
38
39    /// Commands to run when the user submits a prompt (pre-processing)
40    #[serde(default)]
41    pub user_prompt_submit: Vec<HookGroupConfig>,
42
43    /// Commands to run immediately before a tool is executed
44    #[serde(default)]
45    pub pre_tool_use: Vec<HookGroupConfig>,
46
47    /// Commands to run immediately after a tool returns its output
48    #[serde(default)]
49    pub post_tool_use: Vec<HookGroupConfig>,
50
51    /// Commands to run when VT Code is about to request interactive permission approval
52    #[serde(default)]
53    pub permission_request: Vec<HookGroupConfig>,
54
55    /// Commands to run immediately before VT Code compacts conversation history
56    #[serde(default)]
57    pub pre_compact: Vec<HookGroupConfig>,
58
59    /// Commands to run after VT Code produces a final answer but before the turn completes
60    #[serde(default)]
61    pub stop: Vec<HookGroupConfig>,
62
63    /// Deprecated alias for `stop`
64    #[serde(default)]
65    pub task_completion: Vec<HookGroupConfig>,
66
67    /// Deprecated alias for `stop`
68    #[serde(default)]
69    pub task_completed: Vec<HookGroupConfig>,
70
71    /// Commands to run when VT Code emits a runtime notification event
72    #[serde(default)]
73    pub notification: Vec<HookGroupConfig>,
74}
75
76impl LifecycleHooksConfig {
77    pub fn is_empty(&self) -> bool {
78        self.session_start.is_empty()
79            && self.session_end.is_empty()
80            && self.subagent_start.is_empty()
81            && self.subagent_stop.is_empty()
82            && self.user_prompt_submit.is_empty()
83            && self.pre_tool_use.is_empty()
84            && self.post_tool_use.is_empty()
85            && self.permission_request.is_empty()
86            && self.pre_compact.is_empty()
87            && self.stop.is_empty()
88            && self.task_completion.is_empty()
89            && self.task_completed.is_empty()
90            && self.notification.is_empty()
91    }
92
93    pub fn normalized(&self) -> Self {
94        let mut normalized = self.clone();
95        normalized.stop.extend(self.task_completion.clone());
96        normalized.stop.extend(self.task_completed.clone());
97        normalized.task_completion.clear();
98        normalized.task_completed.clear();
99        normalized
100    }
101}
102
103/// A group of hooks sharing a common execution matcher
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105#[derive(Debug, Clone, Deserialize, Serialize, Default)]
106pub struct HookGroupConfig {
107    /// Optional regex matcher to filter when this group runs.
108    /// Matched against context strings (e.g. tool name, project path).
109    #[serde(default)]
110    pub matcher: Option<String>,
111
112    /// List of hook commands to execute sequentially in this group
113    #[serde(default)]
114    pub hooks: Vec<HookCommandConfig>,
115}
116
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
119#[serde(rename_all = "snake_case")]
120#[derive(Default)]
121pub enum HookCommandKind {
122    #[default]
123    Command,
124}
125
126/// Configuration for a single shell command hook
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[derive(Debug, Clone, Deserialize, Serialize, Default)]
129pub struct HookCommandConfig {
130    /// Type of hook command (currently only 'command' is supported)
131    #[serde(default)]
132    #[serde(rename = "type")]
133    pub kind: HookCommandKind,
134
135    /// The shell command string to execute
136    #[serde(default)]
137    pub command: String,
138
139    /// Optional execution timeout in seconds
140    #[serde(default)]
141    pub timeout_seconds: Option<u64>,
142}
143
144impl HooksConfig {
145    pub fn validate(&self) -> Result<()> {
146        self.lifecycle
147            .validate()
148            .context("Invalid lifecycle hooks configuration")
149    }
150}
151
152impl LifecycleHooksConfig {
153    pub fn validate(&self) -> Result<()> {
154        validate_groups(&self.session_start, "session_start")?;
155        validate_groups(&self.session_end, "session_end")?;
156        validate_groups(&self.subagent_start, "subagent_start")?;
157        validate_groups(&self.subagent_stop, "subagent_stop")?;
158        validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
159        validate_groups(&self.pre_tool_use, "pre_tool_use")?;
160        validate_groups(&self.post_tool_use, "post_tool_use")?;
161        validate_groups(&self.permission_request, "permission_request")?;
162        validate_groups(&self.pre_compact, "pre_compact")?;
163        validate_groups(&self.stop, "stop")?;
164        validate_groups(&self.task_completion, "task_completion")?;
165        validate_groups(&self.task_completed, "task_completed")?;
166        validate_groups(&self.notification, "notification")?;
167        Ok(())
168    }
169}
170
171fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
172    for (index, group) in groups.iter().enumerate() {
173        if let Some(pattern) = group.matcher.as_ref() {
174            validate_matcher(pattern).with_context(|| {
175                format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher")
176            })?;
177        }
178
179        ensure!(
180            !group.hooks.is_empty(),
181            "hooks.{context_name}[{index}] must define at least one hook command"
182        );
183
184        for (hook_index, hook) in group.hooks.iter().enumerate() {
185            ensure!(
186                matches!(hook.kind, HookCommandKind::Command),
187                "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
188            );
189
190            ensure!(
191                !hook.command.trim().is_empty(),
192                "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
193            );
194
195            if let Some(timeout) = hook.timeout_seconds {
196                ensure!(
197                    timeout > 0,
198                    "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
199                );
200            }
201        }
202    }
203
204    Ok(())
205}
206
207fn validate_matcher(pattern: &str) -> Result<()> {
208    let trimmed = pattern.trim();
209    if trimmed.is_empty() || trimmed == "*" {
210        return Ok(());
211    }
212
213    let regex_pattern = format!("^(?:{})$", trimmed);
214    Regex::new(&regex_pattern)
215        .with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
216    Ok(())
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn sample_group() -> HookGroupConfig {
224        HookGroupConfig {
225            matcher: None,
226            hooks: vec![HookCommandConfig {
227                kind: HookCommandKind::Command,
228                command: "echo ok".to_string(),
229                timeout_seconds: None,
230            }],
231        }
232    }
233
234    #[test]
235    fn permission_request_and_stop_validate() {
236        let config = LifecycleHooksConfig {
237            permission_request: vec![sample_group()],
238            stop: vec![sample_group()],
239            ..Default::default()
240        };
241
242        config.validate().expect("hooks validate");
243    }
244
245    #[test]
246    fn task_aliases_normalize_into_stop() {
247        let config = LifecycleHooksConfig {
248            task_completion: vec![sample_group()],
249            task_completed: vec![sample_group()],
250            ..Default::default()
251        };
252
253        let normalized = config.normalized();
254        assert_eq!(normalized.stop.len(), 2);
255        assert!(normalized.task_completion.is_empty());
256        assert!(normalized.task_completed.is_empty());
257    }
258}