Skip to main content

tuff_adapter_codex/
lib.rs

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}