1use super::{ConfigFormat, FrameworkAdapter, ParsedHookInput, common};
25use crate::model::EventType;
26use std::path::PathBuf;
27
28pub struct CodexAdapter;
32
33impl FrameworkAdapter for CodexAdapter {
34 fn name(&self) -> &'static str {
35 "codex"
36 }
37
38 fn display_name(&self) -> &'static str {
39 "Codex CLI"
40 }
41
42 fn project_config_path(&self) -> PathBuf {
43 PathBuf::from(".codex/config.toml")
44 }
45
46 fn user_config_path(&self) -> Option<PathBuf> {
47 dirs::home_dir().map(|h| h.join(".codex/config.toml"))
48 }
49
50 fn config_format(&self) -> ConfigFormat {
51 ConfigFormat::Toml
52 }
53
54 fn generate_hooks_config(
55 &self,
56 _enabled_events: &[EventType],
57 mi6_bin: &str,
58 otel_enabled: bool,
59 otel_port: u16,
60 ) -> serde_json::Value {
61 let notify_command = vec![
70 mi6_bin.to_string(),
71 "ingest".to_string(),
72 "event".to_string(),
73 "--framework".to_string(),
74 "codex".to_string(),
75 ];
76
77 let mut config = serde_json::json!({
78 "notify": notify_command
79 });
80
81 if otel_enabled {
84 config["otel"] = serde_json::json!({
85 "exporter": {
86 "otlp-http": {
87 "endpoint": format!("http://127.0.0.1:{}", otel_port),
88 "protocol": "json"
89 }
90 }
91 });
92 }
93
94 config
95 }
96
97 fn merge_config(
98 &self,
99 generated: serde_json::Value,
100 existing: Option<serde_json::Value>,
101 ) -> serde_json::Value {
102 let mut settings = existing.unwrap_or_else(|| serde_json::json!({}));
103
104 if let Some(notify) = generated.get("notify") {
106 settings["notify"] = notify.clone();
107 }
108
109 if let Some(new_otel) = generated.get("otel") {
111 if let Some(existing_otel) = settings.get_mut("otel") {
112 if let (Some(existing_obj), Some(new_obj)) =
113 (existing_otel.as_object_mut(), new_otel.as_object())
114 {
115 for (key, value) in new_obj {
116 existing_obj.insert(key.clone(), value.clone());
117 }
118 }
119 } else {
120 settings["otel"] = new_otel.clone();
121 }
122 }
123
124 settings
125 }
126
127 fn parse_hook_input(&self, _event_type: &str, json: &serde_json::Value) -> ParsedHookInput {
128 ParsedHookInput {
137 session_id: json
139 .get("thread-id")
140 .and_then(|v| v.as_str())
141 .map(String::from),
142 tool_use_id: json
144 .get("turn-id")
145 .and_then(|v| v.as_str())
146 .map(String::from),
147 cwd: json.get("cwd").and_then(|v| v.as_str()).map(String::from),
148 tool_name: None,
150 subagent_type: None,
151 spawned_agent_id: None,
152 permission_mode: None,
153 transcript_path: None,
154 session_source: None,
155 agent_id: None,
156 agent_transcript_path: None,
157 compact_trigger: None,
158 }
159 }
160
161 fn map_event_type(&self, framework_event: &str) -> EventType {
162 match framework_event {
163 "approval-requested" => EventType::PermissionRequest,
165 "agent-turn-complete" => EventType::Custom("TurnComplete".to_string()),
168 other => other
170 .parse()
171 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
172 }
173 }
174
175 fn supported_events(&self) -> Vec<&'static str> {
176 vec!["agent-turn-complete", "approval-requested"]
178 }
179
180 fn framework_specific_events(&self) -> Vec<&'static str> {
181 vec!["agent-turn-complete"]
183 }
184
185 fn detection_env_vars(&self) -> &[&'static str] {
186 &["CODEX_SESSION_ID", "CODEX_PROJECT_DIR", "CODEX_THREAD_ID"]
191 }
192
193 fn is_installed(&self) -> bool {
194 common::is_framework_installed(self.user_config_path(), "codex")
195 }
196
197 fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
198 let mut settings = existing;
199 let mut modified = false;
200
201 if let Some(notify) = settings.get("notify")
203 && let Some(arr) = notify.as_array()
204 {
205 let has_mi6_hook = arr
207 .iter()
208 .any(|v| v.as_str().is_some_and(|s| s.contains("mi6")));
209 if has_mi6_hook && let Some(obj) = settings.as_object_mut() {
210 obj.remove("notify");
211 modified = true;
212 }
213 }
214
215 if let Some(obj) = settings.as_object_mut()
217 && obj.remove("otel").is_some()
218 {
219 modified = true;
220 }
221
222 if modified { Some(settings) } else { None }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn test_name() {
232 let adapter = CodexAdapter;
233 assert_eq!(adapter.name(), "codex");
234 assert_eq!(adapter.display_name(), "Codex CLI");
235 }
236
237 #[test]
238 fn test_config_format() {
239 let adapter = CodexAdapter;
240 assert_eq!(adapter.config_format(), ConfigFormat::Toml);
241 }
242
243 #[test]
244 fn test_project_config_path() {
245 let adapter = CodexAdapter;
246 assert_eq!(
247 adapter.project_config_path(),
248 PathBuf::from(".codex/config.toml")
249 );
250 }
251
252 #[test]
253 fn test_map_event_type() {
254 let adapter = CodexAdapter;
255
256 assert_eq!(
258 adapter.map_event_type("approval-requested"),
259 EventType::PermissionRequest
260 );
261 assert_eq!(
262 adapter.map_event_type("agent-turn-complete"),
263 EventType::Custom("TurnComplete".to_string())
264 );
265
266 assert_eq!(
268 adapter.map_event_type("unknown-event"),
269 EventType::Custom("unknown-event".to_string())
270 );
271 }
272
273 #[test]
274 fn test_parse_hook_input() {
275 let adapter = CodexAdapter;
276 let input = serde_json::json!({
277 "type": "agent-turn-complete",
278 "thread-id": "codex-thread-123",
279 "turn-id": "turn-456",
280 "cwd": "/projects/test"
281 });
282
283 let parsed = adapter.parse_hook_input("agent-turn-complete", &input);
284
285 assert_eq!(parsed.session_id, Some("codex-thread-123".to_string()));
286 assert_eq!(parsed.tool_use_id, Some("turn-456".to_string()));
287 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
288 assert_eq!(parsed.tool_name, None);
290 assert_eq!(parsed.permission_mode, None);
291 assert_eq!(parsed.transcript_path, None);
292 }
293
294 #[test]
295 fn test_generate_hooks_config() -> Result<(), String> {
296 let adapter = CodexAdapter;
297 let events = vec![EventType::PermissionRequest];
298
299 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
300
301 let notify = config
303 .get("notify")
304 .ok_or("missing notify")?
305 .as_array()
306 .ok_or("notify not an array")?;
307 assert_eq!(notify[0], "mi6");
308 assert_eq!(notify[1], "ingest");
309 assert_eq!(notify[2], "event");
310 assert_eq!(notify[3], "--framework");
311 assert_eq!(notify[4], "codex");
312
313 assert!(config.get("otel").is_none());
315 Ok(())
316 }
317
318 #[test]
319 fn test_generate_hooks_config_with_otel() -> Result<(), String> {
320 let adapter = CodexAdapter;
321 let events = vec![];
322
323 let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
324
325 let otel = config.get("otel").ok_or("missing otel")?;
327 assert_eq!(
328 otel["exporter"]["otlp-http"]["endpoint"],
329 "http://127.0.0.1:4318"
330 );
331 assert_eq!(otel["exporter"]["otlp-http"]["protocol"], "json");
332 Ok(())
333 }
334
335 #[test]
336 fn test_merge_config_new() -> Result<(), String> {
337 let adapter = CodexAdapter;
338 let generated = serde_json::json!({
339 "notify": ["mi6", "ingest", "event", "--framework", "codex"]
340 });
341
342 let merged = adapter.merge_config(generated, None);
343
344 let notify = merged
345 .get("notify")
346 .ok_or("missing notify")?
347 .as_array()
348 .ok_or("notify not an array")?;
349 assert_eq!(notify.len(), 5);
350 Ok(())
351 }
352
353 #[test]
354 fn test_merge_config_existing() {
355 let adapter = CodexAdapter;
356 let generated = serde_json::json!({
357 "notify": ["mi6", "ingest", "event", "--framework", "codex"],
358 "otel": {
359 "exporter": {
360 "otlp-http": {
361 "endpoint": "http://127.0.0.1:4318",
362 "protocol": "json"
363 }
364 }
365 }
366 });
367 let existing = serde_json::json!({
368 "model": "gpt-4",
369 "otel": {
370 "headers": { "x-api-key": "secret" }
371 }
372 });
373
374 let merged = adapter.merge_config(generated, Some(existing));
375
376 assert_eq!(merged["model"], "gpt-4");
378 assert!(merged.get("notify").is_some());
380 assert_eq!(
382 merged["otel"]["exporter"]["otlp-http"]["endpoint"],
383 "http://127.0.0.1:4318"
384 );
385 assert_eq!(merged["otel"]["headers"]["x-api-key"], "secret");
387 }
388
389 #[test]
390 fn test_supported_events() {
391 let adapter = CodexAdapter;
392 let events = adapter.supported_events();
393
394 assert!(events.contains(&"agent-turn-complete"));
395 assert!(events.contains(&"approval-requested"));
396 assert_eq!(events.len(), 2);
397 }
398
399 #[test]
400 fn test_framework_specific_events() {
401 let adapter = CodexAdapter;
402 let events = adapter.framework_specific_events();
403
404 assert!(events.contains(&"agent-turn-complete"));
405 assert_eq!(events.len(), 1);
406 }
407
408 #[test]
409 fn test_detection_env_vars() {
410 let adapter = CodexAdapter;
411 let vars = adapter.detection_env_vars();
412
413 assert!(vars.contains(&"CODEX_SESSION_ID"));
414 assert!(vars.contains(&"CODEX_PROJECT_DIR"));
415 assert!(vars.contains(&"CODEX_THREAD_ID"));
416 }
417}