Skip to main content

forge_agent/env/
cli_version.rs

1//! Detect the installed `claude` CLI version and (when reachable)
2//! the latest published version on npm.
3//!
4//! The local probe shells out via [`forge_sdk::transport::process::query_cli_version`]
5//! and normalises the result to a bare `MAJOR.MINOR.PATCH` token -
6//! `claude --version` prints lines like `2.1.116 (anthropic)` or
7//! `claude 2.1.116` depending on the build channel, so callers want
8//! the version token alone for display.
9//!
10//! `CliVersionInfo` is the cross-crate shape returned by the
11//! workspace mediator; both `installed` and `latest` are `Option`
12//! because either probe can fail independently (`claude` not on
13//! PATH, no network, npm rate-limited). The renderer treats `None`
14//! as a ` - ` placeholder so the panel's row count stays constant.
15
16use std::time::Duration;
17
18/// Per-probe timeout (each of the two probes runs in parallel via
19/// `tokio::join!` inside [`crate::env::cli_version::fetch_info`]).
20/// 5 s is generous for a local `--version` invocation and still
21/// well below the user-visible "app feels stuck" threshold for the
22/// startup latency hit.
23const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
24
25/// npm registry endpoint for the published `@anthropic-ai/claude-code`
26/// package. The `/latest` dist-tag returns the same JSON shape as the
27/// full package document but trimmed to the current latest version,
28/// which is all we need.
29const NPM_LATEST_URL: &str = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
30
31/// Snapshot of the installed-vs-latest claude CLI versions. Either
32/// side can be `None` independently; the renderer falls back to a
33/// dim ` - ` placeholder for the missing side.
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub struct CliVersionInfo {
36    /// Version reported by `claude --version` on the local machine,
37    /// normalised to `MAJOR.MINOR.PATCH`. `None` when the binary
38    /// isn't on PATH or the probe failed.
39    pub installed: Option<String>,
40    /// Latest version published on npm under the `latest` dist-tag.
41    /// `None` when the registry probe failed (offline, rate-limit,
42    /// timeout, …).
43    pub latest: Option<String>,
44}
45
46impl CliVersionInfo {
47    /// `true` when both probes succeeded AND the published version
48    /// is strictly newer than the installed one. Comparison uses
49    /// numeric-tuple semver: a non-parseable token on either side
50    /// collapses to `false` (no update banner shown rather than a
51    /// spurious one).
52    pub fn has_update(&self) -> bool {
53        match (self.installed.as_deref(), self.latest.as_deref()) {
54            (Some(installed), Some(latest)) => is_strictly_newer(latest, installed),
55            _ => false,
56        }
57    }
58}
59
60/// Run both probes in parallel and return the merged snapshot.
61/// Always succeeds - failures collapse to `None` on the affected
62/// field with a WARN log; the workspace caller never has to handle
63/// a `Result`.
64pub async fn fetch_info() -> CliVersionInfo {
65    let (installed, latest) = tokio::join!(probe_installed(), probe_latest());
66    CliVersionInfo { installed, latest }
67}
68
69/// Spawn `claude --version` on a blocking thread (the underlying
70/// helper uses `std::process::Command`), bound the wait to
71/// [`PROBE_TIMEOUT`], and normalise the output to a bare
72/// `MAJOR.MINOR.PATCH` token. WARN log + `None` on any failure.
73async fn probe_installed() -> Option<String> {
74    probe_installed_within(PROBE_TIMEOUT).await
75}
76
77/// [`probe_installed`] with the wait as a parameter, so a test driving a
78/// real child can widen the budget rather than race machine load.
79async fn probe_installed_within(budget: Duration) -> Option<String> {
80    let result = tokio::time::timeout(
81        budget,
82        tokio::task::spawn_blocking(|| forge_sdk::transport::process::query_cli_version("claude")),
83    )
84    .await;
85    match result {
86        Ok(Ok(Ok(reported))) => reported_version_or_warn(&reported),
87        Ok(Ok(Err(err))) => {
88            tracing::warn!(
89                target: crate::logging::targets::ENV_GIT,
90                event_name = "cli_version_probe_failed",
91                message = "claude --version probe failed",
92                outcome = "failure",
93                error = %err,
94            );
95            None
96        }
97        Ok(Err(join_err)) => {
98            tracing::warn!(
99                target: crate::logging::targets::ENV_GIT,
100                event_name = "cli_version_probe_join_failed",
101                message = "claude --version probe task panicked",
102                outcome = "failure",
103                error = %join_err,
104            );
105            None
106        }
107        Err(_) => {
108            tracing::warn!(
109                target: crate::logging::targets::ENV_GIT,
110                event_name = "cli_version_probe_timeout",
111                message = "claude --version probe timed out",
112                outcome = "timeout",
113            );
114            None
115        }
116    }
117}
118
119/// Normalise a zero-exit `claude --version` stdout to a bare version
120/// token, WARN + `None` when it carries none - a silent `None` here is
121/// indistinguishable from a probe that never ran.
122fn reported_version_or_warn(reported: &str) -> Option<String> {
123    let Some(token) = extract_semver_token(reported) else {
124        tracing::warn!(
125            target: crate::logging::targets::ENV_GIT,
126            event_name = "cli_version_probe_unparseable",
127            message = "claude --version output carried no version token",
128            outcome = "failure",
129            output = %reported,
130        );
131        return None;
132    };
133    Some(token.to_owned())
134}
135
136/// Result of one npm-registry attempt. The type exists for the retry
137/// decision: `Unreachable` never touched the server (retry direct),
138/// `Unusable` reached it but the response was no good (retry won't
139/// help).
140enum ProbeOutcome {
141    /// Parsed a version token from the response.
142    Version(String),
143    /// Connect / send error - the registry was never reached.
144    Unreachable,
145    /// Reached the registry, but the response was non-success, bad
146    /// JSON, or missing the `version` field.
147    Unusable,
148}
149
150/// GET npm's `/latest` dist-tag and return the newest published
151/// `@anthropic-ai/claude-code` version as `MAJOR.MINOR.PATCH`.
152///
153/// The first attempt honours the ambient proxy env; if it can't reach
154/// the registry (e.g. an exported `HTTPS_PROXY` pointing at a stopped
155/// mitmproxy) it retries once via `.no_proxy()` so the non-sensitive
156/// version check still lands over a direct connection. WARN-and-`None`
157/// on any terminal failure.
158async fn probe_latest() -> Option<String> {
159    let client = build_probe_client(false)?;
160    match attempt_latest(&client, false).await {
161        ProbeOutcome::Version(v) => return Some(v),
162        ProbeOutcome::Unusable => return None,
163        ProbeOutcome::Unreachable => {}
164    }
165    let direct = build_probe_client(true)?;
166    match attempt_latest(&direct, true).await {
167        ProbeOutcome::Version(v) => Some(v),
168        _ => None,
169    }
170}
171
172/// Build the reqwest client for a single npm probe. `direct` adds
173/// `.no_proxy()` so the request bypasses any ambient proxy env and
174/// connects straight to the registry; both variants keep
175/// `with_extra_roots` so a corporate / mitmproxy CA still validates.
176fn build_probe_client(direct: bool) -> Option<reqwest::Client> {
177    let mut builder =
178        crate::http_trust::with_extra_roots(reqwest::Client::builder().timeout(PROBE_TIMEOUT));
179    if direct {
180        builder = builder.no_proxy();
181    }
182    match builder.build() {
183        Ok(client) => Some(client),
184        Err(err) => {
185            tracing::warn!(
186                target: crate::logging::targets::ENV_GIT,
187                event_name = "cli_release_client_build_failed",
188                message = "reqwest client build failed",
189                outcome = "failure",
190                direct,
191                error = %err,
192            );
193            None
194        }
195    }
196}
197
198/// Run one GET against the npm registry with the given client,
199/// classifying the result so the caller can decide whether a direct
200/// retry is worthwhile.
201async fn attempt_latest(client: &reqwest::Client, direct: bool) -> ProbeOutcome {
202    let resp = match client.get(NPM_LATEST_URL).send().await {
203        Ok(resp) => resp,
204        Err(err) => {
205            tracing::warn!(
206                target: crate::logging::targets::ENV_GIT,
207                event_name = "cli_release_fetch_failed",
208                message = "npm registry GET failed",
209                outcome = "failure",
210                direct,
211                error = %err,
212            );
213            return ProbeOutcome::Unreachable;
214        }
215    };
216    if !resp.status().is_success() {
217        tracing::warn!(
218            target: crate::logging::targets::ENV_GIT,
219            event_name = "cli_release_non_success",
220            message = "npm registry returned non-success",
221            outcome = "failure",
222            direct,
223            status = %resp.status(),
224        );
225        return ProbeOutcome::Unusable;
226    }
227    let body: serde_json::Value = match resp.json().await {
228        Ok(v) => v,
229        Err(err) => {
230            tracing::warn!(
231                target: crate::logging::targets::ENV_GIT,
232                event_name = "cli_release_parse_failed",
233                message = "npm registry JSON parse failed",
234                outcome = "failure",
235                direct,
236                error = %err,
237            );
238            return ProbeOutcome::Unusable;
239        }
240    };
241    match latest_version_or_warn(&body, direct) {
242        Some(version) => ProbeOutcome::Version(version),
243        None => ProbeOutcome::Unusable,
244    }
245}
246
247/// Normalise an npm `/latest` response body to a version token, WARN +
248/// `None` when it carries none - the same silent-collapse shape as the
249/// installed probe's miss.
250fn latest_version_or_warn(body: &serde_json::Value, direct: bool) -> Option<String> {
251    let Some(version) = parse_latest_version(body) else {
252        tracing::warn!(
253            target: crate::logging::targets::ENV_GIT,
254            event_name = "cli_release_version_unparseable",
255            message = "npm registry response carried no version token",
256            outcome = "failure",
257            direct,
258            version = ?body.get("version"),
259        );
260        return None;
261    };
262    Some(version)
263}
264
265/// Pull the `MAJOR.MINOR.PATCH` token from an npm `/latest` dist-tag
266/// response body's `version` field.
267fn parse_latest_version(body: &serde_json::Value) -> Option<String> {
268    let version = body.get("version").and_then(|v| v.as_str())?;
269    extract_semver_token(version).map(str::to_owned)
270}
271
272/// Extract the first whitespace-separated token that starts with a
273/// digit - handles `2.1.116 (anthropic)`, `claude 2.1.116`, and
274/// bare `2.1.116` shapes the CLI / npm produce.
275fn extract_semver_token(reported: &str) -> Option<&str> {
276    reported.split_whitespace().find(|t| t.chars().next().is_some_and(|c| c.is_ascii_digit()))
277}
278
279/// `true` when `lhs` parses as a strictly-greater semver triple than
280/// `rhs`. A parse failure on either side collapses to `false` so the
281/// update banner only fires when the comparison is unambiguous.
282fn is_strictly_newer(lhs: &str, rhs: &str) -> bool {
283    let Some(lhs_tuple) = parse_semver_triple(lhs) else { return false };
284    let Some(rhs_tuple) = parse_semver_triple(rhs) else { return false };
285    lhs_tuple > rhs_tuple
286}
287
288/// Parse `MAJOR.MINOR.PATCH` (ignoring any `-pre.1` / `+build` suffix
289/// after the patch number) into a `(u32, u32, u32)` tuple suitable
290/// for direct tuple comparison.
291fn parse_semver_triple(s: &str) -> Option<(u32, u32, u32)> {
292    let mut parts = s.split('.');
293    let major: u32 = parts.next()?.parse().ok()?;
294    let minor: u32 = parts.next()?.parse().ok()?;
295    // The patch component may carry a `-pre.1` / `+build` suffix -
296    // strip everything from the first non-digit char.
297    let patch_token = parts.next()?;
298    let digits: String = patch_token.chars().take_while(char::is_ascii_digit).collect();
299    let patch: u32 = digits.parse().ok()?;
300    Some((major, minor, patch))
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn extract_token_handles_anthropic_suffix() {
309        assert_eq!(extract_semver_token("2.1.116 (anthropic)"), Some("2.1.116"));
310    }
311
312    #[test]
313    fn extract_token_handles_claude_prefix() {
314        assert_eq!(extract_semver_token("claude 2.1.116"), Some("2.1.116"));
315    }
316
317    #[test]
318    fn extract_token_handles_bare_version() {
319        assert_eq!(extract_semver_token("2.1.116"), Some("2.1.116"));
320    }
321
322    #[test]
323    fn extract_token_returns_none_on_no_digit_token() {
324        assert!(extract_semver_token("not a version").is_none());
325    }
326
327    #[test]
328    fn parse_semver_triple_basic() {
329        assert_eq!(parse_semver_triple("2.1.116"), Some((2, 1, 116)));
330    }
331
332    #[test]
333    fn parse_semver_triple_strips_pre_suffix() {
334        assert_eq!(parse_semver_triple("2.1.116-pre.1"), Some((2, 1, 116)));
335    }
336
337    #[test]
338    fn parse_semver_triple_strips_build_suffix() {
339        assert_eq!(parse_semver_triple("2.1.116+abc"), Some((2, 1, 116)));
340    }
341
342    #[test]
343    fn parse_semver_triple_rejects_too_short() {
344        assert!(parse_semver_triple("2.1").is_none());
345    }
346
347    #[test]
348    fn parse_semver_triple_rejects_non_numeric() {
349        assert!(parse_semver_triple("v2.1.116").is_none());
350    }
351
352    #[test]
353    fn has_update_when_latest_is_strictly_newer() {
354        let info =
355            CliVersionInfo { installed: Some("2.0.45".into()), latest: Some("2.0.50".into()) };
356        assert!(info.has_update());
357    }
358
359    #[test]
360    fn has_update_when_minor_bumps() {
361        let info =
362            CliVersionInfo { installed: Some("2.0.99".into()), latest: Some("2.1.0".into()) };
363        assert!(info.has_update());
364    }
365
366    #[test]
367    fn no_update_when_versions_match() {
368        let info =
369            CliVersionInfo { installed: Some("2.0.50".into()), latest: Some("2.0.50".into()) };
370        assert!(!info.has_update());
371    }
372
373    #[test]
374    fn no_update_when_installed_is_newer_dev_build() {
375        let info =
376            CliVersionInfo { installed: Some("2.1.0".into()), latest: Some("2.0.50".into()) };
377        assert!(!info.has_update());
378    }
379
380    #[test]
381    fn no_update_when_either_side_missing() {
382        let info = CliVersionInfo { installed: None, latest: Some("2.0.50".into()) };
383        assert!(!info.has_update());
384        let info = CliVersionInfo { installed: Some("2.0.45".into()), latest: None };
385        assert!(!info.has_update());
386    }
387
388    #[test]
389    fn parse_latest_version_reads_npm_version_field() {
390        let body = serde_json::json!({
391            "name": "@anthropic-ai/claude-code",
392            "version": "2.1.201",
393        });
394        assert_eq!(parse_latest_version(&body), Some("2.1.201".to_owned()));
395    }
396
397    #[test]
398    fn parse_latest_version_none_when_field_missing() {
399        let body = serde_json::json!({ "name": "@anthropic-ai/claude-code" });
400        assert!(parse_latest_version(&body).is_none());
401    }
402
403    /// The npm side of the same defect: a `/latest` response carrying no
404    /// parseable version token must warn rather than read as a registry
405    /// that was never reached. The warn sits in the helper the HTTP path
406    /// calls, because reaching the registry is that path's own concern.
407    #[test]
408    fn an_unusable_npm_latest_body_warns_and_yields_none() {
409        let body = serde_json::json!({
410            "name": "@anthropic-ai/claude-code",
411            "version": "not-a-version",
412        });
413        let (result, captured) = capture_logs(|| latest_version_or_warn(&body, false));
414        assert_eq!(result, None, "an unusable body yields None: {captured:?}");
415        assert!(
416            captured.contains("cli_release_version_unparseable"),
417            "the miss fired no warn: {captured:?}",
418        );
419        assert!(
420            captured.contains("not-a-version"),
421            "the warn must carry the field's value - the diagnostic: {captured:?}",
422        );
423    }
424
425    /// The converse half, or a warn-everything regression passes both
426    /// miss tests: a readable output is not flagged.
427    #[test]
428    fn readable_outputs_are_not_flagged() {
429        let (installed, captured) =
430            capture_logs(|| reported_version_or_warn("2.1.116 (anthropic)"));
431        assert_eq!(installed.as_deref(), Some("2.1.116"), "no token from a readable output");
432        assert!(
433            !captured.contains("cli_version_probe_unparseable"),
434            "warned on a readable version: {captured:?}",
435        );
436
437        let body = serde_json::json!({ "version": "2.1.201" });
438        let (latest, captured) = capture_logs(|| latest_version_or_warn(&body, false));
439        assert_eq!(latest.as_deref(), Some("2.1.201"), "no token from a readable body");
440        assert!(
441            !captured.contains("cli_release_version_unparseable"),
442            "warned on a readable body: {captured:?}",
443        );
444    }
445
446    /// Buffer tracing output so an emitted record can be read back.
447    #[derive(Clone, Default)]
448    struct LogCapture(std::sync::Arc<parking_lot::Mutex<Vec<u8>>>);
449
450    impl std::io::Write for LogCapture {
451        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
452            self.0.lock().extend_from_slice(buf);
453            Ok(buf.len())
454        }
455        fn flush(&mut self) -> std::io::Result<()> {
456            Ok(())
457        }
458    }
459
460    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
461        type Writer = Self;
462        fn make_writer(&'a self) -> Self::Writer {
463            self.clone()
464        }
465    }
466
467    fn capture_logs<R>(f: impl FnOnce() -> R) -> (R, String) {
468        let capture = LogCapture::default();
469        let subscriber = tracing_subscriber::fmt().with_writer(capture.clone()).finish();
470        let result = tracing::subscriber::with_default(subscriber, f);
471        let text = String::from_utf8_lossy(&capture.0.lock().clone()).into_owned();
472        (result, text)
473    }
474
475    /// The defect #1205 names, end to end: a zero-exit `claude --version`
476    /// whose output carries no version token must warn rather than
477    /// collapse to a silent `None`. PATH cannot be injected in-process
478    /// (process-global, and `unsafe` to set under this edition), so the
479    /// test re-execs this binary as a child whose whole PATH is a stub
480    /// dir, and asserts the child's captured log. The child drives the
481    /// probe with a widened budget so machine load cannot spend it before
482    /// the stub answers.
483    #[test]
484    fn a_zero_exit_unreadable_version_warns() {
485        const PROBE: &str = "FORGE_CLI_VERSION_PROBE";
486        const PROBE_OK: &str = "FORGE_CLI_VERSION_PROBE_OK";
487        const PROBE_TEST: &str = "env::cli_version::tests::a_zero_exit_unreadable_version_warns";
488
489        let stub_dir = tempfile::tempdir().expect("tempdir");
490        let stub = stub_dir.path().join("claude");
491        std::fs::write(&stub, "#!/bin/sh\necho 'unknown build'\n").expect("write stub");
492        {
493            use std::os::unix::fs::PermissionsExt;
494            std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755))
495                .expect("chmod stub");
496        }
497
498        if std::env::var(PROBE).is_err() {
499            // Parent mode: re-run this test as a child whose PATH holds
500            // only the stub dir, so `claude` resolves to the stub.
501            let out = std::process::Command::new(std::env::current_exe().expect("current_exe"))
502                .args(["--exact", PROBE_TEST, "--test-threads=1", "--nocapture"])
503                .env(PROBE, "1")
504                .env("PATH", stub_dir.path())
505                .output()
506                .expect("spawn probe");
507            let stdout = String::from_utf8_lossy(&out.stdout);
508            let stderr = String::from_utf8_lossy(&out.stderr);
509            assert!(
510                out.status.success(),
511                "the child probe failed: stdout={stdout}\nstderr={stderr}",
512            );
513            assert!(
514                stdout.contains(PROBE_OK),
515                "the probe never ran (filter matched nothing?): stdout={stdout}\nstderr={stderr}",
516            );
517            return;
518        }
519
520        // Child mode. `unknown build` appears only in the stub's stdout,
521        // so a capture carrying it proves the probe ran the stub rather
522        // than a real `claude`; `cli_version_probe_failed`'s absence
523        // proves it reached the exit-0 arm rather than a spawn failure.
524        let (installed, captured) = capture_logs(|| {
525            tokio::runtime::Builder::new_current_thread()
526                .enable_all()
527                .build()
528                .expect("runtime")
529                .block_on(probe_installed_within(std::time::Duration::from_secs(60)))
530        });
531        assert_eq!(
532            installed, None,
533            "an unreadable `--version` output still yields None: {captured:?}",
534        );
535        assert!(
536            captured.contains("cli_version_probe_unparseable"),
537            "the miss fired no warn, which is the defect: {captured:?}",
538        );
539        assert!(
540            captured.contains("unknown build"),
541            "the warn must carry the raw output - the only diagnostic: {captured:?}",
542        );
543        assert!(
544            !captured.contains("cli_version_probe_failed"),
545            "the probe must have read the stub's output, not failed to spawn it: {captured:?}",
546        );
547        println!("{PROBE_OK}");
548    }
549}