1use std::path::Path;
2
3use tuff_hooks_spec::{
4 CompatibilityEntry, CompatibilityMatrix, CoverageLevel, HookEvent, SPEC_VERSION,
5};
6
7use tuff_core::adapter::{AgentAdapter, extend_hook_groups};
8use tuff_core::manifest::CapabilityType;
9use tuff_core::{
10 error::{Result, TuffError},
11 lockfile,
12};
13
14pub const ID: &str = "codex";
15pub const DISPLAY_NAME: &str = "Codex";
16pub const SUPPORTED_TYPES: &[CapabilityType] = &[
17 CapabilityType::Skill,
18 CapabilityType::Tool,
19 CapabilityType::Hook,
20 CapabilityType::Workflow,
21 CapabilityType::McpServer,
22];
23
24pub const SUPPORTED_AGENTS: &[&str] = &["Codex"];
25
26pub const HOOK_SETTINGS_RELPATH: &str = ".agents/hook.json";
27
28pub struct Codex;
29
30pub const HOOK_COMPATIBILITY: CompatibilityMatrix = CompatibilityMatrix {
31 spec_version: SPEC_VERSION,
32 adapter: ID,
33 events: &[
34 CompatibilityEntry {
35 event: HookEvent::BeforeFinish,
36 native_event: Some("before_finish"),
37 aliases: &[],
38 coverage: CoverageLevel::Full,
39 scope: &[],
40 caveat: None,
41 source: None,
42 since_harness_version: None,
43 until_harness_version: None,
44 },
45 CompatibilityEntry {
46 event: HookEvent::AfterSave,
47 native_event: Some("after_save"),
48 aliases: &[],
49 coverage: CoverageLevel::Full,
50 scope: &[],
51 caveat: None,
52 source: None,
53 since_harness_version: None,
54 until_harness_version: None,
55 },
56 CompatibilityEntry {
57 event: HookEvent::PreToolUse,
58 native_event: Some("pre_tool_execution"),
59 aliases: &["pre_tool_execution"],
60 coverage: CoverageLevel::Partial,
61 scope: &["local function tools", "Bash", "Edit", "Write", "MCP"],
62 caveat: Some("Codex hosted tools do not use the local function-tool hook path."),
63 source: Some("https://learn.chatgpt.com/docs/hooks.md"),
64 since_harness_version: None,
65 until_harness_version: None,
66 },
67 CompatibilityEntry {
68 event: HookEvent::PostToolUse,
69 native_event: Some("post_tool_execution"),
70 aliases: &["post_tool_execution"],
71 coverage: CoverageLevel::Partial,
72 scope: &["local function tools", "Bash", "Edit", "Write", "MCP"],
73 caveat: Some("Codex hosted tools do not use the local function-tool hook path."),
74 source: Some("https://learn.chatgpt.com/docs/hooks.md"),
75 since_harness_version: None,
76 until_harness_version: None,
77 },
78 CompatibilityEntry {
79 event: HookEvent::SessionStart,
80 native_event: None,
81 aliases: &[],
82 coverage: CoverageLevel::Unsupported,
83 scope: &[],
84 caveat: Some("Codex hook.json does not currently define a session-start event."),
85 source: None,
86 since_harness_version: None,
87 until_harness_version: None,
88 },
89 CompatibilityEntry {
90 event: HookEvent::SessionEnd,
91 native_event: None,
92 aliases: &[],
93 coverage: CoverageLevel::Unsupported,
94 scope: &[],
95 caveat: Some("Codex hook.json does not currently define a session-end event."),
96 source: None,
97 since_harness_version: None,
98 until_harness_version: None,
99 },
100 CompatibilityEntry {
101 event: HookEvent::Stop,
102 native_event: None,
103 aliases: &[],
104 coverage: CoverageLevel::Unsupported,
105 scope: &[],
106 caveat: Some("Codex hook.json does not currently define a stop event."),
107 source: None,
108 since_harness_version: None,
109 until_harness_version: None,
110 },
111 ],
112};
113
114pub fn detect(repo_root: &Path) -> bool {
115 repo_root.join(".agents").exists() || repo_root.join("AGENTS.md").exists()
116}
117
118pub fn merge_hook_fragment(
119 existing: Option<&[u8]>,
120 fragment: &serde_json::Value,
121) -> Result<Vec<u8>> {
122 validate_hook_fragment(fragment)?;
123 let mut settings = match existing {
124 Some(bytes) if !bytes.is_empty() => serde_json::from_slice(bytes)?,
125 _ => serde_json::json!({}),
126 };
127 let settings_obj = settings
128 .as_object_mut()
129 .ok_or_else(|| TuffError::corrupt(".agents/hook.json must be a JSON object"))?;
130 let fragment_hooks = fragment["hooks"]
131 .as_object()
132 .expect("validated hooks object");
133 let settings_hooks = settings_obj
134 .entry("hooks")
135 .or_insert_with(|| serde_json::json!({}))
136 .as_object_mut()
137 .ok_or_else(|| TuffError::corrupt(".agents/hook.json field 'hooks' must be an object"))?;
138 for (event, additions) in fragment_hooks {
139 let additions = additions.as_array().ok_or_else(|| {
140 TuffError::usage(format!("--hook-file hooks.{event} must be an array"))
141 })?;
142 let groups = settings_hooks
143 .entry(event.clone())
144 .or_insert_with(|| serde_json::json!([]))
145 .as_array_mut()
146 .ok_or_else(|| {
147 TuffError::corrupt(format!(".agents/hook.json hooks.{event} must be an array"))
148 })?;
149 extend_hook_groups(groups, additions);
150 }
151 Ok(serde_json::to_string_pretty(&settings)?.into_bytes())
152}
153
154pub fn remove_hook_settings(
155 repo_root: &Path,
156 managed_hooks: &[lockfile::ManagedHook],
157) -> Result<()> {
158 if managed_hooks.is_empty() {
159 return Ok(());
160 }
161 let settings_path = repo_root.join(HOOK_SETTINGS_RELPATH);
162 if !settings_path.is_file() {
163 return Ok(());
164 }
165 let mut settings: serde_json::Value =
166 serde_json::from_str(&std::fs::read_to_string(&settings_path)?)?;
167 let Some(hooks) = settings
168 .get_mut("hooks")
169 .and_then(|hooks| hooks.as_object_mut())
170 else {
171 return Ok(());
172 };
173 let mut empty_events = Vec::new();
174 for (event, groups) in hooks.iter_mut() {
175 if let Some(groups) = groups.as_array_mut() {
176 let registrations: Vec<&lockfile::ManagedHook> = managed_hooks
177 .iter()
178 .filter(|hook| hook.settings_path == HOOK_SETTINGS_RELPATH && hook.event == *event)
179 .collect();
180 for group in groups.iter_mut() {
181 if let Some(entries) = group
182 .get_mut("hooks")
183 .and_then(|value| value.as_array_mut())
184 {
185 entries.retain(|entry| {
186 !registrations.iter().any(|hook| {
187 entry.get("command").and_then(serde_json::Value::as_str)
188 == Some(hook.command.as_str())
189 })
190 });
191 }
192 }
193 groups.retain(|group| {
194 group
195 .get("hooks")
196 .and_then(|value| value.as_array())
197 .is_none_or(|entries| !entries.is_empty())
198 });
199 if groups.is_empty() {
200 empty_events.push(event.clone());
201 }
202 }
203 }
204 for event in empty_events {
205 hooks.remove(&event);
206 }
207 std::fs::write(
208 &settings_path,
209 serde_json::to_string_pretty(&settings)? + "\n",
210 )?;
211 eprintln!(
212 "updated Codex hook settings -> {}",
213 lockfile::relative_or_absolute_fs(&settings_path, repo_root)
214 );
215 Ok(())
216}
217
218fn validate_hook_fragment(fragment: &serde_json::Value) -> Result<()> {
219 let obj = fragment
220 .as_object()
221 .ok_or_else(|| TuffError::usage("--hook-file fragment must be a JSON object"))?;
222 if !obj.contains_key("hooks") || obj.keys().any(|key| key != "hooks") {
223 return Err(TuffError::usage(
224 "--hook-file must be a hooks-only fragment",
225 ));
226 }
227 if !fragment["hooks"].is_object() {
228 return Err(TuffError::usage(
229 "--hook-file field 'hooks' must be an object",
230 ));
231 }
232 Ok(())
233}
234
235impl AgentAdapter for Codex {
236 fn id(&self) -> &'static str {
237 ID
238 }
239
240 fn display_name(&self) -> &'static str {
241 DISPLAY_NAME
242 }
243
244 fn dir_prefix(&self) -> &'static str {
245 ".agents"
246 }
247
248 fn mcp_config_relpath(&self) -> &'static str {
249 ".agents/mcp.json"
250 }
251
252 fn supported_agents(&self) -> &[&'static str] {
253 SUPPORTED_AGENTS
254 }
255
256 fn kinds_supported(&self) -> &[CapabilityType] {
257 SUPPORTED_TYPES
258 }
259
260 fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
261 &HOOK_COMPATIBILITY
262 }
263
264 fn hook_settings_relpath(&self) -> &'static str {
265 HOOK_SETTINGS_RELPATH
266 }
267
268 fn scaffold_hook_event(&self) -> &'static str {
269 "before_finish"
270 }
271
272 fn hook_filename(&self) -> &'static str {
273 "run.sh"
274 }
275
276 fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value {
277 serde_json::json!({
278 "hooks": {
279 native_event: [{
280 "hooks": [{"type": "command", "command": command}]
281 }]
282 }
283 })
284 }
285
286 fn merge_hook_fragment(
287 &self,
288 existing: Option<&[u8]>,
289 fragment: &serde_json::Value,
290 ) -> Result<Vec<u8>> {
291 merge_hook_fragment(existing, fragment)
292 }
293
294 fn remove_hook_settings(
295 &self,
296 repo_root: &Path,
297 managed_hooks: &[lockfile::ManagedHook],
298 ) -> Result<()> {
299 remove_hook_settings(repo_root, managed_hooks)
300 }
301
302 fn detect(&self, repo_root: &Path) -> bool {
303 detect(repo_root)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn id_and_display_name_are_not_empty() {
313 assert!(!ID.is_empty());
314 assert!(!DISPLAY_NAME.is_empty());
315 }
316
317 #[test]
318 fn supported_types_covers_all_capability_types() {
319 assert_eq!(SUPPORTED_TYPES.len(), 5);
320 }
321
322 #[test]
323 fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
324 let fragment = serde_json::json!({
325 "hooks": {
326 "before_finish": [{"hooks": [{"type": "command", "command": "sh .agents/hooks/demo/run.sh"}]}]
327 }
328 });
329
330 let once = merge_hook_fragment(None, &fragment).expect("first merge");
331 let twice = merge_hook_fragment(Some(&once), &fragment).expect("second merge");
332
333 let settings: serde_json::Value = serde_json::from_slice(&twice).expect("valid json");
334 let groups = settings["hooks"]["before_finish"]
335 .as_array()
336 .expect("event array");
337 assert_eq!(
338 groups.len(),
339 1,
340 "re-adding a hook must not register it twice"
341 );
342 assert_eq!(
343 once, twice,
344 "a redundant merge must leave the file unchanged"
345 );
346 }
347}