Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

<!-- insert new changelog below this comment -->

## [Unreleased]

### Added

- feat(cli): warn on launch when a newer spec-kit release is available; cached for 24h and suppressed with `SPECIFY_SKIP_UPDATE_CHECK=1`, non-interactive shells, or `CI=1` (#1320)

## [0.6.2] - 2026-04-13

### Changed
Expand Down
10 changes: 10 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ After initialization, you should see the following commands available in your AI

The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.

### Update Notifications

On each launch, `specify` checks once per 24 hours whether a newer release is available on GitHub and prints an upgrade hint if so. The check is silent when:

- `SPECIFY_SKIP_UPDATE_CHECK=1` (or `true`/`yes`/`on`) is set
- stdout is not a TTY (piped output, redirected to a file, etc.)
- the `CI` environment variable is set

Network failures and rate-limit responses are swallowed — the check never blocks the command you ran.
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc claims the update check “never blocks the command you ran,” but the current implementation performs a synchronous network request during the CLI callback on cache misses, which can add latency before the command executes. Either adjust the implementation to be non-blocking, or tweak this wording to match the behavior (e.g., “never aborts/fails the command” rather than “never blocks”).

Suggested change
Network failures and rate-limit responses are swallowed — the check never blocks the command you ran.
Network failures and rate-limit responses are swallowed — the check never aborts or fails the command you ran.

Copilot uses AI. Check for mistakes.

## Troubleshooting

### Enterprise / Air-Gapped Installation
Expand Down
141 changes: 141 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ def callback(ctx: typer.Context):
show_banner()
console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]"))
console.print()
# Addresses #1320: nudge users running outdated CLIs. The `version` subcommand
# already surfaces the version, so skip there to avoid double-printing.
if ctx.invoked_subcommand not in (None, "version"):
_check_for_updates()
Comment on lines +330 to +333
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callback() only triggers _check_for_updates() when ctx.invoked_subcommand is neither None nor version. That means running specify with no subcommand (the banner/launch case) will never perform the update check, which conflicts with the docs text (“On each launch…”) and the PR summary. Consider either (a) including the None case (while still skipping --help/-h), or (b) adjusting the docs/PR wording to match the actual behavior (only runs for subcommands).

Copilot uses AI. Check for mistakes.

def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]:
"""Run a shell command and optionally capture output."""
Expand Down Expand Up @@ -1586,6 +1590,143 @@ def get_speckit_version() -> str:
return "unknown"


# ===== Update check (addresses #1320) =====
#
# Cached once per 24h in the platform user-cache dir. Triggered from the top-level
# callback. Never blocks the user — every failure path swallows the exception.
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says the update check “Never blocks the user”, but _check_for_updates() runs synchronously in the CLI callback and can delay startup by up to _UPDATE_CHECK_TIMEOUT_SECONDS (and potentially longer in practice due to DNS/socket behavior). If the intent is truly non-blocking, this should run asynchronously / in a background thread, or be deferred until after the command completes; otherwise, please reword the comment/docs to clarify that it never fails the command, but may add a small delay on cache misses.

Suggested change
# callback. Never blocks the user — every failure path swallows the exception.
# callback. This is best-effort: every failure path swallows the exception so it
# never fails the command, but cache misses may add a small startup delay while
# performing the network check.

Copilot uses AI. Check for mistakes.

_UPDATE_CHECK_URL = "https://api.github.com/repos/github/spec-kit/releases/latest"
_UPDATE_CHECK_CACHE_TTL_SECONDS = 24 * 60 * 60
_UPDATE_CHECK_TIMEOUT_SECONDS = 2.0


def _parse_version_tuple(version: str) -> tuple[int, ...] | None:
"""Parse `v0.6.2` / `0.6.2` / `0.6.2.dev0` → tuple of ints. Returns None if unparseable."""
if not version:
Comment on lines +1603 to +1605
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_parse_version_tuple is annotated as taking version: str, but the implementation handles falsey/non-str inputs (e.g., tests pass None). To keep annotations accurate (and avoid future type-checking surprises), either update the signature to str | None and guard with isinstance(version, str), or change callers/tests to never pass None.

