diff --git a/CLAUDE.md b/CLAUDE.md
index 976e36f..15ea9e0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,8 +23,11 @@ empaquetada en **Tauri 2.x**. Estado: Fases 0, 1 y 2 completas (parser + UI web
/ `npm run build` **sin** `--prefix web` (un `--prefix web` buscaría `web/web/package.json` y falla).
`src-tauri/` es su **propia raíz de workspace**: `cargo build` en la raíz NO arrastra el backend Tauri.
- Build del frontend: `npm --prefix web run build`.
-- **Tests**: 20 tests unitarios del parser en `src/lib.rs` (`cargo test --release`). Complementan
- —no reemplazan— la verificación contra datos reales del skill `/verify-parser`.
+- **Tests**: 24 tests unitarios en el crate del parser (`src/lib.rs` + `src/update.rs`, `cargo test --release`).
+ Complementan —no reemplazan— la verificación contra datos reales del skill `/verify-parser`.
+- **Update check** (`src/update.rs`, capa de red opt-in y secundaria — el parser nunca la llama): consulta
+ el último Release de GitHub vía `curl` (sin dep HTTP) y **solo avisa** si hay versión nueva, nunca instala.
+ Expuesto en CLI (`--check-update [--json]`), Tauri (`check_update`/`open_url`) y la VersionBadge.
## Modelo de datos (lo NO obvio — léelo antes de tocar el parser)
- Fuente de verdad: transcripts NATIVOS `~/.claude/projects/
/.jsonl`. **No se usa ningún hook.**
diff --git a/README.md b/README.md
index 9d5f41e..b7eb046 100644
--- a/README.md
+++ b/README.md
@@ -63,8 +63,10 @@ installs `arrow.app` into `/Applications`, and clears the Gatekeeper quarantine
> The installer strips the quarantine attribute of an **unsigned** binary so it opens without
> friction — only run it if you trust this source. To **update**, re-run the one-liner; to
-> **uninstall**, `rm -rf /Applications/arrow.app`. (There is no auto-update / package manager yet —
-> a Homebrew Cask is on the [roadmap](ROADMAP.md).)
+> **uninstall**, `rm -rf /Applications/arrow.app`. arrow **tells you when a newer release exists**
+> (a dot on the version badge, or `arrow --check-update` from the CLI) but does not auto-install it
+> yet — being unsigned, a silent in-app updater would fight Gatekeeper. A Homebrew Cask and a signed
+> auto-updater are on the [roadmap](ROADMAP.md).
**Manual:** grab the `.dmg` for your chip (`_aarch64` = Apple Silicon, `_x64` = Intel) from the
[**Releases**](https://github.com/MrArcher23/arrow/releases/latest) page, open it, and drag arrow to
@@ -133,10 +135,13 @@ cargo build --release
# Normalized JSON (the contract the UI consumes)
./target/release/arrow --repo my-project --json
+
+# Is there a newer arrow release? (network; read-only, never installs)
+./target/release/arrow --check-update
```
Options: `--projects-dir ` (defaults to `~/.claude/projects`), `--repo`, `--session`,
-`--list`, `--json`, and `--content --file [--session ]` (emits `{before, after}` for a
+`--list`, `--json`, `--check-update`, and `--content --file [--session ]` (emits `{before, after}` for a
file, for the UI's diff view).
## Web UI (Phase 1)
diff --git a/ROADMAP.md b/ROADMAP.md
index cbec918..2e14407 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -285,6 +285,16 @@ todas deben respetar la honestidad del producto (no afirmar más de lo que el da
que no existe un `.dmg` publicado todavía: el primer `v*` lo generará. **Pendiente:** correr ese
primer tag, la firma/notarización (Apple Developer, ~99 USD/año) para quitar la fricción de
Gatekeeper, y un **Homebrew Cask** (`brew install --cask`) con upgrade/uninstall.
+- **Aviso de actualización (check-for-updates)** — ✅ implementado. arrow consulta el último Release de
+ GitHub y avisa si hay una versión más nueva, **sin descargar ni instalar nada** (honesto: unsigned ⇒
+ un auto-updater silencioso pelearía con Gatekeeper, por eso solo *avisa*). Capa de red **opt-in y
+ secundaria** en `src/update.rs` (fuera del hot path del parser; shell-out a `curl`, sin dep HTTP
+ nueva, time-boxed, degrada con `error` en vez de panic). Expuesto en CLI (`--check-update [--json]`),
+ Tauri (comando `check_update` + `open_url` para abrir el release) y en la **VersionBadge** (punto verde
+ + "Update available → vX.Y.Z" + "Open release"). Tests de orden de versión en `update.rs`.
+ **Pendientes (siguen en backlog):** auto-instalación real vía `tauri-plugin-updater` (requiere keypair
+ de firma del updater + `latest.json` firmado en el job de release; mejor **tras** la notarización) y el
+ Homebrew Cask de arriba para el upgrade nativo en Mac.
- **macOS sin verificar en hardware**: el código ya está adaptado a Mac (titlebar nativa + font-weight
por OS; ver [MACOS.md](MACOS.md)) y la matrix ya **construirá** el `.dmg` en CI, pero el bloque
`cfg(target_os = "macos")` y el `install.sh` aún **no se probaron en una Mac real** → falta correr
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 8890e78..b73ac44 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -73,6 +73,38 @@ fn worktree_sizes(paths: Vec) -> std::collections::HashMap
worktrees::worktree_sizes(&paths)
}
+/// Check GitHub for a newer arrow release (for the version badge's "update
+/// available" hint). Network-bound but time-boxed; read-only — it never
+/// downloads or installs. Runs off the UI thread via `spawn_blocking`.
+#[tauri::command]
+async fn check_update() -> arrow::UpdateStatus {
+ tauri::async_runtime::spawn_blocking(|| arrow::check_update(env!("CARGO_PKG_VERSION")))
+ .await
+ .unwrap_or_else(|_| arrow::check_update(env!("CARGO_PKG_VERSION")))
+}
+
+/// Open a URL in the user's default browser (the "Open release" action). Only
+/// `https://` URLs are honored, so the command can't be coaxed into launching a
+/// local file or a `file://`/`javascript:` scheme. Argv-direct (no shell).
+#[tauri::command]
+fn open_url(url: String) -> Result<(), String> {
+ if !url.starts_with("https://") {
+ return Err("refusing to open a non-https URL".into());
+ }
+ #[cfg(target_os = "macos")]
+ let (bin, args): (&str, Vec) = ("open", vec![url]);
+ #[cfg(target_os = "linux")]
+ let (bin, args): (&str, Vec) = ("xdg-open", vec![url]);
+ #[cfg(target_os = "windows")]
+ let (bin, args): (&str, Vec) =
+ ("cmd", vec!["/C".into(), "start".into(), String::new(), url]);
+ std::process::Command::new(bin)
+ .args(args)
+ .spawn()
+ .map(|_| ())
+ .map_err(|e| format!("couldn't open the browser: {e}"))
+}
+
/// Watcher nativo: vigila `~/.claude/projects` y, con debounce, emite
/// `report-changed` para que el frontend refresque sin polling.
///
@@ -158,7 +190,9 @@ pub fn run() {
editors,
open_in_editor,
worktrees,
- worktree_sizes
+ worktree_sizes,
+ check_update,
+ open_url
])
.setup(|app| {
spawn_watcher(app.handle().clone());
diff --git a/src/lib.rs b/src/lib.rs
index 56eae0b..a7bfce7 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -31,6 +31,12 @@ use serde::Serialize;
use serde_json::Value;
use walkdir::WalkDir;
+// Opt-in, secondary network layer (release-update check). Kept out of the parser
+// hot path — `build_report`/`file_content` never touch it; same separation as the
+// git worktree layer.
+pub mod update;
+pub use update::{check_update, UpdateStatus};
+
// ---------------------------------------------------------------------------
// Modelo interno (acumulación)
// ---------------------------------------------------------------------------
diff --git a/src/main.rs b/src/main.rs
index 8f02f72..b7cbddf 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -46,6 +46,10 @@ struct Cli {
/// Ruta exacta del archivo (para --content)
#[arg(long)]
file: Option,
+
+ /// Check GitHub for a newer arrow release (network; read-only, never installs)
+ #[arg(long)]
+ check_update: bool,
}
const RESET: &str = "\x1b[0m";
@@ -64,6 +68,35 @@ fn main() -> Result<()> {
.clone()
.unwrap_or_else(|| format!("{home}/.claude/projects"));
+ if cli.check_update {
+ let st = arrow::check_update(env!("CARGO_PKG_VERSION"));
+ if cli.json {
+ println!("{}", serde_json::to_string(&st)?);
+ } else if let Some(err) = &st.error {
+ println!("{YELLOW}Couldn't check for updates:{RESET} {err}");
+ println!("{DIM}current: v{}{RESET}", st.current);
+ } else if st.update_available {
+ let latest = st.latest.as_deref().unwrap_or("?");
+ println!(
+ "{GREEN}{BOLD}Update available:{RESET} v{} {DIM}→{RESET} {GREEN}v{latest}{RESET}",
+ st.current
+ );
+ if let Some(url) = &st.url {
+ println!("{DIM}{url}{RESET}");
+ }
+ println!(
+ "{DIM}macOS: re-run the installer · curl -fsSL \
+ https://raw.githubusercontent.com/MrArcher23/arrow/main/install.sh | bash{RESET}"
+ );
+ } else {
+ println!(
+ "{GREEN}arrow is up to date{RESET} {DIM}(v{}){RESET}",
+ st.current
+ );
+ }
+ return Ok(());
+ }
+
if cli.content {
let target = cli
.file
diff --git a/src/update.rs b/src/update.rs
new file mode 100644
index 0000000..2ba89e4
--- /dev/null
+++ b/src/update.rs
@@ -0,0 +1,174 @@
+//! Update check — a lightweight "is there a newer release?" probe.
+//!
+//! arrow ships **unsigned** (no Apple notarization yet), so a silent in-app
+//! auto-updater (tauri-plugin-updater) would fight Gatekeeper; that's deferred.
+//! What we CAN do honestly is *tell* the user a newer version exists and point
+//! them at the release. This module does exactly that and nothing more — it
+//! never downloads or installs anything.
+//!
+//! It is an OPT-IN, secondary network layer, kept out of the parser's hot path
+//! (`build_report`/`file_content` never call it) — same separation as the git
+//! worktree layer. To avoid pulling an HTTP client into the core lib, it shells
+//! out to `curl` (present on macOS and the Linux targets), argv-direct, with a
+//! hard `--max-time`, and degrades gracefully (a missing curl, no network, a
+//! rate-limit, or unparseable JSON all yield `error: Some(..)`, never a panic).
+
+use serde::Serialize;
+use serde_json::Value;
+use std::process::Command;
+
+/// GitHub repo that publishes arrow's releases (the upstream, not a fork).
+const RELEASES_REPO: &str = "MrArcher23/arrow";
+
+/// Outcome of an update check. `update_available` is only ever `true` when a
+/// `latest` was fetched AND parsed AND is strictly newer than `current`.
+#[derive(Serialize, Debug, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateStatus {
+ /// The running version (e.g. "0.1.6"), without a leading "v".
+ pub current: String,
+ /// The latest published release tag, normalized without "v". `None` when the
+ /// check failed or no release exists.
+ pub latest: Option,
+ /// Strictly-newer latest than current.
+ pub update_available: bool,
+ /// Release page to open / share when an update is available.
+ pub url: Option,
+ /// Human-readable reason the check couldn't complete (network, no release,
+ /// curl missing). `None` on a clean check (whether or not an update exists).
+ pub error: Option,
+}
+
+impl UpdateStatus {
+ fn failed(current: &str, error: impl Into) -> Self {
+ UpdateStatus {
+ current: normalize(current).to_string(),
+ latest: None,
+ update_available: false,
+ url: None,
+ error: Some(error.into()),
+ }
+ }
+}
+
+/// Check whether a release newer than `current` exists. Network-bound but
+/// time-boxed; safe to call from a button. Never downloads or installs.
+pub fn check_update(current: &str) -> UpdateStatus {
+ let url = format!("https://api.github.com/repos/{RELEASES_REPO}/releases/latest");
+ let out = Command::new("curl")
+ .args([
+ "-fsSL",
+ "--max-time",
+ "6",
+ "-H",
+ "Accept: application/vnd.github+json",
+ // GitHub rejects requests without a User-Agent.
+ "-A",
+ "arrow-update-check",
+ &url,
+ ])
+ .output();
+
+ let body = match out {
+ Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
+ // curl ran but the request failed (404 = no release yet, 403 = rate
+ // limited, etc.). `-f` makes curl exit non-zero on HTTP errors.
+ Ok(_) => {
+ return UpdateStatus::failed(current, "no published release found (or rate-limited)")
+ }
+ Err(_) => return UpdateStatus::failed(current, "couldn't run curl to check for updates"),
+ };
+
+ let json: Value = match serde_json::from_str(&body) {
+ Ok(v) => v,
+ Err(_) => return UpdateStatus::failed(current, "couldn't parse the release response"),
+ };
+
+ let latest_tag = match json.get("tag_name").and_then(Value::as_str) {
+ Some(t) if !t.is_empty() => t.to_string(),
+ _ => return UpdateStatus::failed(current, "the release had no tag"),
+ };
+ let page = json
+ .get("html_url")
+ .and_then(Value::as_str)
+ .map(str::to_string)
+ .unwrap_or_else(|| format!("https://github.com/{RELEASES_REPO}/releases/latest"));
+
+ let cur = normalize(current);
+ let lat = normalize(&latest_tag);
+ UpdateStatus {
+ current: cur.to_string(),
+ update_available: is_newer(lat, cur),
+ latest: Some(lat.to_string()),
+ url: Some(page),
+ error: None,
+ }
+}
+
+/// Strip a leading "v"/"V" so "v0.1.6" and "0.1.6" compare equal.
+fn normalize(v: &str) -> &str {
+ v.trim().strip_prefix(['v', 'V']).unwrap_or(v.trim())
+}
+
+/// Is `a` a strictly newer semver-ish version than `b`? Compares dot-separated
+/// numeric components (missing components count as 0), so "0.2.0" > "0.1.9" and
+/// "0.1.6" == "0.1.6". A non-numeric component stops the comparison conservatively
+/// (returns false), so a weird tag never spuriously claims an update.
+fn is_newer(a: &str, b: &str) -> bool {
+ let parse = |s: &str| -> Option> {
+ // Drop any pre-release/build suffix (e.g. "0.2.0-rc1" -> "0.2.0").
+ let core = s.split(['-', '+']).next().unwrap_or(s);
+ core.split('.').map(|p| p.parse::().ok()).collect()
+ };
+ match (parse(a), parse(b)) {
+ (Some(av), Some(bv)) => {
+ let n = av.len().max(bv.len());
+ for i in 0..n {
+ let x = av.get(i).copied().unwrap_or(0);
+ let y = bv.get(i).copied().unwrap_or(0);
+ if x != y {
+ return x > y;
+ }
+ }
+ false
+ }
+ _ => false,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn version_ordering() {
+ assert!(is_newer("0.2.0", "0.1.9"));
+ assert!(is_newer("0.1.7", "0.1.6"));
+ assert!(is_newer("1.0.0", "0.9.9"));
+ assert!(!is_newer("0.1.6", "0.1.6"));
+ assert!(!is_newer("0.1.6", "0.2.0"));
+ // Padding: "0.1" == "0.1.0".
+ assert!(!is_newer("0.1", "0.1.0"));
+ assert!(is_newer("0.1.1", "0.1"));
+ // Pre-release suffix dropped to the core.
+ assert!(is_newer("0.2.0-rc1", "0.1.9"));
+ // Non-numeric junk never claims an update.
+ assert!(!is_newer("nightly", "0.1.6"));
+ }
+
+ #[test]
+ fn normalize_strips_v() {
+ assert_eq!(normalize("v0.1.6"), "0.1.6");
+ assert_eq!(normalize("0.1.6"), "0.1.6");
+ assert_eq!(normalize(" v0.1.6 "), "0.1.6");
+ }
+
+ #[test]
+ fn failed_status_is_honest() {
+ let s = UpdateStatus::failed("v0.1.6", "boom");
+ assert_eq!(s.current, "0.1.6");
+ assert!(!s.update_available);
+ assert!(s.latest.is_none());
+ assert_eq!(s.error.as_deref(), Some("boom"));
+ }
+}
diff --git a/web/src/components/VersionBadge.svelte b/web/src/components/VersionBadge.svelte
index 4299572..03c8d23 100644
--- a/web/src/components/VersionBadge.svelte
+++ b/web/src/components/VersionBadge.svelte
@@ -1,9 +1,25 @@