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, HookSettingsShape};
8use tuff_core::manifest::CapabilityType;
9
10pub const ID: &str = "codex";
11pub const DISPLAY_NAME: &str = "Codex";
12pub const SUPPORTED_TYPES: &[CapabilityType] = &[
13    CapabilityType::Skill,
14    CapabilityType::Tool,
15    CapabilityType::Hook,
16    CapabilityType::Workflow,
17    CapabilityType::McpServer,
18];
19
20pub const SUPPORTED_AGENTS: &[&str] = &["Codex"];
21
22pub const HOOK_SETTINGS_RELPATH: &str = ".agents/hook.json";
23
24pub struct Codex;
25
26pub const HOOK_COMPATIBILITY: CompatibilityMatrix = CompatibilityMatrix {
27    spec_version: SPEC_VERSION,
28    adapter: ID,
29    events: &[
30        CompatibilityEntry {
31            event: HookEvent::BeforeFinish,
32            native_event: Some("before_finish"),
33            aliases: &[],
34            coverage: CoverageLevel::Full,
35            scope: &[],
36            caveat: None,
37            source: None,
38            since_harness_version: None,
39            until_harness_version: None,
40        },
41        CompatibilityEntry {
42            event: HookEvent::AfterSave,
43            native_event: Some("after_save"),
44            aliases: &[],
45            coverage: CoverageLevel::Full,
46            scope: &[],
47            caveat: None,
48            source: None,
49            since_harness_version: None,
50            until_harness_version: None,
51        },
52        CompatibilityEntry {
53            event: HookEvent::PreToolUse,
54            native_event: Some("pre_tool_execution"),
55            aliases: &["pre_tool_execution"],
56            coverage: CoverageLevel::Partial,
57            scope: &["local function tools", "Bash", "Edit", "Write", "MCP"],
58            caveat: Some("Codex hosted tools do not use the local function-tool hook path."),
59            source: Some("https://learn.chatgpt.com/docs/hooks.md"),
60            since_harness_version: None,
61            until_harness_version: None,
62        },
63        CompatibilityEntry {
64            event: HookEvent::PostToolUse,
65            native_event: Some("post_tool_execution"),
66            aliases: &["post_tool_execution"],
67            coverage: CoverageLevel::Partial,
68            scope: &["local function tools", "Bash", "Edit", "Write", "MCP"],
69            caveat: Some("Codex hosted tools do not use the local function-tool hook path."),
70            source: Some("https://learn.chatgpt.com/docs/hooks.md"),
71            since_harness_version: None,
72            until_harness_version: None,
73        },
74        CompatibilityEntry {
75            event: HookEvent::SessionStart,
76            native_event: None,
77            aliases: &[],
78            coverage: CoverageLevel::Unsupported,
79            scope: &[],
80            caveat: Some("Codex hook.json does not currently define a session-start event."),
81            source: None,
82            since_harness_version: None,
83            until_harness_version: None,
84        },
85        CompatibilityEntry {
86            event: HookEvent::SessionEnd,
87            native_event: None,
88            aliases: &[],
89            coverage: CoverageLevel::Unsupported,
90            scope: &[],
91            caveat: Some("Codex hook.json does not currently define a session-end event."),
92            source: None,
93            since_harness_version: None,
94            until_harness_version: None,
95        },
96        CompatibilityEntry {
97            event: HookEvent::Stop,
98            native_event: None,
99            aliases: &[],
100            coverage: CoverageLevel::Unsupported,
101            scope: &[],
102            caveat: Some("Codex hook.json does not currently define a stop event."),
103            source: None,
104            since_harness_version: None,
105            until_harness_version: None,
106        },
107    ],
108};
109
110impl AgentAdapter for Codex {
111    fn id(&self) -> &'static str {
112        ID
113    }
114
115    fn display_name(&self) -> &'static str {
116        DISPLAY_NAME
117    }
118
119    fn dir_prefix(&self) -> &'static str {
120        ".agents"
121    }
122
123    fn mcp_config_relpath(&self) -> &'static str {
124        ".agents/mcp.json"
125    }
126
127    fn supported_agents(&self) -> &[&'static str] {
128        SUPPORTED_AGENTS
129    }
130
131    fn kinds_supported(&self) -> &[CapabilityType] {
132        SUPPORTED_TYPES
133    }
134
135    fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
136        &HOOK_COMPATIBILITY
137    }
138
139    fn hook_settings_relpath(&self) -> &'static str {
140        HOOK_SETTINGS_RELPATH
141    }
142
143    fn scaffold_hook_event(&self) -> &'static str {
144        "before_finish"
145    }
146
147    fn hook_settings_shape(&self) -> HookSettingsShape {
148        HookSettingsShape::Grouped
149    }
150
151    fn detect(&self, repo_root: &Path) -> bool {
152        repo_root.join(".agents").exists() || repo_root.join("AGENTS.md").exists()
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    /// RFC-106 D2: Codex shares Claude Code's remote-server shape, `type`
161    /// and `${VAR}` both. Pinned per adapter rather than inferred from the
162    /// shared default, because assuming harnesses agree is what produced
163    /// debt item #1.
164    #[test]
165    fn a_remote_server_entry_declares_type_and_renders_headers() {
166        let server = tuff_core::manifest::McpServerConfig {
167            transport: tuff_core::manifest::McpTransport::Http,
168            command: None,
169            args: Vec::new(),
170            url: Some("https://mcp.example.test/mcp".to_string()),
171            env: Default::default(),
172            headers: [(
173                "Authorization".to_string(),
174                tuff_core::manifest::HeaderRef {
175                    from_env: "EXAMPLE_TOKEN".to_string(),
176                    format: Some("Bearer {}".to_string()),
177                },
178            )]
179            .into_iter()
180            .collect(),
181            metadata: None,
182        };
183
184        let entry = Codex.mcp_server_entry(&server);
185
186        assert_eq!(
187            entry,
188            serde_json::json!({
189                "type": "http",
190                "url": "https://mcp.example.test/mcp",
191                "headers": {"Authorization": "Bearer ${EXAMPLE_TOKEN}"},
192            })
193        );
194    }
195
196    #[test]
197    fn id_and_display_name_are_not_empty() {
198        assert!(!ID.is_empty());
199        assert!(!DISPLAY_NAME.is_empty());
200    }
201
202    #[test]
203    fn supported_types_covers_all_capability_types() {
204        assert_eq!(SUPPORTED_TYPES.len(), 5);
205    }
206
207    #[test]
208    fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
209        let fragment = serde_json::json!({
210            "hooks": {
211                "before_finish": [{"hooks": [{"type": "command", "command": "sh .agents/hooks/demo/run.sh"}]}]
212            }
213        });
214
215        let once = Codex
216            .merge_hook_fragment(None, &fragment)
217            .expect("first merge");
218        let twice = Codex
219            .merge_hook_fragment(Some(&once), &fragment)
220            .expect("second merge");
221
222        let settings: serde_json::Value = serde_json::from_slice(&twice).expect("valid json");
223        let groups = settings["hooks"]["before_finish"]
224            .as_array()
225            .expect("event array");
226        assert_eq!(
227            groups.len(),
228            1,
229            "re-adding a hook must not register it twice"
230        );
231        assert_eq!(
232            once, twice,
233            "a redundant merge must leave the file unchanged"
234        );
235    }
236}