1use std::path::PathBuf;
18use std::process::Command;
19use std::sync::{Arc, Mutex};
20
21use serde_json::Value;
22
23use crate::{
24 spawn_streaming, CredentialSpec, Harness, HarnessCapabilities, HarnessError, HarnessInfo,
25 HarnessReadiness, InstallCallback, InstallEvent, RunCallback, RunHandle, RunMode, RunRequest,
26 RunTuning,
27};
28
29mod parser;
30pub use parser::{parse_codex_line, CodexStreamParser};
31
32pub const CODEX_HARNESS_ID: &str = "codex";
34
35#[derive(Debug, Default, Clone)]
37pub struct CodexHarness;
38
39impl CodexHarness {
40 pub fn new() -> Self {
41 Self
42 }
43}
44
45impl Harness for CodexHarness {
46 fn info(&self) -> HarnessInfo {
47 HarnessInfo {
48 id: CODEX_HARNESS_ID.to_owned(),
49 display_name: "Codex".to_owned(),
50 description: "OpenAI's Codex agent CLI. Uses your existing Codex login.".to_owned(),
51 requires_install: true,
52 capabilities: HarnessCapabilities {
53 credential_required: false,
58 previews_edits: false,
59 models: Vec::new(),
60 allows_custom_model: true,
61 supports_effort: true,
62 supports_max_turns: false,
63 supports_login: true,
64 },
65 }
66 }
67
68 fn readiness(&self) -> HarnessReadiness {
69 let Some(version) = probe_version("codex") else {
70 return HarnessReadiness {
71 harness_id: CODEX_HARNESS_ID.to_owned(),
72 ready: false,
73 installed: false,
74 version: None,
75 auth_configured: false,
76 error: Some("Codex (`codex`) is not installed or not on PATH.".to_owned()),
77 details: Value::Null,
78 };
79 };
80 let signed_in = probe_codex_signed_in()
87 || crate::harness::api_key_value_usable(std::env::var("OPENAI_API_KEY").ok());
88 HarnessReadiness {
89 harness_id: CODEX_HARNESS_ID.to_owned(),
90 ready: signed_in,
91 installed: true,
92 version: Some(version),
93 auth_configured: signed_in,
94 error: if signed_in {
95 None
96 } else {
97 Some(
98 "Codex is installed but not signed in. Click Sign in to connect your ChatGPT/OpenAI account, or set OPENAI_API_KEY."
99 .to_owned(),
100 )
101 },
102 details: Value::Null,
103 }
104 }
105
106 fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
107 (*on_event)(InstallEvent::Step {
108 text: "Installing Codex via npm…".to_owned(),
109 });
110 let output = Command::new("npm")
111 .args(["install", "-g", "@openai/codex"])
112 .env("PATH", crate::augmented_node_path())
113 .output()
114 .map_err(|e| HarnessError::install(format!("failed to run npm: {e}")))?;
115 for line in String::from_utf8_lossy(&output.stdout).lines() {
116 (*on_event)(InstallEvent::Stdout {
117 text: line.to_owned(),
118 });
119 }
120 for line in String::from_utf8_lossy(&output.stderr).lines() {
121 (*on_event)(InstallEvent::Stderr {
122 text: line.to_owned(),
123 });
124 }
125 (*on_event)(InstallEvent::Done {
126 exit_code: output.status.code(),
127 ok: output.status.success(),
128 });
129 Ok(())
130 }
131
132 fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
133 let RunRequest { run_id, prompt, cwd, mode, tuning } = request;
134 let args = build_codex_args(prompt, mode, &tuning);
135 let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
136
137 let parser = Arc::new(Mutex::new(CodexStreamParser::new()));
149 let handle = spawn_streaming(
150 PathBuf::from("codex"),
151 args,
152 Vec::new(),
153 cwd,
154 run_id,
155 move |event| {
156 let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
160 for normalized in parser.on_process_event(event) {
161 (*on_event)(normalized);
162 }
163 },
164 )
165 .map_err(HarnessError::spawn)?;
166 Ok(Box::new(handle))
167 }
168
169 fn credential(&self) -> CredentialSpec {
170 CredentialSpec {
171 label: "Codex login (managed by the codex CLI)".to_owned(),
172 keychain_service: "openai".to_owned(),
173 keychain_account: "OPENAI_API_KEY".to_owned(),
174 required: false,
175 }
176 }
177
178 fn login(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
179 crate::run_login_command("codex", &["login"], on_event)
181 }
182}
183
184fn probe_version(program: &str) -> Option<String> {
185 let output = Command::new(program)
189 .arg("--version")
190 .env("PATH", crate::augmented_node_path())
191 .output()
192 .ok()?;
193 if !output.status.success() {
194 return None;
195 }
196 let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
197 if text.is_empty() {
198 None
199 } else {
200 Some(text)
201 }
202}
203
204fn probe_codex_signed_in() -> bool {
208 Command::new("codex")
209 .args(["login", "status"])
210 .env("PATH", crate::augmented_node_path())
211 .output()
212 .map(|o| o.status.success())
213 .unwrap_or(false)
214}
215
216fn build_codex_args(prompt: String, mode: RunMode, tuning: &RunTuning) -> Vec<String> {
223 let mut args = vec![
231 "exec".to_owned(),
232 "--json".to_owned(),
233 "--skip-git-repo-check".to_owned(),
234 ];
235 if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
236 args.push("--model".to_owned());
237 args.push(model.to_owned());
238 }
239 if let Some(effort) = tuning.effort {
240 args.push("-c".to_owned());
241 args.push(format!("model_reasoning_effort=\"{}\"", effort.as_cli_value()));
242 }
243 if matches!(mode, RunMode::Edit) {
244 args.push("--full-auto".to_owned());
248 }
249 args.push(prompt);
250 args
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::ReasoningEffort;
257
258 #[test]
259 fn codex_info_and_credential() {
260 let h = CodexHarness::new();
261 assert_eq!(h.info().id, CODEX_HARNESS_ID);
262 assert!(h.info().requires_install);
263 assert!(!h.credential().required);
264 }
265
266 fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
268 args.iter()
269 .position(|a| a == flag)
270 .and_then(|i| args.get(i + 1))
271 .map(String::as_str)
272 }
273
274 #[test]
275 fn codex_args_default_omit_model_and_effort() {
276 let args = build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default());
277 assert_eq!(args[0], "exec");
278 assert!(args.contains(&"--json".to_owned()));
279 assert!(args.contains(&"--skip-git-repo-check".to_owned()));
283 assert!(!args.iter().any(|a| a == "--model"));
284 assert!(!args.iter().any(|a| a == "-c"));
285 assert!(!args.iter().any(|a| a == "--full-auto"));
286 assert_eq!(args.last().map(String::as_str), Some("hi"));
288 }
289
290 #[test]
291 fn codex_args_carry_model_and_effort_and_ignore_max_turns() {
292 let tuning = RunTuning {
293 model: Some("gpt-5-codex".to_owned()),
294 effort: Some(ReasoningEffort::High),
295 max_turns: Some(5),
296 };
297 let args = build_codex_args("hi".to_owned(), RunMode::Edit, &tuning);
298 assert_eq!(flag_value(&args, "--model"), Some("gpt-5-codex"));
299 assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"high\""));
300 assert!(args.contains(&"--full-auto".to_owned()));
301 assert!(!args.iter().any(|a| a == "--max-turns"));
303 assert_eq!(args.last().map(String::as_str), Some("hi"));
305 }
306}