#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Preset {
pub id: &'static str,
pub name: &'static str,
pub argv: &'static [&'static str],
pub installed: Option<&'static [&'static str]>,
pub login: &'static str,
}
pub const PRESETS: &[Preset] = &[
Preset {
id: "copilot",
name: "GitHub Copilot",
argv: &["npx", "-y", "@github/copilot@1.0.105", "--acp"],
installed: Some(&["copilot", "--acp"]),
login: "copilot login",
},
Preset {
id: "opencode",
name: "OpenCode",
argv: &["opencode", "acp"],
installed: None,
login: "opencode auth login",
},
Preset {
id: "claude",
name: "Claude Code",
argv: &["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.98.0"],
installed: None,
login: "claude /login",
},
Preset {
id: "codex",
name: "Codex",
argv: &["npx", "-y", "@agentclientprotocol/codex-acp@2.3.0"],
installed: None,
login: "codex login",
},
Preset {
id: "gemini",
name: "Gemini CLI",
argv: &["npx", "-y", "@google/gemini-cli@0.66.0", "--acp"],
installed: None,
login: "gemini (sign in when it asks)",
},
];
#[must_use]
pub fn preset(id: &str) -> Option<&'static Preset> {
PRESETS.iter().find(|p| p.id == id)
}
#[must_use]
pub fn ids() -> String {
PRESETS.iter().map(|p| p.id).collect::<Vec<_>>().join(", ")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Agent {
pub id: String,
pub name: String,
pub argv: Vec<String>,
pub mode: Option<String>,
}
impl Agent {
#[must_use]
pub fn of(p: &Preset, mode: Option<String>) -> Self {
Self {
id: p.id.to_owned(),
name: p.name.to_owned(),
argv: p.argv.iter().map(|a| (*a).to_owned()).collect(),
mode,
}
}
#[must_use]
pub fn custom(argv: Vec<String>, mode: Option<String>) -> Self {
let name = argv
.first()
.map(|p| p.rsplit('/').next().unwrap_or(p).to_owned())
.unwrap_or_default();
Self {
id: "acp".to_owned(),
name,
argv,
mode,
}
}
#[must_use]
pub fn preset(&self) -> Option<&'static Preset> {
preset(&self.id)
}
#[must_use]
pub fn command(&self, on_path: impl Fn(&str) -> bool) -> Vec<String> {
match self.preset().and_then(|p| p.installed) {
Some(installed) if installed.first().is_some_and(|p| on_path(p)) => {
installed.iter().map(|a| (*a).to_owned()).collect()
}
_ => self.argv.clone(),
}
}
#[must_use]
pub fn npm_pin(argv: &[String]) -> Option<(&str, &str)> {
if argv.first().map(String::as_str) != Some("npx") {
return None;
}
let package = argv[1..]
.iter()
.find(|w| !w.starts_with('-') && w.rfind('@').is_some_and(|i| i > 0))?;
let at = package.rfind('@')?;
Some((&package[..at], &package[at + 1..]))
}
}
#[must_use]
pub fn on_path(program: &str) -> bool {
let env: Vec<(String, String)> = std::env::vars().collect();
crate::sandbox::resolve_executable(program, std::path::Path::new("."), &env).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_are_the_registry_commands_pinned_exactly() {
let mut npx = 0;
for p in PRESETS {
let argv: Vec<String> = p.argv.iter().map(|a| (*a).to_owned()).collect();
let Some((package, version)) = Agent::npm_pin(&argv) else {
assert_eq!(p.argv, ["opencode", "acp"], "{} is not pinned", p.id);
continue;
};
npx += 1;
let parts: Vec<&str> = version.split('.').collect();
assert!(
parts.len() == 3
&& parts
.iter()
.all(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())),
"{} pins {package}@{version}, a range rather than a version",
p.id
);
}
assert_eq!(npx, 4);
}
#[test]
fn copilot_runs_installed_when_it_is_on_path() {
let copilot = Agent::of(preset("copilot").unwrap_or(&PRESETS[0]), None);
assert_eq!(copilot.command(|p| p == "copilot"), ["copilot", "--acp"]);
assert_eq!(
copilot.command(|_| false),
["npx", "-y", "@github/copilot@1.0.105", "--acp"]
);
let own = Agent::custom(vec!["/opt/agent".to_owned(), "--acp".to_owned()], None);
assert_eq!(own.command(|_| true), ["/opt/agent", "--acp"]);
assert_eq!((own.id.as_str(), own.name.as_str()), ("acp", "agent"));
}
}