diff --git a/aai_cli/config.py b/aai_cli/config.py index d201ea8a..efe626c1 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -50,6 +50,8 @@ class Config(BaseModel): # as enabled — distinct from an explicit False written by `assembly telemetry disable`. device_id: str | None = None telemetry_enabled: bool | None = None + update_last_check: float | None = None + update_latest_version: str | None = None class StoredSession(BaseModel): @@ -377,6 +379,21 @@ def set_telemetry_enabled(*, enabled: bool) -> None: _dump(cfg) +def get_update_cache() -> tuple[float | None, str | None]: + """The cached (last-check unix ts, latest version seen) for the update notifier.""" + cfg = _load() + return cfg.update_last_check, cfg.update_latest_version + + +def set_update_cache(*, last_check: float, latest_version: str | None) -> None: + """Persist the update-notifier cache. ``latest_version`` is None when the last + fetch failed — the timestamp is still recorded so we don't re-spawn every run.""" + cfg = _load() + cfg.update_last_check = last_check + cfg.update_latest_version = latest_version + _dump(cfg) + + def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = None) -> str: if api_key_flag is not None: if not api_key_flag: diff --git a/aai_cli/context.py b/aai_cli/context.py index 559ee034..4ef0ebaf 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -9,7 +9,7 @@ import keyring.errors import typer -from aai_cli import config, environments, output, telemetry +from aai_cli import config, environments, output, telemetry, update_check from aai_cli.auth import run_login_flow from aai_cli.environments import Environment from aai_cli.errors import APIError, CLIError, NotAuthenticated @@ -192,6 +192,7 @@ def run_command( # before it's folded into a typer.Exit below. with telemetry.track(ctx.command_path): fn(state, json_mode) + update_check.maybe_notify(json_mode=json_mode) except NotAuthenticated as err: if not auto_login or not _should_auto_login(err): output.emit_error(err, json_mode=json_mode) diff --git a/aai_cli/main.py b/aai_cli/main.py index c55bfd12..29290c53 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -332,6 +332,20 @@ def main( app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT) +@app.command( + name="_update-check", + hidden=True, + epilog=examples_epilog( + [("Internal plumbing, spawned by the CLI itself", "assembly _update-check")] + ), +) +def update_check_command() -> None: + """Internal: refresh the cached latest version (spawned detached). Hidden.""" + from aai_cli import update_check + + update_check.fetch_and_cache() + + def run() -> None: """Console-script entry point: run the app, exiting cleanly on a closed pipe. diff --git a/aai_cli/update_check.py b/aai_cli/update_check.py new file mode 100644 index 00000000..71c61ae9 --- /dev/null +++ b/aai_cli/update_check.py @@ -0,0 +1,143 @@ +"""The "update available" notifier. + +Best-effort and never-blocking, in the style of npm's update-notifier / Vercel: +the notice always renders from a ``config.toml`` cache (zero latency), and the +cache is refreshed by a detached ``assembly _update-check`` process — the same +detached-spawn shape as ``telemetry.dispatch`` (see ``aai_cli/telemetry.py``). +Every failure is swallowed: the notice must never delay or break a command. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time + +from packaging.version import InvalidVersion, Version +from rich.console import Group +from rich.panel import Panel +from rich.text import Text + +from aai_cli import __version__, config, output +from aai_cli.errors import CLIError + +ENV_DISABLED = "AAI_NO_UPDATE_CHECK" +_RELEASES_URL = "https://api.github.com/repos/AssemblyAI/cli/releases/latest" +_DOCS_URL = "https://github.com/AssemblyAI/cli#installation" +_CHECK_INTERVAL_SECONDS = 24 * 60 * 60 +_FETCH_TIMEOUT_SECONDS = 5.0 +_USER_AGENT = f"assembly-cli/{__version__}" + + +def is_newer(latest: str, current: str) -> bool: + """True only when ``latest`` is a strictly greater, parseable version.""" + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False + + +def detect_upgrade_command() -> str: + """The exact upgrade command for the install method the running interpreter + lives in, or "" when it can't be determined (callers show a docs hint).""" + exe = (sys.executable or "").lower() + if "/cellar/" in exe or "/homebrew/" in exe or exe.startswith("/usr/local/"): + return "brew upgrade assembly" + if "pipx" in exe: + return "pipx upgrade assembly" + if "/uv/tools/" in exe: + return "uv tool upgrade assembly" + return "" + + +def fetch_and_cache() -> None: + """Fetch the latest release tag from GitHub and cache it. Best-effort. + + Runs only in the detached ``assembly _update-check`` child, so it imports + ``httpx2`` lazily (keeping it off every command's import path) and swallows + all network/parse/IO errors — failures simply mean "no notice next run". + """ + import httpx2 as httpx + + now = time.time() + latest: str | None = None + try: + resp = httpx.get( + _RELEASES_URL, + headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}, + timeout=_FETCH_TIMEOUT_SECONDS, + follow_redirects=True, + ) + resp.raise_for_status() + tag = resp.json().get("tag_name") + if isinstance(tag, str) and tag: + latest = tag.lstrip("v") + except (httpx.HTTPError, ValueError, KeyError, OSError): + latest = None + try: + config.set_update_cache(last_check=now, latest_version=latest) + except (OSError, CLIError): + return + + +def spawn_refresh() -> None: + """Spawn the detached ``assembly _update-check`` child to refresh the cache. + + Own session + discarded stdio so the user's command never waits; the child's + env disables the notifier so a refresh can never spawn another (mirrors + ``telemetry.dispatch``). S603 is ignored project-wide for the CLI's own shell-outs. + """ + subprocess.Popen( + [sys.executable, "-m", "aai_cli", "_update-check"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + env={**os.environ, ENV_DISABLED: "1"}, + ) + + +def _should_notify(*, json_mode: bool) -> bool: + """Notify only on human, interactive, opted-in, non-CI runs.""" + if json_mode: + return False + if os.environ.get(ENV_DISABLED) or os.environ.get("CI"): + return False + return bool(output.error_console.is_terminal) + + +def _render(current: str, latest: str) -> None: + upgrade = detect_upgrade_command() + if upgrade: + action: Text = Text.assemble("Run ", (upgrade, "aai.success"), " to update") + else: + action = Text(f"See {_DOCS_URL} to upgrade") + body = Group( + Text.assemble("Update available ", (current, "aai.muted"), " → ", (latest, "aai.success")), + action, + ) + # Cosmetic panel styling (padding/expand) — not worth pinning behaviorally. + panel = Panel(body, border_style="aai.muted", padding=(1, 3), expand=False) # pragma: no mutate + output.error_console.print(panel) + + +def maybe_notify(*, json_mode: bool) -> None: + """Render the cached notice (if newer) and refresh the cache if stale. + + The single entry point ``run_command`` calls on a command's success path. + Best-effort: a config/render failure is swallowed, never surfaced. + """ + try: + _maybe_notify(json_mode=json_mode) + except (OSError, CLIError): + return + + +def _maybe_notify(*, json_mode: bool) -> None: + if not _should_notify(json_mode=json_mode): + return + last_check, latest = config.get_update_cache() + if latest and is_newer(latest, __version__): + _render(__version__, latest) + if last_check is None or (time.time() - last_check) > _CHECK_INTERVAL_SECONDS: + spawn_refresh() diff --git a/docs/superpowers/plans/2026-06-11-update-notifier.md b/docs/superpowers/plans/2026-06-11-update-notifier.md new file mode 100644 index 00000000..6f31b386 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-update-notifier.md @@ -0,0 +1,733 @@ +# Update Notifier Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show an unobtrusive, cached, never-blocking "update available" notice on stderr for human/TTY runs, with the upgrade command matching the detected install method (brew/pipx/uv). + +**Architecture:** A new `aai_cli/update_check.py` module renders the notice from a `config.toml` cache after a command succeeds (via a hook in `context.run_command`) and, when the cache is >24h stale, spawns a detached hidden `assembly _update-check` process that fetches the latest GitHub release tag and rewrites the cache for the next run. Mirrors the existing `telemetry.py` + hidden `telemetry flush` detached-spawn pattern. + +**Tech Stack:** Typer, Rich (notice rendering), `httpx2` (the release fetch, matching `telemetry.flush_payload`), `packaging.version` (declared dependency, version compare), `config.toml` via `platformdirs`. + +**Spec:** `docs/superpowers/specs/2026-06-11-update-notifier-design.md` + +--- + +## File Structure + +**Created:** +- `aai_cli/update_check.py` — the whole feature: gating, render-from-cache, install-method detection, version compare, detached-spawn, and the network fetch. +- `tests/test_update_check.py` — unit tests for the above. + +**Modified:** +- `pyproject.toml` — declare `packaging` as a direct dependency. +- `aai_cli/config.py` — two cache fields on `Config` + getter/setter. +- `aai_cli/context.py` — call `update_check.maybe_notify(...)` on the success path of `run_command`. +- `aai_cli/main.py` — register the hidden `_update-check` command. +- `uv.lock` — regenerated by `uv lock` after the dependency change. + +**Design note (deviation from spec):** `maybe_notify` takes no `command_path` (the spec sketched one). The hidden `_update-check` and `telemetry flush` commands do **not** route through `run_command`, so there's nothing to filter by command path — an unused param would also trip ruff's `ARG001`. Signature is `maybe_notify(*, json_mode: bool)`. + +--- + +## Task 1: Cache fields in config.py + +**Files:** +- Modify: `aai_cli/config.py` +- Test: `tests/test_update_check.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_update_check.py`: + +```python +"""Tests for the update-available notifier.""" + +from __future__ import annotations + +from aai_cli import config + + +def test_update_cache_roundtrips(tmp_path, monkeypatch): + # Isolate config.toml to a temp dir so the real one is never touched. + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + + assert config.get_update_cache() == (None, None) + + config.set_update_cache(last_check=1718000000.0, latest_version="0.2.0") + assert config.get_update_cache() == (1718000000.0, "0.2.0") + + +def test_update_cache_records_check_even_when_version_unknown(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + # A failed fetch still records the timestamp (so we don't re-spawn every run) + # but leaves the version unknown. + config.set_update_cache(last_check=1718000001.0, latest_version=None) + assert config.get_update_cache() == (1718000001.0, None) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_update_check.py -q` +Expected: FAIL (`AttributeError: module 'aai_cli.config' has no attribute 'get_update_cache'`). + +- [ ] **Step 3: Add the fields + accessors** + +In `aai_cli/config.py`, add two fields to the `Config` dataclass right after `telemetry_enabled: bool | None = None`: + +```python + update_last_check: float | None = None + update_latest_version: str | None = None +``` + +Then add, next to `get_telemetry_enabled` / `set_telemetry_enabled`: + +```python +def get_update_cache() -> tuple[float | None, str | None]: + """The cached (last-check unix ts, latest version seen) for the update notifier.""" + cfg = _load() + return cfg.update_last_check, cfg.update_latest_version + + +def set_update_cache(*, last_check: float, latest_version: str | None) -> None: + """Persist the update-notifier cache. ``latest_version`` is None when the last + fetch failed — the timestamp is still recorded so we don't re-spawn every run.""" + cfg = _load() + cfg.update_last_check = last_check + cfg.update_latest_version = latest_version + _dump(cfg) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_update_check.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/config.py tests/test_update_check.py +git commit -m "Add update-notifier cache fields to config + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Declare `packaging`; version compare + install detection + +**Files:** +- Modify: `pyproject.toml`, `uv.lock` +- Create: `aai_cli/update_check.py` +- Test: `tests/test_update_check.py` + +- [ ] **Step 1: Declare the dependency** + +In `pyproject.toml`, add to the `[project] dependencies = [...]` array (conservative floor so the safe-chain age gate doesn't reject resolution — `packaging` is already resolved transitively at 26.2 in `uv.lock`, so this pins to the same version): + +```toml + "packaging>=24.0", +``` + +Then regenerate the lock: + +Run: `uv lock` +Then verify: `uv lock --check` +Expected: lock is consistent (exit 0). + +- [ ] **Step 2: Write the failing test** + +Append to `tests/test_update_check.py`: + +```python +import sys + +import pytest + +from aai_cli import update_check + + +@pytest.mark.parametrize( + ("latest", "current", "expected"), + [ + ("0.2.0", "0.1.0", True), + ("0.1.1", "0.1.0", True), + ("1.0.0", "0.9.9", True), + ("0.1.0", "0.1.0", False), # equal + ("0.1.0", "0.2.0", False), # older + ("not-a-version", "0.1.0", False), # unparseable -> never notify + ], +) +def test_is_newer(latest, current, expected): + assert update_check.is_newer(latest, current) is expected + + +@pytest.mark.parametrize( + ("exe", "expected"), + [ + ("/opt/homebrew/Cellar/assembly/0.1.0/libexec/bin/python", "brew upgrade assembly"), + ("/usr/local/Cellar/assembly/0.1.0/libexec/bin/python", "brew upgrade assembly"), + ("/Users/x/.local/pipx/venvs/aai-cli/bin/python", "pipx upgrade assembly"), + ("/Users/x/.local/share/uv/tools/aai-cli/bin/python", "uv tool upgrade assembly"), + ("/usr/bin/python3", ""), # unknown -> generic (empty) + ], +) +def test_detect_upgrade_command(exe, expected, monkeypatch): + monkeypatch.setattr(sys, "executable", exe) + assert update_check.detect_upgrade_command() == expected +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `uv run pytest tests/test_update_check.py -q` +Expected: FAIL (`ModuleNotFoundError: No module named 'aai_cli.update_check'`). + +- [ ] **Step 4: Create the module with the two pure functions** + +Create `aai_cli/update_check.py`: + +```python +"""The "update available" notifier. + +Best-effort and never-blocking, in the style of npm's update-notifier / Vercel: +the notice always renders from a ``config.toml`` cache (zero latency), and the +cache is refreshed by a detached ``assembly _update-check`` process — the same +detached-spawn shape as ``telemetry.dispatch`` (see ``aai_cli/telemetry.py``). +Every failure is swallowed: the notice must never delay or break a command. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time + +from packaging.version import InvalidVersion, Version + +from aai_cli import __version__, config, output +from aai_cli.errors import CLIError + +ENV_DISABLED = "AAI_NO_UPDATE_CHECK" +_RELEASES_URL = "https://api.github.com/repos/AssemblyAI/cli/releases/latest" +_DOCS_URL = "https://github.com/AssemblyAI/cli#installation" +_CHECK_INTERVAL_SECONDS = 24 * 60 * 60 +_FETCH_TIMEOUT_SECONDS = 5.0 +_USER_AGENT = f"assembly-cli/{__version__}" + + +def is_newer(latest: str, current: str) -> bool: + """True only when ``latest`` is a strictly greater, parseable version.""" + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False + + +def detect_upgrade_command() -> str: + """The exact upgrade command for the install method the running interpreter + lives in, or "" when it can't be determined (callers show a docs hint).""" + exe = (sys.executable or "").lower() + if "/cellar/" in exe or "/homebrew/" in exe or exe.startswith("/usr/local/"): + return "brew upgrade assembly" + if "pipx" in exe: + return "pipx upgrade assembly" + if "/uv/tools/" in exe: + return "uv tool upgrade assembly" + return "" +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest tests/test_update_check.py -q` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add pyproject.toml uv.lock aai_cli/update_check.py tests/test_update_check.py +git commit -m "Add packaging dep + version-compare and install detection + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: The network fetch + hidden `_update-check` command + +**Files:** +- Modify: `aai_cli/update_check.py`, `aai_cli/main.py` +- Test: `tests/test_update_check.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_update_check.py`: + +```python +import json +import types + + +def _fake_response(payload: dict, status: int = 200): + resp = types.SimpleNamespace() + resp.json = lambda: payload + resp.raise_for_status = lambda: None if status < 400 else (_ for _ in ()).throw( + RuntimeError("http error") + ) + return resp + + +def test_fetch_and_cache_writes_latest(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + + import httpx2 + + captured = {} + + def fake_get(url, **kwargs): + captured["url"] = url + captured["headers"] = kwargs.get("headers", {}) + return _fake_response({"tag_name": "v0.4.0"}) + + monkeypatch.setattr(httpx2, "get", fake_get) + + update_check.fetch_and_cache() + + last_check, latest = config.get_update_cache() + assert latest == "0.4.0" # 'v' stripped + assert last_check is not None + assert captured["url"] == update_check._RELEASES_URL + assert "User-Agent" in captured["headers"] + + +def test_fetch_and_cache_swallows_errors_but_records_check(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + + import httpx2 + + def boom(url, **kwargs): + raise httpx2.HTTPError("network down") + + monkeypatch.setattr(httpx2, "get", boom) + + update_check.fetch_and_cache() # must not raise + + last_check, latest = config.get_update_cache() + assert latest is None # unknown after a failed fetch + assert last_check is not None # but the attempt is recorded +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_update_check.py -k fetch -q` +Expected: FAIL (`AttributeError: ... has no attribute 'fetch_and_cache'`). + +- [ ] **Step 3: Implement `fetch_and_cache`** + +Append to `aai_cli/update_check.py`: + +```python +def fetch_and_cache() -> None: + """Fetch the latest release tag from GitHub and cache it. Best-effort. + + Runs only in the detached ``assembly _update-check`` child, so it imports + ``httpx2`` lazily (keeping it off every command's import path) and swallows + all network/parse/IO errors — failures simply mean "no notice next run". + """ + import httpx2 as httpx + + now = time.time() + latest: str | None = None + try: + resp = httpx.get( + _RELEASES_URL, + headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}, + timeout=_FETCH_TIMEOUT_SECONDS, + follow_redirects=True, + ) + resp.raise_for_status() + tag = resp.json().get("tag_name") + if isinstance(tag, str) and tag: + latest = tag.lstrip("v") + except (httpx.HTTPError, ValueError, KeyError, OSError): + latest = None + try: + config.set_update_cache(last_check=now, latest_version=latest) + except (OSError, CLIError): + return +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_update_check.py -k fetch -q` +Expected: PASS. + +- [ ] **Step 5: Register the hidden command** + +In `aai_cli/main.py`, after the `app` is created and the other `app.add_typer(...)` / command registrations, add: + +```python +@app.command(name="_update-check", hidden=True) +def _update_check() -> None: + """Internal: refresh the cached latest version (spawned detached). Hidden.""" + from aai_cli import update_check + + update_check.fetch_and_cache() +``` + +- [ ] **Step 6: Verify the command is wired (and hidden)** + +Run: `uv run assembly _update-check && echo OK` +Expected: prints `OK` (it runs; with no network it silently records a check). It must NOT appear in `uv run assembly --help`. + +Run: `uv run assembly --help` — confirm no `_update-check` line. (Help output is snapshot-pinned; if a snapshot for `--help` changes, regenerate with `uv run pytest --snapshot-update` — a hidden command should not change it.) + +- [ ] **Step 7: Commit** + +```bash +git add aai_cli/update_check.py aai_cli/main.py tests/test_update_check.py +git commit -m "Add release fetch + hidden _update-check command + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Detached spawn, render, gating, and `maybe_notify` + +**Files:** +- Modify: `aai_cli/update_check.py` +- Test: `tests/test_update_check.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_update_check.py`: + +```python +import io + +from rich.console import Console + + +def _tty_console() -> Console: + # A console that reports as a terminal, with color env pinned (see the + # rich-color-tests-need-empty-environ project memory) so output is stable. + return Console(file=io.StringIO(), force_terminal=True, width=80, _environ={}) + + +def test_maybe_notify_shows_box_for_newer_cached_version(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + # Cache says a newer version exists, checked just now (so no spawn). + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + monkeypatch.setattr(sys, "executable", "/opt/homebrew/Cellar/assembly/9/libexec/bin/python") + + con = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + + update_check.maybe_notify(json_mode=False) + + out = con.file.getvalue() + assert "Update available" in out + assert "9.9.9" in out + assert "brew upgrade assembly" in out # detected command, not a generic hint + + +def test_maybe_notify_silent_under_json(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + con = _tty_console() + monkeypatch.setattr(output, "error_console", con) + + update_check.maybe_notify(json_mode=True) + + assert con.file.getvalue() == "" # JSON mode prints nothing + + +def test_maybe_notify_silent_when_not_a_tty(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + con = Console(file=io.StringIO(), force_terminal=False, _environ={}) # not a tty + monkeypatch.setattr(output, "error_console", con) + + update_check.maybe_notify(json_mode=False) + + assert con.file.getvalue() == "" + + +def test_maybe_notify_silent_in_ci_and_when_disabled(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + con = _tty_console() + monkeypatch.setattr(output, "error_console", con) + + monkeypatch.setenv("CI", "1") + update_check.maybe_notify(json_mode=False) + assert con.file.getvalue() == "" + + monkeypatch.delenv("CI", raising=False) + monkeypatch.setenv(update_check.ENV_DISABLED, "1") + update_check.maybe_notify(json_mode=False) + assert con.file.getvalue() == "" + + +def test_maybe_notify_no_box_when_cache_not_newer(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + from aai_cli import __version__ + + config.set_update_cache(last_check=time.time(), latest_version=__version__) # equal + con = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + + update_check.maybe_notify(json_mode=False) + assert "Update available" not in con.file.getvalue() + + +def test_maybe_notify_spawns_refresh_only_when_stale(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + con = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + + spawned = [] + monkeypatch.setattr(update_check, "spawn_refresh", lambda: spawned.append(True)) + + # Fresh check -> no spawn. + config.set_update_cache(last_check=time.time(), latest_version=None) + update_check.maybe_notify(json_mode=False) + assert spawned == [] + + # Stale check (>24h ago) -> spawn. + config.set_update_cache( + last_check=time.time() - update_check._CHECK_INTERVAL_SECONDS - 1, latest_version=None + ) + update_check.maybe_notify(json_mode=False) + assert spawned == [True] + + +def test_spawn_refresh_is_detached(monkeypatch): + calls = {} + + def fake_popen(args, **kwargs): + calls["args"] = args + calls["kwargs"] = kwargs + return object() + + monkeypatch.setattr(update_check.subprocess, "Popen", fake_popen) + update_check.spawn_refresh() + + assert calls["args"][:3] == [sys.executable, "-m", "aai_cli"] + assert calls["args"][3] == "_update-check" + assert calls["kwargs"]["start_new_session"] is True + assert calls["kwargs"]["env"][update_check.ENV_DISABLED] == "1" # child can't re-spawn +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_update_check.py -k "maybe_notify or spawn" -q` +Expected: FAIL (`AttributeError: ... 'spawn_refresh' / 'maybe_notify'`). + +- [ ] **Step 3: Implement spawn, render, gating, and orchestration** + +Append to `aai_cli/update_check.py`. Add these imports at the top of the file (with the existing imports): + +```python +from rich.console import Group +from rich.panel import Panel +from rich.text import Text +``` + +Then add the functions: + +```python +def spawn_refresh() -> None: + """Spawn the detached ``assembly _update-check`` child to refresh the cache. + + Own session + discarded stdio so the user's command never waits; the child's + env disables the notifier so a refresh can never spawn another (mirrors + ``telemetry.dispatch``). S603 is ignored project-wide for the CLI's own shell-outs. + """ + subprocess.Popen( + [sys.executable, "-m", "aai_cli", "_update-check"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + env={**os.environ, ENV_DISABLED: "1"}, + ) + + +def _should_notify(*, json_mode: bool) -> bool: + """Notify only on human, interactive, opted-in, non-CI runs.""" + if json_mode: + return False + if os.environ.get(ENV_DISABLED) or os.environ.get("CI"): + return False + return bool(output.error_console.is_terminal) + + +def _render(current: str, latest: str) -> None: + upgrade = detect_upgrade_command() + if upgrade: + action: Text = Text.assemble("Run ", (upgrade, "aai.success"), " to update") + else: + action = Text(f"See {_DOCS_URL} to upgrade") + body = Group( + Text.assemble( + "Update available ", (current, "aai.muted"), " → ", (latest, "aai.success") + ), + action, + ) + output.error_console.print(Panel(body, border_style="aai.muted", padding=(1, 3), expand=False)) + + +def maybe_notify(*, json_mode: bool) -> None: + """Render the cached notice (if newer) and refresh the cache if stale. + + The single entry point ``run_command`` calls on a command's success path. + Best-effort: a config/render failure is swallowed, never surfaced. + """ + try: + _maybe_notify(json_mode=json_mode) + except (OSError, CLIError): + return + + +def _maybe_notify(*, json_mode: bool) -> None: + if not _should_notify(json_mode=json_mode): + return + last_check, latest = config.get_update_cache() + if latest and is_newer(latest, __version__): + _render(__version__, latest) + if last_check is None or (time.time() - last_check) > _CHECK_INTERVAL_SECONDS: + spawn_refresh() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_update_check.py -q` +Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/update_check.py tests/test_update_check.py +git commit -m "Add detached refresh, notice rendering, and gating + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Wire into `run_command` + end-to-end test + full gate + +**Files:** +- Modify: `aai_cli/context.py` +- Test: `tests/test_update_check.py` + +- [ ] **Step 1: Write the failing end-to-end test** + +Append to `tests/test_update_check.py` (drives a real command through Typer and asserts the box rides along on a human/TTY success, and is absent under `--json`): + +```python +from typer.testing import CliRunner + +from aai_cli.main import app + + +def test_notice_appears_after_a_real_command(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + monkeypatch.setattr(sys, "executable", "/opt/homebrew/Cellar/assembly/9/libexec/bin/python") + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + # Force the stderr console to look like a TTY so the gate passes under CliRunner. + monkeypatch.setattr(output.error_console, "is_terminal", True, raising=False) + + # `telemetry status` is a simple, side-effect-free command that runs through + # run_command; CliRunner merges stderr into output. + result = CliRunner().invoke(app, ["telemetry", "status"]) + assert result.exit_code == 0 + assert "Update available" in result.output + + +def test_no_notice_under_json(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + monkeypatch.setattr(output.error_console, "is_terminal", True, raising=False) + + result = CliRunner().invoke(app, ["telemetry", "status", "--json"]) + assert result.exit_code == 0 + assert "Update available" not in result.output +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_update_check.py -k "real_command or under_json" -q` +Expected: FAIL on `test_notice_appears_after_a_real_command` (no notice yet — `run_command` doesn't call `maybe_notify`). + +- [ ] **Step 3: Wire the hook into `run_command`** + +In `aai_cli/context.py`, add `update_check` to the existing package import: + +```python +from aai_cli import config, environments, output, telemetry, update_check +``` + +Then in `run_command`, call `maybe_notify` on the success path — immediately after the `with telemetry.track(...)` block, still inside the `try`: + +```python + with telemetry.track(ctx.command_path): + fn(state, json_mode) + update_check.maybe_notify(json_mode=json_mode) +``` + +(It runs only when `fn` returns normally; on any exception the `except` clauses below handle it and the notice is correctly skipped.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_update_check.py -q` +Expected: PASS (all). + +- [ ] **Step 5: Run the full gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` + +Watch specifically for: +- **deptry**: confirms `packaging` is now a declared (not transitive) dependency — the Task 2 declaration satisfies it. +- **patch coverage / mutation gate** (diff-scoped vs `origin/main`): the new `aai_cli/update_check.py` lines and the `context.py` change are in-diff, so every changed line needs a test that fails if it breaks. The Task 4 behavioral asserts (exact box text, gating produces empty output, spawn-only-when-stale, detached args) are written to kill those mutants; if a specific line survives, add the asserting test the mutation output names. +- If any syrupy `--help`/command snapshot shifts (it shouldn't — the command is hidden and no visible output changed), regenerate with `uv run pytest --snapshot-update` and re-check; never hand-edit snapshots. + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/context.py tests/test_update_check.py +git commit -m "Show the update notice after successful commands + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Version source = GitHub releases API → Task 3 (`_RELEASES_URL`, `fetch_and_cache`). ✅ +- Render-from-cache + detached refresh, never blocks → Task 4 (`maybe_notify` reads cache, `spawn_refresh` detached) + Task 5 (hook). ✅ +- Install-method detection (brew/pipx/uv/generic) → Task 2 (`detect_upgrade_command`). ✅ +- `config.toml` cache fields → Task 1. ✅ +- Hidden `_update-check` subcommand, telemetry-style spawn → Task 3 (registration) + Task 4 (`spawn_refresh`). ✅ +- Suppression (json, non-TTY, `AAI_NO_UPDATE_CHECK`, `CI`, not-newer) → Task 4 (`_should_notify`, `_maybe_notify`) with a test each. ✅ +- Show on success only → Task 5 (placement after the `with` block, before the `except`s). ✅ +- `packaging` declared, conservative floor → Task 2. ✅ +- Best-effort/swallow-everything → `fetch_and_cache` and `maybe_notify` wrappers. ✅ +- Test plan (detection, is_newer, gating, spawn, fetch, e2e) → Tasks 2–5. ✅ + +**2. Placeholder scan:** none — every step has concrete code/commands. + +**3. Type/name consistency:** `get_update_cache` returns `(float|None, str|None)`; `set_update_cache(*, last_check, latest_version)`; `maybe_notify(*, json_mode)`; `detect_upgrade_command() -> str` ("" = unknown); `is_newer(latest, current)`; `spawn_refresh`/`fetch_and_cache` — all referenced consistently across tasks. `ENV_DISABLED`, `_RELEASES_URL`, `_CHECK_INTERVAL_SECONDS`, `_DOCS_URL` referenced by the same names in tests and module. + +**Deviation from spec (intentional, noted above):** `maybe_notify` drops the `command_path` param — nothing routes the hidden/flush commands through `run_command`, and an unused param trips `ARG001`. diff --git a/docs/superpowers/specs/2026-06-11-update-notifier-design.md b/docs/superpowers/specs/2026-06-11-update-notifier-design.md new file mode 100644 index 00000000..d6470976 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-update-notifier-design.md @@ -0,0 +1,188 @@ +# Update notifier — "a new version is available" notice in the CLI + +**Date:** 2026-06-11 +**Status:** Approved design + +## Summary + +Add an unobtrusive "update available" notice to the CLI, in the style of npm's +`update-notifier` / Vercel / gh / Gemini: when a newer release exists, the next +human, interactive run prints a small bordered box on **stderr** after the command +finishes, telling the user how to upgrade. The check is **best-effort, cached, and +never blocks** — the network fetch happens in a detached background process (the +same pattern `telemetry.py` already uses), and the rendered notice always comes +from cache, so it adds zero latency to any command. + +``` + ╭────────────────────────────────────────────────╮ + │ │ + │ Update available 0.1.0 → 0.2.0 │ + │ Run brew upgrade assembly to update │ + │ │ + ╰────────────────────────────────────────────────╯ +``` + +The upgrade command shown is **detected from how the CLI was installed** (Homebrew +/ pipx / uv tool), so the user sees the command that actually applies to them. + +## Why these choices (context) + +- **Version source = GitHub Releases API.** The PyPI name `assemblyai-cli` is + squatted, so there is no PyPI to query. Releases ship as git tags + GitHub + Releases (see the bottle pipeline), so "latest" comes from + `GET https://api.github.com/repos/AssemblyAI/cli/releases/latest` → `tag_name` + (e.g. `v0.2.0`). `releases/latest` excludes drafts and pre-releases, so we only + ever notify about real, stable releases. +- **Render-from-cache + detached refresh.** This is the npm `update-notifier` + model. The current run never waits on `api.github.com` (frequently blocked in + sandboxes anyway); it renders whatever the last background check cached. The + tradeoff — the first time you're behind, the box appears on the *following* run — + is the standard, accepted behavior. +- **Reuse existing patterns.** The repo already has the exact scaffolding: the + detached hidden-subcommand spawner in `telemetry.py` + (`subprocess.Popen([sys.executable, "-m", "aai_cli", …], stdout=DEVNULL, + start_new_session=True)`), the per-command hook in `context.run_command` + (where `telemetry.track` is invoked), the stderr/TTY/JSON discipline in + `output.py`, and non-secret persisted state in `config.toml`. + +## Architecture + +One new module, `aai_cli/update_check.py`, plus a hidden `_update-check` +subcommand for the network fetch, hooked into `context.run_command`. Mirrors the +`telemetry.py` + hidden `telemetry flush` split. + +### Data flow + +1. A command runs. After the body completes, `run_command` calls + `update_check.maybe_notify(ctx.command_path)`. +2. `maybe_notify` checks the gates (below). If they pass and `config.toml`'s cached + `update_latest_version` is newer than `aai_cli.__version__`, it renders the box + on `output.error_console`. +3. Independently, if the cache is **stale** (`now - update_last_check > 24h`, or + never checked), `maybe_notify` calls `spawn_refresh()` — a detached + `assembly _update-check` process — and returns immediately. That process GETs + the releases API, parses `tag_name`, and writes `update_latest_version` + + `update_last_check` into `config.toml`. The result is used by the *next* run. + +So the only synchronous work on the hot path is: read two config fields, compare +two versions, maybe print a panel, maybe fork a detached process. No network, no +blocking. + +### Components (`aai_cli/update_check.py`) + +- `maybe_notify(command_path: str) -> None` — the single entry point + `run_command` calls. Orchestrates gating → render-from-cache → maybe spawn + refresh. Swallows everything. +- `_should_notify() -> bool` — the gate (see "Suppression"). +- `detect_upgrade_command() -> str` — inspect `sys.executable` (the venv/bin the + running `assembly` lives in): + - path under a Homebrew prefix (`/opt/homebrew`, `/usr/local`, or + `$(brew --prefix)` Cellar) → `brew upgrade assembly` + - path under a pipx venv (contains `pipx/venvs`, or under `$PIPX_HOME`) → + `pipx upgrade assembly` + - path under a uv tools dir (contains `uv/tools`, or under `$UV_TOOL_DIR`) → + `uv tool upgrade assembly` + - otherwise → a generic `See https://github.com/AssemblyAI/cli#installation` + hint. +- `_render(current: str, latest: str, upgrade: str) -> None` — build a Rich + `Panel` and print it to `output.error_console`. Theme-consistent with the rest + of the CLI (reuse `aai_cli/theme.py` styles). +- `spawn_refresh() -> None` — detached `Popen` clone of telemetry's spawner; + `start_new_session=True`, stdio → `DEVNULL`. Guarded so the `_update-check` + process can never spawn another (mirrors telemetry's self-spawn guard). +- `fetch_and_cache() -> None` — the hidden subcommand body. stdlib + `urllib.request` GET with a `User-Agent` header (GitHub requires one), 5s + timeout, parse `tag_name`, normalize (`lstrip("v")`), write the two config + fields. **Every exception swallowed** (`URLError`, `OSError`, JSON/key errors, + rate-limit non-200s). +- `is_newer(latest: str, current: str) -> bool` — `packaging.version.Version` + compare; `InvalidVersion` → `False` (never notify on a version we can't parse). + +### Hidden subcommand + +Register `_update-check` (`hidden=True`) the same way `telemetry flush` is +registered — an explicit, reviewable entry point invoked as +`python -m aai_cli _update-check`. Its body is `fetch_and_cache()`. + +### `config.py` additions + +Two new optional fields on the `Config` dataclass, persisted in `config.toml` +alongside `telemetry_enabled`/`device_id`: + +- `update_last_check: float | None` — unix timestamp of the last fetch attempt. +- `update_latest_version: str | None` — the last `tag_name` seen (without the + `v`). + +Plus getters/setters following the existing `get_telemetry_enabled` / +`set_telemetry_enabled` shape. + +### Dependency + +Declare `packaging` as a direct `[project.dependencies]` entry (it's already +resolved transitively at 26.2 in `uv.lock`, so it pins to the same version — no +new download). Use a **conservative floor** (e.g. `packaging>=24.0`) per the +project's safe-chain age-gate behavior, then `uv lock` so `uv lock --check` +passes. This replaces hand-rolled version parsing with the standard, robust +`Version` comparator and keeps `deptry` happy (no transitive dep used directly). + +## Suppression (no notice when …) + +`_should_notify()` returns `False` — and no box prints — when **any** of: + +- the active output mode is JSON / agentic (`output.resolve_json(...)` true, or + the agentic signal in `output.py`), +- **stderr is not a TTY** (piped or redirected) — the box would corrupt captured + stderr, +- `AAI_NO_UPDATE_CHECK` is set (any non-empty value), +- `CI` is set (don't nag in pipelines), +- the command is the hidden `_update-check` itself (and the `telemetry flush` + hidden command), +- there is no cached version, or the cached version is not newer. + +The notice prints **only on a command's success path** — not stacked under an +error message. (Considered and rejected: always-show, à la Vercel — an update box +directly under an error reads as noise.) + +## Error handling + +Best-effort throughout, matching `telemetry.py`: the synchronous path catches and +swallows config-read and render errors; the detached `fetch_and_cache` swallows +all network/parse/IO errors. A failure anywhere means "no notice this run," never +a traceback and never a delayed or broken command. + +## Testing + +Hermetic (pytest-socket stays armed; the fetch is mocked, the spawn is +monkeypatched), mirroring the telemetry tests: + +- `detect_upgrade_command`: monkeypatch `sys.executable` to a brew-prefix path, a + `pipx/venvs` path, a `uv/tools` path, and an unknown path → asserts each returns + the right command / generic hint. +- `is_newer`: newer, equal, older, and an unparseable string → `False`. +- `maybe_notify` gating (behavioral asserts that survive the mutation gate): + - newer version cached + TTY + human → the box prints, and its text contains + the `current → latest` arrow and the detected command (substring assert on the + actionable keyword). + - **no** output under `--json`, when stderr is not a TTY, when `CI` is set, and + when `AAI_NO_UPDATE_CHECK` is set (assert empty stderr for each). + - cache equal/older → no box. +- `maybe_notify` spawns the refresh only when the cache is stale: monkeypatch + `Popen`, freeze time with **time-machine**, assert the detached args + + `start_new_session=True` when stale, and **no** spawn when fresh. +- `fetch_and_cache`: feed a sample `releases/latest` JSON via a mocked opener → + asserts the two config fields are written and `v`-prefix stripped; feed an error + / non-200 / bad JSON → asserts it swallows and writes nothing (or only the + timestamp). +- syrupy snapshot of the rendered panel (so its exact look is pinned, like other + CLI output). + +## Out of scope + +- A `assembly update`/self-update command that performs the upgrade (we only + *notify*; upgrading is the package manager's job). +- A config-file UI or `assembly` subcommand to toggle the check — `AAI_NO_UPDATE_CHECK` + + `CI` detection is enough (YAGNI). +- Checking more often than daily, or any synchronous/blocking check. +- Notifying about pre-releases (the releases API excludes them). +- Folding the refresh into the telemetry flusher — kept separate so update checks + and telemetry opt-out independently (the extra process only spawns ~once/day). diff --git a/pyproject.toml b/pyproject.toml index 3ed7d936..0b40be95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,10 @@ dependencies = [ "openai>=2.41.0", "yt-dlp>=2026.3.17", "questionary>=2.0.1", + # Version compare for the update notifier. Conservative floor so safe-chain's + # minimum-package-age check doesn't reject resolution; packaging already arrives + # transitively, so the lock pins the same release. + "packaging>=24.0", # audioop (used for PCM resampling) left the stdlib in 3.13; this backport provides it. "audioop-lts>=0.2; python_version >= '3.13'", ] diff --git a/scripts/bump_minor.sh b/scripts/bump_minor.sh new file mode 100755 index 00000000..87087c79 --- /dev/null +++ b/scripts/bump_minor.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# Bump the AssemblyAI CLI version by one minor increment (X.Y.Z -> X.(Y+1).0), +# updating the two files that must stay in lock-step: pyproject.toml and +# aai_cli/__init__.py. It does NOT commit, tag, or push. +# +# ./scripts/bump_minor.sh # bump and write both files +# ./scripts/bump_minor.sh -n # dry run: print the new version, write nothing +# +# Typical flow: run this on a branch, commit the change, open + merge the PR, +# then run ./scripts/cut_release.sh on main to tag the new version. +set -eu + +DRY_RUN=0 +for arg in "$@"; do + case "$arg" in + -n | --dry-run) DRY_RUN=1 ;; + -h | --help) + sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + printf 'unknown argument: %s (try --help)\n' "$arg" >&2 + exit 2 + ;; + esac +done + +info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } +err() { + printf '\033[1;31merror:\033[0m %s\n' "$1" >&2 + exit 1 +} + +# Run from the repo root so the relative paths below resolve regardless of CWD. +root="$(git rev-parse --show-toplevel)" || err "not inside a git repository." +cd "$root" + +# --- Single source of truth: the version in pyproject.toml ----------------- +version="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "([^"]+)".*/\1/')" +[ -n "$version" ] || err "could not read version from pyproject.toml." + +# __version__ must already match, or the two files would diverge on the bump. +init_version="$(grep -m1 '__version__' aai_cli/__init__.py | sed -E 's/.*"([^"]+)".*/\1/')" +[ "$init_version" = "$version" ] || + err "version mismatch: pyproject.toml=$version but aai_cli/__init__.py=$init_version." + +# --- Compute the minor bump (X.Y.Z -> X.(Y+1).0) --------------------------- +echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || + err "version '$version' is not a plain MAJOR.MINOR.PATCH triple; bump it by hand." + +major="$(echo "$version" | cut -d. -f1)" +minor="$(echo "$version" | cut -d. -f2)" +new_version="${major}.$((minor + 1)).0" +info "Bumping ${version} -> ${new_version}." + +if [ "$DRY_RUN" -eq 1 ]; then + info "Dry run: not writing any files." + exit 0 +fi + +# --- Rewrite both files (portable in-place edit via temp file) ------------- +replace_in_file() { + # $1 file, $2 sed expression + tmp="$(mktemp)" + sed -E "$2" "$1" >"$tmp" + mv "$tmp" "$1" +} + +replace_in_file pyproject.toml "s/^version = \"${version}\"/version = \"${new_version}\"/" +replace_in_file aai_cli/__init__.py "s/__version__ = \"${version}\"/__version__ = \"${new_version}\"/" + +info "Updated pyproject.toml and aai_cli/__init__.py to ${new_version}." +info "Next: commit the change, open + merge the PR, then run ./scripts/cut_release.sh on main." diff --git a/scripts/check.sh b/scripts/check.sh index 641139b8..c72ed423 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -122,7 +122,7 @@ echo "==> shellcheck" # Static-lint this gate script. CI's ubuntu runner ships shellcheck; # locally it's skipped with a notice if not installed. if command -v shellcheck >/dev/null 2>&1; then - shellcheck scripts/check.sh scripts/docker_build_check.sh scripts/cut_release.sh + shellcheck scripts/check.sh scripts/docker_build_check.sh scripts/cut_release.sh scripts/bump_minor.sh else echo " shellcheck not found; skipping (CI runs it)" fi diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 60dd1e57..cd7c3ab4 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -1,4 +1,23 @@ # serializer version: 1 +# name: test_command_help_matches_snapshot[_update-check] + ''' + + Usage: assembly _update-check [OPTIONS] + + Internal: refresh the cached latest version (spawned detached). Hidden. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Internal plumbing, spawned by the CLI itself + $ assembly _update-check + + + + ''' +# --- # name: test_command_help_matches_snapshot[agent] ''' diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5d287ea6..510f8449 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -87,7 +87,9 @@ def test_help_lists_commands_in_workflow_order(): # Typer (>=0.13) vendors its own click; the root command is a TyperGroup. assert isinstance(cmd, TyperGroup) ctx = cmd.make_context("assembly", [], resilient_parsing=True) - names = cmd.list_commands(ctx) # the order shown under --help + # The order shown under --help; hidden internal commands (e.g. _update-check, + # the detached update-check refresh) aren't displayed, so exclude them. + names = [n for n in cmd.list_commands(ctx) if not cmd.commands[n].hidden] # Grouped into Rich help panels (see help_panels.py): Quick Start, Build an App, # Run AssemblyAI, Setup & Tools, History, then Account. assert names == [ diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 00000000..bed3f98b --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,334 @@ +"""Tests for the update-available notifier.""" + +from __future__ import annotations + +import io +import sys +import time +import types + +import httpx2 +import pytest +from rich.console import Console + +from aai_cli import __version__, config, output, theme, update_check + + +def test_update_cache_roundtrips(tmp_path, monkeypatch): + # Isolate config.toml to a temp dir so the real one is never touched. + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + + assert config.get_update_cache() == (None, None) + + config.set_update_cache(last_check=1718000000.0, latest_version="0.2.0") + assert config.get_update_cache() == (1718000000.0, "0.2.0") + + +def test_update_cache_records_check_even_when_version_unknown(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + # A failed fetch still records the timestamp (so we don't re-spawn every run) + # but leaves the version unknown. + config.set_update_cache(last_check=1718000001.0, latest_version=None) + assert config.get_update_cache() == (1718000001.0, None) + + +@pytest.mark.parametrize( + ("latest", "current", "expected"), + [ + ("0.2.0", "0.1.0", True), + ("0.1.1", "0.1.0", True), + ("1.0.0", "0.9.9", True), + ("0.1.0", "0.1.0", False), # equal + ("0.1.0", "0.2.0", False), # older + ("not-a-version", "0.1.0", False), # unparseable -> never notify + ], +) +def test_is_newer(latest, current, expected): + assert update_check.is_newer(latest, current) is expected + + +@pytest.mark.parametrize( + ("exe", "expected"), + [ + ("/opt/homebrew/Cellar/assembly/0.1.0/libexec/bin/python", "brew upgrade assembly"), + ("/usr/local/Cellar/assembly/0.1.0/libexec/bin/python", "brew upgrade assembly"), + ("/Users/x/.local/pipx/venvs/aai-cli/bin/python", "pipx upgrade assembly"), + ("/Users/x/.local/share/uv/tools/aai-cli/bin/python", "uv tool upgrade assembly"), + ("/usr/bin/python3", ""), # unknown -> generic (empty) + ], +) +def test_detect_upgrade_command(exe, expected, monkeypatch): + monkeypatch.setattr(sys, "executable", exe) + assert update_check.detect_upgrade_command() == expected + + +def _fake_response(payload: dict[str, object]) -> types.SimpleNamespace: + return types.SimpleNamespace(json=lambda: payload, raise_for_status=lambda: None) + + +def test_fetch_and_cache_writes_latest(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + + # Untyped capture dict (mirrors the pattern in tests/test_telemetry.py). + captured = {} + + def fake_get(url, **kwargs): + captured["url"] = url + captured["headers"] = kwargs.get("headers", {}) + captured["timeout"] = kwargs.get("timeout") + captured["follow_redirects"] = kwargs.get("follow_redirects") + return _fake_response({"tag_name": "v0.4.0"}) + + monkeypatch.setattr(httpx2, "get", fake_get) + + update_check.fetch_and_cache() + + last_check, latest = config.get_update_cache() + assert latest == "0.4.0" # 'v' stripped + assert last_check is not None + assert captured["url"] == update_check._RELEASES_URL + assert "User-Agent" in captured["headers"] + assert captured["timeout"] == 5.0 # the configured fetch timeout flows through + assert captured["follow_redirects"] is True # GitHub's latest-release URL redirects + + +def test_check_interval_is_24_hours(): + assert update_check._CHECK_INTERVAL_SECONDS == 86400 + + +def test_fetch_and_cache_empty_tag_leaves_version_unknown(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + # An empty tag_name is present but unusable: it must NOT be cached as a version. + monkeypatch.setattr(httpx2, "get", lambda url, **kwargs: _fake_response({"tag_name": ""})) + + update_check.fetch_and_cache() + + _, latest = config.get_update_cache() + assert latest is None + + +def test_fetch_and_cache_swallows_errors_but_records_check(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + + def boom(url, **kwargs): + raise httpx2.HTTPError("network down") + + monkeypatch.setattr(httpx2, "get", boom) + + update_check.fetch_and_cache() # must not raise + + last_check, latest = config.get_update_cache() + assert latest is None # unknown after a failed fetch + assert last_check is not None # but the attempt is recorded + + +def _tty_console() -> tuple[Console, io.StringIO]: + # A theme-aware console (so aai.* styles resolve, like the real error_console) + # that reports as a terminal, with color env pinned (see the + # rich-color-tests-need-empty-environ project memory) so output is stable. + # Returns the buffer too: Console.file is typed IO[str] (no .getvalue()). + buf = io.StringIO() + return theme.make_console(file=buf, force_terminal=True, width=80, _environ={}), buf + + +def test_maybe_notify_shows_box_for_newer_cached_version(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + # Cache says a newer version exists, checked just now (so no spawn). + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + monkeypatch.setattr(sys, "executable", "/opt/homebrew/Cellar/assembly/9/libexec/bin/python") + + con, buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + + update_check.maybe_notify(json_mode=False) + + out = buf.getvalue() + assert "Update available" in out + assert "9.9.9" in out + assert "brew upgrade assembly" in out # detected command, not a generic hint + + +def test_maybe_notify_silent_under_json(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + con, buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + + update_check.maybe_notify(json_mode=True) + + assert buf.getvalue() == "" # JSON mode prints nothing + + +def test_maybe_notify_silent_when_not_a_tty(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + buf = io.StringIO() + con = theme.make_console(file=buf, force_terminal=False, _environ={}) # not a tty + monkeypatch.setattr(output, "error_console", con) + + update_check.maybe_notify(json_mode=False) + + assert buf.getvalue() == "" + + +def test_maybe_notify_silent_in_ci_and_when_disabled(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + con, buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + + monkeypatch.setenv("CI", "1") + update_check.maybe_notify(json_mode=False) + assert buf.getvalue() == "" + + monkeypatch.delenv("CI", raising=False) + monkeypatch.setenv(update_check.ENV_DISABLED, "1") + update_check.maybe_notify(json_mode=False) + assert buf.getvalue() == "" + + +def test_maybe_notify_no_box_when_cache_not_newer(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version=__version__) # equal + con, buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + + update_check.maybe_notify(json_mode=False) + assert "Update available" not in buf.getvalue() + + +def test_maybe_notify_spawns_refresh_only_when_stale(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + con, _buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + + spawned: list[bool] = [] + monkeypatch.setattr(update_check, "spawn_refresh", lambda: spawned.append(True)) + + # Fresh check -> no spawn. + config.set_update_cache(last_check=time.time(), latest_version=None) + update_check.maybe_notify(json_mode=False) + assert spawned == [] + + # Stale check (>24h ago) -> spawn. + config.set_update_cache( + last_check=time.time() - update_check._CHECK_INTERVAL_SECONDS - 1, latest_version=None + ) + update_check.maybe_notify(json_mode=False) + assert spawned == [True] + + +def test_spawn_refresh_is_detached(monkeypatch): + # Untyped capture dict (mirrors the pattern in tests/test_telemetry.py). + calls = {} + + def fake_popen(args, **kwargs): + calls["args"] = args + calls["kwargs"] = kwargs + return object() + + monkeypatch.setattr(update_check.subprocess, "Popen", fake_popen) + update_check.spawn_refresh() + + assert calls["args"][:3] == [sys.executable, "-m", "aai_cli"] + assert calls["args"][3] == "_update-check" + assert calls["kwargs"]["start_new_session"] is True + assert calls["kwargs"]["env"][update_check.ENV_DISABLED] == "1" # child can't re-spawn + + +def test_notice_appears_after_a_real_command(tmp_path, monkeypatch): + from typer.testing import CliRunner + + from aai_cli.main import app + + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + monkeypatch.setattr(sys, "executable", "/opt/homebrew/Cellar/assembly/9/libexec/bin/python") + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + # is_terminal is a read-only property, so swap in a tty-reporting console and + # read its buffer (CliRunner doesn't capture our substituted stderr console). + con, buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + + # `telemetry status` is a simple, side-effect-free command that runs through + # run_command — exercising the maybe_notify hook end to end. + result = CliRunner().invoke(app, ["telemetry", "status"]) + assert result.exit_code == 0 + assert "Update available" in buf.getvalue() + + +def test_no_notice_under_json(tmp_path, monkeypatch): + from typer.testing import CliRunner + + from aai_cli.main import app + + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + con, buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + + result = CliRunner().invoke(app, ["telemetry", "status", "--json"]) + assert result.exit_code == 0 + assert "Update available" not in buf.getvalue() + + +def test_maybe_notify_generic_hint_when_install_unknown(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + monkeypatch.setattr(sys, "executable", "/usr/bin/python3") # unknown -> no command + con, buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + + update_check.maybe_notify(json_mode=False) + + out = buf.getvalue() + assert "Update available" in out + assert "github.com/AssemblyAI/cli#installation" in out # docs hint, not a command + assert "brew upgrade" not in out + + +def test_fetch_and_cache_no_tag_leaves_version_unknown(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + monkeypatch.setattr(httpx2, "get", lambda url, **kwargs: _fake_response({})) # no tag_name + + update_check.fetch_and_cache() + + last_check, latest = config.get_update_cache() + assert latest is None + assert last_check is not None + + +def test_fetch_and_cache_swallows_cache_write_error(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + monkeypatch.setattr(httpx2, "get", lambda url, **kwargs: _fake_response({"tag_name": "v0.4.0"})) + + def boom(**kwargs): + raise OSError("disk full") + + monkeypatch.setattr(config, "set_update_cache", boom) + + update_check.fetch_and_cache() # the cache-write failure must be swallowed + + +def test_maybe_notify_swallows_config_errors(tmp_path, monkeypatch): + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + con, _buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + + def boom() -> tuple[float | None, str | None]: + raise OSError("config unreadable") + + monkeypatch.setattr(config, "get_update_cache", boom) + + update_check.maybe_notify(json_mode=False) # a config read failure must be swallowed diff --git a/uv.lock b/uv.lock index 8a247b23..c3352120 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,7 @@ dependencies = [ { name = "httpx2" }, { name = "keyring" }, { name = "openai" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "pydantic" }, { name = "questionary" }, @@ -66,6 +67,7 @@ requires-dist = [ { name = "httpx2", specifier = ">=2.0.0" }, { name = "keyring", specifier = ">=25.7.0" }, { name = "openai", specifier = ">=2.41.0" }, + { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.10.0" }, { name = "pydantic", specifier = ">=2.13.4" }, { name = "questionary", specifier = ">=2.0.1" },