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