1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct Preset {
10 pub id: &'static str,
12 pub name: &'static str,
14 pub argv: &'static [&'static str],
16 pub installed: Option<&'static [&'static str]>,
19 pub login: &'static str,
21}
22
23pub const PRESETS: &[Preset] = &[
26 Preset {
27 id: "copilot",
28 name: "GitHub Copilot",
29 argv: &["npx", "-y", "@github/copilot@1.0.105", "--acp"],
30 installed: Some(&["copilot", "--acp"]),
31 login: "copilot login",
32 },
33 Preset {
36 id: "opencode",
37 name: "OpenCode",
38 argv: &["opencode", "acp"],
39 installed: None,
40 login: "opencode auth login",
41 },
42 Preset {
43 id: "claude",
44 name: "Claude Code",
45 argv: &["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.98.0"],
46 installed: None,
47 login: "claude /login",
48 },
49 Preset {
50 id: "codex",
51 name: "Codex",
52 argv: &["npx", "-y", "@agentclientprotocol/codex-acp@2.3.0"],
53 installed: None,
54 login: "codex login",
55 },
56 Preset {
57 id: "gemini",
58 name: "Gemini CLI",
59 argv: &["npx", "-y", "@google/gemini-cli@0.66.0", "--acp"],
60 installed: None,
61 login: "gemini (sign in when it asks)",
62 },
63];
64
65#[must_use]
67pub fn preset(id: &str) -> Option<&'static Preset> {
68 PRESETS.iter().find(|p| p.id == id)
69}
70
71#[must_use]
73pub fn ids() -> String {
74 PRESETS.iter().map(|p| p.id).collect::<Vec<_>>().join(", ")
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Agent {
80 pub id: String,
82 pub name: String,
84 pub argv: Vec<String>,
86 pub mode: Option<String>,
89}
90
91impl Agent {
92 #[must_use]
94 pub fn of(p: &Preset, mode: Option<String>) -> Self {
95 Self {
96 id: p.id.to_owned(),
97 name: p.name.to_owned(),
98 argv: p.argv.iter().map(|a| (*a).to_owned()).collect(),
99 mode,
100 }
101 }
102
103 #[must_use]
105 pub fn custom(argv: Vec<String>, mode: Option<String>) -> Self {
106 let name = argv
107 .first()
108 .map(|p| p.rsplit('/').next().unwrap_or(p).to_owned())
109 .unwrap_or_default();
110 Self {
111 id: "acp".to_owned(),
112 name,
113 argv,
114 mode,
115 }
116 }
117
118 #[must_use]
120 pub fn preset(&self) -> Option<&'static Preset> {
121 preset(&self.id)
122 }
123
124 #[must_use]
127 pub fn command(&self, on_path: impl Fn(&str) -> bool) -> Vec<String> {
128 match self.preset().and_then(|p| p.installed) {
129 Some(installed) if installed.first().is_some_and(|p| on_path(p)) => {
130 installed.iter().map(|a| (*a).to_owned()).collect()
131 }
132 _ => self.argv.clone(),
133 }
134 }
135
136 #[must_use]
138 pub fn npm_pin(argv: &[String]) -> Option<(&str, &str)> {
139 if argv.first().map(String::as_str) != Some("npx") {
140 return None;
141 }
142 let package = argv[1..]
143 .iter()
144 .find(|w| !w.starts_with('-') && w.rfind('@').is_some_and(|i| i > 0))?;
145 let at = package.rfind('@')?;
146 Some((&package[..at], &package[at + 1..]))
147 }
148}
149
150#[must_use]
152pub fn on_path(program: &str) -> bool {
153 let env: Vec<(String, String)> = std::env::vars().collect();
154 crate::sandbox::resolve_executable(program, std::path::Path::new("."), &env).is_some()
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn presets_are_the_registry_commands_pinned_exactly() {
163 let mut npx = 0;
164 for p in PRESETS {
165 let argv: Vec<String> = p.argv.iter().map(|a| (*a).to_owned()).collect();
166 let Some((package, version)) = Agent::npm_pin(&argv) else {
167 assert_eq!(p.argv, ["opencode", "acp"], "{} is not pinned", p.id);
168 continue;
169 };
170 npx += 1;
171 let parts: Vec<&str> = version.split('.').collect();
172 assert!(
173 parts.len() == 3
174 && parts
175 .iter()
176 .all(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())),
177 "{} pins {package}@{version}, a range rather than a version",
178 p.id
179 );
180 }
181 assert_eq!(npx, 4);
182 }
183
184 #[test]
185 fn copilot_runs_installed_when_it_is_on_path() {
186 let copilot = Agent::of(preset("copilot").unwrap_or(&PRESETS[0]), None);
187 assert_eq!(copilot.command(|p| p == "copilot"), ["copilot", "--acp"]);
188 assert_eq!(
189 copilot.command(|_| false),
190 ["npx", "-y", "@github/copilot@1.0.105", "--acp"]
191 );
192 let own = Agent::custom(vec!["/opt/agent".to_owned(), "--acp".to_owned()], None);
194 assert_eq!(own.command(|_| true), ["/opt/agent", "--acp"]);
195 assert_eq!((own.id.as_str(), own.name.as_str()), ("acp", "agent"));
196 }
197}