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