use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use crate::{
spawn_streaming, CredentialSpec, Harness, HarnessCapabilities, HarnessError, HarnessInfo,
HarnessReadiness, InstallCallback, InstallEvent, RunCallback, RunHandle, RunMode, RunRequest,
RunTuning,
};
mod parser;
pub use parser::{parse_codex_line, CodexStreamParser};
pub const CODEX_HARNESS_ID: &str = "codex";
#[derive(Debug, Default, Clone)]
pub struct CodexHarness;
impl CodexHarness {
pub fn new() -> Self {
Self
}
}
impl Harness for CodexHarness {
fn info(&self) -> HarnessInfo {
HarnessInfo {
id: CODEX_HARNESS_ID.to_owned(),
display_name: "Codex".to_owned(),
description: "OpenAI's Codex agent CLI. Uses your existing Codex login.".to_owned(),
requires_install: true,
capabilities: HarnessCapabilities {
credential_required: false,
previews_edits: false,
models: Vec::new(),
allows_custom_model: true,
supports_effort: true,
supports_max_turns: false,
supports_login: true,
},
}
}
fn readiness(&self) -> HarnessReadiness {
let Some(version) = probe_version("codex") else {
return HarnessReadiness {
harness_id: CODEX_HARNESS_ID.to_owned(),
ready: false,
installed: false,
version: None,
auth_configured: false,
error: Some("Codex (`codex`) is not installed or not on PATH.".to_owned()),
details: Value::Null,
};
};
let signed_in = probe_codex_signed_in()
|| crate::harness::api_key_value_usable(std::env::var("OPENAI_API_KEY").ok());
HarnessReadiness {
harness_id: CODEX_HARNESS_ID.to_owned(),
ready: signed_in,
installed: true,
version: Some(version),
auth_configured: signed_in,
error: if signed_in {
None
} else {
Some(
"Codex is installed but not signed in. Click Sign in to connect your ChatGPT/OpenAI account, or set OPENAI_API_KEY."
.to_owned(),
)
},
details: Value::Null,
}
}
fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
(*on_event)(InstallEvent::Step {
text: "Installing Codex via npm…".to_owned(),
});
let output = Command::new("npm")
.args(["install", "-g", "@openai/codex"])
.env("PATH", crate::augmented_node_path())
.output()
.map_err(|e| HarnessError::install(format!("failed to run npm: {e}")))?;
for line in String::from_utf8_lossy(&output.stdout).lines() {
(*on_event)(InstallEvent::Stdout {
text: line.to_owned(),
});
}
for line in String::from_utf8_lossy(&output.stderr).lines() {
(*on_event)(InstallEvent::Stderr {
text: line.to_owned(),
});
}
(*on_event)(InstallEvent::Done {
exit_code: output.status.code(),
ok: output.status.success(),
});
Ok(())
}
fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
let RunRequest { run_id, prompt, cwd, mode, tuning } = request;
let args = build_codex_args(prompt, mode, &tuning);
let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let parser = Arc::new(Mutex::new(CodexStreamParser::new()));
let handle = spawn_streaming(
PathBuf::from("codex"),
args,
Vec::new(),
cwd,
run_id,
move |event| {
let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
for normalized in parser.on_process_event(event) {
(*on_event)(normalized);
}
},
)
.map_err(HarnessError::spawn)?;
Ok(Box::new(handle))
}
fn credential(&self) -> CredentialSpec {
CredentialSpec {
label: "Codex login (managed by the codex CLI)".to_owned(),
keychain_service: "openai".to_owned(),
keychain_account: "OPENAI_API_KEY".to_owned(),
required: false,
}
}
fn login(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
crate::run_login_command("codex", &["login"], on_event)
}
}
fn probe_version(program: &str) -> Option<String> {
let output = Command::new(program)
.arg("--version")
.env("PATH", crate::augmented_node_path())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if text.is_empty() {
None
} else {
Some(text)
}
}
fn probe_codex_signed_in() -> bool {
Command::new("codex")
.args(["login", "status"])
.env("PATH", crate::augmented_node_path())
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn build_codex_args(prompt: String, mode: RunMode, tuning: &RunTuning) -> Vec<String> {
let mut args = vec![
"exec".to_owned(),
"--json".to_owned(),
"--skip-git-repo-check".to_owned(),
];
if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
args.push("--model".to_owned());
args.push(model.to_owned());
}
if let Some(effort) = tuning.effort {
args.push("-c".to_owned());
args.push(format!("model_reasoning_effort=\"{}\"", effort.as_cli_value()));
}
if matches!(mode, RunMode::Edit) {
args.push("--full-auto".to_owned());
}
args.push(prompt);
args
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ReasoningEffort;
#[test]
fn codex_info_and_credential() {
let h = CodexHarness::new();
assert_eq!(h.info().id, CODEX_HARNESS_ID);
assert!(h.info().requires_install);
assert!(!h.credential().required);
}
fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.map(String::as_str)
}
#[test]
fn codex_args_default_omit_model_and_effort() {
let args = build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default());
assert_eq!(args[0], "exec");
assert!(args.contains(&"--json".to_owned()));
assert!(args.contains(&"--skip-git-repo-check".to_owned()));
assert!(!args.iter().any(|a| a == "--model"));
assert!(!args.iter().any(|a| a == "-c"));
assert!(!args.iter().any(|a| a == "--full-auto"));
assert_eq!(args.last().map(String::as_str), Some("hi"));
}
#[test]
fn codex_args_carry_model_and_effort_and_ignore_max_turns() {
let tuning = RunTuning {
model: Some("gpt-5-codex".to_owned()),
effort: Some(ReasoningEffort::High),
max_turns: Some(5),
};
let args = build_codex_args("hi".to_owned(), RunMode::Edit, &tuning);
assert_eq!(flag_value(&args, "--model"), Some("gpt-5-codex"));
assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"high\""));
assert!(args.contains(&"--full-auto".to_owned()));
assert!(!args.iter().any(|a| a == "--max-turns"));
assert_eq!(args.last().map(String::as_str), Some("hi"));
}
}