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.
This commit is contained in:
2026-03-10 11:48:19 -07:00
committed by Naomi Carrigan
parent 292bf50f50
commit 45c1caa133
4 changed files with 176 additions and 5 deletions
+60
View File
@@ -662,6 +662,37 @@ pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
.collect())
}
fn parse_npm_cli_version(json: &str) -> Result<String, String> {
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<String, String> {
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]
+1
View File
@@ -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,
+64 -2
View File
@@ -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<string | null>(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<string>("get_claude_version");
@@ -42,13 +52,28 @@
}
}
async function fetchLatestNpmVersion() {
try {
const result = await invoke<string>("check_cli_latest_version");
latestNpmVersion = result;
} catch (error) {
console.error("Failed to check latest CLI version:", error);
}
}
onMount(() => {
fetchVersion();
fetchLatestNpmVersion();
});
</script>
<div class="cli-versions">
<div class="cli-version">
<div
class="cli-version {updateAvailable ? 'update-available' : ''}"
title={updateAvailable
? `Update available: ${latestNpmVersion} run: npm install -g @anthropic-ai/claude-code`
: "Installed CLI version"}
>
<svg
class="terminal-icon"
width="14"
@@ -64,6 +89,22 @@
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {displayVersion}</span>
{#if updateAvailable}
<svg
class="update-icon"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
{/if}
</div>
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
@@ -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;
+51 -3
View File
@@ -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);
});
});