mirror of
https://github.com/github/spec-kit.git
synced 2026-01-31 05:02:02 +00:00
chore: merge main into feature branch
This commit is contained in:
@@ -51,6 +51,7 @@ from typer.core import TyperGroup
|
||||
import readchar
|
||||
import ssl
|
||||
import truststore
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
client = httpx.Client(verify=ssl_context)
|
||||
@@ -64,6 +65,63 @@ def _github_auth_headers(cli_token: str | None = None) -> dict:
|
||||
token = _github_token(cli_token)
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
def _parse_rate_limit_headers(headers: httpx.Headers) -> dict:
|
||||
"""Extract and parse GitHub rate-limit headers."""
|
||||
info = {}
|
||||
|
||||
# Standard GitHub rate-limit headers
|
||||
if "X-RateLimit-Limit" in headers:
|
||||
info["limit"] = headers.get("X-RateLimit-Limit")
|
||||
if "X-RateLimit-Remaining" in headers:
|
||||
info["remaining"] = headers.get("X-RateLimit-Remaining")
|
||||
if "X-RateLimit-Reset" in headers:
|
||||
reset_epoch = int(headers.get("X-RateLimit-Reset", "0"))
|
||||
if reset_epoch:
|
||||
reset_time = datetime.fromtimestamp(reset_epoch, tz=timezone.utc)
|
||||
info["reset_epoch"] = reset_epoch
|
||||
info["reset_time"] = reset_time
|
||||
info["reset_local"] = reset_time.astimezone()
|
||||
|
||||
# Retry-After header (seconds or HTTP-date)
|
||||
if "Retry-After" in headers:
|
||||
retry_after = headers.get("Retry-After")
|
||||
try:
|
||||
info["retry_after_seconds"] = int(retry_after)
|
||||
except ValueError:
|
||||
# HTTP-date format - not implemented, just store as string
|
||||
info["retry_after"] = retry_after
|
||||
|
||||
return info
|
||||
|
||||
def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) -> str:
|
||||
"""Format a user-friendly error message with rate-limit information."""
|
||||
rate_info = _parse_rate_limit_headers(headers)
|
||||
|
||||
lines = [f"GitHub API returned status {status_code} for {url}"]
|
||||
lines.append("")
|
||||
|
||||
if rate_info:
|
||||
lines.append("[bold]Rate Limit Information:[/bold]")
|
||||
if "limit" in rate_info:
|
||||
lines.append(f" • Rate Limit: {rate_info['limit']} requests/hour")
|
||||
if "remaining" in rate_info:
|
||||
lines.append(f" • Remaining: {rate_info['remaining']}")
|
||||
if "reset_local" in rate_info:
|
||||
reset_str = rate_info["reset_local"].strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
lines.append(f" • Resets at: {reset_str}")
|
||||
if "retry_after_seconds" in rate_info:
|
||||
lines.append(f" • Retry after: {rate_info['retry_after_seconds']} seconds")
|
||||
lines.append("")
|
||||
|
||||
# Add troubleshooting guidance
|
||||
lines.append("[bold]Troubleshooting Tips:[/bold]")
|
||||
lines.append(" • If you're on a shared CI or corporate environment, you may be rate-limited.")
|
||||
lines.append(" • Consider using a GitHub token via --github-token or the GH_TOKEN/GITHUB_TOKEN")
|
||||
lines.append(" environment variable to increase rate limits.")
|
||||
lines.append(" • Authenticated requests have a limit of 5,000/hour vs 60/hour for unauthenticated.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# Agent configuration with name, folder, install URL, and CLI tool requirement
|
||||
AGENT_CONFIG = {
|
||||
"copilot": {
|
||||
@@ -150,6 +208,12 @@ AGENT_CONFIG = {
|
||||
"install_url": "https://ampcode.com/manual#install",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"shai": {
|
||||
"name": "SHAI",
|
||||
"folder": ".shai/",
|
||||
"install_url": "https://github.com/ovh/shai",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"bob": {
|
||||
"name": "IBM Bob",
|
||||
"folder": ".bob/",
|
||||
@@ -583,10 +647,11 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
||||
)
|
||||
status = response.status_code
|
||||
if status != 200:
|
||||
msg = f"GitHub API returned {status} for {api_url}"
|
||||
# Format detailed error message with rate-limit info
|
||||
error_msg = _format_rate_limit_error(status, response.headers, api_url)
|
||||
if debug:
|
||||
msg += f"\nResponse headers: {response.headers}\nBody (truncated 500): {response.text[:500]}"
|
||||
raise RuntimeError(msg)
|
||||
error_msg += f"\n\n[dim]Response body (truncated 500):[/dim]\n{response.text[:500]}"
|
||||
raise RuntimeError(error_msg)
|
||||
try:
|
||||
release_data = response.json()
|
||||
except ValueError as je:
|
||||
@@ -633,8 +698,11 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
||||
headers=_github_auth_headers(github_token),
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
body_sample = response.text[:400]
|
||||
raise RuntimeError(f"Download failed with {response.status_code}\nHeaders: {response.headers}\nBody (truncated): {body_sample}")
|
||||
# Handle rate-limiting on download as well
|
||||
error_msg = _format_rate_limit_error(response.status_code, response.headers, download_url)
|
||||
if debug:
|
||||
error_msg += f"\n\n[dim]Response body (truncated 400):[/dim]\n{response.text[:400]}"
|
||||
raise RuntimeError(error_msg)
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
with open(zip_path, 'wb') as f:
|
||||
if total_size == 0:
|
||||
@@ -871,7 +939,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, q, or bob"),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, or bob"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
|
||||
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||
@@ -1208,6 +1276,85 @@ def check():
|
||||
if not any(agent_results.values()):
|
||||
console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]")
|
||||
|
||||
@app.command()
|
||||
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
|
||||
|
||||
# Fetch latest template release version
|
||||
repo_owner = "github"
|
||||
repo_name = "spec-kit"
|
||||
api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||
|
||||
template_version = "unknown"
|
||||
release_date = "unknown"
|
||||
|
||||
try:
|
||||
response = client.get(
|
||||
api_url,
|
||||
timeout=10,
|
||||
follow_redirects=True,
|
||||
headers=_github_auth_headers(),
|
||||
)
|
||||
if response.status_code == 200:
|
||||
release_data = response.json()
|
||||
template_version = release_data.get("tag_name", "unknown")
|
||||
# Remove 'v' prefix if present
|
||||
if template_version.startswith("v"):
|
||||
template_version = template_version[1:]
|
||||
release_date = release_data.get("published_at", "unknown")
|
||||
if release_date != "unknown":
|
||||
# Format the date nicely
|
||||
try:
|
||||
dt = datetime.fromisoformat(release_date.replace('Z', '+00:00'))
|
||||
release_date = dt.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
info_table.add_column("Key", style="cyan", justify="right")
|
||||
info_table.add_column("Value", style="white")
|
||||
|
||||
info_table.add_row("CLI Version", cli_version)
|
||||
info_table.add_row("Template Version", template_version)
|
||||
info_table.add_row("Released", release_date)
|
||||
info_table.add_row("", "")
|
||||
info_table.add_row("Python", platform.python_version())
|
||||
info_table.add_row("Platform", platform.system())
|
||||
info_table.add_row("Architecture", platform.machine())
|
||||
info_table.add_row("OS Version", platform.version())
|
||||
|
||||
panel = Panel(
|
||||
info_table,
|
||||
title="[bold cyan]Specify CLI Information[/bold cyan]",
|
||||
border_style="cyan",
|
||||
padding=(1, 2)
|
||||
)
|
||||
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user