From d31a81ef3f25a91c50bca5081487c11ce9a60bff Mon Sep 17 00:00:00 2001
From: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
Date: Thu, 2 Apr 2026 11:30:55 -0700
Subject: [PATCH] Add background update check & notification for arcade CLI
(#800)
## Summary
- On every CLI command invocation (except `update`/`upgrade`/`mcp`), a
detached subprocess checks PyPI for newer versions (throttled to once
per 4 hours) and caches the result at `~/.arcade/update_cache.json`
- On the next invocation, if a newer version is known, a yellow
one-liner notification is printed suggesting `arcade update`
- Respects `ARCADE_DISABLE_AUTOUPDATE=1` environment variable to opt out
entirely
---
> [!NOTE]
> **Medium Risk**
> Adds a background PyPI version check that spawns detached subprocesses
and may print update notifications on most CLI invocations; mistakes
could impact CLI reliability or corrupt MCP stdio output (mitigated by
explicit command exclusions).
>
> **Overview**
> Adds `arcade update` (and hidden `arcade upgrade` alias) to
self-upgrade the `arcade-mcp` CLI by detecting the original install
method (`uv tool`, `pipx`, `uv pip`, or `pip`) and running the
appropriate upgrade command.
>
> Introduces a **throttled background update check** on most CLI
invocations: a detached subprocess queries PyPI, writes
`~/.arcade/update_cache.json`, and on subsequent runs prints a one-line
notification when a newer version is cached; this is disabled via
`ARCADE_DISABLE_AUTOUPDATE=1` and explicitly skipped for
`update`/`upgrade`/`mcp` to avoid MCP stdio output corruption.
>
> Bumps the package version to `1.13.0`, adds a `packaging` dependency,
and includes comprehensive tests covering PyPI/yanked/prerelease
handling, install-method detection, caching, and callback integration.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2d9646ecc2211e8cfecd6e4901d14b1f5b7bb306. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Claude Opus 4.6
---
CLAUDE.md | 3 +-
libs/arcade-cli/arcade_cli/main.py | 34 ++
libs/arcade-cli/arcade_cli/update.py | 319 ++++++++++++++
libs/tests/cli/test_update.py | 622 +++++++++++++++++++++++++++
pyproject.toml | 3 +-
5 files changed, 979 insertions(+), 2 deletions(-)
create mode 100644 libs/arcade-cli/arcade_cli/update.py
create mode 100644 libs/tests/cli/test_update.py
diff --git a/CLAUDE.md b/CLAUDE.md
index d723ffbd..0602c4a4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,7 +31,7 @@ arcade-core (base: config, errors, catalog, telemetry)
## Versioning Rules
-- Use semver. ALWAYS bump the version in `pyproject.toml` when modifying a library's code.
+- Use semver. Bump the version in `pyproject.toml` when modifying a library's code — but first check `git diff main` to see if the version has already been bumped in the current branch. Only bump once per branch/PR.
- ALWAYS bump the minimum required dependency version when making breaking changes between libraries.
## Key Patterns
@@ -67,6 +67,7 @@ Transports: `stdio` (default) and `http` (tools that require auth or secrets nee
- **All changes must have tests and follow TDD.** Every new feature, bug fix, or behavioral change needs a corresponding test in `libs/tests/`.
- **Always use uv.** Never use `pip`, `pip install`, `python`, or `python -m` directly. Use `uv run`, `uv sync`, `uv build`, etc.
+- **Never pollute stdout/stderr in MCP stdio paths.** Code reachable by `arcade-mcp-server` or the `arcade mcp` CLI command must never print, log to stdout, or spawn processes that write to stdout/stderr. The MCP stdio transport requires a clean JSON-only channel — any stray output corrupts the protocol. When adding CLI-wide hooks or notifications, always gate them to exclude MCP transport paths.
## Code Quality
diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py
index 9b934e82..9d7add7a 100644
--- a/libs/arcade-cli/arcade_cli/main.py
+++ b/libs/arcade-cli/arcade_cli/main.py
@@ -1,4 +1,5 @@
import asyncio
+import contextlib
import os
import subprocess
import sys
@@ -28,6 +29,7 @@ from arcade_cli.project import app as project_app
from arcade_cli.secret import app as secret_app
from arcade_cli.server import app as server_app
from arcade_cli.show import show_logic
+from arcade_cli.update import check_and_notify, run_update
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
from arcade_cli.utils import (
ModelSpec,
@@ -892,6 +894,30 @@ def deploy(
handle_cli_error("Failed to deploy server", e, debug)
+@cli.command(help="Check for and install CLI updates", rich_help_panel="Manage")
+def update(
+ debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
+) -> None:
+ """Check for updates to the Arcade CLI and install if available."""
+ try:
+ run_update()
+ except Exception as e:
+ handle_cli_error("Failed to check for updates", e, debug)
+
+
+@cli.command(
+ name="upgrade",
+ help="Check for and install CLI updates (alias for update)",
+ rich_help_panel="Manage",
+ hidden=True,
+)
+def upgrade(
+ debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
+) -> None:
+ """Alias for `arcade update`."""
+ update(debug=debug)
+
+
@cli.command(help="Open the Arcade Dashboard in a web browser", rich_help_panel="User")
def dashboard(
host: str = typer.Option(
@@ -963,6 +989,12 @@ def main_callback(
help="Print version and exit.",
),
) -> None:
+ # Background update check + notification (skip for update/upgrade/mcp to avoid
+ # corrupting MCP stdio protocol with non-JSON output)
+ if ctx.invoked_subcommand not in {update.__name__, upgrade.__name__, mcp.__name__}:
+ with contextlib.suppress(Exception):
+ check_and_notify()
+
# Commands that do not require a logged in user
public_commands = {
login.__name__,
@@ -973,6 +1005,8 @@ def main_callback(
new.__name__,
show.__name__,
configure.__name__,
+ update.__name__,
+ upgrade.__name__,
}
if ctx.invoked_subcommand in public_commands:
return
diff --git a/libs/arcade-cli/arcade_cli/update.py b/libs/arcade-cli/arcade_cli/update.py
new file mode 100644
index 00000000..f07f9f40
--- /dev/null
+++ b/libs/arcade-cli/arcade_cli/update.py
@@ -0,0 +1,319 @@
+"""Logic for the `arcade update` / `arcade upgrade` CLI command."""
+
+from __future__ import annotations
+
+import contextlib
+import dataclasses
+import json
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from dataclasses import dataclass
+from enum import Enum
+from importlib import metadata
+from urllib.request import urlopen
+
+from arcade_core.constants import ARCADE_CONFIG_PATH
+from arcade_core.subprocess_utils import (
+ build_windows_hidden_startupinfo,
+ get_windows_no_window_creationflags,
+)
+from packaging.version import Version
+
+from arcade_cli.console import console
+
+logger = logging.getLogger(__name__)
+
+PACKAGE_NAME = "arcade-mcp"
+PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
+# Pattern to match PACKAGE_NAME exactly (not as a prefix of e.g. "arcade-mcp-server")
+_PACKAGE_RE = re.compile(rf"(?:^|\s){re.escape(PACKAGE_NAME)}(?:\s|$)", re.MULTILINE)
+
+UPDATE_CACHE_PATH = os.path.join(ARCADE_CONFIG_PATH, "update_cache.json")
+# Minimum interval between background PyPI version checks
+CHECK_INTERVAL_SECONDS = 4 * 60 * 60 # 4 hours
+
+
+# ---------------------------------------------------------------------------
+# Update cache dataclass and I/O
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class UpdateCache:
+ latest_version: str
+ checked_at: float # time.time()
+
+
+def read_update_cache(cache_path: str) -> UpdateCache | None:
+ """Read the update cache from disk. Returns None if missing or corrupt."""
+ try:
+ with open(cache_path) as f:
+ data = json.load(f)
+ # Only extract fields defined on UpdateCache so schema changes stay in sync
+ fields = {f.name for f in dataclasses.fields(UpdateCache)}
+ return UpdateCache(**{k: data[k] for k in fields})
+ except Exception:
+ return None
+
+
+def write_update_cache(cache_path: str, cache: UpdateCache) -> None:
+ """Write the update cache to disk."""
+ os.makedirs(os.path.dirname(cache_path), exist_ok=True)
+ with open(cache_path, "w") as f:
+ json.dump(dataclasses.asdict(cache), f)
+
+
+def should_check_for_update(cache: UpdateCache | None) -> bool:
+ """Determine whether a background PyPI check is needed."""
+ if os.environ.get("ARCADE_DISABLE_AUTOUPDATE") == "1":
+ return False
+ if cache is None:
+ return True
+ return (time.time() - cache.checked_at) > CHECK_INTERVAL_SECONDS
+
+
+def fork_background_check() -> None:
+ """Spawn a detached process that checks PyPI and writes the cache."""
+ cache = read_update_cache(UPDATE_CACHE_PATH)
+ if not should_check_for_update(cache):
+ return
+ cmd = [
+ sys.executable,
+ "-c",
+ "from arcade_cli.update import _background_check; _background_check()",
+ ]
+ with contextlib.suppress(Exception):
+ if sys.platform == "win32":
+ subprocess.Popen(
+ cmd,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ creationflags=get_windows_no_window_creationflags(new_process_group=True),
+ startupinfo=build_windows_hidden_startupinfo(),
+ close_fds=True,
+ )
+ else:
+ subprocess.Popen(
+ cmd,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ close_fds=True,
+ )
+
+
+def _background_check() -> None:
+ """Entry point for the detached background process. Fetches PyPI, writes cache.
+
+ Always updates ``checked_at`` so that failed checks don't bypass the throttle
+ and spawn a new background process on every CLI invocation.
+ """
+ latest = fetch_latest_pypi_version()
+ # Preserve the previously cached version when the fetch fails
+ existing = read_update_cache(UPDATE_CACHE_PATH)
+ cached_version = latest or (existing.latest_version if existing else "")
+ write_update_cache(
+ UPDATE_CACHE_PATH, UpdateCache(latest_version=cached_version, checked_at=time.time())
+ )
+
+
+def check_and_notify() -> None:
+ """Read cache, print notification if update available, fork background check."""
+ if os.environ.get("ARCADE_DISABLE_AUTOUPDATE") == "1":
+ return
+ cache = read_update_cache(UPDATE_CACHE_PATH)
+ if cache:
+ try:
+ cached_version = Version(cache.latest_version)
+ if cached_version > Version(metadata.version(PACKAGE_NAME)):
+ console.print(
+ f"Update available: {metadata.version(PACKAGE_NAME)} → {cache.latest_version} "
+ f"Run `arcade update` to upgrade.",
+ style="yellow",
+ )
+ except Exception:
+ logger.debug("Failed to check cached update version", exc_info=True)
+ fork_background_check()
+
+
+class InstallMethod(str, Enum):
+ UV_TOOL = "uv_tool"
+ PIPX = "pipx"
+ UV_PIP = "uv_pip"
+ PIP = "pip"
+
+
+def fetch_latest_pypi_version() -> str | None:
+ """Query PyPI for the latest stable version of the package.
+
+ Returns None only if the fetch fails or no stable version exists.
+ If ``data["info"]["version"]`` is a stable, non-yanked release it is returned
+ immediately. Otherwise falls back to scanning all releases for the highest
+ stable, non-yanked version.
+ """
+ try:
+ with urlopen(PYPI_URL, timeout=5) as resp: # noqa: S310
+ if resp.status != 200:
+ return None
+ data = json.loads(resp.read())
+ latest: str = data["info"]["version"]
+ releases = data.get("releases", {})
+ latest_files = releases.get(latest, [])
+ # Return immediately only if stable AND not yanked
+ if (
+ not Version(latest).is_prerelease
+ and latest_files
+ and not all(f.get("yanked", False) for f in latest_files)
+ ):
+ return latest
+ # Scan releases for the newest stable, non-yanked version
+ releases = data.get("releases", {})
+ stable_versions: list[Version] = []
+ for v, files in releases.items():
+ with contextlib.suppress(Exception):
+ parsed = Version(v)
+ if parsed.is_prerelease:
+ continue
+ # Skip yanked releases (all files marked yanked, or no files)
+ if not files or all(f.get("yanked", False) for f in files):
+ continue
+ stable_versions.append(parsed)
+ if not stable_versions:
+ return None
+ return str(max(stable_versions))
+ except Exception:
+ return None
+
+
+def detect_install_method() -> InstallMethod:
+ """Auto-detect how the user originally installed the CLI.
+
+ Detection order:
+ 1. uv tool — check ``uv tool list`` for the package
+ 2. pipx — check ``pipx list`` for the package
+ 3. uv pip — if ``uv`` is on PATH but the package wasn't installed via uv tool
+ 4. pip — fallback
+ """
+ if shutil.which("uv"):
+ try:
+ result = subprocess.run(
+ ["uv", "tool", "list"], # noqa: S607
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode == 0 and _PACKAGE_RE.search(result.stdout):
+ return InstallMethod.UV_TOOL
+ except Exception:
+ logger.debug("Failed to check uv tool list", exc_info=True)
+
+ if shutil.which("pipx"):
+ try:
+ result = subprocess.run(
+ ["pipx", "list"], # noqa: S607
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode == 0 and _PACKAGE_RE.search(result.stdout):
+ return InstallMethod.PIPX
+ except Exception:
+ logger.debug("Failed to check pipx list", exc_info=True)
+
+ if shutil.which("uv"):
+ # Only use uv pip if the package is actually installed in the current
+ # interpreter's environment. Without this check we might upgrade into a
+ # different environment than the one backing the running ``arcade`` executable.
+ try:
+ result = subprocess.run(
+ ["uv", "pip", "show", PACKAGE_NAME, "--python", sys.executable], # noqa: S607
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode == 0 and PACKAGE_NAME in result.stdout:
+ return InstallMethod.UV_PIP
+ except Exception:
+ logger.debug("Failed to check uv pip show", exc_info=True)
+
+ return InstallMethod.PIP
+
+
+_UPGRADE_COMMANDS: dict[InstallMethod, list[str]] = {
+ InstallMethod.UV_TOOL: ["uv", "tool", "upgrade", PACKAGE_NAME],
+ InstallMethod.PIPX: ["pipx", "upgrade", PACKAGE_NAME],
+ InstallMethod.UV_PIP: [
+ "uv",
+ "pip",
+ "install",
+ "--upgrade",
+ "--python",
+ sys.executable,
+ PACKAGE_NAME,
+ ],
+ InstallMethod.PIP: ["pip", "install", "--upgrade", PACKAGE_NAME],
+}
+
+_METHOD_LABELS: dict[InstallMethod, str] = {
+ InstallMethod.UV_TOOL: "uv tool",
+ InstallMethod.PIPX: "pipx",
+ InstallMethod.UV_PIP: "uv pip",
+ InstallMethod.PIP: "pip",
+}
+
+
+def run_update() -> None:
+ """Check for updates and install if available."""
+ console.print("Checking for updates…")
+
+ latest = fetch_latest_pypi_version()
+ if latest is None:
+ console.print(
+ "Could not check for updates. Verify your internet connection and try again.",
+ style="yellow",
+ )
+ return
+
+ current = metadata.version(PACKAGE_NAME)
+
+ if Version(current) >= Version(latest):
+ console.print(
+ f"Arcade CLI is already up to date (version {current}).",
+ style="bold green",
+ )
+ return
+
+ console.print(f"Update available: {current} → {latest}")
+
+ method = detect_install_method()
+ cmd = _UPGRADE_COMMANDS[method]
+
+ # Warn if not using the recommended install method
+ if method != InstallMethod.UV_TOOL:
+ console.print(
+ f"\n⚠️ Detected install method: {_METHOD_LABELS[method]}. "
+ f"We recommend installing via `uv tool install {PACKAGE_NAME}` for the best experience.\n",
+ style="yellow",
+ )
+
+ console.print(f"Upgrading via: {' '.join(cmd)}")
+ result = subprocess.run(cmd)
+
+ if result.returncode == 0:
+ console.print(f"Successfully updated to {latest}!", style="bold green")
+ else:
+ console.print(
+ f"\nAuto-upgrade failed. Try upgrading manually:\n"
+ f" uv tool upgrade {PACKAGE_NAME}\n\n"
+ f"If you don't use `uv tool`, try one of these alternatives instead:\n"
+ f" uv pip install -U {PACKAGE_NAME}\n"
+ f" pip install -U {PACKAGE_NAME}",
+ style="red",
+ )
diff --git a/libs/tests/cli/test_update.py b/libs/tests/cli/test_update.py
new file mode 100644
index 00000000..06017a7a
--- /dev/null
+++ b/libs/tests/cli/test_update.py
@@ -0,0 +1,622 @@
+"""Tests for the `arcade update` CLI command."""
+
+import json
+import os
+import sys
+import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+from arcade_cli.main import cli
+from arcade_cli.update import (
+ InstallMethod,
+ UpdateCache,
+ _background_check,
+ check_and_notify,
+ detect_install_method,
+ fetch_latest_pypi_version,
+ fork_background_check,
+ read_update_cache,
+ should_check_for_update,
+ write_update_cache,
+)
+from typer.testing import CliRunner
+
+runner = CliRunner()
+
+PACKAGE_NAME = "arcade-mcp"
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for fetch_latest_pypi_version
+# ---------------------------------------------------------------------------
+
+
+class TestFetchLatestPypiVersion:
+ def test_returns_version_on_success(self) -> None:
+ mock_response = MagicMock()
+ mock_response.status = 200
+ mock_response.read.return_value = json.dumps({
+ "info": {"version": "2.0.0"},
+ "releases": {"2.0.0": [{"filename": "a.whl", "yanked": False}]},
+ }).encode()
+ mock_response.__enter__ = lambda s: s
+ mock_response.__exit__ = MagicMock(return_value=False)
+
+ with patch("arcade_cli.update.urlopen", return_value=mock_response):
+ assert fetch_latest_pypi_version() == "2.0.0"
+
+ def test_falls_back_to_stable_release_when_latest_is_prerelease(self) -> None:
+ """When info.version is a pre-release, return the highest stable from releases."""
+ mock_response = MagicMock()
+ mock_response.status = 200
+ payload = json.dumps({
+ "info": {"version": "2.0.0rc1"},
+ "releases": {
+ "1.9.0": [{"filename": "a.whl", "yanked": False}],
+ "2.0.0rc1": [{"filename": "b.whl", "yanked": False}],
+ "1.10.0": [{"filename": "c.whl", "yanked": False}],
+ },
+ }).encode()
+ mock_response.read.return_value = payload
+ mock_response.__enter__ = lambda s: s
+ mock_response.__exit__ = MagicMock(return_value=False)
+
+ with patch("arcade_cli.update.urlopen", return_value=mock_response):
+ assert fetch_latest_pypi_version() == "1.10.0"
+
+ def test_returns_none_when_only_prereleases_exist(self) -> None:
+ """When all releases are pre-releases, return None."""
+ mock_response = MagicMock()
+ mock_response.status = 200
+ payload = json.dumps({
+ "info": {"version": "2.0.0rc1"},
+ "releases": {"2.0.0a1": [], "2.0.0rc1": []},
+ }).encode()
+ mock_response.read.return_value = payload
+ mock_response.__enter__ = lambda s: s
+ mock_response.__exit__ = MagicMock(return_value=False)
+
+ with patch("arcade_cli.update.urlopen", return_value=mock_response):
+ assert fetch_latest_pypi_version() is None
+
+ def test_skips_yanked_releases(self) -> None:
+ """Yanked releases should not be returned even if they are the highest stable."""
+ mock_response = MagicMock()
+ mock_response.status = 200
+ payload = json.dumps({
+ "info": {"version": "2.0.0rc1"},
+ "releases": {
+ "1.9.0": [{"filename": "a.whl", "yanked": False}],
+ "1.10.0": [{"filename": "b.whl", "yanked": True}],
+ "2.0.0rc1": [{"filename": "c.whl", "yanked": False}],
+ },
+ }).encode()
+ mock_response.read.return_value = payload
+ mock_response.__enter__ = lambda s: s
+ mock_response.__exit__ = MagicMock(return_value=False)
+
+ with patch("arcade_cli.update.urlopen", return_value=mock_response):
+ # 1.10.0 is yanked, so should fall back to 1.9.0
+ assert fetch_latest_pypi_version() == "1.9.0"
+
+ def test_falls_back_when_latest_stable_is_yanked(self) -> None:
+ """When info.version is stable but yanked, fall back to scanning releases."""
+ mock_response = MagicMock()
+ mock_response.status = 200
+ payload = json.dumps({
+ "info": {"version": "2.0.0"},
+ "releases": {
+ "1.9.0": [{"filename": "a.whl", "yanked": False}],
+ "2.0.0": [{"filename": "b.whl", "yanked": True}],
+ },
+ }).encode()
+ mock_response.read.return_value = payload
+ mock_response.__enter__ = lambda s: s
+ mock_response.__exit__ = MagicMock(return_value=False)
+
+ with patch("arcade_cli.update.urlopen", return_value=mock_response):
+ # 2.0.0 is yanked, so should fall back to 1.9.0
+ assert fetch_latest_pypi_version() == "1.9.0"
+
+ def test_returns_none_on_http_error(self) -> None:
+ with patch("arcade_cli.update.urlopen", side_effect=Exception("network error")):
+ assert fetch_latest_pypi_version() is None
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for detect_install_method
+# ---------------------------------------------------------------------------
+
+
+class TestDetectInstallMethod:
+ def test_detects_uv_tool(self) -> None:
+ """If `uv tool list` output contains the package name, method is UV_TOOL."""
+ uv_tool_output = "arcade-mcp v1.12.1\n- arcade\n- arcade-mcp\nother-package v0.1.0\n"
+ with patch("arcade_cli.update.shutil") as mock_shutil:
+ mock_shutil.which.return_value = "/usr/local/bin/uv"
+ with patch("arcade_cli.update.subprocess.run") as mock_run:
+ mock_run.return_value = MagicMock(returncode=0, stdout=uv_tool_output)
+ assert detect_install_method() == InstallMethod.UV_TOOL
+
+ def test_detects_pipx(self) -> None:
+ """If uv tool doesn't have it but pipx does, method is PIPX."""
+
+ def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
+ if cmd[0] == "uv":
+ return MagicMock(returncode=0, stdout="other-package v1.0.0\n")
+ if cmd[0] == "pipx":
+ return MagicMock(returncode=0, stdout=f" package {PACKAGE_NAME} 1.12.1")
+ return MagicMock(returncode=1, stdout="")
+
+ with patch("arcade_cli.update.shutil") as mock_shutil:
+ mock_shutil.which.side_effect = lambda name: f"/usr/local/bin/{name}"
+ with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect):
+ assert detect_install_method() == InstallMethod.PIPX
+
+ def test_falls_back_to_uv_pip_when_uv_available(self) -> None:
+ """If uv is available but package not in uv tool or pipx, use UV_PIP."""
+
+ def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
+ # uv tool list / pipx list don't have it
+ if "tool" in cmd:
+ return MagicMock(returncode=0, stdout="other-package v1.0.0\n")
+ # uv pip show confirms the package is in the current environment
+ if "show" in cmd:
+ return MagicMock(returncode=0, stdout=f"Name: {PACKAGE_NAME}\nVersion: 1.0.0\n")
+ return MagicMock(returncode=0, stdout="other-package v1.0.0\n")
+
+ def which_side_effect(name: str) -> str | None:
+ if name == "uv":
+ return "/usr/local/bin/uv"
+ return None # pipx not available
+
+ with patch("arcade_cli.update.shutil") as mock_shutil:
+ mock_shutil.which.side_effect = which_side_effect
+ with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect):
+ assert detect_install_method() == InstallMethod.UV_PIP
+
+ def test_falls_back_to_pip_when_uv_pip_show_fails(self) -> None:
+ """If uv is on PATH but package is not in the current env, fall back to PIP."""
+
+ def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
+ if "tool" in cmd:
+ return MagicMock(returncode=0, stdout="other-package v1.0.0\n")
+ if "show" in cmd:
+ return MagicMock(returncode=1, stdout="")
+ return MagicMock(returncode=0, stdout="")
+
+ def which_side_effect(name: str) -> str | None:
+ if name == "uv":
+ return "/usr/local/bin/uv"
+ return None
+
+ with patch("arcade_cli.update.shutil") as mock_shutil:
+ mock_shutil.which.side_effect = which_side_effect
+ with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect):
+ assert detect_install_method() == InstallMethod.PIP
+
+ def test_does_not_false_positive_on_prefix_match(self) -> None:
+ """arcade-mcp-server should NOT be detected as arcade-mcp in uv tool list."""
+ uv_tool_output = "arcade-mcp-server v1.0.0\n- arcade-mcp-server\n"
+
+ def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
+ if "tool" in cmd:
+ return MagicMock(returncode=0, stdout=uv_tool_output)
+ # uv pip show confirms the package IS in the current env
+ if "show" in cmd:
+ return MagicMock(returncode=0, stdout=f"Name: {PACKAGE_NAME}\nVersion: 1.0.0\n")
+ return MagicMock(returncode=0, stdout="")
+
+ with patch("arcade_cli.update.shutil") as mock_shutil:
+ mock_shutil.which.return_value = "/usr/local/bin/uv"
+ with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect):
+ # Should NOT detect UV_TOOL since only arcade-mcp-server is in uv tool list
+ # but should detect UV_PIP since uv pip show confirms the package
+ assert detect_install_method() == InstallMethod.UV_PIP
+
+ def test_falls_back_to_pip(self) -> None:
+ """If neither uv nor pipx is available, fall back to PIP."""
+ with patch("arcade_cli.update.shutil") as mock_shutil:
+ mock_shutil.which.return_value = None # nothing on PATH
+ assert detect_install_method() == InstallMethod.PIP
+
+
+# ---------------------------------------------------------------------------
+# Integration tests for the `arcade update` CLI command
+# ---------------------------------------------------------------------------
+
+
+class TestUpdateCommand:
+ def test_already_up_to_date(self) -> None:
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.12.1"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ ):
+ mock_meta.version.return_value = "1.12.1"
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0
+ assert "up to date" in result.output.lower()
+
+ def test_update_available_uv_tool(self) -> None:
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_TOOL),
+ patch("arcade_cli.update.subprocess.run") as mock_run,
+ ):
+ mock_meta.version.return_value = "1.12.1"
+ mock_run.return_value = MagicMock(returncode=0)
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0
+ mock_run.assert_called_once()
+ cmd = mock_run.call_args[0][0]
+ assert cmd == ["uv", "tool", "upgrade", PACKAGE_NAME]
+
+ def test_update_available_pipx_shows_warning(self) -> None:
+ """Non-uv-tool methods should show a warning recommending uv tool install."""
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.PIPX),
+ patch("arcade_cli.update.subprocess.run") as mock_run,
+ ):
+ mock_meta.version.return_value = "1.12.1"
+ mock_run.return_value = MagicMock(returncode=0)
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0
+ assert "recommend" in result.output.lower()
+ assert "uv tool" in result.output.lower()
+
+ def test_update_available_uv_pip_shows_warning(self) -> None:
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_PIP),
+ patch("arcade_cli.update.subprocess.run") as mock_run,
+ ):
+ mock_meta.version.return_value = "1.12.1"
+ mock_run.return_value = MagicMock(returncode=0)
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0
+ assert "recommend" in result.output.lower()
+ assert "uv tool" in result.output.lower()
+ cmd = mock_run.call_args[0][0]
+ assert cmd == ["uv", "pip", "install", "--upgrade", "--python", sys.executable, PACKAGE_NAME]
+
+ def test_update_available_pip_shows_warning(self) -> None:
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.PIP),
+ patch("arcade_cli.update.subprocess.run") as mock_run,
+ ):
+ mock_meta.version.return_value = "1.12.1"
+ mock_run.return_value = MagicMock(returncode=0)
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0
+ assert "recommend" in result.output.lower()
+ assert "uv tool" in result.output.lower()
+ cmd = mock_run.call_args[0][0]
+ assert cmd == ["pip", "install", "--upgrade", PACKAGE_NAME]
+
+ def test_upgrade_alias_works(self) -> None:
+ """The `arcade upgrade` alias should behave identically."""
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.12.1"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ ):
+ mock_meta.version.return_value = "1.12.1"
+ result = runner.invoke(cli, ["upgrade"])
+ assert result.exit_code == 0
+ assert "up to date" in result.output.lower()
+
+ def test_pypi_fetch_failure(self) -> None:
+ with patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None):
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0
+ assert "could not check" in result.output.lower()
+
+ def test_upgrade_command_failure_shows_manual_instructions(self) -> None:
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_TOOL),
+ patch("arcade_cli.update.subprocess.run") as mock_run,
+ ):
+ mock_meta.version.return_value = "1.12.1"
+ mock_run.return_value = MagicMock(returncode=1)
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0
+ output = result.output.lower()
+ # Should show preferred method and alternatives
+ assert "uv tool upgrade" in output
+ assert "alternatives" in output
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for UpdateCache and cache I/O
+# ---------------------------------------------------------------------------
+
+
+class TestUpdateCache:
+ def test_read_cache_returns_none_when_file_missing(self, tmp_path: pytest.TempPathFactory) -> None:
+ assert read_update_cache(str(tmp_path / "nonexistent.json")) is None
+
+ def test_read_cache_returns_none_on_corrupt_json(self, tmp_path: pytest.TempPathFactory) -> None:
+ cache_file = tmp_path / "update_cache.json"
+ cache_file.write_text("not valid json{{{")
+ assert read_update_cache(str(cache_file)) is None
+
+ def test_write_and_read_cache_roundtrip(self, tmp_path: pytest.TempPathFactory) -> None:
+ cache_file = str(tmp_path / "update_cache.json")
+ cache = UpdateCache(latest_version="2.0.0", checked_at=1000.0)
+ write_update_cache(cache_file, cache)
+ result = read_update_cache(cache_file)
+ assert result is not None
+ assert result.latest_version == "2.0.0"
+ assert result.checked_at == 1000.0
+
+ def test_read_cache_ignores_unknown_fields(self, tmp_path: pytest.TempPathFactory) -> None:
+ cache_file = tmp_path / "update_cache.json"
+ cache_file.write_text(json.dumps({
+ "latest_version": "2.0.0",
+ "checked_at": 1000.0,
+ "unknown_field": "should be ignored",
+ }))
+ result = read_update_cache(str(cache_file))
+ assert result is not None
+ assert result.latest_version == "2.0.0"
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for should_check_for_update
+# ---------------------------------------------------------------------------
+
+
+class TestShouldCheckForUpdate:
+ def test_returns_true_when_no_cache(self) -> None:
+ assert should_check_for_update(None) is True
+
+ def test_returns_true_when_cache_expired(self) -> None:
+ old_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time() - 5 * 3600)
+ assert should_check_for_update(old_cache) is True
+
+ def test_returns_false_when_cache_fresh(self) -> None:
+ fresh_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time() - 60)
+ assert should_check_for_update(fresh_cache) is False
+
+ def test_returns_false_when_disabled_env_var(self) -> None:
+ with patch.dict(os.environ, {"ARCADE_DISABLE_AUTOUPDATE": "1"}):
+ assert should_check_for_update(None) is False
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for fork_background_check
+# ---------------------------------------------------------------------------
+
+
+class TestForkBackgroundCheck:
+ def test_background_check_import_string_is_valid(self) -> None:
+ """The hardcoded import string in fork_background_check must resolve to the real function."""
+ import importlib
+ import inspect
+
+ source = inspect.getsource(fork_background_check)
+ # Extract the import string from the "-c" argument
+ assert "from arcade_cli.update import _background_check; _background_check()" in source
+ # Verify the function is actually importable at that path
+ mod = importlib.import_module("arcade_cli.update")
+ assert hasattr(mod, "_background_check") and callable(mod._background_check)
+
+ def test_spawns_detached_subprocess_unix(self) -> None:
+ """On Unix, spawns a detached subprocess with start_new_session=True."""
+ with (
+ patch("arcade_cli.update.read_update_cache", return_value=None),
+ patch("arcade_cli.update.should_check_for_update", return_value=True),
+ patch("arcade_cli.update.sys") as mock_sys,
+ patch("arcade_cli.update.subprocess.Popen") as mock_popen,
+ ):
+ mock_sys.platform = "darwin"
+ mock_sys.executable = "/usr/bin/python3"
+ fork_background_check()
+ mock_popen.assert_called_once()
+ call_kwargs = mock_popen.call_args[1]
+ assert call_kwargs.get("start_new_session") is True
+ assert call_kwargs.get("close_fds") is True
+
+ def test_spawns_detached_subprocess_windows(self) -> None:
+ """On Windows, spawns a subprocess with creationflags instead of start_new_session."""
+ with (
+ patch("arcade_cli.update.read_update_cache", return_value=None),
+ patch("arcade_cli.update.should_check_for_update", return_value=True),
+ patch("arcade_cli.update.sys") as mock_sys,
+ patch("arcade_cli.update.subprocess.Popen") as mock_popen,
+ patch("arcade_cli.update.get_windows_no_window_creationflags", return_value=0x08000200),
+ patch("arcade_cli.update.build_windows_hidden_startupinfo", return_value=None),
+ ):
+ mock_sys.platform = "win32"
+ mock_sys.executable = "python.exe"
+ fork_background_check()
+ mock_popen.assert_called_once()
+ call_kwargs = mock_popen.call_args[1]
+ assert "start_new_session" not in call_kwargs
+ assert call_kwargs.get("creationflags") == 0x08000200
+ assert call_kwargs.get("close_fds") is True
+
+ def test_does_not_spawn_when_check_not_needed(self) -> None:
+ """When cache is fresh, no subprocess is spawned."""
+ fresh_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time())
+ with (
+ patch("arcade_cli.update.read_update_cache", return_value=fresh_cache),
+ patch("arcade_cli.update.should_check_for_update", return_value=False),
+ patch("arcade_cli.update.subprocess.Popen") as mock_popen,
+ ):
+ fork_background_check()
+ mock_popen.assert_not_called()
+
+ def test_swallows_exceptions(self) -> None:
+ """Popen raising an exception should not crash."""
+ with (
+ patch("arcade_cli.update.read_update_cache", return_value=None),
+ patch("arcade_cli.update.should_check_for_update", return_value=True),
+ patch("arcade_cli.update.subprocess.Popen", side_effect=OSError("spawn failed")),
+ ):
+ # Should not raise
+ fork_background_check()
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for check_and_notify
+# ---------------------------------------------------------------------------
+
+
+class TestCheckAndNotify:
+ def test_prints_notification_when_update_available(self) -> None:
+ cache = UpdateCache(latest_version="2.0.0", checked_at=time.time())
+ with (
+ patch("arcade_cli.update.read_update_cache", return_value=cache),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ patch("arcade_cli.update.fork_background_check"),
+ patch("arcade_cli.update.console") as mock_console,
+ ):
+ mock_meta.version.return_value = "1.0.0"
+ check_and_notify()
+ output = mock_console.print.call_args[0][0]
+ assert "update available" in output.lower()
+ assert "arcade update" in output.lower()
+ assert mock_console.print.call_args[1]["style"] == "yellow"
+
+ def test_no_notification_when_up_to_date(self) -> None:
+ cache = UpdateCache(latest_version="1.0.0", checked_at=time.time())
+ with (
+ patch("arcade_cli.update.read_update_cache", return_value=cache),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ patch("arcade_cli.update.fork_background_check"),
+ patch("arcade_cli.update.console") as mock_console,
+ ):
+ mock_meta.version.return_value = "1.0.0"
+ check_and_notify()
+ mock_console.print.assert_not_called()
+
+ def test_no_notification_when_no_cache(self) -> None:
+ with (
+ patch("arcade_cli.update.read_update_cache", return_value=None),
+ patch("arcade_cli.update.fork_background_check"),
+ patch("arcade_cli.update.console") as mock_console,
+ ):
+ check_and_notify()
+ mock_console.print.assert_not_called()
+
+ def test_no_notification_when_disabled(self) -> None:
+ with (
+ patch.dict(os.environ, {"ARCADE_DISABLE_AUTOUPDATE": "1"}),
+ patch("arcade_cli.update.read_update_cache") as mock_read,
+ patch("arcade_cli.update.fork_background_check") as mock_fork,
+ patch("arcade_cli.update.console") as mock_console,
+ ):
+ check_and_notify()
+ mock_read.assert_not_called()
+ mock_fork.assert_not_called()
+ mock_console.print.assert_not_called()
+
+ def test_forks_background_check(self) -> None:
+ with (
+ patch("arcade_cli.update.read_update_cache", return_value=None),
+ patch("arcade_cli.update.fork_background_check") as mock_fork,
+ ):
+ check_and_notify()
+ mock_fork.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for _background_check
+# ---------------------------------------------------------------------------
+
+
+class TestBackgroundCheck:
+ def test_updates_timestamp_when_fetch_returns_none(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ """When fetch returns None, the cache timestamp is still updated to throttle retries."""
+ cache_path = str(tmp_path / "update_cache.json")
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None),
+ patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path),
+ ):
+ _background_check()
+ result = read_update_cache(cache_path)
+ assert result is not None
+ assert result.latest_version == ""
+ assert result.checked_at > 0
+
+ def test_preserves_cached_version_when_fetch_fails(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ """When fetch fails but a previous version is cached, preserve it."""
+ cache_path = str(tmp_path / "update_cache.json")
+ old_cache = UpdateCache(latest_version="1.5.0", checked_at=1000.0)
+ write_update_cache(cache_path, old_cache)
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None),
+ patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path),
+ ):
+ _background_check()
+ result = read_update_cache(cache_path)
+ assert result is not None
+ assert result.latest_version == "1.5.0"
+ assert result.checked_at > old_cache.checked_at
+
+ def test_caches_stable_release(self, tmp_path: pytest.TempPathFactory) -> None:
+ """Stable versions from PyPI should be cached."""
+ cache_path = str(tmp_path / "update_cache.json")
+ with (
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
+ patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path),
+ ):
+ _background_check()
+ result = read_update_cache(cache_path)
+ assert result is not None
+ assert result.latest_version == "2.0.0"
+
+
+# ---------------------------------------------------------------------------
+# Tests for main_callback integration with check_and_notify
+# ---------------------------------------------------------------------------
+
+
+class TestMainCallbackUpdateNotification:
+ def test_main_callback_calls_check_and_notify(self) -> None:
+ """Running a non-update command triggers check_and_notify."""
+ with patch("arcade_cli.main.check_and_notify") as mock_notify:
+ # Use 'show' as a public command that goes through main_callback
+ runner.invoke(cli, ["show", "--help"])
+ mock_notify.assert_called_once()
+
+ def test_main_callback_skips_check_for_update_command(self) -> None:
+ """Running `arcade update` should NOT trigger check_and_notify."""
+ with (
+ patch("arcade_cli.main.check_and_notify") as mock_notify,
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.0.0"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ ):
+ mock_meta.version.return_value = "1.0.0"
+ runner.invoke(cli, ["update"])
+ mock_notify.assert_not_called()
+
+ def test_main_callback_skips_check_for_upgrade_command(self) -> None:
+ """Running `arcade upgrade` should NOT trigger check_and_notify."""
+ with (
+ patch("arcade_cli.main.check_and_notify") as mock_notify,
+ patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.0.0"),
+ patch("arcade_cli.update.metadata") as mock_meta,
+ ):
+ mock_meta.version.return_value = "1.0.0"
+ runner.invoke(cli, ["upgrade"])
+ mock_notify.assert_not_called()
+
+ def test_main_callback_skips_check_for_mcp_command(self) -> None:
+ """Running `arcade mcp` should NOT trigger check_and_notify (would corrupt stdio)."""
+ with patch("arcade_cli.main.check_and_notify") as mock_notify:
+ runner.invoke(cli, ["mcp", "--help"])
+ mock_notify.assert_not_called()
diff --git a/pyproject.toml b/pyproject.toml
index 46fb9c01..87c7d0e6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "arcade-mcp"
-version = "1.12.2"
+version = "1.13.0"
description = "Arcade.dev - Tool Calling platform for Agents"
readme = "README.md"
license = { file = "LICENSE" }
@@ -29,6 +29,7 @@ dependencies = [
"tqdm==4.67.1",
"click==8.1.8",
"python-dateutil>=2.8.2",
+ "packaging>=23.0",
"platformdirs>=4.3.6; platform_system == 'Windows'",
]