diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fa20c809d..5b37ed0be 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,6 @@ name: Lint +permissions: + contents: read on: push: diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 new file mode 100644 index 000000000..3bf74c92d --- /dev/null +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -0,0 +1,416 @@ +#!/usr/bin/env pwsh +#requires -Version 7.0 + +<# +.SYNOPSIS + Build Spec Kit template release archives for each supported AI assistant and script type. + +.DESCRIPTION + create-release-packages.ps1 (workflow-local) + Build Spec Kit template release archives for each supported AI assistant and script type. + +.PARAMETER Version + Version string with leading 'v' (e.g., v0.2.0) + +.PARAMETER Agents + Comma or space separated subset of agents to build (default: all) + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q + +.PARAMETER Scripts + Comma or space separated subset of script types to build (default: both) + Valid scripts: sh, ps + +.EXAMPLE + .\create-release-packages.ps1 -Version v0.2.0 + +.EXAMPLE + .\create-release-packages.ps1 -Version v0.2.0 -Agents claude,copilot -Scripts sh + +.EXAMPLE + .\create-release-packages.ps1 -Version v0.2.0 -Agents claude -Scripts ps +#> + +param( + [Parameter(Mandatory=$true, Position=0)] + [string]$Version, + + [Parameter(Mandatory=$false)] + [string]$Agents = "", + + [Parameter(Mandatory=$false)] + [string]$Scripts = "" +) + +$ErrorActionPreference = "Stop" + +# Validate version format +if ($Version -notmatch '^v\d+\.\d+\.\d+$') { + Write-Error "Version must look like v0.0.0" + exit 1 +} + +Write-Host "Building release packages for $Version" + +# Create and use .genreleases directory for all build artifacts +$GenReleasesDir = ".genreleases" +if (Test-Path $GenReleasesDir) { + Remove-Item -Path $GenReleasesDir -Recurse -Force -ErrorAction SilentlyContinue +} +New-Item -ItemType Directory -Path $GenReleasesDir -Force | Out-Null + +function Rewrite-Paths { + param([string]$Content) + + $Content = $Content -replace '(/?)\bmemory/', '.specify/memory/' + $Content = $Content -replace '(/?)\bscripts/', '.specify/scripts/' + $Content = $Content -replace '(/?)\btemplates/', '.specify/templates/' + return $Content +} + +function Generate-Commands { + param( + [string]$Agent, + [string]$Extension, + [string]$ArgFormat, + [string]$OutputDir, + [string]$ScriptVariant + ) + + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + + $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue + + foreach ($template in $templates) { + $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) + + # Read file content and normalize line endings + $fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n" + + # Extract description from YAML frontmatter + $description = "" + if ($fileContent -match '(?m)^description:\s*(.+)$') { + $description = $matches[1] + } + + # Extract script command from YAML frontmatter + $scriptCommand = "" + if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") { + $scriptCommand = $matches[1] + } + + if ([string]::IsNullOrEmpty($scriptCommand)) { + Write-Warning "No script command found for $ScriptVariant in $($template.Name)" + $scriptCommand = "(Missing script command for $ScriptVariant)" + } + + # Extract agent_script command from YAML frontmatter if present + $agentScriptCommand = "" + if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") { + $agentScriptCommand = $matches[1].Trim() + } + + # Replace {SCRIPT} placeholder with the script command + $body = $fileContent -replace '\{SCRIPT\}', $scriptCommand + + # Replace {AGENT_SCRIPT} placeholder with the agent script command if found + if (-not [string]::IsNullOrEmpty($agentScriptCommand)) { + $body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand + } + + # Remove the scripts: and agent_scripts: sections from frontmatter + $lines = $body -split "`n" + $outputLines = @() + $inFrontmatter = $false + $skipScripts = $false + $dashCount = 0 + + foreach ($line in $lines) { + if ($line -match '^---$') { + $outputLines += $line + $dashCount++ + if ($dashCount -eq 1) { + $inFrontmatter = $true + } else { + $inFrontmatter = $false + } + continue + } + + if ($inFrontmatter) { + if ($line -match '^(scripts|agent_scripts):$') { + $skipScripts = $true + continue + } + if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { + $skipScripts = $false + } + if ($skipScripts -and $line -match '^\s+') { + continue + } + } + + $outputLines += $line + } + + $body = $outputLines -join "`n" + + # Apply other substitutions + $body = $body -replace '\{ARGS\}', $ArgFormat + $body = $body -replace '__AGENT__', $Agent + $body = Rewrite-Paths -Content $body + + # Generate output file based on extension + $outputFile = Join-Path $OutputDir "speckit.$name.$Extension" + + switch ($Extension) { + 'toml' { + $body = $body -replace '\\', '\\' + $output = "description = `"$description`"`n`nprompt = `"`"`"`n$body`n`"`"`"" + Set-Content -Path $outputFile -Value $output -NoNewline + } + 'md' { + Set-Content -Path $outputFile -Value $body -NoNewline + } + 'chatmode.md' { + Set-Content -Path $outputFile -Value $body -NoNewline + } + } + } +} + +function Generate-CopilotPrompts { + param( + [string]$ChatmodesDir, + [string]$PromptsDir + ) + + New-Item -ItemType Directory -Path $PromptsDir -Force | Out-Null + + $chatmodeFiles = Get-ChildItem -Path "$ChatmodesDir/speckit.*.chatmode.md" -File -ErrorAction SilentlyContinue + + foreach ($chatmodeFile in $chatmodeFiles) { + $basename = $chatmodeFile.Name -replace '\.chatmode\.md$', '' + $promptFile = Join-Path $PromptsDir "$basename.prompt.md" + + $content = @" +--- +agent: $basename +--- +"@ + Set-Content -Path $promptFile -Value $content + } +} + +function Build-Variant { + param( + [string]$Agent, + [string]$Script + ) + + $baseDir = Join-Path $GenReleasesDir "sdd-${Agent}-package-${Script}" + Write-Host "Building $Agent ($Script) package..." + New-Item -ItemType Directory -Path $baseDir -Force | Out-Null + + # Copy base structure but filter scripts by variant + $specDir = Join-Path $baseDir ".specify" + New-Item -ItemType Directory -Path $specDir -Force | Out-Null + + # Copy memory directory + if (Test-Path "memory") { + Copy-Item -Path "memory" -Destination $specDir -Recurse -Force + Write-Host "Copied memory -> .specify" + } + + # Only copy the relevant script variant directory + if (Test-Path "scripts") { + $scriptsDestDir = Join-Path $specDir "scripts" + New-Item -ItemType Directory -Path $scriptsDestDir -Force | Out-Null + + switch ($Script) { + 'sh' { + if (Test-Path "scripts/bash") { + Copy-Item -Path "scripts/bash" -Destination $scriptsDestDir -Recurse -Force + Write-Host "Copied scripts/bash -> .specify/scripts" + } + } + 'ps' { + if (Test-Path "scripts/powershell") { + Copy-Item -Path "scripts/powershell" -Destination $scriptsDestDir -Recurse -Force + Write-Host "Copied scripts/powershell -> .specify/scripts" + } + } + } + + # Copy any script files that aren't in variant-specific directories + Get-ChildItem -Path "scripts" -File -ErrorAction SilentlyContinue | ForEach-Object { + Copy-Item -Path $_.FullName -Destination $scriptsDestDir -Force + } + } + + # Copy templates (excluding commands directory and vscode-settings.json) + if (Test-Path "templates") { + $templatesDestDir = Join-Path $specDir "templates" + New-Item -ItemType Directory -Path $templatesDestDir -Force | Out-Null + + Get-ChildItem -Path "templates" -Recurse -File | Where-Object { + $_.FullName -notmatch 'templates[/\\]commands[/\\]' -and $_.Name -ne 'vscode-settings.json' + } | ForEach-Object { + $relativePath = $_.FullName.Substring((Resolve-Path "templates").Path.Length + 1) + $destFile = Join-Path $templatesDestDir $relativePath + $destFileDir = Split-Path $destFile -Parent + New-Item -ItemType Directory -Path $destFileDir -Force | Out-Null + Copy-Item -Path $_.FullName -Destination $destFile -Force + } + Write-Host "Copied templates -> .specify/templates" + } + + # Generate agent-specific command files + switch ($Agent) { + 'claude' { + $cmdDir = Join-Path $baseDir ".claude/commands" + Generate-Commands -Agent 'claude' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'gemini' { + $cmdDir = Join-Path $baseDir ".gemini/commands" + Generate-Commands -Agent 'gemini' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script + if (Test-Path "agent_templates/gemini/GEMINI.md") { + Copy-Item -Path "agent_templates/gemini/GEMINI.md" -Destination (Join-Path $baseDir "GEMINI.md") + } + } + 'copilot' { + $chatmodesDir = Join-Path $baseDir ".github/chatmodes" + Generate-Commands -Agent 'copilot' -Extension 'chatmode.md' -ArgFormat '$ARGUMENTS' -OutputDir $chatmodesDir -ScriptVariant $Script + + # Generate companion prompt files + $promptsDir = Join-Path $baseDir ".github/prompts" + Generate-CopilotPrompts -ChatmodesDir $chatmodesDir -PromptsDir $promptsDir + + # Create VS Code workspace settings + $vscodeDir = Join-Path $baseDir ".vscode" + New-Item -ItemType Directory -Path $vscodeDir -Force | Out-Null + if (Test-Path "templates/vscode-settings.json") { + Copy-Item -Path "templates/vscode-settings.json" -Destination (Join-Path $vscodeDir "settings.json") + } + } + 'cursor-agent' { + $cmdDir = Join-Path $baseDir ".cursor/commands" + Generate-Commands -Agent 'cursor-agent' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'qwen' { + $cmdDir = Join-Path $baseDir ".qwen/commands" + Generate-Commands -Agent 'qwen' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script + if (Test-Path "agent_templates/qwen/QWEN.md") { + Copy-Item -Path "agent_templates/qwen/QWEN.md" -Destination (Join-Path $baseDir "QWEN.md") + } + } + 'opencode' { + $cmdDir = Join-Path $baseDir ".opencode/command" + Generate-Commands -Agent 'opencode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'windsurf' { + $cmdDir = Join-Path $baseDir ".windsurf/workflows" + Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'codex' { + $cmdDir = Join-Path $baseDir ".codex/prompts" + Generate-Commands -Agent 'codex' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'kilocode' { + $cmdDir = Join-Path $baseDir ".kilocode/workflows" + Generate-Commands -Agent 'kilocode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'auggie' { + $cmdDir = Join-Path $baseDir ".augment/commands" + Generate-Commands -Agent 'auggie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'roo' { + $cmdDir = Join-Path $baseDir ".roo/commands" + Generate-Commands -Agent 'roo' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'codebuddy' { + $cmdDir = Join-Path $baseDir ".codebuddy/commands" + Generate-Commands -Agent 'codebuddy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'amp' { + $cmdDir = Join-Path $baseDir ".agents/commands" + Generate-Commands -Agent 'amp' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'q' { + $cmdDir = Join-Path $baseDir ".amazonq/prompts" + Generate-Commands -Agent 'q' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + } + + # Create zip archive + $zipFile = Join-Path $GenReleasesDir "spec-kit-template-${Agent}-${Script}-${Version}.zip" + Compress-Archive -Path "$baseDir/*" -DestinationPath $zipFile -Force + Write-Host "Created $zipFile" +} + +# Define all agents and scripts +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q') +$AllScripts = @('sh', 'ps') + +function Normalize-List { + param([string]$Input) + + if ([string]::IsNullOrEmpty($Input)) { + return @() + } + + # Split by comma or space and remove duplicates while preserving order + $items = $Input -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique + return $items +} + +function Validate-Subset { + param( + [string]$Type, + [string[]]$Allowed, + [string[]]$Items + ) + + $ok = $true + foreach ($item in $Items) { + if ($item -notin $Allowed) { + Write-Error "Unknown $Type '$item' (allowed: $($Allowed -join ', '))" + $ok = $false + } + } + return $ok +} + +# Determine agent list +if (-not [string]::IsNullOrEmpty($Agents)) { + $AgentList = Normalize-List -Input $Agents + if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) { + exit 1 + } +} else { + $AgentList = $AllAgents +} + +# Determine script list +if (-not [string]::IsNullOrEmpty($Scripts)) { + $ScriptList = Normalize-List -Input $Scripts + if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) { + exit 1 + } +} else { + $ScriptList = $AllScripts +} + +Write-Host "Agents: $($AgentList -join ', ')" +Write-Host "Scripts: $($ScriptList -join ', ')" + +# Build all variants +foreach ($agent in $AgentList) { + foreach ($script in $ScriptList) { + Build-Variant -Agent $agent -Script $script + } +} + +Write-Host "`nArchives in ${GenReleasesDir}:" +Get-ChildItem -Path $GenReleasesDir -Filter "spec-kit-template-*-${Version}.zip" | ForEach-Object { + Write-Host " $($_.Name)" +} diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index f9d3b1ae1..49f2ddad3 100644 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -95,12 +95,32 @@ generate_commands() { { echo "description = \"$description\""; echo; echo "prompt = \"\"\""; echo "$body"; echo "\"\"\""; } > "$output_dir/speckit.$name.$ext" ;; md) echo "$body" > "$output_dir/speckit.$name.$ext" ;; - prompt.md) + chatmode.md) echo "$body" > "$output_dir/speckit.$name.$ext" ;; esac done } +generate_copilot_prompts() { + local chatmodes_dir=$1 prompts_dir=$2 + mkdir -p "$prompts_dir" + + # Generate a .prompt.md file for each .chatmode.md file + for chatmode_file in "$chatmodes_dir"/speckit.*.chatmode.md; do + [[ -f "$chatmode_file" ]] || continue + + local basename=$(basename "$chatmode_file" .chatmode.md) + local prompt_file="$prompts_dir/${basename}.prompt.md" + + # Create prompt file with agent frontmatter + cat > "$prompt_file" </commands/` - **IDE agents**: Follow IDE-specific patterns: - - Copilot: `.github/prompts/` + - Copilot: `.github/chatmodes/` - Cursor: `.cursor/commands/` - Windsurf: `.windsurf/workflows/` diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6729145..a467feacf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.21] - 2025-10-21 + +- Fixes [#975](https://github.com/github/spec-kit/issues/975) (thank you [@fgalarraga](https://github.com/fgalarraga)). +- Adds support for Amp CLI. +- Adds support for VS Code hand-offs and moves prompts to be full-fledged chat modes. +- Adds support for `version` command (addresses [#811](https://github.com/github/spec-kit/issues/811) and [#486](https://github.com/github/spec-kit/issues/486), thank you [@mcasalaina](https://github.com/mcasalaina) and [@dentity007](https://github.com/dentity007)). +- Adds support for rendering the rate limit errors from the CLI when encountered ([#970](https://github.com/github/spec-kit/issues/970), thank you [@psmman](https://github.com/psmman)). + ## [0.0.20] - 2025-10-14 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5897b383..c413dd018 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,7 @@ To test your templates, commands, and other changes locally, follow these steps: 1. **Create release packages** Run the following command to generate the local packages: + ``` ./.github/workflows/scripts/create-release-packages.sh v1.0.0 ``` diff --git a/pyproject.toml b/pyproject.toml index 567d48cd4..e4d2791bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.0.20" +version = "0.0.21" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 53adbcef1..353acccb8 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -87,17 +87,45 @@ cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" mkdir -p "$SPECS_DIR" -HIGHEST=0 +# Get highest number from specs directory +HIGHEST_FROM_SPECS=0 if [ -d "$SPECS_DIR" ]; then for dir in "$SPECS_DIR"/*; do [ -d "$dir" ] || continue dirname=$(basename "$dir") number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") number=$((10#$number)) - if [ "$number" -gt "$HIGHEST" ]; then HIGHEST=$number; fi + if [ "$number" -gt "$HIGHEST_FROM_SPECS" ]; then HIGHEST_FROM_SPECS=$number; fi done fi +# Get highest number from branch names (both local and remote) +HIGHEST_FROM_BRANCHES=0 +if [ "$HAS_GIT" = true ]; then + # Get all branches (local and remote) + branches=$(git branch -a 2>/dev/null || echo "") + + if [ -n "$branches" ]; then + while IFS= read -r branch; do + # Clean branch name: remove leading markers and remote prefixes + clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + + # Extract feature number if branch matches pattern ###-* + if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$HIGHEST_FROM_BRANCHES" ]; then HIGHEST_FROM_BRANCHES=$number; fi + fi + done <<< "$branches" + fi +fi + +# Use the highest number from either source +HIGHEST=$HIGHEST_FROM_SPECS +if [ "$HIGHEST_FROM_BRANCHES" -gt "$HIGHEST" ]; then + HIGHEST=$HIGHEST_FROM_BRANCHES +fi + NEXT=$((HIGHEST + 1)) FEATURE_NUM=$(printf "%03d" "$NEXT") diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 2a44c68a1..e327869d8 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -61,7 +61,7 @@ AGENT_TYPE="${1:-}" # Agent-specific file paths CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" GEMINI_FILE="$REPO_ROOT/GEMINI.md" -COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md" +COPILOT_FILE="$REPO_ROOT/.github/chatmodes/copilot-instructions.md" CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" QWEN_FILE="$REPO_ROOT/QWEN.md" AGENTS_FILE="$REPO_ROOT/AGENTS.md" diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 83e286aca..f267377d4 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -79,15 +79,40 @@ Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' New-Item -ItemType Directory -Path $specsDir -Force | Out-Null -$highest = 0 +# Get highest number from specs directory +$highestFromSpecs = 0 if (Test-Path $specsDir) { Get-ChildItem -Path $specsDir -Directory | ForEach-Object { if ($_.Name -match '^(\d{3})') { $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } + if ($num -gt $highestFromSpecs) { $highestFromSpecs = $num } } } } + +# Get highest number from branch names (both local and remote) +$highestFromBranches = 0 +try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($branch in $branches) { + # Clean branch name: remove leading markers and remote prefixes + $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + + # Extract feature number if branch matches pattern ###-* + if ($cleanBranch -match '^(\d{3})-') { + $num = [int]$matches[1] + if ($num -gt $highestFromBranches) { $highestFromBranches = $num } + } + } + } +} catch { + # If git command fails, just continue with specs-only check + Write-Verbose "Could not check Git branches: $_" +} + +# Use the highest number from either source +$highest = [Math]::Max($highestFromSpecs, $highestFromBranches) $next = $highest + 1 $featureNum = ('{0:000}' -f $next) diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index db6e9f2d7..d0ed582fa 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -59,4 +59,3 @@ if ($Json) { Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" Write-Output "HAS_GIT: $($paths.HAS_GIT)" } - diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 695e28b8d..d8fe34bf1 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -46,7 +46,7 @@ $NEW_PLAN = $IMPL_PLAN # Agent file paths $CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' $GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' -$COPILOT_FILE = Join-Path $REPO_ROOT '.github/copilot-instructions.md' +$COPILOT_FILE = Join-Path $REPO_ROOT '.github/chatmodes/copilot-instructions.md' $CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' $QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' $AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index a33a1c61a..3e1d41d56 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -51,6 +51,7 @@ 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": { @@ -577,10 +635,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: @@ -627,8 +686,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: @@ -1202,6 +1264,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", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}") + 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() diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index 9a62dfa74..4de842aa6 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -1,5 +1,9 @@ --- description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec. +handoffs: + - label: Build Technical Plan + agent: speckit.plan + prompt: Create a plan for the spec. I am building with... scripts: sh: scripts/bash/check-prerequisites.sh --json --paths-only ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index f557dd17c..cf81f08c2 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -1,5 +1,9 @@ --- -description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync +description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. +handoffs: + - label: Build Specification + agent: speckit.specify + prompt: Implement the feature specification based on the updated constitution. I want to build... --- ## User Input diff --git a/templates/commands/implement.md b/templates/commands/implement.md index 38601920d..fde3651e2 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -135,4 +135,3 @@ You **MUST** consider the user input before proceeding (if not empty). - Report final status with summary of completed work Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list. - diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 7dfe63a2f..147da0afa 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -1,5 +1,13 @@ --- description: Execute the implementation planning workflow using the plan template to generate design artifacts. +handoffs: + - label: Create Tasks + agent: speckit.tasks + prompt: Break the plan into tasks + send: true + - label: Create Checklist + agent: speckit.checklist + prompt: Create a checklist for the following domain... scripts: sh: scripts/bash/setup-plan.sh --json ps: scripts/powershell/setup-plan.ps1 -Json diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 03f681e57..61a6ff994 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -1,5 +1,13 @@ --- description: Create or update the feature specification from a natural language feature description. +handoffs: + - label: Build Technical Plan + agent: speckit.plan + prompt: Create a plan for the spec. I am building with... + - label: Clarify Spec Requirements + agent: speckit.clarify + prompt: Clarify specification requirements + send: true scripts: sh: scripts/bash/create-new-feature.sh --json "{ARGS}" ps: scripts/powershell/create-new-feature.ps1 -Json "{ARGS}" diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4663c1b67..86a766829 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -1,5 +1,14 @@ --- description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. +handoffs: + - label: Analyze For Consistency + agent: speckit.analyze + prompt: Run a project analysis for consistency + send: true + - label: Implement Project + agent: speckit.implement + prompt: Start the implementation in phases + send: true scripts: sh: scripts/bash/check-prerequisites.sh --json ps: scripts/powershell/check-prerequisites.ps1 -Json diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md new file mode 100644 index 000000000..b381da952 --- /dev/null +++ b/templates/commands/taskstoissues.md @@ -0,0 +1,31 @@ +--- +description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. +tools: ['github/github-mcp-server/create_issue'] +scripts: + sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks + ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Outline + +1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). +1. From the executed script, extract the path to **tasks**. +1. Get the Git remote by running: + +```bash +git config --get remote.origin.url +``` + +**ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL** + +1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. + +**UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL**