Skip to content

Commit 5c373b9

Browse files
committed
feat: add check latest option
1 parent 6322a4d commit 5c373b9

4 files changed

Lines changed: 145 additions & 21 deletions

File tree

docs/reference/core.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,15 @@ specify init my-project --integration copilot --branch-numbering timestamp
6565

6666
```bash
6767
specify check
68+
specify check --latest
6869
```
6970

7071
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool.
7172

73+
Use `--latest` to also check whether a newer Spec Kit CLI release is available.
74+
The latest-version check is opt-in; plain `specify check` stays offline.
75+
Run `specify check --latest` when a command behaves like an older Spec Kit version or an expected CLI feature is missing.
76+
7277
## Version Information
7378

7479
```bash

docs/upgrade.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,15 @@ Only Spec Kit infrastructure files:
388388

389389
### "CLI upgrade doesn't seem to work"
390390

391+
If a command behaves like an older Spec Kit version, first check for local CLI drift:
392+
393+
```bash
394+
specify check --latest
395+
```
396+
397+
For CLI-only version checks, `specify self check` reports the same latest-release status.
398+
Plain `specify check` stays offline; `--latest` opts into the release lookup.
399+
391400
Verify the installation:
392401

393402
```bash

src/specify_cli/__init__.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,7 +1113,13 @@ def _display_cmd(name: str) -> str:
11131113
console.print(enhancements_panel)
11141114

11151115
@app.command()
1116-
def check():
1116+
def check(
1117+
latest: bool = typer.Option(
1118+
False,
1119+
"--latest",
1120+
help="Also check whether a newer specify-cli release is available.",
1121+
),
1122+
):
11171123
"""Check that all required tools are installed."""
11181124
show_banner()
11191125
console.print("[bold]Checking for installed tools...[/bold]\n")
@@ -1156,6 +1162,9 @@ def check():
11561162
if not any(agent_results.values()):
11571163
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
11581164

1165+
if latest:
1166+
_print_latest_release_check(heading="Specify CLI")
1167+
11591168

11601169
def _feature_capabilities() -> dict[str, bool]:
11611170
"""Return stable local CLI capability flags for humans and agents."""
@@ -1308,24 +1317,11 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
13081317
return None, "offline or timeout"
13091318

13101319

1311-
# ===== Self Commands =====
1312-
self_app = typer.Typer(
1313-
name="self",
1314-
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
1315-
add_completion=False,
1316-
)
1317-
app.add_typer(self_app, name="self")
1318-
1319-
@self_app.command("check")
1320-
def self_check() -> None:
1321-
"""Check whether a newer specify-cli release is available. Read-only.
1322-
1323-
This command only checks for updates; it does not modify your installation.
1324-
The reserved (and currently non-destructive) `specify self upgrade` command
1325-
is the name that a future release will use for actual self-upgrade — its
1326-
behavior is not implemented in this release and is intentionally out of
1327-
scope here. See `specify self upgrade --help` for its current status.
1328-
"""
1320+
def _print_latest_release_check(heading: str | None = None) -> None:
1321+
"""Print latest-release status and upgrade guidance for specify-cli."""
1322+
if heading:
1323+
console.print()
1324+
console.print(f"[bold]{heading}[/bold]")
13291325

13301326
installed = _get_installed_version()
13311327
tag, failure_reason = _fetch_latest_release_tag()
@@ -1365,6 +1361,28 @@ def self_check() -> None:
13651361
console.print(f"[green]Up to date:[/green] {installed}")
13661362

13671363

