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