1use std::path::Path;
11use std::time::Duration;
12
13use agent_client_protocol::schema::ProtocolVersion;
14use agent_client_protocol::schema::v1::{
15 CloseSessionRequest, InitializeRequest, NewSessionRequest,
16};
17use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent as AgentRole, ConnectionTo};
18use speccoder_core::{AgentCard, AgentTick, TickState};
19
20use super::agent::{Agent, PRESETS, Preset};
21use super::modes::{self, Advertised, ModePlan, Why};
22
23pub const PROBE: Duration = Duration::from_secs(20);
25
26#[derive(Debug, Clone, Default)]
28pub struct Probed {
29 pub info: Option<(String, String)>,
31 pub sign_in: Option<Vec<super::session::SignIn>>,
33 pub error: Option<String>,
35 pub mode: Option<(
37 ModePlan,
38 Option<&'static modes::ModeProfile>,
39 Option<Advertised>,
40 )>,
41}
42
43#[must_use]
45pub fn probe(agent: &Agent, argv: &[String]) -> Probed {
46 let Ok(dir) = tempfile::tempdir() else {
47 return Probed {
48 error: Some("no scratch directory".to_owned()),
49 ..Probed::default()
50 };
51 };
52 let Ok(rt) = tokio::runtime::Builder::new_current_thread()
53 .enable_all()
54 .build()
55 else {
56 return Probed {
57 error: Some("the runtime could not start".to_owned()),
58 ..Probed::default()
59 };
60 };
61 let Some((program, args)) = argv.split_first() else {
62 return Probed::default();
63 };
64 let mut config = AcpAgentConfig::new(program).args(args.iter().cloned());
65 if agent.mode.is_none()
66 && let Some((k, v)) = modes::launch_profile(&agent.id).and_then(|p| p.launch_env)
67 {
68 config = config.env(k, modes::launch_value(v, std::env::var(k).ok().as_deref()));
69 }
70 let cwd = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
71 let started = AcpAgent::new(super::session::in_directory(config, &cwd));
72 let out = std::sync::Arc::new(std::sync::Mutex::new(Probed::default()));
73 let result = std::sync::Arc::clone(&out);
74 let preference = agent.mode.clone();
75 let argv = argv.to_vec();
76 let ran = rt.block_on(async move {
77 tokio::time::timeout(
78 PROBE,
79 agent_client_protocol::Client
80 .builder()
81 .name("speccoder")
82 .connect_with(started, move |c: ConnectionTo<AgentRole>| async move {
83 let p = open(&c, &cwd, preference.as_deref(), &argv).await;
84 if let Ok(mut r) = result.lock() {
85 *r = p;
86 }
87 Ok(())
88 }),
89 )
90 .await
91 });
92 let mut p = out.lock().map(|p| p.clone()).unwrap_or_default();
93 match ran {
94 Err(_) if p.info.is_none() => {
95 p.error = Some(format!("it did not answer within {} s", PROBE.as_secs()));
96 }
97 Ok(Err(e)) if p.info.is_none() && p.error.is_none() => p.error = Some(e.to_string()),
98 _ => {}
99 }
100 p
101}
102
103async fn open(
104 c: &ConnectionTo<AgentRole>,
105 cwd: &Path,
106 preference: Option<&str>,
107 argv: &[String],
108) -> Probed {
109 let mut p = Probed::default();
110 let init = match c
111 .send_request(
112 InitializeRequest::new(ProtocolVersion::V1)
113 .client_capabilities(super::session::declared()),
114 )
115 .block_task()
116 .await
117 {
118 Ok(i) => i,
119 Err(e) => {
120 p.error = Some(format!("it did not initialize: {}", e.message));
121 return p;
122 }
123 };
124 p.info = init
125 .agent_info
126 .as_ref()
127 .map(|i| (i.name.clone(), i.version.clone()));
128 let methods = serde_json::to_value(&init.auth_methods).unwrap_or_default();
129 let new = match c
130 .send_request(NewSessionRequest::new(cwd.to_path_buf()))
131 .block_task()
132 .await
133 {
134 Ok(n) => n,
135 Err(e) => {
136 if e.code == agent_client_protocol::ErrorCode::AuthRequired {
137 p.sign_in = Some(super::session::sign_in_methods(&methods, argv));
138 }
139 p.error = Some(e.message);
140 return p;
141 }
142 };
143 let setup = serde_json::to_value(&new).unwrap_or_default();
144 let profile = p
145 .info
146 .as_ref()
147 .and_then(|(n, v)| modes::profile_for(n, v, &setup["configOptions"], &setup["modes"]));
148 let advertised = Advertised::read(&setup["configOptions"], &setup["modes"], profile);
149 p.mode = Some((
150 ModePlan::of(profile, advertised.as_ref(), preference),
151 profile,
152 advertised,
153 ));
154 if init.agent_capabilities.session_capabilities.close.is_some() {
155 let _ = tokio::time::timeout(
156 Duration::from_secs(2),
157 c.send_request(CloseSessionRequest::new(new.session_id))
158 .block_task(),
159 )
160 .await;
161 }
162 p
163}
164
165fn install(p: &Preset, on_path: &dyn Fn(&str) -> bool) -> AgentTick {
168 let mac = cfg!(target_os = "macos") && on_path("brew");
169 let s = |a: &[&str]| vec![a.iter().map(|w| (*w).to_owned()).collect::<Vec<String>>()];
170 let missing = AgentTick::new(TickState::No, format!("`{}` is not on PATH", p.argv[0]));
171 match p.id {
172 "copilot" if mac => missing.with_command(s(&["brew", "install", "--cask", "copilot-cli"])),
173 "copilot" if on_path("npm") => {
174 missing.with_command(s(&["npm", "install", "-g", "@github/copilot"]))
175 }
176 "opencode" if mac => missing.with_command(s(&["brew", "install", "opencode"])),
177 "opencode" if on_path("npm") => {
178 missing.with_command(s(&["npm", "install", "-g", "opencode-ai"]))
179 }
180 _ if p.argv[0] == "npx" && mac => {
181 AgentTick::new(TickState::No, "it runs through `npx`, which needs Node.js")
182 .with_command(s(&["brew", "install", "node"]))
183 }
184 _ if p.argv[0] == "npx" => {
185 AgentTick::new(TickState::No, "it runs through `npx`, which needs Node.js")
186 .with_words("install Node.js (https://nodejs.org), then come back")
187 }
188 _ => missing.with_words(format!(
189 "install {} and put `{}` on PATH",
190 p.name, p.argv[0]
191 )),
192 }
193}
194
195#[must_use]
197pub fn card(
198 p: &Preset,
199 chosen: Option<&Agent>,
200 on_path: &dyn Fn(&str) -> bool,
201 probe_it: bool,
202) -> AgentCard {
203 let agent = match chosen.filter(|a| a.id == p.id) {
204 Some(a) => a.clone(),
205 None => Agent::of(p, None),
206 };
207 let argv = agent.command(|x| on_path(x));
208 let npx = argv.first().is_some_and(|a| a == "npx");
209 let pin = Agent::npm_pin(&argv).map(|(n, v)| format!("{n}@{v}"));
210 let runs = argv.first().is_some_and(|a| on_path(a));
211 let installed = if runs {
212 match &pin {
213 Some(pin) => AgentTick::new(
214 TickState::Yes,
215 format!("through npx: {pin}, downloaded when it first runs"),
216 ),
217 None => AgentTick::new(TickState::Yes, format!("`{}` on PATH", argv.join(" "))),
218 }
219 } else {
220 install(p, on_path)
221 };
222 let login =
223 || -> Vec<Vec<String>> { vec![p.login.split_whitespace().map(str::to_owned).collect()] };
224 let mut signed_in = AgentTick::new(TickState::Unknown, "checked when it first runs");
225 let mut asks_first = recorded(p, pin.as_deref());
226 if runs && !npx && probe_it {
227 let probed = probe(&agent, &argv);
228 let version = probed.info.as_ref().map(|(_, v)| v.clone());
229 signed_in = match (&probed.sign_in, &probed.error) {
230 (Some(ways), _) => {
231 let mut t = AgentTick::new(
232 TickState::No,
233 ways.iter()
234 .find_map(|w| w.description.clone())
235 .unwrap_or_else(|| format!("{} needs signing in", p.name)),
236 );
237 let commands: Vec<Vec<String>> = ways
238 .iter()
239 .filter_map(|w| w.command.as_deref())
240 .map(|c| c.split_whitespace().map(str::to_owned).collect())
241 .collect();
242 for c in if commands.is_empty() {
243 login()
244 } else {
245 commands
246 } {
247 t = t.with_command(vec![c]);
248 }
249 t
250 }
251 (None, Some(e)) => {
252 AgentTick::new(TickState::Unknown, format!("it could not be asked: {e}"))
253 }
254 (None, None) => AgentTick::new(
255 TickState::Yes,
256 format!(
257 "it opened a session{}",
258 version
259 .as_deref()
260 .map(|v| format!(" ({v})"))
261 .unwrap_or_default()
262 ),
263 ),
264 };
265 if let Some((plan, profile, advertised)) = &probed.mode {
266 asks_first = asks(p, plan, *profile, advertised.as_ref());
267 }
268 } else if runs && !npx {
269 signed_in = AgentTick::new(TickState::Unknown, "not checked (doctor --agents asks it)");
270 } else if npx {
271 signed_in = AgentTick::new(
272 TickState::Unknown,
273 "checked when it first runs: npx downloads it then",
274 );
275 }
276 AgentCard {
277 id: p.id.to_owned(),
278 name: p.name.to_owned(),
279 chosen: chosen.is_some_and(|a| a.id == p.id),
280 installed,
281 signed_in,
282 asks_first,
283 }
284}
285
286fn asks(
288 p: &Preset,
289 plan: &ModePlan,
290 profile: Option<&modes::ModeProfile>,
291 advertised: Option<&Advertised>,
292) -> AgentTick {
293 let name = |v: &str| advertised.map_or_else(|| v.to_owned(), |a| a.name_of(v));
294 if profile.is_some_and(|p| !p.recorded)
295 && matches!(plan.why, Why::AskedForAskFirst | Why::AsksAlready)
296 {
297 return AgentTick::new(
298 TickState::Unknown,
299 "its ask-first mode is known from its source only, not recorded: not known whether it asks",
300 );
301 }
302 match (plan.why, &plan.request) {
303 (Why::AskedForAskFirst, Some(v)) => AgentTick::new(
304 TickState::Yes,
305 format!("put into {} before each attempt", name(v)),
306 ),
307 (Why::AsksAlready, _) if profile.is_some_and(|p| p.launch_env.is_some()) => AgentTick::new(
308 TickState::Yes,
309 "started with every permission rule set to ask",
310 ),
311 (Why::AsksAlready, _) => AgentTick::new(
312 TickState::Yes,
313 format!(
314 "it asks first: {}",
315 advertised.map_or_else(String::new, |a| a.name_of(&a.current))
316 ),
317 ),
318 (Why::YouChose, _) => {
319 AgentTick::new(TickState::Unknown, "you chose its mode ([executor] mode)")
320 }
321 (Why::YouSetIt, _) => AgentTick::new(
322 TickState::No,
323 format!(
324 "its own settings put it in {}, which speccoder keeps: it decides some calls alone",
325 advertised.map_or_else(|| "another mode".to_owned(), |a| a.name_of(&a.current))
326 ),
327 )
328 .with_words(format!(
329 "set {} back to its ask-first mode in its own settings",
330 p.name
331 )),
332 (Why::Unprofiled, _) => AgentTick::new(
333 TickState::Unknown,
334 "no recording of this version says which mode asks first: it may decide alone",
335 ),
336 _ => AgentTick::new(
337 TickState::No,
338 "it offers no mode known to ask first: it may decide alone",
339 ),
340 }
341}
342
343fn recorded(p: &Preset, pin: Option<&str>) -> AgentTick {
345 let Some(profile) = modes::launch_profile(p.id) else {
346 return AgentTick::new(
347 TickState::Unknown,
348 "no recording says which mode asks first",
349 );
350 };
351 if !profile.recorded {
352 return AgentTick::new(
353 TickState::Unknown,
354 "its ask-first mode is known from its source only, not recorded",
355 );
356 }
357 let version = pin.and_then(|x| x.rsplit('@').next());
358 match version {
359 Some(v) if v != profile.agent_info.1 => AgentTick::new(
360 TickState::Unknown,
361 format!(
362 "recorded for {}; {v} is checked against it when it runs",
363 profile.agent_info.1
364 ),
365 ),
366 _ => AgentTick::new(
367 TickState::Yes,
368 format!(
369 "recorded for {} {}",
370 profile.agent_info.0, profile.agent_info.1
371 ),
372 ),
373 }
374}
375
376#[must_use]
378pub fn cards(chosen: Option<&Agent>, probe_them: bool) -> Vec<AgentCard> {
379 let on_path = |x: &str| super::agent::on_path(x);
380 std::thread::scope(|s| {
381 let handles: Vec<_> = PRESETS
382 .iter()
383 .map(|p| s.spawn(move || card(p, chosen, &on_path, probe_them)))
384 .collect();
385 handles.into_iter().filter_map(|h| h.join().ok()).collect()
386 })
387}
388
389#[must_use]
392pub fn next_after(
393 current: Option<&str>,
394 on_path: &dyn Fn(&str) -> bool,
395) -> Option<&'static Preset> {
396 let start = current
397 .and_then(|c| PRESETS.iter().position(|p| p.id == c))
398 .map_or(0, |i| i + 1);
399 PRESETS
400 .iter()
401 .cycle()
402 .skip(start)
403 .take(PRESETS.len())
404 .filter(|p| Some(p.id) != current)
405 .find(|p| {
406 Agent::of(p, None)
407 .command(on_path)
408 .first()
409 .is_some_and(|a| on_path(a))
410 })
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 fn preset(id: &str) -> &'static Preset {
418 super::super::agent::preset(id).unwrap_or(&PRESETS[0])
419 }
420
421 #[test]
422 fn a_missing_agent_has_its_install_command_and_an_npx_one_is_not_started() {
423 let none = |_: &str| false;
424 let c = card(preset("opencode"), None, &none, true);
425 assert_eq!(c.installed.state, TickState::No);
426 assert!(!c.installed.fix.is_empty(), "{:?}", c.installed);
427 let npm = |x: &str| x == "npm";
430 let c = card(preset("opencode"), None, &npm, true);
431 let shown = serde_json::to_value(&c.installed).unwrap_or_default();
432 let step = &shown["fix_steps"][0];
433 assert_eq!(
434 step["argvs"],
435 serde_json::json!([["npm", "install", "-g", "opencode-ai"]]),
436 "{shown}"
437 );
438 assert_eq!(step["how"], "command", "{shown}");
439 assert!(
440 step["outside"]
441 .as_str()
442 .unwrap_or_default()
443 .starts_with("installs "),
444 "{shown}"
445 );
446 let npx = |x: &str| x == "npx";
447 let c = card(preset("claude"), None, &npx, true);
448 assert_eq!(c.installed.state, TickState::Yes);
449 assert!(
450 c.installed
451 .detail
452 .contains("@agentclientprotocol/claude-agent-acp@0.98.0")
453 );
454 assert_eq!(c.signed_in.state, TickState::Unknown);
455 assert_eq!(c.asks_first.state, TickState::Unknown);
457 let c = card(preset("copilot"), None, &npx, false);
458 assert_eq!(c.asks_first.state, TickState::Yes, "{:?}", c.asks_first);
459 }
460
461 #[test]
462 fn the_next_agent_is_the_next_installed_one() {
463 let have = |x: &str| x == "opencode" || x == "copilot";
464 assert_eq!(
465 next_after(Some("opencode"), &have).map(|p| p.id),
466 Some("copilot")
467 );
468 let npx = |x: &str| x == "opencode" || x == "npx";
469 assert_eq!(
470 next_after(Some("opencode"), &npx).map(|p| p.id),
471 Some("claude")
472 );
473 let only = |x: &str| x == "opencode";
474 assert_eq!(next_after(Some("opencode"), &only), None);
475 assert_eq!(next_after(None, &only).map(|p| p.id), Some("opencode"));
476 }
477}