car-external-agents 0.25.0

Detection of installed agentic CLIs (Claude Code, Codex, Gemini) for the Common Agent Runtime.
//! OpenAI Codex CLI adapter.

use crate::adapters::{json_top_level_keys, HealthFuture};
use crate::health::{base_health, run_status_probe, HealthStatus};
use crate::types::{AdapterId, AuthKind, Capabilities};
use serde_json::json;
use std::path::Path;

pub(super) const CAPABILITIES: Capabilities = Capabilities {
    tool_use: true,
    mcp: false,
    hooks: false,
    sessions: true,
    streaming: true,
};

/// `codex --version` emits `codex-cli 0.128.0` (newer builds) or
/// just `0.128.0` (older). Same versionish-token scanner as the other
/// adapters.
pub(super) fn parse_version(stdout: &str) -> Option<String> {
    crate::adapters::first_versionish_token(stdout)
}

/// Codex CLI stores credentials under `~/.codex/auth.json`. The new
/// CLI supports both ChatGPT login (Subscription) and OPENAI_API_KEY
/// (ApiKey). Keys differ by shape — we differentiate via top-level
/// presence without reading the credential value.
pub(super) fn probe_auth(home: &Path) -> AuthKind {
    let dir = home.join(".codex");
    let path = dir.join("auth.json");
    if !path.exists() {
        // Same reasoning as the Claude adapter: config dir present
        // means the binary has been used; OS-keystore-backed auth
        // leaves no cred file to inspect.
        return if dir.is_dir() {
            AuthKind::Unknown
        } else {
            AuthKind::Unauthenticated
        };
    }
    let Some(keys) = json_top_level_keys(&path) else {
        return AuthKind::Unknown;
    };
    let lower: Vec<String> = keys.iter().map(|k| k.to_ascii_lowercase()).collect();
    let has_oauth = lower
        .iter()
        .any(|k| k.contains("token") || k.contains("chatgpt") || k.contains("session"));
    let has_api_key = lower
        .iter()
        .any(|k| k.contains("openai_api_key") || k.contains("api_key") || k.contains("apikey"));
    match (has_oauth, has_api_key) {
        (true, _) => AuthKind::Subscription,
        (false, true) => AuthKind::ApiKey,
        (false, false) => AuthKind::Unknown,
    }
}

/// `codex login status` writes its result to **stderr** (not stdout)
/// and exits 0 in both authenticated and unauthenticated cases:
///
/// - `Logged in using ChatGPT` → Ready, method=ChatGPT (Subscription).
/// - `Logged in using API key` → Ready, method=API key.
/// - `Not logged in` → NotConfigured.
///
/// We concatenate both streams before pattern-matching so the parser
/// is robust to the upstream tool moving output between streams.
pub(super) fn health_check(binary_path: &Path) -> HealthFuture {
    let bin = binary_path.to_path_buf();
    Box::pin(async move {
        let mut h = base_health(AdapterId::Codex);
        match run_status_probe(&bin, &["login", "status"]).await {
            Ok((stdout, stderr, code)) => {
                let combined = format!("{}\n{}", stdout.trim(), stderr.trim());
                let lower = combined.to_ascii_lowercase();
                if code == 0 && lower.contains("logged in") && !lower.contains("not logged in") {
                    h.status = HealthStatus::Ready;
                    let method = if lower.contains("chatgpt") {
                        "ChatGPT"
                    } else if lower.contains("api key") {
                        "API key"
                    } else {
                        "Unknown"
                    };
                    h.details = json!({ "method": method, "raw": combined.trim() });
                } else if lower.contains("not logged in") || lower.contains("logged out") {
                    h.status = HealthStatus::NotConfigured;
                    h.details = json!({ "raw": combined.trim() });
                    h.reason = Some("Run `codex login` to authenticate".to_string());
                } else {
                    h.status = HealthStatus::Unknown;
                    h.reason = Some(format!(
                        "`codex login status` exited {code}: {}",
                        combined.trim()
                    ));
                }
            }
            Err(e) => {
                h.status = HealthStatus::NetworkError;
                h.reason = Some(format!("status probe failed: {e}"));
            }
        }
        h
    })
}