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, 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    /// RFC-106 D2: Codex shares Claude Code's remote-server shape, `type`
312    /// and `${VAR}` both. Pinned per adapter rather than inferred from the
313    /// shared default, because assuming harnesses agree is what produced
314    /// debt item #1.
315    #[test]
316    fn a_remote_server_entry_declares_type_and_renders_headers() {
317        let server = tuff_core::manifest::McpServerConfig {
318            transport: tuff_core::manifest::McpTransport::Http,
319            command: None,
320            args: Vec::new(),
321            url: Some("https://mcp.example.test/mcp".to_string()),
322            env: Default::default(),
323            headers: [(
324                "Authorization".to_string(),
325                tuff_core::manifest::HeaderRef {
326                    from_env: "EXAMPLE_TOKEN".to_string(),
327                    format: Some("Bearer {}".to_string()),
328                },
329            )]
330            .into_iter()
331            .collect(),
332            metadata: None,
333        };
334
335        let entry = Codex.mcp_server_entry(&server);
336
337        assert_eq!(
338            entry,
339            serde_json::json!({
340                "type": "http",
341                "url": "https://mcp.example.test/mcp",
342                "headers": {"Authorization": "Bearer ${EXAMPLE_TOKEN}"},
343            })
344        );
345    }
346
347    #[test]
348    fn id_and_display_name_are_not_empty() {
349        assert!(!ID.is_empty());
350        assert!(!DISPLAY_NAME.is_empty());
351    }
352
353    #[test]
354    fn supported_types_covers_all_capability_types() {
355        assert_eq!(SUPPORTED_TYPES.len(), 5);
356    }
357
358    #[test]
359    fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
360        let fragment = serde_json::json!({
361            "hooks": {
362                "before_finish": [{"hooks": [{"type": "command", "command": "sh .agents/hooks/demo/run.sh"}]}]
363            }
364        });
365
366        let once = merge_hook_fragment(None, &fragment).expect("first merge");
367        let twice = merge_hook_fragment(Some(&once), &fragment).expect("second merge");
368
369        let settings: serde_json::Value = serde_json::from_slice(&twice).expect("valid json");
370        let groups = settings["hooks"]["before_finish"]
371            .as_array()
372            .expect("event array");
373        assert_eq!(
374            groups.len(),
375            1,
376            "re-adding a hook must not register it twice"
377        );
378        assert_eq!(
379            once, twice,
380            "a redundant merge must leave the file unchanged"
381        );
382    }
383}