use std::{
io::IsTerminal,
path::{Path, PathBuf},
};
use agent_harness_adapter_core::{Scope, Tool, harness::expand, installed};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Choice {
Given(Vec<String>),
Installed(Vec<String>),
Picked(Vec<String>),
}
impl Choice {
pub fn ids(&self) -> &[String] {
match self {
Choice::Given(i) | Choice::Installed(i) | Choice::Picked(i) => i,
}
}
}
pub fn interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
pub fn choose<T: Tool + ?Sized>(
given: &[String],
tool: &T,
scope: Scope,
interactive: bool,
ask: impl FnOnce(&[(String, bool)]) -> Result<Vec<String>>,
) -> Result<Choice> {
if !given.is_empty() {
return Ok(Choice::Given(expand(tool, given, scope)));
}
let now = installed(tool, scope)?;
if !now.is_empty() {
return Ok(Choice::Installed(now));
}
if !interactive {
return Err(Error::Usage(
"say which harnesses: --harness <ids|all>".into(),
));
}
let options: Vec<(String, bool)> = tool
.harnesses()
.iter()
.filter(|h| h.scopes().contains(&scope))
.map(|h| (h.id().to_string(), h.program().is_some_and(on_path)))
.collect();
let picked = ask(&options)?;
if picked.is_empty() {
return Err(Error::Usage("no harness picked".into()));
}
Ok(Choice::Picked(picked))
}
pub fn ask(options: &[(String, bool)]) -> Result<Vec<String>> {
let labels: Vec<String> = options
.iter()
.map(|(id, found)| {
if *found {
format!("{id} (found)")
} else {
id.clone()
}
})
.collect();
let defaults: Vec<bool> = options.iter().map(|(_, found)| *found).collect();
let picked = dialoguer::MultiSelect::new()
.with_prompt("Which agents do you use? (space to pick, enter to confirm)")
.items(&labels)
.defaults(&defaults)
.interact_on_opt(&dialoguer::console::Term::stderr())
.map_err(|e| Error::Usage(format!("could not ask: {e}")))?
.ok_or_else(|| Error::Usage("no harness picked".into()))?;
Ok(picked.into_iter().map(|i| options[i].0.clone()).collect())
}
pub fn on_path(program: &str) -> bool {
let path = std::env::var_os("PATH").unwrap_or_default();
let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
find_in(program, &path, cfg!(windows), &exts).is_some()
}
fn find_in(program: &str, path: &std::ffi::OsStr, windows: bool, exts: &str) -> Option<PathBuf> {
std::env::split_paths(path).find_map(|dir| {
let names: Vec<String> = if windows {
exts.split(';')
.filter(|e| !e.is_empty())
.map(|e| format!("{program}{e}"))
.collect()
} else {
vec![program.to_string()]
};
names.into_iter().map(|n| dir.join(n)).find(|p| runnable(p))
})
}
#[cfg(unix)]
fn runnable(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
p.metadata()
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn runnable(p: &Path) -> bool {
p.is_file()
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn programs_are_found_when_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("aha-pick-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let exe = dir.join("claude");
std::fs::write(&exe, "").unwrap();
std::fs::write(dir.join("codex"), "").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = std::env::join_paths([dir.join("none"), dir.clone()]).unwrap();
assert_eq!(find_in("claude", &path, false, ""), Some(exe));
assert_eq!(find_in("codex", &path, false, ""), None);
assert_eq!(find_in("gemini", &path, false, ""), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn windows_tries_each_ending() {
let dir = std::env::temp_dir().join(format!("aha-pick-win-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let shim = dir.join("codex.CMD");
std::fs::write(&shim, "").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = std::env::join_paths([dir.clone()]).unwrap();
assert_eq!(find_in("codex", &path, true, ".EXE;.CMD"), Some(shim));
assert_eq!(find_in("codex", &path, false, ".EXE;.CMD"), None);
std::fs::remove_dir_all(&dir).unwrap();
}
fn tool(dir: &Path) -> agent_harness_adapter_core::manifest::ManifestTool {
use agent_harness_adapter_core::manifest::{Manifest, ManifestTool, config_text};
let text = config_text(Scope::Project, "agents");
let m = Manifest::from_text(&text, false, dir, "config").unwrap();
ManifestTool::new(m, dir, dir.join("home"))
}
#[test]
fn harnesses_come_from_the_flag_the_record_or_a_question() {
let dir = std::env::temp_dir().join(format!("aha-pick-choose-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("AGENTS.md"), "Be kind.\n").unwrap();
let t = tool(&dir);
let never = |_: &[(String, bool)]| -> Result<Vec<String>> { panic!("asked") };
let given = choose(&["all".to_string()], &t, Scope::Local, false, never).unwrap();
assert_eq!(
given,
Choice::Given(vec!["claude".into(), "copilot".into()])
);
let e = choose(&[], &t, Scope::Project, false, never)
.unwrap_err()
.to_string();
assert_eq!(e, "say which harnesses: --harness <ids|all>");
let mut offered = Vec::new();
let picked = choose(&[], &t, Scope::Project, true, |o| {
offered = o.to_vec();
Ok(vec!["codex".into()])
})
.unwrap();
assert_eq!(picked, Choice::Picked(vec!["codex".into()]));
assert_eq!(offered.len(), 12);
assert_eq!(offered.last().unwrap(), &("agents".to_string(), false));
let e = choose(&[], &t, Scope::Project, true, |_| Ok(vec![]))
.unwrap_err()
.to_string();
assert_eq!(e, "no harness picked");
agent_harness_adapter_core::install(
&t,
&agent_harness_adapter_core::InstallOptions::new(["claude"], Scope::Project),
)
.unwrap();
let again = choose(&[], &t, Scope::Project, true, never).unwrap();
assert_eq!(again, Choice::Installed(vec!["claude".into()]));
assert_eq!(again.ids(), ["claude"]);
std::fs::remove_dir_all(&dir).unwrap();
}
}