use crate::adapters::{self, Adapter};
use crate::types::ExternalAgentSpec;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
const SCRATCH_PREFIXES: &[&str] = &["/tmp/", "/private/tmp/", "/var/tmp/", "/dev/shm/"];
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn resolve_in_path(bin_name: &str, path_var: &str, scratch_prefixes: &[&str]) -> Option<PathBuf> {
let separator = if cfg!(windows) { ';' } else { ':' };
for dir in path_var.split(separator) {
if dir.is_empty() {
continue;
}
let candidate = Path::new(dir).join(bin_name);
let candidate_str = candidate.to_string_lossy();
if scratch_prefixes
.iter()
.any(|prefix| candidate_str.starts_with(prefix))
{
continue;
}
if !candidate.exists() {
continue;
}
let Ok(meta) = std::fs::metadata(&candidate) else {
continue;
};
if !meta.is_file() {
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 == 0 {
continue;
}
}
return Some(candidate);
}
None
}
async fn probe_version(bin: &Path) -> Option<String> {
use tokio::process::Command;
let mut cmd = Command::new(bin);
cmd.arg("--version");
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::null());
cmd.kill_on_drop(true);
let child = cmd.spawn().ok()?;
let output = match tokio::time::timeout(VERSION_PROBE_TIMEOUT, child.wait_with_output()).await {
Ok(Ok(out)) => out,
_ => return None,
};
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let trimmed = stdout.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
async fn detect_one(
adapter: &Adapter,
path_var: &str,
home: &Path,
scratch_prefixes: &[&str],
) -> Option<ExternalAgentSpec> {
let binary_path = resolve_in_path(adapter.bin_name, path_var, scratch_prefixes)?;
let version_raw = probe_version(&binary_path).await;
let version = version_raw.and_then(|raw| (adapter.parse_version)(&raw));
let auth_kind = (adapter.probe_auth)(home);
Some(ExternalAgentSpec {
id: adapter.id.as_str().to_string(),
display_name: adapter.id.display_name().to_string(),
binary_path,
version,
auth_kind,
capabilities: adapter.capabilities.clone(),
detected_at: now_secs(),
health: None,
})
}
pub async fn detect() -> Vec<ExternalAgentSpec> {
let path_var = std::env::var("PATH").unwrap_or_default();
let Some(home) = home_dir() else {
return detect_with_paths(&path_var, Path::new("/")).await;
};
detect_with_paths(&path_var, &home).await
}
pub(crate) async fn detect_with_paths(path_var: &str, home: &Path) -> Vec<ExternalAgentSpec> {
detect_with_paths_filtered(path_var, home, SCRATCH_PREFIXES).await
}
pub(crate) async fn detect_with_paths_filtered(
path_var: &str,
home: &Path,
scratch_prefixes: &[&str],
) -> Vec<ExternalAgentSpec> {
let probes = adapters::all()
.iter()
.map(|adapter| detect_one(adapter, path_var, home, scratch_prefixes));
let mut specs: Vec<ExternalAgentSpec> =
futures::future::join_all(probes).await.into_iter().flatten().collect();
specs.sort_by(|a, b| a.id.cmp(&b.id));
specs
}
pub async fn detect_with_health(force: bool) -> Vec<ExternalAgentSpec> {
let mut specs = detect().await;
let healths = crate::health::check_all(&specs, force).await;
let by_id: std::collections::HashMap<&str, &crate::health::ExternalAgentHealth> =
healths.iter().map(|h| (h.id.as_str(), h)).collect();
for spec in specs.iter_mut() {
if let Some(h) = by_id.get(spec.id.as_str()) {
spec.health = Some((*h).clone());
}
}
specs
}
#[cfg(test)]
mod tests {
use super::*;
fn make_fake_bin(dir: &Path, name: &str, version_output: &str) -> PathBuf {
let path = dir.join(name);
let script = format!("#!/bin/sh\necho '{version_output}'\n");
std::fs::write(&path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
path
}
#[tokio::test]
async fn detect_finds_fake_binary_on_path() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51 (Claude Code)");
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code");
assert!(claude.is_some(), "expected claude-code in {specs:?}");
let claude = claude.unwrap();
assert_eq!(claude.version.as_deref(), Some("1.0.51"));
assert!(matches!(
claude.auth_kind,
crate::types::AuthKind::Unauthenticated
));
}
#[tokio::test]
async fn detect_omits_uninstalled_binaries() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths(&path_var, home_dir.path()).await;
assert!(specs.is_empty(), "expected no detections, got {specs:?}");
}
#[tokio::test]
async fn detect_picks_subscription_when_oauth_creds_present() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51");
let claude_dir = home_dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join(".credentials.json"),
r#"{"oauthAccount": {"email": "[email protected]"}}"#,
)
.unwrap();
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
assert!(matches!(
claude.auth_kind,
crate::types::AuthKind::Subscription
));
}
#[tokio::test]
async fn detect_picks_apikey_when_only_apikey_present() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51");
let claude_dir = home_dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join(".credentials.json"),
r#"{"apiKey": "sk-ant-..."}"#,
)
.unwrap();
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
assert!(matches!(claude.auth_kind, crate::types::AuthKind::ApiKey));
}
#[tokio::test]
async fn detect_rejects_scratch_dir_binaries() {
let tmp = std::env::temp_dir();
if !tmp.starts_with("/tmp") && !tmp.starts_with("/private/tmp") {
return;
}
let bin_dir = tempfile::TempDir::new_in("/tmp").unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51");
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths(&path_var, home_dir.path()).await;
assert!(
specs.iter().all(|s| s.id != "claude-code"),
"scratch-dir binary must be rejected, got {specs:?}"
);
}
#[tokio::test]
async fn detect_keeps_entry_when_version_probe_fails() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
let path = bin_dir.path().join("claude");
std::fs::write(&path, "#!/bin/sh\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code");
assert!(claude.is_some(), "entry must survive failed version probe");
assert!(claude.unwrap().version.is_none());
}
}