1364+
# ===== Self Commands =====
1365+
self_app = typer.Typer(
1366+
name="self",
1367+
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
1368+
add_completion=False,
1369+
)
1370+
app.add_typer(self_app, name="self")
1371+
1372+
@self_app.command("check")
1373+
def self_check() -> None:
1374+
"""Check whether a newer specify-cli release is available. Read-only.
1375+
1376+
This command only checks for updates; it does not modify your installation.
1377+
The reserved (and currently non-destructive) `specify self upgrade` command
1378+
is the name that a future release will use for actual self-upgrade — its
1379+
behavior is not implemented in this release and is intentionally out of
1380+
scope here. See `specify self upgrade --help` for its current status.
1381+
"""
1382+
1383+
_print_latest_release_check()
1384+
1385+
13681386
@self_app.command("upgrade")
13691387
def self_upgrade() -> None:
13701388
"""Reserved command surface for self-upgrade; not implemented in this release.

tests/test_check_tool.py

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77

88
from unittest.mock import patch, MagicMock
99

10-
from specify_cli import check_tool
10+
import pytest
11+
from typer.testing import CliRunner
12+
13+
from specify_cli import app, check_tool
14+
from tests.conftest import strip_ansi
15+
16+
17+
runner = CliRunner()
1118

1219

1320
class TestCheckToolClaude:
@@ -103,4 +110,89 @@ def fake_which(name):
103110
return "/usr/bin/kiro" if name == "kiro" else None
104111

105112
with patch("shutil.which", side_effect=fake_which):
106-
assert check_tool("kiro-cli") is True
113+
assert check_tool("kiro-cli") is True
114+
115+
116+
class TestCheckLatest:
117+
"""`specify check --latest` should opt into release checks."""
118+
119+
def test_check_without_latest_does_not_fetch_latest_release(self):
120+
with (
121+
patch("specify_cli.check_tool", return_value=True),
122+
patch(
123+
"specify_cli._fetch_latest_release_tag",
124+
side_effect=AssertionError("latest release lookup should not run"),
125+
) as fetch_latest,
126+
):
127+
result = runner.invoke(app, ["check"])
128+
129+
assert result.exit_code == 0
130+
fetch_latest.assert_not_called()
131+
132+
def test_check_latest_reports_update_available(self):
133+
with (
134+
patch("specify_cli.check_tool", return_value=True),
135+
patch("specify_cli._get_installed_version", return_value="0.8.2"),
136+
patch("specify_cli._fetch_latest_release_tag", return_value=("v0.8.8", None)),
137+
):
138+
result = runner.invoke(app, ["check", "--latest"])
139+
140+
output = strip_ansi(result.output)
141+
assert result.exit_code == 0
142+
assert "Specify CLI" in output
143+
assert "Update available" in output
144+
assert "0.8.2" in output
145+
assert "0.8.8" in output
146+
assert "git+https://github.com/github/spec-kit.git@v0.8.8" in output
147+
148+
def test_check_latest_reports_up_to_date(self):
149+
with (
150+
patch("specify_cli.check_tool", return_value=True),
151+
patch("specify_cli._get_installed_version", return_value="0.8.8"),
152+
patch("specify_cli._fetch_latest_release_tag", return_value=("v0.8.8", None)),
153+
):
154+
result = runner.invoke(app, ["check", "--latest"])
155+
156+
output = strip_ansi(result.output)
157+
assert result.exit_code == 0
158+
assert "Specify CLI" in output
159+
assert "Up to date: 0.8.8" in output
160+
assert "Update available" not in output
161+
162+
def test_check_latest_handles_unknown_installed_version(self):
163+
with (
164+
patch("specify_cli.check_tool", return_value=True),
165+
patch("specify_cli._get_installed_version", return_value="unknown"),
166+
patch("specify_cli._fetch_latest_release_tag", return_value=("v0.8.8", None)),
167+
):
168+
result = runner.invoke(app, ["check", "--latest"])
169+
170+
output = strip_ansi(result.output)
171+
assert result.exit_code == 0
172+
assert "Specify CLI" in output
173+
assert "Current version could not be determined" in output
174+
assert "Latest release: 0.8.8" in output
175+
assert "git+https://github.com/github/spec-kit.git@v0.8.8" in output
176+
177+
@pytest.mark.parametrize(
178+
"failure_reason",
179+
[
180+
"offline or timeout",
181+
"rate limited (configure ~/.specify/auth.json with a GitHub token)",
182+
],
183+
)
184+
def test_check_latest_handles_lookup_failure(self, failure_reason):
185+
with (
186+
patch("specify_cli.check_tool", return_value=True),
187+
patch("specify_cli._get_installed_version", return_value="0.8.2"),
188+
patch("specify_cli._fetch_latest_release_tag", return_value=(None, failure_reason)),
189+
):
190+
result = runner.invoke(app, ["check", "--latest"])
191+
192+
output = strip_ansi(result.output)
193+
normalized = " ".join(output.split())
194+
normalized_reason = " ".join(failure_reason.split())
195+
assert result.exit_code == 0
196+
assert "Specify CLI" in output
197+
assert "Installed: 0.8.2" in output
198+
assert f"Could not check latest release: {normalized_reason}" in normalized

0 commit comments

Comments
 (0)