use std::path::Path;
use std::time::Duration;
pub const TIMEOUT: Duration = Duration::from_secs(5);
pub fn first_line_within(
binary: &Path,
envs: &[(&str, &str)],
timeout: Duration,
) -> Result<String, String> {
use std::io::BufRead;
let name = binary
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let mut command = std::process::Command::new(binary);
command
.arg("--version")
.envs(envs.iter().copied())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
crate::core::process_compat::no_console_window(&mut command);
let mut child = command.spawn().map_err(|e| e.to_string())?;
let deadline = std::time::Instant::now() + timeout;
let (tx, rx) = std::sync::mpsc::channel();
if let Some(stdout) = child.stdout.take() {
std::thread::spawn(move || {
let mut line = String::new();
let _ = std::io::BufReader::new(stdout).read_line(&mut line);
let _ = tx.send(line);
});
}
let late = || format!("`{name} --version` did not answer in time");
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(25));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(late());
}
Err(e) => return Err(e.to_string()),
}
};
if !status.success() {
return Err(format!("`{name} --version` exited with {status}"));
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
rx.recv_timeout(remaining).map_err(|_| late())
}
pub fn version_of(binary: &Path) -> Option<String> {
version_in(&first_line_within(binary, &[], TIMEOUT).ok()?)
}
pub fn version_in(line: &str) -> Option<String> {
line.split_whitespace()
.map(|word| word.strip_prefix('v').unwrap_or(word))
.find(|word| super::agent_versions::looks_like_version(word))
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_version_word_is_found_in_each_cli_s_own_wording() {
assert_eq!(
version_in("codex-cli 0.130.0\n").as_deref(),
Some("0.130.0")
);
assert_eq!(
version_in("2.1.304 (Claude Code)").as_deref(),
Some("2.1.304")
);
assert_eq!(version_in("v1.18.35").as_deref(), Some("1.18.35"));
assert_eq!(version_in("cline/3.40.1 darwin-arm64").as_deref(), None);
assert_eq!(version_in("unknown"), None);
assert_eq!(version_in(""), None);
}
#[cfg(unix)]
#[test]
fn the_probe_is_bounded_when_a_descendant_holds_stdout() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("dir");
let script = |name: &str, body: &str| {
let path = dir.path().join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("script");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
path
};
let answers = script("answers", "(sleep 30) &\necho codex-cli 0.130.0");
let started = std::time::Instant::now();
let line = first_line_within(&answers, &[], Duration::from_secs(3)).expect("line");
assert_eq!(version_in(&line).as_deref(), Some("0.130.0"));
assert!(started.elapsed() < Duration::from_secs(3));
let silent = script("silent", "(sleep 30) &\nexit 0");
let started = std::time::Instant::now();
assert!(first_line_within(&silent, &[], Duration::from_millis(500)).is_err());
assert!(started.elapsed() < Duration::from_secs(3));
}
}