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