Suggested change
def _parse_version_tuple(version: str) -> tuple[int, ...] | None:
"""Parse `v0.6.2` / `0.6.2` / `0.6.2.dev0` → tuple of ints. Returns None if unparseable."""
if not version:
def _parse_version_tuple(version: str | None) -> tuple[int, ...] | None:
"""Parse `v0.6.2` / `0.6.2` / `0.6.2.dev0` → tuple of ints. Returns None if unparseable."""
if not isinstance(version, str) or not version:

Copilot uses AI. Check for mistakes.
return None
s = version.strip().lstrip("vV")
# Drop PEP 440 pre/post/dev/local segments; we only compare release numbers.
for sep in ("-", "+", "a", "b", "rc", ".dev", ".post"):
idx = s.find(sep)
if idx != -1:
s = s[:idx]
parts: list[int] = []
for piece in s.split("."):
if not piece.isdigit():
return None
parts.append(int(piece))
return tuple(parts) if parts else None


def _update_check_cache_path() -> Path | None:
try:
from platformdirs import user_cache_dir
return Path(user_cache_dir("specify-cli")) / "version_check.json"
except Exception:
return None


def _read_update_check_cache(path: Path) -> dict | None:
try:
import time
if not path.exists():
return None
data = json.loads(path.read_text())
checked_at = float(data.get("checked_at", 0))
Comment on lines +1633 to +1635
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_read_update_check_cache uses path.read_text() without an explicit encoding, while other JSON reads in this module use encoding="utf-8" (e.g., _read_integration_json). For consistency and to avoid platform default-encoding surprises, specify UTF-8 explicitly here.

This issue also appears on line 1646 of the same file.

Copilot uses AI. Check for mistakes.
if time.time() - checked_at > _UPDATE_CHECK_CACHE_TTL_SECONDS:
return None
return data
except Exception:
return None


def _write_update_check_cache(path: Path, latest: str) -> None:
try:
import time
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"checked_at": time.time(), "latest": latest}))
except Exception:
# Cache write failures are non-fatal.
pass


def _fetch_latest_version() -> str | None:
"""Query GitHub for the latest release tag. Returns None on any failure."""
try:
import urllib.request
req = urllib.request.Request(
_UPDATE_CHECK_URL,
headers={"Accept": "application/vnd.github+json", "User-Agent": "specify-cli"},
)
with urllib.request.urlopen(req, timeout=_UPDATE_CHECK_TIMEOUT_SECONDS) as resp:
payload = json.loads(resp.read().decode("utf-8"))
tag = payload.get("tag_name")
return tag if isinstance(tag, str) and tag else None
except Exception:
return None


def _should_skip_update_check() -> bool:
if os.environ.get("SPECIFY_SKIP_UPDATE_CHECK", "").strip().lower() in ("1", "true", "yes", "on"):
return True
if os.environ.get("CI"):
return True
try:
if not sys.stdout.isatty():
return True
except Exception:
return True
return False


def _check_for_updates() -> None:
"""Print a one-line upgrade hint when a newer spec-kit release is available.

Fully best-effort — any error (offline, rate-limited, parse failure) is
swallowed so the command the user actually invoked is never blocked.
"""
if _should_skip_update_check():
return
try:
current_str = get_speckit_version()
current = _parse_version_tuple(current_str)
if current is None:
return

cache_path = _update_check_cache_path()
latest_str: str | None = None
if cache_path is not None:
cached = _read_update_check_cache(cache_path)
if cached:
latest_str = cached.get("latest")

if latest_str is None:
latest_str = _fetch_latest_version()
if latest_str and cache_path is not None:
_write_update_check_cache(cache_path, latest_str)

latest = _parse_version_tuple(latest_str) if latest_str else None
if latest is None or latest <= current:
return

current_display = current_str.lstrip("vV")
latest_display = latest_str.lstrip("vV")
console.print(
f"[yellow]⚠ A new spec-kit version is available: "
f"v{latest_display} (you have v{current_display})[/yellow]"
)
console.print(
f"[dim] Upgrade: uv tool install specify-cli --force "
f"--from git+https://github.com/github/spec-kit.git@v{latest_display}[/dim]"
)
console.print(
"[dim] (set SPECIFY_SKIP_UPDATE_CHECK=1 to silence this check)[/dim]"
)
except Exception:
# Update check must never surface an error to the user.
return


# ===== Integration Commands =====

integration_app = typer.Typer(
Expand Down
Loading