Skip to main content

agent_abstraction/
probe.rs

1//! Asking a CLI what version it is, and whether this crate was built for it.
2//!
3//! Every flag mapping here was verified against a specific release. Those
4//! releases move: `codex exec resume` accepts `--sandbox` in some versions and
5//! rejects it outright in 0.145.0, and Copilot gained a headless session id and
6//! an event stream that an older wrapper still models as absent. Without a
7//! version check, that drift is discovered by a run failing halfway through with
8//! "unexpected argument", which names a flag rather than the cause.
9//!
10//! This module makes the check available up front. It is **not** run
11//! automatically: probing spawns a process, and paying that on every request to
12//! guard against an occasional upstream change is the wrong trade. A host
13//! should probe once at startup, or when a run fails with
14//! [`crate::Error::FlagRejected`], and surface the result to whoever can act on
15//! it.
16
17use std::fmt;
18
19use crate::agent::Agent;
20use crate::error::{Error, Result};
21
22/// A three-part version, compared numerically.
23///
24/// Deliberately not a `semver` dependency: these CLIs report a plain dotted
25/// triple inside prose ("codex-cli 0.145.0", "2.1.205 (Claude Code)"), none of
26/// them publishes pre-release or build metadata here, and a whole crate to
27/// compare three integers is not worth the graph.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub struct Version {
30    /// Breaking-change component.
31    pub major: u32,
32    /// Feature component.
33    pub minor: u32,
34    /// Fix component.
35    pub patch: u32,
36}
37
38impl Version {
39    /// Find the first dotted triple anywhere in `text`.
40    ///
41    /// Each CLI wraps its version in different prose, so this scans rather than
42    /// parsing a fixed shape: `2.1.205 (Claude Code)`, `codex-cli 0.145.0`, and
43    /// `GitHub Copilot CLI 1.0.75.` all yield their number.
44    #[must_use]
45    pub fn find(text: &str) -> Option<Version> {
46        let bytes = text.as_bytes();
47        let mut start = 0;
48        while start < bytes.len() {
49            if bytes[start].is_ascii_digit()
50                // Only start at a boundary, so `1.0.75` inside `abc1.0.75` is
51                // not read from the middle of a token.
52                && (start == 0 || !bytes[start - 1].is_ascii_digit() && bytes[start - 1] != b'.')
53                && let Some(version) = Version::parse_at(&text[start..])
54            {
55                return Some(version);
56            }
57            start += 1;
58        }
59        None
60    }
61
62    /// Parse a triple anchored at the start of `text`, ignoring any trailing
63    /// prose.
64    fn parse_at(text: &str) -> Option<Version> {
65        let mut parts = [0u32; 3];
66        let mut rest = text;
67        for (index, part) in parts.iter_mut().enumerate() {
68            if index > 0 {
69                rest = rest.strip_prefix('.')?;
70            }
71            let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
72            if digits.is_empty() {
73                return None;
74            }
75            *part = digits.parse().ok()?;
76            rest = &rest[digits.len()..];
77        }
78        Some(Version {
79            major: parts[0],
80            minor: parts[1],
81            patch: parts[2],
82        })
83    }
84}
85
86impl fmt::Display for Version {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
89    }
90}
91
92/// How an installed CLI relates to the release this crate was verified against.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum VersionStatus {
95    /// Exactly the verified release. Mappings are known good.
96    Verified,
97    /// Newer. The likely direction of drift: flags may have been renamed,
98    /// replaced, or moved between subcommands.
99    Newer,
100    /// Older. Flags this crate relies on may not exist yet.
101    Older,
102    /// The CLI answered, but no version could be read out of it.
103    Unrecognized,
104}
105
106impl VersionStatus {
107    /// Whether mappings are known good for this version.
108    #[must_use]
109    pub fn is_verified(self) -> bool {
110        self == VersionStatus::Verified
111    }
112}
113
114/// What an installed agent CLI reported about itself.
115#[derive(Debug, Clone)]
116#[non_exhaustive]
117pub struct Probe {
118    /// The agent probed.
119    pub agent: Agent,
120    /// The binary that answered.
121    pub bin: String,
122    /// Its `--version` output, trimmed.
123    pub reported: String,
124    /// The version read out of that output, if one could be.
125    pub version: Option<Version>,
126    /// The release this crate's mappings were verified against.
127    pub verified: Version,
128    /// How the two relate.
129    pub status: VersionStatus,
130}
131
132impl Probe {
133    /// Ask `agent`'s default binary for its version.
134    ///
135    /// # Errors
136    /// [`Error::NotInstalled`] if the binary is missing, [`Error::Spawn`] if it
137    /// cannot be run.
138    pub async fn run(agent: Agent) -> Result<Probe> {
139        Probe::run_bin(agent, agent.bin()).await
140    }
141
142    /// Ask a specific binary for its version, for a caller that overrides the
143    /// path with [`crate::Request::bin`].
144    ///
145    /// # Errors
146    /// [`Error::NotInstalled`] if the binary is missing, [`Error::Spawn`] if it
147    /// cannot be run.
148    pub async fn run_bin(agent: Agent, bin: &str) -> Result<Probe> {
149        let output = tokio::process::Command::new(bin)
150            .arg("--version")
151            .output()
152            .await
153            .map_err(|source| {
154                if source.kind() == std::io::ErrorKind::NotFound {
155                    Error::NotInstalled {
156                        agent,
157                        bin: bin.to_string(),
158                        hint: agent.install_hint(),
159                    }
160                } else {
161                    Error::Spawn {
162                        bin: bin.to_string(),
163                        source,
164                    }
165                }
166            })?;
167
168        // Some CLIs print their version to stderr; take whichever answered.
169        let mut reported = String::from_utf8_lossy(&output.stdout).trim().to_string();
170        if reported.is_empty() {
171            reported = String::from_utf8_lossy(&output.stderr).trim().to_string();
172        }
173
174        let verified = agent.verified_version();
175        let version = Version::find(&reported);
176        let status = match version {
177            None => VersionStatus::Unrecognized,
178            Some(found) if found == verified => VersionStatus::Verified,
179            Some(found) if found > verified => VersionStatus::Newer,
180            Some(_) => VersionStatus::Older,
181        };
182
183        Ok(Probe {
184            agent,
185            bin: bin.to_string(),
186            reported,
187            version,
188            verified,
189            status,
190        })
191    }
192
193    /// A sentence explaining a non-verified version, or `None` when it matches.
194    ///
195    /// Written to be shown to a person: it says what was found, what was
196    /// expected, and what that means for them.
197    #[must_use]
198    pub fn advisory(&self) -> Option<String> {
199        let agent = self.agent;
200        let verified = self.verified;
201        match self.status {
202            VersionStatus::Verified => None,
203            VersionStatus::Newer => Some(format!(
204                "{agent} is newer than the {verified} this crate's flags were verified \
205                 against ({}). Flags may have been renamed or moved between subcommands; \
206                 a run failing with an unexpected-argument error is the likely symptom.",
207                self.version
208                    .map_or_else(|| "unknown".into(), |v| v.to_string()),
209            )),
210            VersionStatus::Older => Some(format!(
211                "{agent} is older than the {verified} this crate's flags were verified \
212                 against ({}). Flags this crate relies on may not exist in it yet.",
213                self.version
214                    .map_or_else(|| "unknown".into(), |v| v.to_string()),
215            )),
216            VersionStatus::Unrecognized => Some(format!(
217                "could not read a version out of what {agent} reported ({:?}), so its flags \
218                 cannot be checked against the verified {verified}.",
219                self.reported,
220            )),
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    /// The real strings, as each CLI actually prints them.
230    #[test]
231    fn versions_are_found_inside_each_cli_s_own_prose() {
232        assert_eq!(
233            Version::find("2.1.205 (Claude Code)"),
234            Some(Version {
235                major: 2,
236                minor: 1,
237                patch: 205
238            })
239        );
240        assert_eq!(
241            Version::find("codex-cli 0.145.0"),
242            Some(Version {
243                major: 0,
244                minor: 145,
245                patch: 0
246            })
247        );
248        // Note the trailing period, which must not be read as another part.
249        assert_eq!(
250            Version::find("GitHub Copilot CLI 1.0.75."),
251            Some(Version {
252                major: 1,
253                minor: 0,
254                patch: 75
255            })
256        );
257    }
258
259    #[test]
260    fn text_without_a_triple_yields_nothing() {
261        for text in ["", "no version here", "1.2", "v1", "beta"] {
262            assert_eq!(Version::find(text), None, "{text:?}");
263        }
264    }
265
266    /// A version embedded in a longer token must not be read from its middle.
267    #[test]
268    fn a_triple_is_only_read_from_a_token_boundary() {
269        assert_eq!(
270            Version::find("build20251.0.75"),
271            Some(Version {
272                major: 20251,
273                minor: 0,
274                patch: 75
275            }),
276            "the whole leading number belongs to the version"
277        );
278    }
279
280    #[test]
281    fn versions_order_numerically_not_lexically() {
282        let v = |major, minor, patch| Version {
283            major,
284            minor,
285            patch,
286        };
287        // The case a string comparison gets wrong: 205 > 99.
288        assert!(v(2, 1, 205) > v(2, 1, 99));
289        assert!(v(0, 145, 0) > v(0, 99, 9));
290        assert!(v(1, 0, 0) > v(0, 999, 999));
291    }
292
293    #[test]
294    fn every_agent_declares_a_parseable_verified_version() {
295        for agent in Agent::ALL {
296            let verified = agent.verified_version();
297            assert!(verified.major > 0 || verified.minor > 0, "{agent}");
298        }
299    }
300
301    #[tokio::test]
302    async fn probing_a_missing_binary_says_how_to_install_it() {
303        let err = Probe::run_bin(Agent::Claude, "agent-abstraction-no-such-binary")
304            .await
305            .unwrap_err();
306        assert!(matches!(err, Error::NotInstalled { .. }), "{err:?}");
307    }
308}