use crate::errors::{Error, Result};
use std::path::PathBuf;
use std::time::Duration;
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)
}
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}")))
}
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()));
}
}