codex-cli-sdk 0.0.1

Rust SDK for the OpenAI Codex CLI
Documentation
use crate::errors::{Error, Result};
use std::path::PathBuf;
use std::time::Duration;

/// Search for the Codex CLI binary on the system.
///
/// Search order:
/// 1. `which codex` (PATH lookup)
/// 2. Platform-specific known install locations
pub fn find_cli() -> Result<PathBuf> {
    if let Ok(path) = which::which("codex") {
        return Ok(path);
    }

    for path in platform_paths() {
        if path.is_file() {
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                if let Ok(meta) = path.metadata() {
                    if meta.permissions().mode() & 0o111 == 0 {
                        return Err(Error::CliNotExecutable { path });
                    }
                }
            }
            return Ok(path);
        }
    }

    Err(Error::CliNotFound)
}

/// Check the CLI version by running `codex --version`.
pub async fn check_version(
    cli_path: &std::path::Path,
    timeout: Option<Duration>,
) -> Result<String> {
    let timeout = timeout.unwrap_or(Duration::from_secs(5));

    let output = tokio::time::timeout(timeout, async {
        tokio::process::Command::new(cli_path)
            .arg("--version")
            .output()
            .await
            .map_err(Error::SpawnFailed)
    })
    .await
    .map_err(|_| Error::Timeout {
        operation: "version check".into(),
    })??;

    if !output.status.success() {
        return Err(Error::VersionCheck(
            String::from_utf8_lossy(&output.stderr).into_owned(),
        ));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    parse_version(&stdout)
        .ok_or_else(|| Error::VersionCheck(format!("could not parse version from: {stdout}")))
}

/// Extract semver from version output.
fn parse_version(output: &str) -> Option<String> {
    let trimmed = output.trim();
    for word in trimmed.split_whitespace() {
        let word = word.strip_prefix('v').unwrap_or(word);
        if word.contains('.') && word.chars().next().is_some_and(|c| c.is_ascii_digit()) {
            return Some(word.to_string());
        }
    }
    None
}

#[cfg(unix)]
fn platform_paths() -> Vec<PathBuf> {
    let home = dirs_next::home_dir().unwrap_or_else(|| {
        tracing::warn!("Could not determine home directory");
        PathBuf::new()
    });
    vec![
        home.join(".npm-global/bin/codex"),
        PathBuf::from("/usr/local/bin/codex"),
        home.join(".local/bin/codex"),
        PathBuf::from("/opt/homebrew/bin/codex"),
        PathBuf::from("node_modules/.bin/codex"),
        home.join(".yarn/bin/codex"),
        home.join(".codex/bin/codex"),
    ]
}

#[cfg(windows)]
fn platform_paths() -> Vec<PathBuf> {
    let home = dirs_next::home_dir().unwrap_or_else(|| {
        tracing::warn!("Could not determine home directory");
        PathBuf::new()
    });
    vec![
        home.join("AppData/Roaming/npm/codex.exe"),
        home.join("scoop/shims/codex.exe"),
        PathBuf::from("node_modules/.bin/codex.exe"),
        home.join(".codex/bin/codex.exe"),
    ]
}

#[cfg(not(any(unix, windows)))]
fn platform_paths() -> Vec<PathBuf> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_version_plain() {
        assert_eq!(parse_version("0.104.0"), Some("0.104.0".into()));
    }

    #[test]
    fn parse_version_with_prefix() {
        assert_eq!(parse_version("codex 0.104.0"), Some("0.104.0".into()));
    }

    #[test]
    fn parse_version_with_v() {
        assert_eq!(parse_version("v0.104.0"), Some("0.104.0".into()));
    }

    #[test]
    fn parse_version_garbage() {
        assert_eq!(parse_version("garbage"), None);
    }

    #[test]
    fn parse_version_with_newline() {
        assert_eq!(parse_version("0.104.0\n"), Some("0.104.0".into()));
    }
}