Skip to main content

speccoder_runtime/acp/
agent.rs

1//! Which agents can make an attempt, and how each is started: the command
2//! the ACP registry publishes for it, an `npx` package pinned to an exact
3//! version (`npx` would otherwise fetch whatever is newest).
4//!
5//! Adapted from devplane's `acp::agent` (same owner, MIT OR Apache-2.0).
6
7/// One preset.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct Preset {
10    /// `agent = "<id>"` in `[executor]`.
11    pub id: &'static str,
12    /// The agent's name, for a person.
13    pub name: &'static str,
14    /// The command line.
15    pub argv: &'static [&'static str],
16    /// A program on `PATH` that is the same agent without `npx` (it is used
17    /// when found).
18    pub installed: Option<&'static [&'static str]>,
19    /// How a person logs the agent in, when the agent does not say.
20    pub login: &'static str,
21}
22
23/// The presets, each `npx` package pinned to an exact `x.y.z` (the current
24/// release on 2026-10-28).
25pub 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    // The registry distributes OpenCode as a binary; there is no `npx`
34    // entry to pin.
35    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/// The preset called `id`.
66#[must_use]
67pub fn preset(id: &str) -> Option<&'static Preset> {
68    PRESETS.iter().find(|p| p.id == id)
69}
70
71/// The preset ids, for messages: `copilot, claude, codex, gemini, opencode`.
72#[must_use]
73pub fn ids() -> String {
74    PRESETS.iter().map(|p| p.id).collect::<Vec<_>>().join(", ")
75}
76
77/// The agent an attempt starts.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Agent {
80    /// A preset's id, or `acp` for an `argv` of the person's.
81    pub id: String,
82    /// For a person.
83    pub name: String,
84    /// The command line, as configured (a preset's own, or `argv`).
85    pub argv: Vec<String>,
86    /// The permission mode the person chose, in the agent's own spelling;
87    /// `None` lets speccoder ask for the agent's ask-first mode.
88    pub mode: Option<String>,
89}
90
91impl Agent {
92    /// The preset `p`.
93    #[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    /// Any ACP agent, started as `argv`.
104    #[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    /// The preset, if it is one.
119    #[must_use]
120    pub fn preset(&self) -> Option<&'static Preset> {
121        preset(&self.id)
122    }
123
124    /// The command line started: an installed program where the preset has
125    /// one and `on_path` finds it, else the configured one.
126    #[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    /// The npm package and exact version an `npx` command is pinned to.
137    #[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/// Whether `program` is an executable on `PATH`.
151#[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        // An argv of the person's is started as written.
193        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}