Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
183 changes: 166 additions & 17 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# "platformdirs",
# "readchar",
# "json5",
# "pyyaml",
# "packaging",
# ]
# ///
"""
Expand Down Expand Up @@ -34,8 +36,12 @@
import json5
import stat
import shlex
import urllib.error
import urllib.request
import yaml
from pathlib import Path

from packaging.version import InvalidVersion, Version
from typing import Any, Optional
Comment thread
chordpli marked this conversation as resolved.

import typer
Expand All @@ -51,6 +57,8 @@
# For cross-platform keyboard input
import readchar

GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"

def _build_agent_config() -> dict[str, dict[str, Any]]:
"""Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
from .integrations import INTEGRATION_REGISTRY
Expand Down Expand Up @@ -318,7 +326,7 @@ def run_selection_loop():

return selected_key

console = Console()
console = Console(highlight=False)

class BannerGroup(TyperGroup):
"""Custom group that shows banner before help."""
Expand Down Expand Up @@ -1599,25 +1607,10 @@ def check():
def version():
"""Display version and system information."""
import platform
import importlib.metadata

show_banner()

# Get CLI version from package metadata
cli_version = "unknown"
try:
cli_version = importlib.metadata.version("specify-cli")
except Exception:
# Fallback: try reading from pyproject.toml if running from source
try:
import tomllib
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
cli_version = data.get("project", {}).get("version", "unknown")
except Exception:
pass
cli_version = get_speckit_version()

info_table = Table(show_header=False, box=None, padding=(0, 2))
info_table.add_column("Key", style="cyan", justify="right")
Expand All @@ -1640,6 +1633,162 @@ def version():
console.print(panel)
console.print()

def _get_installed_version() -> str:
"""Return the installed specify-cli distribution version or 'unknown'.

Uses importlib.metadata so the value reflects what was actually installed
by pip/uv/pipx — not a value read from pyproject.toml. This is
intentional for `specify self check`, which should reason about the
installed distribution rather than a source-tree fallback. Callers must
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
"""

import importlib.metadata

metadata_errors = [importlib.metadata.PackageNotFoundError]
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
if invalid_metadata_error is not None:
metadata_errors.append(invalid_metadata_error)

try:
return importlib.metadata.version("specify-cli")
except tuple(metadata_errors):
return "unknown"
Comment thread
mnriem marked this conversation as resolved.

def _normalize_tag(tag: str) -> str:
"""Strip exactly one leading 'v' from a release tag.

Returns the rest of the string unchanged. This handles the common
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
aggressively (e.g., two leading 'v's keeps one).
"""
return tag[1:] if tag.startswith("v") else tag

def _is_newer(latest: str, current: str) -> bool:
"""Return True iff `latest` is strictly greater than `current` under PEP 440.

Returns False whenever either side is 'unknown' or fails to parse; this
keeps the comparison indeterminate (rather than crashing or falsely
recommending a downgrade) on edge inputs.
"""
if latest == "unknown" or current == "unknown":
return False
try:
return Version(latest) > Version(current)
except InvalidVersion:
return False


def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
Comment thread
chordpli marked this conversation as resolved.

On success: (tag_name, None).
On a documented network/HTTP failure (added in T029/T030): (None, category).
On anything else — including a malformed response body — the exception
propagates; there is no catch-all (research D-006).
"""
req = urllib.request.Request(
GITHUB_API_LATEST,
headers={"Accept": "application/vnd.github+json"},
)
token = None
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
candidate = os.environ.get(env_var)
if candidate is not None:
candidate = candidate.strip()
if candidate:
token = candidate
break
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=5) as resp:
payload = json.loads(resp.read().decode("utf-8"))
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag:
raise ValueError("GitHub API response missing valid tag_name")
return tag, None
except urllib.error.HTTPError as e:
# Order matters: HTTPError is a subclass of URLError.
if e.code == 403:
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
return None, f"HTTP {e.code}"
Comment thread
chordpli marked this conversation as resolved.
except (urllib.error.URLError, OSError):
return None, "offline or timeout"


# ===== Self Commands =====
self_app = typer.Typer(
name="self",
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
add_completion=False,
)
app.add_typer(self_app, name="self")

@self_app.command("check")
def self_check() -> None:
"""Check whether a newer specify-cli release is available. Read-only.

This command only checks for updates; it does not modify your installation.
The reserved (and currently non-destructive) `specify self upgrade` command
is the name that a future release will use for actual self-upgrade — its
behavior is not implemented in this release and is intentionally out of
scope here. See `specify self upgrade --help` for its current status.
"""

installed = _get_installed_version()
tag, failure_reason = _fetch_latest_release_tag()

if tag is None:
# Graceful-failure path (FR-008). `failure_reason` is one of the
# enumerated strings produced by _fetch_latest_release_tag() — it
# never contains a URL, headers, response body, or traceback.
Comment thread
chordpli marked this conversation as resolved.
console.print(f"Installed: {installed}")
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
return

latest_normalized = _normalize_tag(tag)

if installed == "unknown":
# FR-020: surface the latest release and the recovery action even
# when the local distribution metadata is unavailable.
console.print("Current version could not be determined.")
console.print(f"Latest release: {latest_normalized}")
console.print("\nTo reinstall:")
console.print(" uv tool install specify-cli --force \\")
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
return

if _is_newer(latest_normalized, installed):
console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}")
console.print("\nTo upgrade:")
console.print(" uv tool install specify-cli --force \\")
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
return

# Installed is parseable AND is >= latest → "up to date" (FR-006).
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
# returns False, and the up-to-date branch is the safer default per
# FR-004 / test T016.
console.print(f"[green]Up to date:[/green] {installed}")


@self_app.command("upgrade")
def self_upgrade() -> None:
"""Reserved command surface for self-upgrade; not implemented in this release.

This command is a documented non-destructive stub in this release: it
performs no outbound network request, no install-method detection, and
invokes no installer. It prints a three-line guidance message and exits 0.
Actual self-upgrade is planned as follow-up work.

Use `specify self check` today to see whether a newer release is available
and to get a copy-pasteable reinstall command.
"""
console.print("specify self upgrade is not implemented yet.")
console.print("Run 'specify self check' to see whether a newer release is available.")
console.print("Actual self-upgrade is planned as follow-up work.")


# ===== Extension Commands =====

Expand Down
Loading
Loading