From 45c1caa1330922341c87aca6d18f07fc53c6d03c Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 10 Mar 2026 11:48:19 -0700 Subject: [PATCH] feat: add CLI update check with npm registry indicator On app start, check the npm registry for the latest @anthropic-ai/claude-code version and compare against the installed version. If behind, the CLI badge turns amber with a pulsing up-arrow and a tooltip advising how to update. Also bumps SUPPORTED_CLI_VERSION to 2.1.72. --- src-tauri/src/commands.rs | 60 ++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/components/CliVersion.svelte | 66 ++++++++++++++++++++++++++- src/lib/components/CliVersion.test.ts | 54 ++++++++++++++++++++-- 4 files changed, 176 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 35838e8..2d652f7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -662,6 +662,37 @@ pub async fn fetch_changelog() -> Result, String> { .collect()) } +fn parse_npm_cli_version(json: &str) -> Result { + let data: serde_json::Value = + serde_json::from_str(json).map_err(|e| format!("Failed to parse response: {}", e))?; + data.get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "No version field in response".to_string()) +} + +#[tauri::command] +pub async fn check_cli_latest_version() -> Result { + let client = reqwest::Client::new(); + let response = client + .get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest") + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch CLI version: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Registry returned status: {}", response.status())); + } + + let body = response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_npm_cli_version(&body) +} + #[derive(Debug, Clone, serde::Serialize)] pub struct SavedFileInfo { pub path: String, @@ -2838,6 +2869,35 @@ mod tests { assert!(json.contains("null") || json.contains("release_notes")); } + // ==================== parse_npm_cli_version tests ==================== + + #[test] + fn test_parse_npm_cli_version_valid() { + let json = r#"{"name":"@anthropic-ai/claude-code","version":"2.1.72","description":"Claude Code"}"#; + let result = parse_npm_cli_version(json).unwrap(); + assert_eq!(result, "2.1.72"); + } + + #[test] + fn test_parse_npm_cli_version_missing_field() { + let json = r#"{"name":"@anthropic-ai/claude-code","description":"no version here"}"#; + let result = parse_npm_cli_version(json); + assert!(result.is_err()); + } + + #[test] + fn test_parse_npm_cli_version_invalid_json() { + let result = parse_npm_cli_version("not json at all"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_npm_cli_version_non_string_version() { + let json = r#"{"version":123}"#; + let result = parse_npm_cli_version(json); + assert!(result.is_err()); + } + // ==================== SavedFileInfo struct tests ==================== #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9710b77..7cec226 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -134,6 +134,7 @@ pub fn run() { list_skills, check_for_updates, fetch_changelog, + check_cli_latest_version, save_temp_file, register_temp_file, get_temp_files, diff --git a/src/lib/components/CliVersion.svelte b/src/lib/components/CliVersion.svelte index 8b8fed5..5f1028a 100644 --- a/src/lib/components/CliVersion.svelte +++ b/src/lib/components/CliVersion.svelte @@ -2,9 +2,10 @@ import { invoke } from "@tauri-apps/api/core"; import { onMount } from "svelte"; - const SUPPORTED_CLI_VERSION = "2.1.53"; + const SUPPORTED_CLI_VERSION = "2.1.72"; let installedVersion = $state("Loading..."); + let latestNpmVersion = $state(null); function compareVersions(a: string, b: string): number { const aParts = a.split(".").map(Number); @@ -32,6 +33,15 @@ return "current"; }); + let updateAvailable = $derived.by(() => { + if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") { + return false; + } + const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion); + if (!semverMatch) return false; + return compareVersions(semverMatch[1], latestNpmVersion) < 0; + }); + async function fetchVersion() { try { const result = await invoke("get_claude_version"); @@ -42,13 +52,28 @@ } } + async function fetchLatestNpmVersion() { + try { + const result = await invoke("check_cli_latest_version"); + latestNpmVersion = result; + } catch (error) { + console.error("Failed to check latest CLI version:", error); + } + } + onMount(() => { fetchVersion(); + fetchLatestNpmVersion(); });
-
+
CLI {displayVersion} + {#if updateAvailable} + + + + + {/if}
@@ -135,6 +176,27 @@ color: var(--error-color, #f44336); } + .cli-version.update-available { + border-color: var(--warning-color, #ff9800); + color: var(--warning-color, #ff9800); + cursor: help; + } + + .update-icon { + flex-shrink: 0; + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } + .terminal-icon { flex-shrink: 0; opacity: 0.7; diff --git a/src/lib/components/CliVersion.test.ts b/src/lib/components/CliVersion.test.ts index a16516f..37f5b8d 100644 --- a/src/lib/components/CliVersion.test.ts +++ b/src/lib/components/CliVersion.test.ts @@ -19,7 +19,7 @@ import { describe, it, expect } from "vitest"; -const SUPPORTED_CLI_VERSION = "2.1.53"; +const SUPPORTED_CLI_VERSION = "2.1.72"; function compareVersions(a: string, b: string): number { const aParts = a.split(".").map(Number); @@ -41,7 +41,7 @@ describe("SUPPORTED_CLI_VERSION", () => { }); it("matches the expected audited version", () => { - expect(SUPPORTED_CLI_VERSION).toBe("2.1.53"); + expect(SUPPORTED_CLI_VERSION).toBe("2.1.72"); }); }); @@ -128,7 +128,55 @@ describe("compareVersions", () => { }); it("returns 0 for exactly the supported version", () => { - expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0); + expect(compareVersions("2.1.72", SUPPORTED_CLI_VERSION)).toBe(0); }); }); }); + +// Mirrors the updateAvailable derived logic in CliVersion.svelte +function isUpdateAvailable(installedVersion: string, latestNpmVersion: string | null): boolean { + if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") { + return false; + } + const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion); + if (!semverMatch) return false; + return compareVersions(semverMatch[1], latestNpmVersion) < 0; +} + +describe("updateAvailable", () => { + it("returns false when latestNpmVersion is null", () => { + expect(isUpdateAvailable("2.1.70", null)).toBe(false); + }); + + it("returns false when installed is Loading...", () => { + expect(isUpdateAvailable("Loading...", "2.1.72")).toBe(false); + }); + + it("returns false when installed is Unknown", () => { + expect(isUpdateAvailable("Unknown", "2.1.72")).toBe(false); + }); + + it("returns false when installed equals latest", () => { + expect(isUpdateAvailable("2.1.72", "2.1.72")).toBe(false); + }); + + it("returns false when installed is ahead of latest", () => { + expect(isUpdateAvailable("2.1.73", "2.1.72")).toBe(false); + }); + + it("returns true when installed is behind latest", () => { + expect(isUpdateAvailable("2.1.70", "2.1.72")).toBe(true); + }); + + it("returns true when installed has a lower minor version", () => { + expect(isUpdateAvailable("2.0.99", "2.1.72")).toBe(true); + }); + + it("handles version strings with extra info like '2.1.70 (build 123)'", () => { + expect(isUpdateAvailable("2.1.70 (build 123)", "2.1.72")).toBe(true); + }); + + it("returns false for unparseable installed version", () => { + expect(isUpdateAvailable("not-a-version", "2.1.72")).toBe(false); + }); +});