Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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.
Comment thread
mnriem marked this conversation as resolved.
Outdated

## 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 thread
mnriem marked this conversation as resolved.

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.
Comment thread
mnriem marked this conversation as resolved.
Outdated

_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 thread
mnriem marked this conversation as resolved.
Outdated
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 thread
mnriem marked this conversation as resolved.
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