1use super::{ConfigFormat, FrameworkAdapter, ParsedHookInput, ParsedHookInputBuilder, 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 ParsedHookInputBuilder::new(json)
137 .session_id("thread-id")
138 .tool_use_id("turn-id")
139 .cwd("cwd")
140 .build()
141 }
144
145 fn map_event_type(&self, framework_event: &str) -> EventType {
146 match framework_event {
147 "approval-requested" => EventType::PermissionRequest,
149 "agent-turn-complete" => EventType::Custom("TurnComplete".to_string()),
152 other => other
154 .parse()
155 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
156 }
157 }
158
159 fn supported_events(&self) -> Vec<&'static str> {
160 vec!["agent-turn-complete", "approval-requested"]
162 }
163
164 fn framework_specific_events(&self) -> Vec<&'static str> {
165 vec!["agent-turn-complete"]
167 }
168
169 fn detection_env_vars(&self) -> &[&'static str] {
170 &["CODEX_SESSION_ID", "CODEX_PROJECT_DIR", "CODEX_THREAD_ID"]
175 }
176
177 fn is_installed(&self) -> bool {
178 common::is_framework_installed(self.user_config_path(), "codex")
179 }
180
181 fn otel_support(&self) -> super::OtelSupport {
182 use super::OtelSupport;
183 let Some(config_path) = self.user_config_path() else {
185 return OtelSupport::Disabled;
186 };
187 let Ok(contents) = std::fs::read_to_string(&config_path) else {
188 return OtelSupport::Disabled;
189 };
190 let Ok(toml_val) = toml::from_str::<toml::Value>(&contents) else {
191 return OtelSupport::Disabled;
192 };
193
194 if let Some(otel) = toml_val.get("otel") {
196 if otel.get("exporter").is_some() {
198 return OtelSupport::Enabled;
199 }
200 }
201 OtelSupport::Disabled
202 }
203
204 fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
205 let mut settings = existing;
206 let mut modified = false;
207
208 if let Some(notify) = settings.get("notify")
210 && let Some(arr) = notify.as_array()
211 {
212 let has_mi6_hook = arr
214 .iter()
215 .any(|v| v.as_str().is_some_and(|s| s.contains("mi6")));
216 if has_mi6_hook && let Some(obj) = settings.as_object_mut() {
217 obj.remove("notify");
218 modified = true;
219 }
220 }
221
222 if let Some(obj) = settings.as_object_mut()
224 && obj.remove("otel").is_some()
225 {
226 modified = true;
227 }
228
229 if modified { Some(settings) } else { None }
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_name() {
239 let adapter = CodexAdapter;
240 assert_eq!(adapter.name(), "codex");
241 assert_eq!(adapter.display_name(), "Codex CLI");
242 }
243
244 #[test]
245 fn test_config_format() {
246 let adapter = CodexAdapter;
247 assert_eq!(adapter.config_format(), ConfigFormat::Toml);
248 }
249
250 #[test]
251 fn test_project_config_path() {
252 let adapter = CodexAdapter;
253 assert_eq!(
254 adapter.project_config_path(),
255 PathBuf::from(".codex/config.toml")
256 );
257 }
258
259 #[test]
260 fn test_map_event_type() {
261 let adapter = CodexAdapter;
262
263 assert_eq!(
265 adapter.map_event_type("approval-requested"),
266 EventType::PermissionRequest
267 );
268 assert_eq!(
269 adapter.map_event_type("agent-turn-complete"),
270 EventType::Custom("TurnComplete".to_string())
271 );
272
273 assert_eq!(
275 adapter.map_event_type("unknown-event"),
276 EventType::Custom("unknown-event".to_string())
277 );
278 }
279
280 #[test]
281 fn test_parse_hook_input() {
282 let adapter = CodexAdapter;
283 let input = serde_json::json!({
284 "type": "agent-turn-complete",
285 "thread-id": "codex-thread-123",
286 "turn-id": "turn-456",
287 "cwd": "/projects/test"
288 });
289
290 let parsed = adapter.parse_hook_input("agent-turn-complete", &input);
291
292 assert_eq!(parsed.session_id, Some("codex-thread-123".to_string()));
293 assert_eq!(parsed.tool_use_id, Some("turn-456".to_string()));
294 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
295 assert_eq!(parsed.tool_name, None);
297 assert_eq!(parsed.permission_mode, None);
298 assert_eq!(parsed.transcript_path, None);
299 }
300
301 #[test]
302 fn test_generate_hooks_config() -> Result<(), String> {
303 let adapter = CodexAdapter;
304 let events = vec![EventType::PermissionRequest];
305
306 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
307
308 let notify = config
310 .get("notify")
311 .ok_or("missing notify")?
312 .as_array()
313 .ok_or("notify not an array")?;
314 assert_eq!(notify[0], "mi6");
315 assert_eq!(notify[1], "ingest");
316 assert_eq!(notify[2], "event");
317 assert_eq!(notify[3], "--framework");
318 assert_eq!(notify[4], "codex");
319
320 assert!(config.get("otel").is_none());
322 Ok(())
323 }
324
325 #[test]
326 fn test_generate_hooks_config_with_otel() -> Result<(), String> {
327 let adapter = CodexAdapter;
328 let events = vec![];
329
330 let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
331
332 let otel = config.get("otel").ok_or("missing otel")?;
334 assert_eq!(
335 otel["exporter"]["otlp-http"]["endpoint"],
336 "http://127.0.0.1:4318"
337 );
338 assert_eq!(otel["exporter"]["otlp-http"]["protocol"], "json");
339 Ok(())
340 }
341
342 #[test]
343 fn test_merge_config_new() -> Result<(), String> {
344 let adapter = CodexAdapter;
345 let generated = serde_json::json!({
346 "notify": ["mi6", "ingest", "event", "--framework", "codex"]
347 });
348
349 let merged = adapter.merge_config(generated, None);
350
351 let notify = merged
352 .get("notify")
353 .ok_or("missing notify")?
354 .as_array()
355 .ok_or("notify not an array")?;
356 assert_eq!(notify.len(), 5);
357 Ok(())
358 }
359
360 #[test]
361 fn test_merge_config_existing() {
362 let adapter = CodexAdapter;
363 let generated = serde_json::json!({
364 "notify": ["mi6", "ingest", "event", "--framework", "codex"],
365 "otel": {
366 "exporter": {
367 "otlp-http": {
368 "endpoint": "http://127.0.0.1:4318",
369 "protocol": "json"
370 }
371 }
372 }
373 });
374 let existing = serde_json::json!({
375 "model": "gpt-4",
376 "otel": {
377 "headers": { "x-api-key": "secret" }
378 }
379 });
380
381 let merged = adapter.merge_config(generated, Some(existing));
382
383 assert_eq!(merged["model"], "gpt-4");
385 assert!(merged.get("notify").is_some());
387 assert_eq!(
389 merged["otel"]["exporter"]["otlp-http"]["endpoint"],
390 "http://127.0.0.1:4318"
391 );
392 assert_eq!(merged["otel"]["headers"]["x-api-key"], "secret");
394 }
395
396 #[test]
397 fn test_supported_events() {
398 let adapter = CodexAdapter;
399 let events = adapter.supported_events();
400
401 assert!(events.contains(&"agent-turn-complete"));
402 assert!(events.contains(&"approval-requested"));
403 assert_eq!(events.len(), 2);
404 }
405
406 #[test]
407 fn test_framework_specific_events() {
408 let adapter = CodexAdapter;
409 let events = adapter.framework_specific_events();
410
411 assert!(events.contains(&"agent-turn-complete"));
412 assert_eq!(events.len(), 1);
413 }
414
415 #[test]
416 fn test_detection_env_vars() {
417 let adapter = CodexAdapter;
418 let vars = adapter.detection_env_vars();
419
420 assert!(vars.contains(&"CODEX_SESSION_ID"));
421 assert!(vars.contains(&"CODEX_PROJECT_DIR"));
422 assert!(vars.contains(&"CODEX_THREAD_ID"));
423 }
424}