mirror of
https://github.com/github/spec-kit.git
synced 2026-03-16 18:33:07 +00:00
* Initial plan * feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * test(templates): add comprehensive unit tests for template pack system Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat(presets): pluggable preset system with template/command overrides, catalog, and resolver - Rename 'template packs' to 'presets' to avoid naming collision with core templates - PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py - Extract CommandRegistrar to agents.py as shared infrastructure - CLI: specify preset list/add/remove/search/resolve/info - CLI: specify preset catalog list/add/remove - --preset option on specify init - Priority-based preset stacking (--priority, lower = higher precedence) - Command overrides registered into all detected agent directories (17+ agents) - Extension command safety: skip registration if target extension not installed - Multi-catalog support: env var, project config, user config, built-in defaults - resolve_template() / Resolve-Template in bash/PowerShell scripts - Self-test preset: overrides all 6 core templates + 1 command - Scaffold with 4 examples: core/extension template and command overrides - Preset catalog (catalog.json, catalog.community.json) - Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md - 110 preset tests, 253 total tests passing * feat(presets): propagate command overrides to skills via init-options - Add save_init_options() / load_init_options() helpers that persist CLI flags from 'specify init' to .specify/init-options.json - PresetManager._register_skills() overwrites SKILL.md files when --ai-skills was used during init and corresponding skill dirs exist - PresetManager._unregister_skills() restores core template content on preset removal - registered_skills stored in preset registry metadata - 8 new tests covering skill override, skip conditions, and restore * fix: address PR check failures (ruff F541, CodeQL URL substring) - Remove extraneous f-prefix from two f-strings without placeholders - Replace substring URL check in test with startswith/endswith assertions to satisfy CodeQL incomplete URL substring sanitization rule * fix: address Copilot PR review comments - Move save_init_options() before preset install so skills propagation works during 'specify init --preset --ai-skills' - Clean up downloaded ZIP after successful preset install during init - Validate --from URL scheme (require HTTPS, HTTP only for localhost) - Expose unregister_commands() on extensions.py CommandRegistrar wrapper instead of reaching into private _registrar field - Use _get_merged_packs() for search() and get_pack_info() so all active catalogs are searched, not just the highest-priority one - Fix fetch_catalog() cache to verify cached URL matches current URL - Fix PresetResolver: script resolution uses .sh extension, consistent file extensions throughout resolve(), and resolve_with_source() delegates to resolve() to honor template_type parameter - Fix bash common.sh: fall through to directory scan when python3 returns empty preset list - Fix PowerShell Resolve-Template: filter out dot-folders and sort extensions deterministically * fix: narrow empty except blocks and add explanatory comments * fix: address Copilot PR review comments (round 2) - Fix init --preset error masking: distinguish "not found" from real errors - Fix bash resolve_template: skip hidden dirs in extensions (match Python/PS) - Fix temp dir leaks in tests: use temp_dir fixture instead of mkdtemp - Fix self-test catalog entry: add note that it's local-only (no download_url) - Fix Windows path issue in resolve_with_source: use Path.relative_to() - Fix skill restore path: use project's .specify/templates/commands/ not source tree - Add encoding="utf-8" to all file read/write in agents.py - Update test to set up core command templates for skill restoration * fix: remove self-test from catalog.json (local-only preset) * fix: address Copilot PR review comments (round 3) - Fix PS Resolve-Template fallback to skip dot-prefixed dirs (.cache) - Rename _catalog to _catalog_name for consistency with extension system - Enforce install_allowed policy in CLI preset add and download_pack() - Fix shell injection: pass registry path via env var instead of string interpolation * fix: correct PresetError docstring from template to preset * Removed CHANGELOG requirement * Applying review recommendations * Applying review recommendations * Applying review recommendations * Applying review recommendations --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
309 lines
10 KiB
PowerShell
309 lines
10 KiB
PowerShell
#!/usr/bin/env pwsh
|
|
# Create a new feature
|
|
[CmdletBinding()]
|
|
param(
|
|
[switch]$Json,
|
|
[string]$ShortName,
|
|
[int]$Number = 0,
|
|
[switch]$Help,
|
|
[Parameter(ValueFromRemainingArguments = $true)]
|
|
[string[]]$FeatureDescription
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Show help if requested
|
|
if ($Help) {
|
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>"
|
|
Write-Host ""
|
|
Write-Host "Options:"
|
|
Write-Host " -Json Output in JSON format"
|
|
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
|
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
|
Write-Host " -Help Show this help message"
|
|
Write-Host ""
|
|
Write-Host "Examples:"
|
|
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
|
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
|
exit 0
|
|
}
|
|
|
|
# Check if feature description provided
|
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] <feature description>"
|
|
exit 1
|
|
}
|
|
|
|
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
|
|
|
# Validate description is not empty after trimming (e.g., user passed only whitespace)
|
|
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
|
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
|
exit 1
|
|
}
|
|
|
|
# Resolve repository root. Prefer git information when available, but fall back
|
|
# to searching for repository markers so the workflow still functions in repositories that
|
|
# were initialized with --no-git.
|
|
function Find-RepositoryRoot {
|
|
param(
|
|
[string]$StartDir,
|
|
[string[]]$Markers = @('.git', '.specify')
|
|
)
|
|
$current = Resolve-Path $StartDir
|
|
while ($true) {
|
|
foreach ($marker in $Markers) {
|
|
if (Test-Path (Join-Path $current $marker)) {
|
|
return $current
|
|
}
|
|
}
|
|
$parent = Split-Path $current -Parent
|
|
if ($parent -eq $current) {
|
|
# Reached filesystem root without finding markers
|
|
return $null
|
|
}
|
|
$current = $parent
|
|
}
|
|
}
|
|
|
|
function Get-HighestNumberFromSpecs {
|
|
param([string]$SpecsDir)
|
|
|
|
$highest = 0
|
|
if (Test-Path $SpecsDir) {
|
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
|
if ($_.Name -match '^(\d+)') {
|
|
$num = [int]$matches[1]
|
|
if ($num -gt $highest) { $highest = $num }
|
|
}
|
|
}
|
|
}
|
|
return $highest
|
|
}
|
|
|
|
function Get-HighestNumberFromBranches {
|
|
param()
|
|
|
|
$highest = 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+)-') {
|
|
$num = [int]$matches[1]
|
|
if ($num -gt $highest) { $highest = $num }
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
# If git command fails, return 0
|
|
Write-Verbose "Could not check Git branches: $_"
|
|
}
|
|
return $highest
|
|
}
|
|
|
|
function Get-NextBranchNumber {
|
|
param(
|
|
[string]$SpecsDir
|
|
)
|
|
|
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
|
try {
|
|
git fetch --all --prune 2>$null | Out-Null
|
|
} catch {
|
|
# Ignore fetch errors
|
|
}
|
|
|
|
# Get highest number from ALL branches (not just matching short name)
|
|
$highestBranch = Get-HighestNumberFromBranches
|
|
|
|
# Get highest number from ALL specs (not just matching short name)
|
|
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
|
|
|
# Take the maximum of both
|
|
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
|
|
|
# Return next number
|
|
return $maxNum + 1
|
|
}
|
|
|
|
function ConvertTo-CleanBranchName {
|
|
param([string]$Name)
|
|
|
|
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
|
}
|
|
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
|
|
if (-not $fallbackRoot) {
|
|
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
|
|
exit 1
|
|
}
|
|
|
|
# Load common functions (includes Resolve-Template)
|
|
. "$PSScriptRoot/common.ps1"
|
|
|
|
try {
|
|
$repoRoot = git rev-parse --show-toplevel 2>$null
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$hasGit = $true
|
|
} else {
|
|
throw "Git not available"
|
|
}
|
|
} catch {
|
|
$repoRoot = $fallbackRoot
|
|
$hasGit = $false
|
|
}
|
|
|
|
Set-Location $repoRoot
|
|
|
|
$specsDir = Join-Path $repoRoot 'specs'
|
|
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
|
|
|
|
# Function to generate branch name with stop word filtering and length filtering
|
|
function Get-BranchName {
|
|
param([string]$Description)
|
|
|
|
# Common stop words to filter out
|
|
$stopWords = @(
|
|
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
|
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
|
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
|
'want', 'need', 'add', 'get', 'set'
|
|
)
|
|
|
|
# Convert to lowercase and extract words (alphanumeric only)
|
|
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
|
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
|
|
|
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
|
$meaningfulWords = @()
|
|
foreach ($word in $words) {
|
|
# Skip stop words
|
|
if ($stopWords -contains $word) { continue }
|
|
|
|
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
|
|
if ($word.Length -ge 3) {
|
|
$meaningfulWords += $word
|
|
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
|
# Keep short words if they appear as uppercase in original (likely acronyms)
|
|
$meaningfulWords += $word
|
|
}
|
|
}
|
|
|
|
# If we have meaningful words, use first 3-4 of them
|
|
if ($meaningfulWords.Count -gt 0) {
|
|
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
|
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
|
return $result
|
|
} else {
|
|
# Fallback to original logic if no meaningful words found
|
|
$result = ConvertTo-CleanBranchName -Name $Description
|
|
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
|
return [string]::Join('-', $fallbackWords)
|
|
}
|
|
}
|
|
|
|
# Generate branch name
|
|
if ($ShortName) {
|
|
# Use provided short name, just clean it up
|
|
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
|
} else {
|
|
# Generate from description with smart filtering
|
|
$branchSuffix = Get-BranchName -Description $featureDesc
|
|
}
|
|
|
|
# Determine branch number
|
|
if ($Number -eq 0) {
|
|
if ($hasGit) {
|
|
# Check existing branches on remotes
|
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
|
} else {
|
|
# Fall back to local directory check
|
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
}
|
|
}
|
|
|
|
$featureNum = ('{0:000}' -f $Number)
|
|
$branchName = "$featureNum-$branchSuffix"
|
|
|
|
# GitHub enforces a 244-byte limit on branch names
|
|
# Validate and truncate if necessary
|
|
$maxBranchLength = 244
|
|
if ($branchName.Length -gt $maxBranchLength) {
|
|
# Calculate how much we need to trim from suffix
|
|
# Account for: feature number (3) + hyphen (1) = 4 chars
|
|
$maxSuffixLength = $maxBranchLength - 4
|
|
|
|
# Truncate suffix
|
|
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
|
# Remove trailing hyphen if truncation created one
|
|
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
|
|
|
$originalBranchName = $branchName
|
|
$branchName = "$featureNum-$truncatedSuffix"
|
|
|
|
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
|
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
|
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
|
}
|
|
|
|
if ($hasGit) {
|
|
$branchCreated = $false
|
|
try {
|
|
git checkout -q -b $branchName 2>$null | Out-Null
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$branchCreated = $true
|
|
}
|
|
} catch {
|
|
# Exception during git command
|
|
}
|
|
|
|
if (-not $branchCreated) {
|
|
# Check if branch already exists
|
|
$existingBranch = git branch --list $branchName 2>$null
|
|
if ($existingBranch) {
|
|
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
|
exit 1
|
|
} else {
|
|
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
|
exit 1
|
|
}
|
|
}
|
|
} else {
|
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
|
}
|
|
|
|
$featureDir = Join-Path $specsDir $branchName
|
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
|
|
|
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
|
$specFile = Join-Path $featureDir 'spec.md'
|
|
if ($template -and (Test-Path $template)) {
|
|
Copy-Item $template $specFile -Force
|
|
} else {
|
|
New-Item -ItemType File -Path $specFile | Out-Null
|
|
}
|
|
|
|
# Set the SPECIFY_FEATURE environment variable for the current session
|
|
$env:SPECIFY_FEATURE = $branchName
|
|
|
|
if ($Json) {
|
|
$obj = [PSCustomObject]@{
|
|
BRANCH_NAME = $branchName
|
|
SPEC_FILE = $specFile
|
|
FEATURE_NUM = $featureNum
|
|
HAS_GIT = $hasGit
|
|
}
|
|
$obj | ConvertTo-Json -Compress
|
|
} else {
|
|
Write-Output "BRANCH_NAME: $branchName"
|
|
Write-Output "SPEC_FILE: $specFile"
|
|
Write-Output "FEATURE_NUM: $featureNum"
|
|
Write-Output "HAS_GIT: $hasGit"
|
|
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
|
}
|
|
|