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::new(".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::new(".agents/hook.json field 'hooks' must be an object"))?;
138 for (event, additions) in fragment_hooks {
139 let additions = additions
140 .as_array()
141 .ok_or_else(|| TuffError::new(format!("--hook-file hooks.{event} must be an array")))?;
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::new(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::new("--hook-file fragment must be a JSON object"))?;
222 if !obj.contains_key("hooks") || obj.keys().any(|key| key != "hooks") {
223 return Err(TuffError::new("--hook-file must be a hooks-only fragment"));
224 }
225 if !fragment["hooks"].is_object() {
226 return Err(TuffError::new(
227 "--hook-file field 'hooks' must be an object",
228 ));
229 }
230 Ok(())
231}
232
233impl AgentAdapter for Codex {
234 fn id(&self) -> &'static str {
235 ID
236 }
237
238 fn display_name(&self) -> &'static str {
239 DISPLAY_NAME
240 }
241
242 fn dir_prefix(&self) -> &'static str {
243 ".agents"
244 }
245
246 fn mcp_config_relpath(&self) -> &'static str {
247 ".agents/mcp.json"
248 }
249
250 fn supported_agents(&self) -> &[&'static str] {
251 SUPPORTED_AGENTS
252 }
253
254 fn kinds_supported(&self) -> &[CapabilityType] {
255 SUPPORTED_TYPES
256 }
257
258 fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
259 &HOOK_COMPATIBILITY
260 }
261
262 fn hook_settings_relpath(&self) -> &'static str {
263 HOOK_SETTINGS_RELPATH
264 }
265
266 fn scaffold_hook_event(&self) -> &'static str {
267 "before_finish"
268 }
269
270 fn hook_filename(&self) -> &'static str {
271 "run.sh"
272 }
273
274 fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value {
275 serde_json::json!({
276 "hooks": {
277 native_event: [{
278 "hooks": [{"type": "command", "command": command}]
279 }]
280 }
281 })
282 }
283
284 fn merge_hook_fragment(
285 &self,
286 existing: Option<&[u8]>,
287 fragment: &serde_json::Value,
288 ) -> Result<Vec<u8>> {
289 merge_hook_fragment(existing, fragment)
290 }
291
292 fn remove_hook_settings(
293 &self,
294 repo_root: &Path,
295 managed_hooks: &[lockfile::ManagedHook],
296 ) -> Result<()> {
297 remove_hook_settings(repo_root, managed_hooks)
298 }
299
300 fn detect(&self, repo_root: &Path) -> bool {
301 detect(repo_root)
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn id_and_display_name_are_not_empty() {
311 assert!(!ID.is_empty());
312 assert!(!DISPLAY_NAME.is_empty());
313 }
314
315 #[test]
316 fn supported_types_covers_all_capability_types() {
317 assert_eq!(SUPPORTED_TYPES.len(), 5);
318 }
319
320 #[test]
321 fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
322 let fragment = serde_json::json!({
323 "hooks": {
324 "before_finish": [{"hooks": [{"type": "command", "command": "sh .agents/hooks/demo/run.sh"}]}]
325 }
326 });
327
328 let once = merge_hook_fragment(None, &fragment).expect("first merge");
329 let twice = merge_hook_fragment(Some(&once), &fragment).expect("second merge");
330
331 let settings: serde_json::Value = serde_json::from_slice(&twice).expect("valid json");
332 let groups = settings["hooks"]["before_finish"]
333 .as_array()
334 .expect("event array");
335 assert_eq!(
336 groups.len(),
337 1,
338 "re-adding a hook must not register it twice"
339 );
340 assert_eq!(
341 once, twice,
342 "a redundant merge must leave the file unchanged"
343 );
344 }
345}