use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::agent::{AgentId, AgentInstallation, InstallationSource, LoginMethod};
use crate::catalog::{AgentProfile, Upgrade};
use crate::event::{Diagnostic, DiagnosticLevel};
use crate::process::login_shell_path;
use crate::runtime::{DiscoveryReport, MissingAgent};
pub(crate) async fn discover(profiles: &[AgentProfile]) -> DiscoveryReport {
let mut report = DiscoveryReport {
agents: Vec::new(),
missing: Vec::new(),
diagnostics: Vec::new(),
};
if profiles.is_empty() {
return report;
}
let home = std::env::home_dir().unwrap_or_default();
let path = std::env::var("PATH").ok();
let login = login_shell_path().await;
let scans = profiles
.iter()
.map(|profile| scan(profile, &home, path.as_deref(), login.as_deref()));
for (found, diagnostics) in futures::future::join_all(scans).await {
match found {
Ok(agent) => report.agents.push(agent),
Err(missing) => report.missing.push(missing),
}
report.diagnostics.extend(diagnostics);
}
report
}
async fn scan(
profile: &AgentProfile,
home: &Path,
path: Option<&str>,
login: Option<&str>,
) -> (Result<AgentInstallation, MissingAgent>, Vec<Diagnostic>) {
let mut diagnostics = Vec::new();
if let Some(exe) = env_override(profile, &mut diagnostics) {
let mut agent = installation(profile, exe, InstallationSource::EnvOverride);
let dirs = search_dirs(profile, home, path, login);
if let Some(upgrade) = &profile.upgrade {
agent.upgrade = resolve_upgrade(profile, upgrade, &dirs, home).err();
}
return (Ok(agent), diagnostics);
}
let dirs = search_dirs(profile, home, path, login);
let upgrade = profile
.upgrade
.as_ref()
.map(|upgrade| resolve_upgrade(profile, upgrade, &dirs, home));
let found = match (resolve(profile.cli, &dirs), upgrade) {
(_, Some(Ok((exe, source, args)))) => {
let mut agent = installation(profile, exe, source);
agent.acp_args = Some(args);
Ok(agent)
}
(Some((exe, source)), upgrade) => {
let mut agent = installation(profile, exe, source);
agent.upgrade = upgrade.and_then(Result::err);
Ok(agent)
}
(None, _) => Err(MissingAgent {
id: AgentId::new(profile.id),
name: profile.name.into(),
searched: dirs.into_iter().map(|(dir, _)| dir).collect(),
install_hint: profile.install_hint.into(),
}),
};
(found, diagnostics)
}
#[allow(clippy::type_complexity)]
fn resolve_upgrade(
profile: &AgentProfile,
upgrade: &Upgrade,
dirs: &[(PathBuf, InstallationSource)],
home: &Path,
) -> Result<(PathBuf, InstallationSource, Vec<String>), MissingAgent> {
let mut dirs = dirs.to_vec();
dirs.extend(
upgrade
.extra_paths
.iter()
.map(|extra| (under(home, extra), InstallationSource::KnownLocation)),
);
match resolve(upgrade.cli, &dirs) {
Some((exe, source)) => Ok((
exe,
source,
upgrade.acp_args.iter().map(|a| (*a).to_owned()).collect(),
)),
None => Err(MissingAgent {
id: AgentId::new(profile.id),
name: upgrade.name.into(),
searched: dirs.into_iter().map(|(dir, _)| dir).collect(),
install_hint: upgrade.install_hint.into(),
}),
}
}
fn env_override(profile: &AgentProfile, diagnostics: &mut Vec<Diagnostic>) -> Option<PathBuf> {
let exe = PathBuf::from(std::env::var(profile.executable_env).ok()?);
if is_executable(&exe) {
return Some(exe);
}
diagnostics.push(Diagnostic {
level: DiagnosticLevel::Warning,
message: format!(
"{} is set but {} is not an executable file",
profile.executable_env,
exe.display()
),
});
None
}
fn search_dirs(
profile: &AgentProfile,
home: &Path,
path: Option<&str>,
login_path: Option<&str>,
) -> Vec<(PathBuf, InstallationSource)> {
let mut seen = std::collections::HashSet::new();
let mut dirs = Vec::new();
let mut add = |dir: PathBuf, source: InstallationSource| {
if !dir.as_os_str().is_empty() && seen.insert(dir.clone()) {
dirs.push((dir, source));
}
};
for dir in split_path(path) {
add(dir, InstallationSource::Path);
}
for dir in split_path(login_path) {
add(dir, InstallationSource::LoginShellPath);
}
for dir in version_manager_dirs(home) {
add(dir, InstallationSource::VersionManager);
}
for dir in ["/opt/homebrew/bin", "/usr/local/bin"] {
add(PathBuf::from(dir), InstallationSource::KnownLocation);
}
for extra in profile.extra_paths {
let dir = Path::new(extra);
let dir = if dir.is_absolute() {
dir.to_owned()
} else {
under(home, extra)
};
add(dir, InstallationSource::KnownLocation);
}
dirs
}
#[cfg(unix)]
const EXE_SUFFIXES: &[&str] = &[""];
#[cfg(windows)]
const EXE_SUFFIXES: &[&str] = &[".exe", ".cmd", ".bat"];
pub(crate) fn is_named(path: &Path, cli: &str) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.and_then(|name| name.strip_prefix(cli))
.is_some_and(|suffix| EXE_SUFFIXES.contains(&suffix))
}
fn resolve(
cli: &str,
dirs: &[(PathBuf, InstallationSource)],
) -> Option<(PathBuf, InstallationSource)> {
dirs.iter().find_map(|(dir, source)| {
EXE_SUFFIXES.iter().find_map(|suffix| {
let exe = dir.join(format!("{cli}{suffix}"));
is_executable(&exe).then(|| (exe, source.clone()))
})
})
}
fn under(home: &Path, rel: &str) -> PathBuf {
rel.split('/')
.fold(home.to_path_buf(), |path, part| path.join(part))
}
fn version_manager_dirs(home: &Path) -> Vec<PathBuf> {
let mut dirs = vec![
under(home, ".volta/bin"),
under(home, ".bun/bin"),
under(home, ".local/share/pnpm"),
under(home, "Library/pnpm"),
under(home, ".npm-global/bin"),
];
dirs.extend(versions_newest_first(
&under(home, ".nvm/versions/node"),
"bin",
));
dirs.extend(versions_newest_first(
&under(home, ".local/share/fnm/node-versions"),
"installation/bin",
));
dirs.extend(versions_newest_first(
&under(home, "Library/Application Support/fnm/node-versions"),
"installation/bin",
));
dirs
}
fn versions_newest_first(root: &Path, suffix: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut versions: Vec<(Vec<u64>, PathBuf)> = entries
.flatten()
.map(|e| (version_key(&e.file_name().to_string_lossy()), e.path()))
.collect();
versions.sort_by(|a, b| b.0.cmp(&a.0));
versions
.into_iter()
.map(|(_, path)| under(&path, suffix))
.collect()
}
fn version_key(name: &str) -> Vec<u64> {
name.trim_start_matches('v')
.split('.')
.filter_map(|part| part.parse().ok())
.collect()
}
fn split_path(path: Option<&str>) -> impl Iterator<Item = PathBuf> + '_ {
std::env::split_paths(path.unwrap_or_default())
}
fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
path.metadata()
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
path.is_file()
}
}
fn installation(
profile: &AgentProfile,
executable: PathBuf,
source: InstallationSource,
) -> AgentInstallation {
AgentInstallation {
id: AgentId::new(profile.id),
name: profile.name.into(),
executable_path: executable,
source,
upgrade: None,
acp_args: None,
}
}
pub(crate) fn login_methods(profile: &AgentProfile, exe: &Path) -> Vec<LoginMethod> {
let mut methods = Vec::new();
if !profile.login_args.is_empty() {
let mut command = vec![exe.to_string_lossy().into_owned()];
command.extend(profile.login_args.iter().map(|a| a.to_string()));
methods.push(LoginMethod::Terminal {
description: format!("Run `{}` in a terminal", command.join(" ")),
command,
env: BTreeMap::new(),
});
}
for var in profile.api_key_env {
methods.push(LoginMethod::EnvVar {
name: var.to_string(),
});
}
methods
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Connection;
use crate::testutil::stub;
fn profile() -> AgentProfile {
AgentProfile {
id: "fake",
name: "Fake",
cli: "fake-agent",
executable_env: "ANYAGENT_TEST_UNSET",
config_home_env: None,
connection: Connection::Acp { args: &[] },
api_key_env: &["ANYAGENT_TEST_UNSET_KEY"],
open_auth_kind: None,
auth_error_hints: &[],
login_args: &["login"],
install_hint: "install fake",
extra_paths: &["custom/bin"],
upgrade: None,
}
}
#[test]
fn search_follows_the_resolution_order() {
let home = Path::new("/h");
let path = |dirs: [&str; 2]| std::env::join_paths(dirs).unwrap().into_string().unwrap();
let dirs = search_dirs(
&profile(),
home,
Some(&path(["/a", "/b"])),
Some(&path(["/b", "/c"])),
);
let find = |path: &str| {
dirs.iter()
.position(|(dir, _)| dir == Path::new(path))
.unwrap()
};
assert_eq!(dirs[0], (PathBuf::from("/a"), InstallationSource::Path));
assert_eq!(dirs[1], (PathBuf::from("/b"), InstallationSource::Path));
assert_eq!(
dirs[2],
(PathBuf::from("/c"), InstallationSource::LoginShellPath)
);
assert!(find("/h/.volta/bin") < find("/opt/homebrew/bin"));
assert_eq!(
*dirs.last().unwrap(),
(
PathBuf::from("/h/custom/bin"),
InstallationSource::KnownLocation
)
);
}
#[test]
fn resolves_the_newest_version_manager_install() {
let home = tempfile::tempdir().unwrap();
stub(
&home.path().join(".nvm/versions/node/v9.9.9/bin"),
"fake-agent",
);
let newest = stub(
&home.path().join(".nvm/versions/node/v20.1.0/bin"),
"fake-agent",
);
let dirs = search_dirs(&profile(), home.path(), None, None);
let (exe, source) = resolve("fake-agent", &dirs).unwrap();
assert_eq!(exe, newest);
assert_eq!(source, InstallationSource::VersionManager);
}
#[test]
fn login_methods_list_the_command_then_the_keys() {
let login = login_methods(&profile(), Path::new("/h/bin/fake-agent"));
assert!(matches!(
&login[0],
LoginMethod::Terminal { command, .. } if command == &vec!["/h/bin/fake-agent".to_string(), "login".to_string()]
));
assert!(matches!(
&login[1],
LoginMethod::EnvVar { name } if name == "ANYAGENT_TEST_UNSET_KEY"
));
assert_eq!(login.len(), 2);
}
}