Skip to main content

codex_config/
hook_config.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3use std::path::PathBuf;
4
5use codex_protocol::protocol::HookEventName;
6use schemars::JsonSchema;
7use serde::Deserialize;
8use serde::Serialize;
9
10#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11#[serde(deny_unknown_fields)]
12pub struct HooksFile {
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub description: Option<String>,
15    #[serde(default)]
16    pub hooks: HookEventsToml,
17}
18
19#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20pub struct HooksToml {
21    #[serde(flatten)]
22    pub events: HookEventsToml,
23    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
24    pub state: BTreeMap<String, HookStateToml>,
25}
26
27#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
28pub struct HookStateToml {
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub enabled: Option<bool>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub trusted_hash: Option<String>,
33}
34
35#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36pub struct HookEventsToml {
37    #[serde(rename = "PreToolUse", default)]
38    pub pre_tool_use: Vec<MatcherGroup>,
39    #[serde(rename = "PermissionRequest", default)]
40    pub permission_request: Vec<MatcherGroup>,
41    #[serde(rename = "PostToolUse", default)]
42    pub post_tool_use: Vec<MatcherGroup>,
43    #[serde(rename = "PreCompact", default)]
44    pub pre_compact: Vec<MatcherGroup>,
45    #[serde(rename = "PostCompact", default)]
46    pub post_compact: Vec<MatcherGroup>,
47    #[serde(rename = "SessionStart", default)]
48    pub session_start: Vec<MatcherGroup>,
49    #[serde(rename = "SessionEnd", default)]
50    pub session_end: Vec<MatcherGroup>,
51    #[serde(rename = "UserPromptSubmit", default)]
52    pub user_prompt_submit: Vec<MatcherGroup>,
53    #[serde(rename = "SubagentStart", default)]
54    pub subagent_start: Vec<MatcherGroup>,
55    #[serde(rename = "SubagentStop", default)]
56    pub subagent_stop: Vec<MatcherGroup>,
57    #[serde(rename = "Stop", default)]
58    pub stop: Vec<MatcherGroup>,
59}
60
61impl HookEventsToml {
62    pub fn is_empty(&self) -> bool {
63        let Self {
64            pre_tool_use,
65            permission_request,
66            post_tool_use,
67            pre_compact,
68            post_compact,
69            session_start,
70            session_end,
71            user_prompt_submit,
72            subagent_start,
73            subagent_stop,
74            stop,
75        } = self;
76        pre_tool_use.is_empty()
77            && permission_request.is_empty()
78            && post_tool_use.is_empty()
79            && pre_compact.is_empty()
80            && post_compact.is_empty()
81            && session_start.is_empty()
82            && session_end.is_empty()
83            && user_prompt_submit.is_empty()
84            && subagent_start.is_empty()
85            && subagent_stop.is_empty()
86            && stop.is_empty()
87    }
88
89    pub fn handler_count(&self) -> usize {
90        let Self {
91            pre_tool_use,
92            permission_request,
93            post_tool_use,
94            pre_compact,
95            post_compact,
96            session_start,
97            session_end,
98            user_prompt_submit,
99            subagent_start,
100            subagent_stop,
101            stop,
102        } = self;
103        [
104            pre_tool_use,
105            permission_request,
106            post_tool_use,
107            pre_compact,
108            post_compact,
109            session_start,
110            session_end,
111            user_prompt_submit,
112            subagent_start,
113            subagent_stop,
114            stop,
115        ]
116        .into_iter()
117        .flatten()
118        .map(|group| group.hooks.len())
119        .sum()
120    }
121
122    pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 11] {
123        [
124            (HookEventName::PreToolUse, self.pre_tool_use),
125            (HookEventName::PermissionRequest, self.permission_request),
126            (HookEventName::PostToolUse, self.post_tool_use),
127            (HookEventName::PreCompact, self.pre_compact),
128            (HookEventName::PostCompact, self.post_compact),
129            (HookEventName::SessionStart, self.session_start),
130            (HookEventName::SessionEnd, self.session_end),
131            (HookEventName::UserPromptSubmit, self.user_prompt_submit),
132            (HookEventName::SubagentStart, self.subagent_start),
133            (HookEventName::SubagentStop, self.subagent_stop),
134            (HookEventName::Stop, self.stop),
135        ]
136    }
137}
138
139#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
140pub struct MatcherGroup {
141    #[serde(default)]
142    pub matcher: Option<String>,
143    #[serde(default)]
144    pub hooks: Vec<HookHandlerConfig>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
148#[serde(tag = "type")]
149pub enum HookHandlerConfig {
150    #[serde(rename = "command")]
151    Command {
152        command: String,
153        #[serde(default, rename = "commandWindows", alias = "command_windows")]
154        command_windows: Option<String>,
155        #[serde(default, rename = "timeout")]
156        timeout_sec: Option<u64>,
157        #[serde(default)]
158        r#async: bool,
159        #[serde(default, rename = "statusMessage")]
160        status_message: Option<String>,
161        /// Approximate token threshold for spilling this hook's `additionalContext` to disk.
162        /// Unset uses 2,500 tokens; `0` disables spilling for this hook. The threshold is
163        /// evaluated against the original context; a spilled preview also includes recovery
164        /// metadata.
165        #[serde(
166            default,
167            rename = "additionalContextLimit",
168            skip_serializing_if = "Option::is_none"
169        )]
170        additional_context_limit: Option<usize>,
171    },
172    #[serde(rename = "prompt")]
173    Prompt {},
174    #[serde(rename = "agent")]
175    Agent {},
176}
177
178#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ManagedHooksRequirementsToml {
180    pub managed_dir: Option<PathBuf>,
181    pub windows_managed_dir: Option<PathBuf>,
182    #[serde(flatten)]
183    pub hooks: HookEventsToml,
184}
185
186impl ManagedHooksRequirementsToml {
187    pub fn is_empty(&self) -> bool {
188        let Self {
189            managed_dir,
190            windows_managed_dir,
191            hooks,
192        } = self;
193        managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty()
194    }
195
196    pub fn handler_count(&self) -> usize {
197        self.hooks.handler_count()
198    }
199
200    pub fn managed_dir_for_current_platform(&self) -> Option<&Path> {
201        #[cfg(windows)]
202        {
203            self.windows_managed_dir.as_deref()
204        }
205
206        #[cfg(not(windows))]
207        {
208            self.managed_dir.as_deref()
209        }
210    }
211}
212
213#[cfg(test)]
214#[path = "hooks_tests.rs"]
215mod tests;