Skip to content

Commit b0c8954

Browse files
authored
feat(cli): add advisory update check to doctor (#346)
Surface when the installed repowise CLI is out of date and the exact command to update it, without ever auto-updating the user's environment. - Add `repowise.cli.update_check`: a conservative, never-raising helper that reads the current version, queries the PyPI JSON endpoint via the bundled httpx (short timeout), and suggests an install-method-aware upgrade command (uv tool / pipx / pip / editable checkout). - Add a `CLI version` row to `repowise doctor`: advisory only — it shows current vs latest plus both the resolved PATH executable and the full running command (these can differ), never flips doctor's pass/fail, and swallows network errors. Reminds the user to restart their MCP client when an update is available. - Treat an unparsable PyPI version as "unknown" rather than "up to date". - Document the check in CLI_REFERENCE.md and USER_GUIDE.md. Closes #338
1 parent bbb568d commit b0c8954

6 files changed

Lines changed: 502 additions & 0 deletions

File tree

docs/CLI_REFERENCE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,17 @@ repowise doctor --workspace # every workspace repo
527527
repowise doctor --workspace --repair # also drop dead entries / sync drift
528528
```
529529

530+
**CLI update check.** `doctor` also prints a best-effort `CLI version` row that
531+
compares your installed CLI against the latest release on PyPI and, when an
532+
update is available, shows the suggested upgrade command (e.g. `uv tool upgrade
533+
repowise`, `pipx upgrade repowise`, or `python -m pip install -U repowise`). It
534+
shows both the `repowise` resolved on your `PATH` and the command that launched
535+
the current process, since these can differ. This check is advisory: it never
536+
updates anything automatically and does not fail `doctor` when PyPI is
537+
unreachable. After upgrading, **restart Claude/Codex/Cursor or any MCP client**
538+
so it picks up the new executable. (A standalone `repowise version --check` may
539+
be added later.)
540+
530541
---
531542

532543
### `repowise delete [REPO_ID]`

docs/USER_GUIDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,14 @@ Checks:
584584
- `state.json` valid
585585
- Providers installed and importable
586586
- Stale page count
587+
- **CLI version** — best-effort check of your installed CLI against the latest
588+
PyPI release
589+
590+
The CLI version row is advisory: when a newer release exists it prints the
591+
right upgrade command for your install method (`uv tool upgrade repowise`,
592+
`pipx upgrade repowise`, or `python -m pip install -U repowise`) plus a reminder
593+
to **restart Claude/Codex/Cursor or any MCP client** afterwards. It never
594+
upgrades automatically and never fails `doctor` when PyPI is unavailable.
587595

588596
---
589597

packages/cli/src/repowise/cli/commands/doctor_cmd.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,59 @@ def _check(name: str, ok: bool, detail: str = "") -> tuple[str, str, str]:
2323
return (name, status, detail)
2424

2525

26+
def _print_cli_version_status() -> None:
27+
"""Print a best-effort CLI update-check line.
28+
29+
Advisory only: an outdated CLI is informational, not a broken repo, so this
30+
never affects doctor's pass/fail outcome and never fails on network errors.
31+
Runs once per invocation (the CLI version is global, not per-repo).
32+
"""
33+
try:
34+
from repowise.cli.update_check import get_cli_update_check
35+
36+
check = get_cli_update_check()
37+
except Exception:
38+
return # never let the update check break doctor
39+
40+
# Show the full running command and resolved path verbatim — they can
41+
# differ (e.g. a stale shim on PATH vs the venv that launched this process),
42+
# and surfacing that mismatch is the point of this row.
43+
path_detail = check.resolved_executable or "not on PATH"
44+
running = check.running_executable or "?"
45+
46+
if check.latest_version is None:
47+
status = "[green]OK[/green]"
48+
detail = (
49+
f"current {check.current_version}, could not check latest version, "
50+
f"path {path_detail}, running {running}"
51+
)
52+
elif check.update_available:
53+
status = "[yellow]WARN[/yellow]"
54+
detail = (
55+
f"current {check.current_version}, latest {check.latest_version}, "
56+
f"path {path_detail}, running {running}"
57+
)
58+
else:
59+
status = "[green]OK[/green]"
60+
detail = (
61+
f"current {check.current_version} (latest), "
62+
f"path {path_detail}, running {running}"
63+
)
64+
65+
table = Table(show_header=False, box=None, pad_edge=False)
66+
table.add_column(style="cyan")
67+
table.add_column()
68+
table.add_column()
69+
table.add_row("CLI version", status, detail)
70+
console.print(table)
71+
72+
if check.update_available:
73+
console.print(f" [yellow]Update available:[/yellow] {check.suggested_command}")
74+
console.print(
75+
" [dim]Restart Claude/Codex/Cursor or any MCP client after updating.[/dim]"
76+
)
77+
78+
2679
def _run_repo_checks(repo_path: _DoctorPath, repair: bool) -> bool:
2780
"""Run the standard health checks against one repo. Returns ``True`` if
2881
all checks passed.
@@ -443,6 +496,9 @@ def doctor_command(
443496
)
444497
target.notice(console, command="doctor")
445498

499+
# Advisory CLI update check — printed once, above the repo check table(s).
500+
_print_cli_version_status()
501+
446502
if not target.is_workspace:
447503
assert target.repo_path is not None
448504
_run_repo_checks(target.repo_path, repair)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Best-effort check for whether the installed ``repowise`` CLI is stale.
2+
3+
This is intentionally conservative:
4+
5+
* Current version comes from :data:`repowise.cli.__version__`.
6+
* Latest version is fetched from the PyPI JSON endpoint via the already-bundled
7+
``httpx`` with a short timeout.
8+
* An install-method-appropriate upgrade *command* is suggested, but never run.
9+
10+
Network failures are swallowed — callers (e.g. ``repowise doctor``) must keep
11+
working when PyPI is unreachable. This module never raises and never invokes a
12+
package manager.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import shutil
18+
import sys
19+
from dataclasses import dataclass
20+
from pathlib import Path
21+
22+
PYPI_URL = "https://pypi.org/pypi/repowise/json"
23+
24+
25+
@dataclass(frozen=True)
26+
class UpdateCheck:
27+
"""Result of a CLI update check.
28+
29+
``update_available`` is ``None`` when the latest version could not be
30+
determined (network error, parse failure) so callers can distinguish
31+
"up to date" from "unknown".
32+
"""
33+
34+
current_version: str
35+
latest_version: str | None
36+
resolved_executable: str | None
37+
running_executable: str
38+
python: str
39+
update_available: bool | None
40+
suggested_command: str
41+
install_hint: str
42+
error: str | None = None
43+
44+
45+
def _parse_release(version: str) -> tuple[int, ...] | None:
46+
"""Parse the leading numeric release parts of a version string.
47+
48+
Returns a tuple of ints for the dotted numeric prefix (so ``0.15.2`` ->
49+
``(0, 15, 2)``). Pre-release/local suffixes such as ``-rc1`` or ``+local``
50+
are ignored. Returns ``None`` when no numeric component can be parsed.
51+
"""
52+
parts: list[int] = []
53+
for chunk in version.strip().split("."):
54+
digits = ""
55+
for ch in chunk:
56+
if ch.isdigit():
57+
digits += ch
58+
else:
59+
break
60+
if not digits:
61+
break
62+
parts.append(int(digits))
63+
return tuple(parts) or None
64+
65+
66+
def is_newer_version(latest: str, current: str) -> bool:
67+
"""Return ``True`` if ``latest`` is a strictly newer release than ``current``.
68+
69+
Uses a simple numeric-release comparison. If either version cannot be
70+
parsed, returns ``False`` (no update decision) rather than raising.
71+
"""
72+
lat = _parse_release(latest)
73+
cur = _parse_release(current)
74+
if lat is None or cur is None:
75+
return False
76+
# Pad to equal length so 0.15 compares correctly against 0.15.2.
77+
length = max(len(lat), len(cur))
78+
lat = lat + (0,) * (length - len(lat))
79+
cur = cur + (0,) * (length - len(cur))
80+
return lat > cur
81+
82+
83+
def suggest_update_command(executable: str | None, python: str) -> tuple[str, str]:
84+
"""Suggest an upgrade command and human-readable install hint.
85+
86+
Heuristic, based on the resolved executable path. Returns
87+
``(command, hint)``. When the install method is unknown, falls back to a
88+
safe ``<python> -m pip install -U repowise``.
89+
"""
90+
path = (executable or "").replace("\\", "/").lower()
91+
92+
if "pipx" in path:
93+
return ("pipx upgrade repowise", "pipx")
94+
# uv tool installs live under a uv data dir, e.g.
95+
# ~/.local/share/uv/tools/... or a uv-managed bin shim.
96+
if "/uv/" in path or "uv/tools" in path or "/uv/tools/" in path:
97+
return ("uv tool upgrade repowise", "uv tool")
98+
return (f"{python} -m pip install -U repowise", "pip")
99+
100+
101+
def _editable_checkout() -> Path | None:
102+
"""Return the repo root when ``repowise`` is running from a source checkout.
103+
104+
Detected by the package not living under ``site-packages``/``dist-packages``
105+
while a parent directory carries both ``pyproject.toml`` and ``.git``.
106+
"""
107+
try:
108+
from repowise.cli import __file__ as cli_file
109+
except Exception:
110+
return None
111+
pkg = Path(cli_file).resolve()
112+
if "site-packages" in pkg.parts or "dist-packages" in pkg.parts:
113+
return None
114+
for parent in pkg.parents:
115+
if (parent / "pyproject.toml").exists() and (parent / ".git").exists():
116+
return parent
117+
return None
118+
119+
120+
def get_cli_update_check(timeout: float = 2.0) -> UpdateCheck:
121+
"""Check whether the installed ``repowise`` CLI is out of date.
122+
123+
Always returns an :class:`UpdateCheck`; never raises. On network/parse
124+
failure, ``latest_version`` is ``None``, ``update_available`` is ``None``,
125+
and ``error`` carries a short description.
126+
"""
127+
from repowise.cli import __version__
128+
129+
current = __version__
130+
resolved = shutil.which("repowise")
131+
running = sys.argv[0] if sys.argv else ""
132+
python = sys.executable or "python"
133+
134+
checkout = _editable_checkout()
135+
if checkout is not None:
136+
suggested = f"cd {checkout} && git pull && {python} -m pip install -e ."
137+
hint = "editable"
138+
else:
139+
suggested, hint = suggest_update_command(resolved or running, python)
140+
141+
latest: str | None = None
142+
error: str | None = None
143+
try:
144+
import httpx
145+
146+
resp = httpx.get(PYPI_URL, timeout=timeout)
147+
resp.raise_for_status()
148+
fetched = resp.json()["info"]["version"]
149+
# Only accept a version we can actually compare; otherwise leave
150+
# latest=None so callers report "unknown" rather than a false "latest".
151+
if _parse_release(fetched) is None:
152+
error = f"unparsable latest version: {fetched!r}"
153+
else:
154+
latest = fetched
155+
except Exception as exc: # network, JSON, missing key — all advisory
156+
error = str(exc) or exc.__class__.__name__
157+
158+
if latest is None:
159+
update_available: bool | None = None
160+
else:
161+
update_available = is_newer_version(latest, current)
162+
163+
return UpdateCheck(
164+
current_version=current,
165+
latest_version=latest,
166+
resolved_executable=resolved,
167+
running_executable=running,
168+
python=python,
169+
update_available=update_available,
170+
suggested_command=suggested,
171+
install_hint=hint,
172+
error=error,
173+
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Tests for the advisory CLI version row in ``repowise doctor``."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
7+
import pytest
8+
from rich.console import Console
9+
10+
from repowise.cli.commands import doctor_cmd
11+
from repowise.cli.update_check import UpdateCheck
12+
13+
14+
def _capture(monkeypatch: pytest.MonkeyPatch, check: UpdateCheck) -> str:
15+
buf = io.StringIO()
16+
monkeypatch.setattr(doctor_cmd, "console", Console(file=buf, width=200))
17+
monkeypatch.setattr("repowise.cli.update_check.get_cli_update_check", lambda *a, **k: check)
18+
doctor_cmd._print_cli_version_status()
19+
return buf.getvalue()
20+
21+
22+
def _make(**overrides) -> UpdateCheck:
23+
base = dict(
24+
current_version="0.13.0",
25+
latest_version="0.15.2",
26+
resolved_executable="/usr/local/bin/repowise",
27+
running_executable="/tmp/venv/bin/repowise",
28+
python="/usr/bin/python",
29+
update_available=True,
30+
suggested_command="/usr/bin/python -m pip install -U repowise",
31+
install_hint="pip",
32+
error=None,
33+
)
34+
base.update(overrides)
35+
return UpdateCheck(**base)
36+
37+
38+
def test_update_available_shows_warn_and_command(
39+
monkeypatch: pytest.MonkeyPatch,
40+
) -> None:
41+
out = _capture(monkeypatch, _make())
42+
assert "CLI version" in out
43+
assert "WARN" in out
44+
assert "current 0.13.0" in out
45+
assert "latest 0.15.2" in out
46+
assert "/usr/local/bin/repowise" in out # resolved path
47+
assert "/tmp/venv/bin/repowise" in out # full running command (may differ)
48+
assert "pip install -U repowise" in out
49+
assert "Restart" in out
50+
51+
52+
def test_up_to_date_shows_ok_no_command(monkeypatch: pytest.MonkeyPatch) -> None:
53+
out = _capture(
54+
monkeypatch,
55+
_make(current_version="0.15.2", latest_version="0.15.2", update_available=False),
56+
)
57+
assert "OK" in out
58+
assert "WARN" not in out
59+
assert "(latest)" in out
60+
assert "Restart" not in out
61+
62+
63+
def test_latest_unknown_stays_neutral(monkeypatch: pytest.MonkeyPatch) -> None:
64+
out = _capture(
65+
monkeypatch,
66+
_make(latest_version=None, update_available=None, error="no network"),
67+
)
68+
assert "OK" in out
69+
assert "WARN" not in out
70+
assert "could not check latest version" in out
71+
assert "Restart" not in out
72+
73+
74+
def test_never_raises_when_check_errors(monkeypatch: pytest.MonkeyPatch) -> None:
75+
buf = io.StringIO()
76+
monkeypatch.setattr(doctor_cmd, "console", Console(file=buf, width=200))
77+
78+
def _boom(*a, **k):
79+
raise RuntimeError("unexpected")
80+
81+
monkeypatch.setattr("repowise.cli.update_check.get_cli_update_check", _boom)
82+
# Should swallow the error and print nothing rather than crash doctor.
83+
doctor_cmd._print_cli_version_status()
84+
assert buf.getvalue() == ""

0 commit comments

Comments
 (0)