mirror of
https://github.com/github/spec-kit.git
synced 2026-03-21 04:43:08 +00:00
Compare commits
8 Commits
6003a232d8
...
chore/rele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e733cd563 | ||
|
|
ec60c5b2fe | ||
|
|
e56d37db8c | ||
|
|
33e853e9c9 | ||
|
|
929fab5d98 | ||
|
|
56095f06d2 | ||
|
|
2632a0f52d | ||
|
|
4ab91fbadf |
@@ -8,15 +8,15 @@ run_command() {
|
||||
local command_to_run="$*"
|
||||
local output
|
||||
local exit_code
|
||||
|
||||
|
||||
# Capture all output (stdout and stderr)
|
||||
output=$(eval "$command_to_run" 2>&1) || exit_code=$?
|
||||
exit_code=${exit_code:-0}
|
||||
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo -e "\033[0;31m[ERROR] Command failed (Exit Code $exit_code): $command_to_run\033[0m" >&2
|
||||
echo -e "\033[0;31m$output\033[0m" >&2
|
||||
|
||||
|
||||
exit $exit_code
|
||||
fi
|
||||
}
|
||||
@@ -53,7 +53,7 @@ echo "✅ Done"
|
||||
|
||||
echo -e "\n🤖 Installing Kiro CLI..."
|
||||
# https://kiro.dev/docs/cli/
|
||||
KIRO_INSTALLER_URL="https://cli.kiro.dev/install"
|
||||
KIRO_INSTALLER_URL="https://kiro.dev/install.sh"
|
||||
KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"
|
||||
KIRO_INSTALLER_PATH="$(mktemp)"
|
||||
|
||||
@@ -80,6 +80,11 @@ fi
|
||||
run_command "$kiro_binary --help > /dev/null"
|
||||
echo "✅ Done"
|
||||
|
||||
echo -e "\n🤖 Installing Kimi CLI..."
|
||||
# https://code.kimi.com
|
||||
run_command "pipx install kimi-cli"
|
||||
echo "✅ Done"
|
||||
|
||||
echo -e "\n🤖 Installing CodeBuddy CLI..."
|
||||
run_command "npm install -g @tencent-ai/codebuddy-code@latest"
|
||||
echo "✅ Done"
|
||||
|
||||
2
.github/workflows/scripts/create-github-release.sh
vendored
Normal file → Executable file
2
.github/workflows/scripts/create-github-release.sh
vendored
Normal file → Executable file
@@ -56,6 +56,8 @@ gh release create "$VERSION" \
|
||||
.genreleases/spec-kit-template-bob-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-vibe-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
|
||||
--title "Spec Kit Templates - $VERSION_NO_V" \
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
.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, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, generic
|
||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, generic
|
||||
|
||||
.PARAMETER Scripts
|
||||
Comma or space separated subset of script types to build (default: both)
|
||||
@@ -33,10 +33,10 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)]
|
||||
[string]$Version,
|
||||
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Agents = "",
|
||||
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Scripts = ""
|
||||
)
|
||||
@@ -60,7 +60,7 @@ 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/'
|
||||
@@ -75,55 +75,55 @@ function Generate-Commands {
|
||||
[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
|
||||
@@ -135,7 +135,7 @@ function Generate-Commands {
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if ($inFrontmatter) {
|
||||
if ($line -match '^(scripts|agent_scripts):$') {
|
||||
$skipScripts = $true
|
||||
@@ -148,20 +148,20 @@ function Generate-Commands {
|
||||
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 '\\', '\\'
|
||||
@@ -183,15 +183,15 @@ function Generate-CopilotPrompts {
|
||||
[string]$AgentsDir,
|
||||
[string]$PromptsDir
|
||||
)
|
||||
|
||||
|
||||
New-Item -ItemType Directory -Path $PromptsDir -Force | Out-Null
|
||||
|
||||
|
||||
$agentFiles = Get-ChildItem -Path "$AgentsDir/speckit.*.agent.md" -File -ErrorAction SilentlyContinue
|
||||
|
||||
|
||||
foreach ($agentFile in $agentFiles) {
|
||||
$basename = $agentFile.Name -replace '\.agent\.md$', ''
|
||||
$promptFile = Join-Path $PromptsDir "$basename.prompt.md"
|
||||
|
||||
|
||||
$content = @"
|
||||
---
|
||||
agent: $basename
|
||||
@@ -201,31 +201,118 @@ agent: $basename
|
||||
}
|
||||
}
|
||||
|
||||
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format.
|
||||
# Kimi CLI discovers skills as directories containing a SKILL.md file,
|
||||
# invoked with /skill:<name> (e.g. /skill:speckit.specify).
|
||||
function New-KimiSkills {
|
||||
param(
|
||||
[string]$SkillsDir,
|
||||
[string]$ScriptVariant
|
||||
)
|
||||
|
||||
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($template in $templates) {
|
||||
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
|
||||
$skillName = "speckit.$name"
|
||||
$skillDir = Join-Path $SkillsDir $skillName
|
||||
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
|
||||
|
||||
$fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n"
|
||||
|
||||
# Extract description
|
||||
$description = "Spec Kit: $name workflow"
|
||||
if ($fileContent -match '(?m)^description:\s*(.+)$') {
|
||||
$description = $matches[1]
|
||||
}
|
||||
|
||||
# Extract script command
|
||||
$scriptCommand = "(Missing script command for $ScriptVariant)"
|
||||
if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") {
|
||||
$scriptCommand = $matches[1]
|
||||
}
|
||||
|
||||
# Extract agent_script command from frontmatter if present
|
||||
$agentScriptCommand = ""
|
||||
if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") {
|
||||
$agentScriptCommand = $matches[1].Trim()
|
||||
}
|
||||
|
||||
# Replace {SCRIPT}, strip scripts sections, rewrite paths
|
||||
$body = $fileContent -replace '\{SCRIPT\}', $scriptCommand
|
||||
if (-not [string]::IsNullOrEmpty($agentScriptCommand)) {
|
||||
$body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand
|
||||
}
|
||||
|
||||
$lines = $body -split "`n"
|
||||
$outputLines = @()
|
||||
$inFrontmatter = $false
|
||||
$skipScripts = $false
|
||||
$dashCount = 0
|
||||
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match '^---$') {
|
||||
$outputLines += $line
|
||||
$dashCount++
|
||||
$inFrontmatter = ($dashCount -eq 1)
|
||||
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"
|
||||
$body = $body -replace '\{ARGS\}', '$ARGUMENTS'
|
||||
$body = $body -replace '__AGENT__', 'kimi'
|
||||
$body = Rewrite-Paths -Content $body
|
||||
|
||||
# Strip existing frontmatter, keep only body
|
||||
$templateBody = ""
|
||||
$fmCount = 0
|
||||
$inBody = $false
|
||||
foreach ($line in ($body -split "`n")) {
|
||||
if ($line -match '^---$') {
|
||||
$fmCount++
|
||||
if ($fmCount -eq 2) { $inBody = $true }
|
||||
continue
|
||||
}
|
||||
if ($inBody) { $templateBody += "$line`n" }
|
||||
}
|
||||
|
||||
$skillContent = "---`nname: `"$skillName`"`ndescription: `"$description`"`n---`n`n$templateBody"
|
||||
Set-Content -Path (Join-Path $skillDir "SKILL.md") -Value $skillContent -NoNewline
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
@@ -240,18 +327,17 @@ function Build-Variant {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 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 {
|
||||
@@ -263,7 +349,7 @@ function Build-Variant {
|
||||
}
|
||||
Write-Host "Copied templates -> .specify/templates"
|
||||
}
|
||||
|
||||
|
||||
# Generate agent-specific command files
|
||||
switch ($Agent) {
|
||||
'claude' {
|
||||
@@ -280,12 +366,10 @@ function Build-Variant {
|
||||
'copilot' {
|
||||
$agentsDir = Join-Path $baseDir ".github/agents"
|
||||
Generate-Commands -Agent 'copilot' -Extension 'agent.md' -ArgFormat '$ARGUMENTS' -OutputDir $agentsDir -ScriptVariant $Script
|
||||
|
||||
# Generate companion prompt files
|
||||
|
||||
$promptsDir = Join-Path $baseDir ".github/prompts"
|
||||
Generate-CopilotPrompts -AgentsDir $agentsDir -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") {
|
||||
@@ -361,19 +445,24 @@ function Build-Variant {
|
||||
$cmdDir = Join-Path $baseDir ".agent/workflows"
|
||||
Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'generic' {
|
||||
$cmdDir = Join-Path $baseDir ".speckit/commands"
|
||||
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'vibe' {
|
||||
$cmdDir = Join-Path $baseDir ".vibe/prompts"
|
||||
Generate-Commands -Agent 'vibe' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'kimi' {
|
||||
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||
New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script
|
||||
}
|
||||
'generic' {
|
||||
$cmdDir = Join-Path $baseDir ".speckit/commands"
|
||||
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
default {
|
||||
throw "Unsupported agent '$Agent'."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Create zip archive
|
||||
$zipFile = Join-Path $GenReleasesDir "spec-kit-template-${Agent}-${Script}-${Version}.zip"
|
||||
Compress-Archive -Path "$baseDir/*" -DestinationPath $zipFile -Force
|
||||
@@ -381,17 +470,16 @@ function Build-Variant {
|
||||
}
|
||||
|
||||
# Define all agents and scripts
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'generic')
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'generic')
|
||||
$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
|
||||
}
|
||||
@@ -402,7 +490,7 @@ function Validate-Subset {
|
||||
[string[]]$Allowed,
|
||||
[string[]]$Items
|
||||
)
|
||||
|
||||
|
||||
$ok = $true
|
||||
foreach ($item in $Items) {
|
||||
if ($item -notin $Allowed) {
|
||||
|
||||
120
.github/workflows/scripts/create-release-packages.sh
vendored
120
.github/workflows/scripts/create-release-packages.sh
vendored
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
||||
# Version argument should include leading 'v'.
|
||||
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli generic (default: all)
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic (default: all)
|
||||
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
||||
# Examples:
|
||||
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
||||
@@ -45,19 +45,19 @@ generate_commands() {
|
||||
[[ -f "$template" ]] || continue
|
||||
local name description script_command agent_script_command body
|
||||
name=$(basename "$template" .md)
|
||||
|
||||
|
||||
# Normalize line endings
|
||||
file_content=$(tr -d '\r' < "$template")
|
||||
|
||||
|
||||
# Extract description and script command from YAML frontmatter
|
||||
description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}')
|
||||
script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}')
|
||||
|
||||
|
||||
if [[ -z $script_command ]]; then
|
||||
echo "Warning: no script command found for $script_variant in $template" >&2
|
||||
script_command="(Missing script command for $script_variant)"
|
||||
fi
|
||||
|
||||
|
||||
# Extract agent_script command from YAML frontmatter if present
|
||||
agent_script_command=$(printf '%s\n' "$file_content" | awk '
|
||||
/^agent_scripts:$/ { in_agent_scripts=1; next }
|
||||
@@ -68,15 +68,15 @@ generate_commands() {
|
||||
}
|
||||
in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 }
|
||||
')
|
||||
|
||||
|
||||
# Replace {SCRIPT} placeholder with the script command
|
||||
body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g")
|
||||
|
||||
|
||||
# Replace {AGENT_SCRIPT} placeholder with the agent script command if found
|
||||
if [[ -n $agent_script_command ]]; then
|
||||
body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g")
|
||||
fi
|
||||
|
||||
|
||||
# Remove the scripts: and agent_scripts: sections from frontmatter while preserving YAML structure
|
||||
body=$(printf '%s\n' "$body" | awk '
|
||||
/^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next }
|
||||
@@ -86,10 +86,10 @@ generate_commands() {
|
||||
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
||||
{ print }
|
||||
')
|
||||
|
||||
|
||||
# Apply other substitutions
|
||||
body=$(printf '%s\n' "$body" | sed "s/{ARGS}/$arg_format/g" | sed "s/__AGENT__/$agent/g" | rewrite_paths)
|
||||
|
||||
|
||||
case $ext in
|
||||
toml)
|
||||
body=$(printf '%s\n' "$body" | sed 's/\\/\\\\/g')
|
||||
@@ -105,15 +105,14 @@ generate_commands() {
|
||||
generate_copilot_prompts() {
|
||||
local agents_dir=$1 prompts_dir=$2
|
||||
mkdir -p "$prompts_dir"
|
||||
|
||||
|
||||
# Generate a .prompt.md file for each .agent.md file
|
||||
for agent_file in "$agents_dir"/speckit.*.agent.md; do
|
||||
[[ -f "$agent_file" ]] || continue
|
||||
|
||||
|
||||
local basename=$(basename "$agent_file" .agent.md)
|
||||
local prompt_file="$prompts_dir/${basename}.prompt.md"
|
||||
|
||||
# Create prompt file with agent frontmatter
|
||||
|
||||
cat > "$prompt_file" <<EOF
|
||||
---
|
||||
agent: ${basename}
|
||||
@@ -122,41 +121,104 @@ EOF
|
||||
done
|
||||
}
|
||||
|
||||
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format.
|
||||
# Kimi CLI discovers skills as directories containing a SKILL.md file,
|
||||
# invoked with /skill:<name> (e.g. /skill:speckit.specify).
|
||||
create_kimi_skills() {
|
||||
local skills_dir="$1"
|
||||
local script_variant="$2"
|
||||
|
||||
for template in templates/commands/*.md; do
|
||||
[[ -f "$template" ]] || continue
|
||||
local name
|
||||
name=$(basename "$template" .md)
|
||||
local skill_name="speckit.${name}"
|
||||
local skill_dir="${skills_dir}/${skill_name}"
|
||||
mkdir -p "$skill_dir"
|
||||
|
||||
local file_content
|
||||
file_content=$(tr -d '\r' < "$template")
|
||||
|
||||
# Extract description from frontmatter
|
||||
local description
|
||||
description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}')
|
||||
[[ -z "$description" ]] && description="Spec Kit: ${name} workflow"
|
||||
|
||||
# Extract script command
|
||||
local script_command
|
||||
script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}')
|
||||
[[ -z "$script_command" ]] && script_command="(Missing script command for $script_variant)"
|
||||
|
||||
# Extract agent_script command from frontmatter if present
|
||||
local agent_script_command
|
||||
agent_script_command=$(printf '%s\n' "$file_content" | awk '
|
||||
/^agent_scripts:$/ { in_agent_scripts=1; next }
|
||||
in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ {
|
||||
sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 }
|
||||
')
|
||||
|
||||
# Build body: replace placeholders, strip scripts sections, rewrite paths
|
||||
local body
|
||||
body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g")
|
||||
if [[ -n $agent_script_command ]]; then
|
||||
body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g")
|
||||
fi
|
||||
body=$(printf '%s\n' "$body" | awk '
|
||||
/^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next }
|
||||
in_frontmatter && /^scripts:$/ { skip_scripts=1; next }
|
||||
in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next }
|
||||
in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 }
|
||||
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
||||
{ print }
|
||||
')
|
||||
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed 's/__AGENT__/kimi/g' | rewrite_paths)
|
||||
|
||||
# Strip existing frontmatter and prepend Kimi frontmatter
|
||||
local template_body
|
||||
template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found')
|
||||
|
||||
{
|
||||
printf -- '---\n'
|
||||
printf 'name: "%s"\n' "$skill_name"
|
||||
printf 'description: "%s"\n' "$description"
|
||||
printf -- '---\n\n'
|
||||
printf '%s\n' "$template_body"
|
||||
} > "$skill_dir/SKILL.md"
|
||||
done
|
||||
}
|
||||
|
||||
build_variant() {
|
||||
local agent=$1 script=$2
|
||||
local base_dir="$GENRELEASES_DIR/sdd-${agent}-package-${script}"
|
||||
echo "Building $agent ($script) package..."
|
||||
mkdir -p "$base_dir"
|
||||
|
||||
|
||||
# Copy base structure but filter scripts by variant
|
||||
SPEC_DIR="$base_dir/.specify"
|
||||
mkdir -p "$SPEC_DIR"
|
||||
|
||||
|
||||
[[ -d memory ]] && { cp -r memory "$SPEC_DIR/"; echo "Copied memory -> .specify"; }
|
||||
|
||||
|
||||
# Only copy the relevant script variant directory
|
||||
if [[ -d scripts ]]; then
|
||||
mkdir -p "$SPEC_DIR/scripts"
|
||||
case $script in
|
||||
sh)
|
||||
[[ -d scripts/bash ]] && { cp -r scripts/bash "$SPEC_DIR/scripts/"; echo "Copied scripts/bash -> .specify/scripts"; }
|
||||
# Copy any script files that aren't in variant-specific directories
|
||||
find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true
|
||||
;;
|
||||
ps)
|
||||
[[ -d scripts/powershell ]] && { cp -r scripts/powershell "$SPEC_DIR/scripts/"; echo "Copied scripts/powershell -> .specify/scripts"; }
|
||||
# Copy any script files that aren't in variant-specific directories
|
||||
find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
|
||||
[[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" -exec cp --parents {} "$SPEC_DIR"/ \; ; echo "Copied templates -> .specify/templates"; }
|
||||
|
||||
# NOTE: We substitute {ARGS} internally. Outward tokens differ intentionally:
|
||||
# * Markdown/prompt (claude, copilot, cursor-agent, opencode): $ARGUMENTS
|
||||
# * TOML (gemini, qwen, tabnine): {{args}}
|
||||
# This keeps formats readable without extra abstraction.
|
||||
|
||||
case $agent in
|
||||
claude)
|
||||
@@ -169,9 +231,7 @@ build_variant() {
|
||||
copilot)
|
||||
mkdir -p "$base_dir/.github/agents"
|
||||
generate_commands copilot agent.md "\$ARGUMENTS" "$base_dir/.github/agents" "$script"
|
||||
# Generate companion prompt files
|
||||
generate_copilot_prompts "$base_dir/.github/agents" "$base_dir/.github/prompts"
|
||||
# Create VS Code workspace settings
|
||||
mkdir -p "$base_dir/.vscode"
|
||||
[[ -f templates/vscode-settings.json ]] && cp templates/vscode-settings.json "$base_dir/.vscode/settings.json"
|
||||
;;
|
||||
@@ -228,6 +288,9 @@ build_variant() {
|
||||
vibe)
|
||||
mkdir -p "$base_dir/.vibe/prompts"
|
||||
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
||||
kimi)
|
||||
mkdir -p "$base_dir/.kimi/skills"
|
||||
create_kimi_skills "$base_dir/.kimi/skills" "$script" ;;
|
||||
generic)
|
||||
mkdir -p "$base_dir/.speckit/commands"
|
||||
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
||||
@@ -237,11 +300,10 @@ build_variant() {
|
||||
}
|
||||
|
||||
# Determine agent list
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli generic)
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic)
|
||||
ALL_SCRIPTS=(sh ps)
|
||||
|
||||
norm_list() {
|
||||
# convert comma+space separated -> line separated unique while preserving order of first occurrence
|
||||
tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?"\n":"") $i);out=1}}}END{printf("\n")}'
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
||||
| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI |
|
||||
| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI |
|
||||
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
|
||||
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
|
||||
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
||||
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
||||
|
||||
@@ -324,6 +325,7 @@ Require a command-line tool to be installed:
|
||||
- **Amp**: `amp` CLI
|
||||
- **SHAI**: `shai` CLI
|
||||
- **Tabnine CLI**: `tabnine` CLI
|
||||
- **Kimi Code**: `kimi` CLI
|
||||
|
||||
### IDE-Based Agents
|
||||
|
||||
@@ -337,7 +339,7 @@ Work within integrated development environments:
|
||||
|
||||
### Markdown Format
|
||||
|
||||
Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob
|
||||
Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code
|
||||
|
||||
**Standard format:**
|
||||
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -7,25 +7,18 @@ Recent 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.2.1] - 2026-03-09
|
||||
|
||||
### Added
|
||||
|
||||
- feat(templates): Pluggable template system with template packs, catalog, and resolver
|
||||
- Template pack manifest (`template-pack.yml`) with validation for artifact, command, and script types
|
||||
- `TemplatePackManifest`, `TemplatePackRegistry`, `TemplatePackManager`, `TemplateCatalog`, `TemplateResolver` classes in `src/specify_cli/templates.py`
|
||||
- CLI commands: `specify template search`, `specify template add`, `specify template list`, `specify template remove`, `specify template resolve`
|
||||
- `--template` option for `specify init` to install template packs during initialization
|
||||
- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts
|
||||
- Template resolution priority stack: overrides → packs → extensions → core
|
||||
- Template catalog files (`templates/catalog.json`, `templates/catalog.community.json`)
|
||||
- Template pack scaffold directory (`templates/template/`)
|
||||
- Scripts updated to use template resolution instead of hardcoded paths
|
||||
|
||||
## [0.2.0] - 2026-03-09
|
||||
## [0.2.1] - 2026-03-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Added February 2026 newsletter (#1812)
|
||||
- feat: add Kimi Code CLI agent support (#1790)
|
||||
- docs: fix broken links in quickstart guide (#1759) (#1797)
|
||||
- docs: add catalog cli help documentation (#1793) (#1794)
|
||||
- fix: use quiet checkout to avoid exception on git checkout (#1792)
|
||||
- feat(extensions): support .extensionignore to exclude files during install (#1781)
|
||||
- feat: add Codex support for extension command registration (#1767)
|
||||
- chore: bump version to 0.2.0 (#1786)
|
||||
- fix: sync agent list comments with actual supported agents (#1785)
|
||||
- feat(extensions): support multiple active catalogs simultaneously (#1720)
|
||||
- Pavel/add tabnine cli support (#1503)
|
||||
@@ -59,6 +52,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
|
||||
|
||||
## [0.2.0] - 2026-03-09
|
||||
|
||||
### Changed
|
||||
|
||||
- feat: add Kimi Code CLI agent support
|
||||
- fix: sync agent list comments with actual supported agents (#1785)
|
||||
- feat(extensions): support multiple active catalogs simultaneously (#1720)
|
||||
- Pavel/add tabnine cli support (#1503)
|
||||
- Add Understanding extension to community catalog (#1778)
|
||||
- Add ralph extension to community catalog (#1780)
|
||||
- Update README with project initialization instructions (#1772)
|
||||
- feat: add review extension to community catalog (#1775)
|
||||
- Add fleet extension to community catalog (#1771)
|
||||
- Integration of Mistral vibe support into speckit (#1725)
|
||||
- fix: Remove duplicate options in specify.md (#1765)
|
||||
- fix: use global branch numbering instead of per-short-name detection (#1757)
|
||||
- Add Community Walkthroughs section to README (#1766)
|
||||
- feat(extensions): add Jira Integration to community catalog (#1764)
|
||||
- Add Azure DevOps Integration extension to community catalog (#1734)
|
||||
- Fix docs: update Antigravity link and add initialization example (#1748)
|
||||
- fix: wire after_tasks and after_implement hook events into command templates (#1702)
|
||||
- make c ignores consistent with c++ (#1747)
|
||||
- chore: bump version to 0.1.13 (#1746)
|
||||
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
|
||||
- feat: add verify extension to community catalog (#1726)
|
||||
- Add Retrospective Extension to community catalog README table (#1741)
|
||||
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
|
||||
- fix: correct Copilot extension command registration (#1724)
|
||||
- fix(implement): remove Makefile from C ignore patterns (#1558)
|
||||
- Add sync extension to community catalog (#1728)
|
||||
- fix(checklist): clarify file handling behavior for append vs create (#1556)
|
||||
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
|
||||
- chore: bump version to 0.1.12 (#1737)
|
||||
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
|
||||
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
||||
|
||||
## [0.1.14] - 2026-03-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -179,6 +179,7 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | |
|
||||
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | |
|
||||
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | |
|
||||
| [Kimi Code](https://code.kimi.com/) | ✅ | |
|
||||
| [Windsurf](https://windsurf.com/) | ✅ | |
|
||||
| [Antigravity (agy)](https://antigravity.google/) | ✅ | |
|
||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||
@@ -192,14 +193,14 @@ The `specify` command supports the following options:
|
||||
| Command | Description |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `init` | Initialize a new Specify project from the latest template |
|
||||
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`) |
|
||||
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`) |
|
||||
|
||||
### `specify init` Arguments & Options
|
||||
|
||||
| Argument/Option | Type | Description |
|
||||
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
|
||||
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, or `generic` (requires `--ai-commands-dir`) |
|
||||
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, or `generic` (requires `--ai-commands-dir`) |
|
||||
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
|
||||
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
|
||||
|
||||
@@ -173,6 +173,6 @@ Finally, implement the solution:
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read the [complete methodology](../spec-driven.md) for in-depth guidance
|
||||
- Check out [more examples](../templates) in the repository
|
||||
- Read the [complete methodology](https://github.com/github/spec-kit/blob/main/spec-driven.md) for in-depth guidance
|
||||
- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository
|
||||
- Explore the [source code on GitHub](https://github.com/github/spec-kit)
|
||||
|
||||
@@ -332,6 +332,67 @@ echo "$config"
|
||||
|
||||
---
|
||||
|
||||
## Excluding Files with `.extensionignore`
|
||||
|
||||
Extension authors can create a `.extensionignore` file in the extension root to exclude files and folders from being copied when a user installs the extension with `specify extension add`. This is useful for keeping development-only files (tests, CI configs, docs source, etc.) out of the installed copy.
|
||||
|
||||
### Format
|
||||
|
||||
The file uses `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library:
|
||||
|
||||
- Blank lines are ignored
|
||||
- Lines starting with `#` are comments
|
||||
- `*` matches anything **except** `/` (does not cross directory boundaries)
|
||||
- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`)
|
||||
- `?` matches any single character except `/`
|
||||
- A trailing `/` restricts a pattern to directories only
|
||||
- Patterns containing `/` (other than a trailing slash) are anchored to the extension root
|
||||
- Patterns without `/` match at any depth in the tree
|
||||
- `!` negates a previously excluded pattern (re-includes a file)
|
||||
- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility
|
||||
- The `.extensionignore` file itself is always excluded automatically
|
||||
|
||||
### Example
|
||||
|
||||
```gitignore
|
||||
# .extensionignore
|
||||
|
||||
# Development files
|
||||
tests/
|
||||
.github/
|
||||
.gitignore
|
||||
|
||||
# Build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
dist/
|
||||
|
||||
# Documentation source (keep only the built README)
|
||||
docs/
|
||||
CONTRIBUTING.md
|
||||
```
|
||||
|
||||
### Pattern Matching
|
||||
|
||||
| Pattern | Matches | Does NOT match |
|
||||
|---------|---------|----------------|
|
||||
| `*.pyc` | Any `.pyc` file in any directory | — |
|
||||
| `tests/` | The `tests` directory (and all its contents) | A file named `tests` |
|
||||
| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) |
|
||||
| `.env` | The `.env` file at any level | — |
|
||||
| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — |
|
||||
| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — |
|
||||
|
||||
### Unsupported Features
|
||||
|
||||
The following `.gitignore` features are **not applicable** in this context:
|
||||
|
||||
- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories)
|
||||
- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here
|
||||
- **Negation inside excluded directories**: Because file copying uses `shutil.copytree`, excluding a directory prevents recursion into it entirely. A negation pattern cannot re-include a file inside a directory that was itself excluded. For example, the combination `tests/` followed by `!tests/important.py` will **not** preserve `tests/important.py` — the `tests/` directory is skipped at the root level and its contents are never evaluated. To work around this, exclude the directory's contents individually instead of the directory itself (e.g., `tests/*.pyc` and `tests/.cache/` rather than `tests/`).
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Extension ID
|
||||
|
||||
@@ -432,6 +432,26 @@ Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simul
|
||||
specify extension catalog list
|
||||
```
|
||||
|
||||
### Managing Catalogs via CLI
|
||||
|
||||
You can view the main catalog management commands using `--help`:
|
||||
|
||||
```text
|
||||
specify extension catalog --help
|
||||
|
||||
Usage: specify extension catalog [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Manage extension catalogs
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────────╮
|
||||
│ --help Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Commands ───────────────────────────────────────────────────────────────────────╮
|
||||
│ list List all active extension catalogs. │
|
||||
│ add Add a catalog to .specify/extension-catalogs.yml. │
|
||||
│ remove Remove a catalog from .specify/extension-catalogs.yml. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### Adding a Catalog (Project-scoped)
|
||||
|
||||
```bash
|
||||
|
||||
54
newsletters/2026-February.md
Normal file
54
newsletters/2026-February.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Spec Kit - February 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in February 2026. Versions v0.1.7 through v0.1.13 shipped during the month, addressing bugs and adding features including a dual-catalog extension system and additional agent integrations. Community activity included blog posts, tutorials, and meetup sessions. A category summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (Feb 2026)** | **Community & Content** | **Roadmap & Next** |
|
||||
| --- | --- | --- |
|
||||
| Versions **v0.1.7** through **v0.1.13** shipped with bug fixes and features, including a **dual-catalog extension system** and new agent integrations. Over 300 issues were closed (of ~800 filed). The repo reached 71k stars and 6.4k forks. [\[github.com\]](https://github.com/github/spec-kit/releases) [\[github.com\]](https://github.com/github/spec-kit/issues) [\[rywalker.com\]](https://rywalker.com/research/github-spec-kit) | Eduardo Luz published a LinkedIn article on SDD and Spec Kit [\[linkedin.com\]](https://www.linkedin.com/pulse/specification-driven-development-sdd-github-spec-kit-elevating-luz-tojmc?tl=en). Erick Matsen blogged a walkthrough of building a bioinformatics pipeline with Spec Kit [\[matsen.fredhutch.org\]](https://matsen.fredhutch.org/general/2026/02/10/spec-kit-walkthrough.html). Microsoft MVP [Eric Boyd](https://ericboyd.com/) (not the Microsoft AI Platform VP of the same name) presented at the Cleveland .NET User Group [\[ericboyd.com\]](https://ericboyd.com/events/cleveland-csharp-user-group-february-25-2026-spec-driven-development-sdd-github-spec-kit). | **v0.2.0** was released in early March, consolidating February's work. It added extensions for Jira and Azure DevOps, community plugin support, and agents for Tabnine CLI and Kiro CLI [\[github.com\]](https://github.com/github/spec-kit/releases). Future work includes spec lifecycle management and progress toward a stable 1.0 release [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html). |
|
||||
|
||||
***
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
Spec Kit released versions **v0.1.7** through **v0.1.13** during February. Version 0.1.7 (early February) updated documentation for the newly introduced **dual-catalog extension system**, which allows both core and community extension catalogs to coexist. Subsequent patches (0.1.8, 0.1.9, etc.) bumped dependencies such as GitHub Actions versions and resolved minor issues. **v0.1.10** fixed YAML front-matter handling in generated files. By late February, **v0.1.12** and **v0.1.13** shipped with additional fixes in preparation for the next version bump. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
The main architectural addition was the **modular extension system** with separate "core" and "community" extension catalogs for third-party add-ons. Multiple community-contributed extensions were merged during the month, including a **Jira extension** for issue tracker integration, an **Azure DevOps extension**, and utility extensions for code review, retrospective documentation, and CI/CD sync. The pending 0.2.0 release changelog lists over a dozen changes from February, including the extension additions and support for **multiple agent catalogs concurrently**. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
By end of February, **over 330 issues/feature requests had been closed on GitHub** (out of ~870 filed to date). External contributors submitted pull requests including the **Tabnine CLI support**, which was merged in late February. The repository reached ~71k stars and crossed 6,000 forks. [\[github.com\]](https://github.com/github/spec-kit/issues) [\[github.com\]](https://github.com/github/spec-kit/releases) [\[rywalker.com\]](https://rywalker.com/research/github-spec-kit)
|
||||
|
||||
On the stability side, February's work focused on tightening core workflows and fixing edge-case bugs in the specification, planning, and task-generation commands. The team addressed file-handling issues (e.g., clarifying how output files are created/appended) and improved the reliability of the automated release pipeline. The project also added **Kiro CLI** to the supported agent list and updated integration scripts for Cursor and Code Interpreter, bringing the total number of supported AI coding assistants to over 20. [\[github.com\]](https://github.com/github/spec-kit/releases) [\[github.com\]](https://github.com/github/spec-kit)
|
||||
|
||||
## Community & Content
|
||||
|
||||
**Eduardo Luz** published a LinkedIn article on Feb 15 titled *"Specification Driven Development (SDD) and the GitHub Spec Kit: Elevating Software Engineering."* The article draws on his experience as a senior engineer to describe common causes of technical debt and inconsistent designs, and how SDD addresses them. It walks through Spec Kit's **four-layer approach** (Constitution, Design, Tasks, Implementation) and discusses treating specifications as a source of truth. The post generated discussion among software architects on LinkedIn about reducing misunderstandings and rework through spec-driven workflows. [\[linkedin.com\]](https://www.linkedin.com/pulse/specification-driven-development-sdd-github-spec-kit-elevating-luz-tojmc?tl=en)
|
||||
|
||||
**Erick Matsen** (Fred Hutchinson Cancer Center) posted a detailed walkthrough on Feb 10 titled *"Spec-Driven Development with spec-kit."* He describes building a **bioinformatics pipeline** in a single day using Spec Kit's workflow (from `speckit.constitution` to `speckit.implement`). The post includes command outputs and notes on decisions made along the way, such as refining the spec to add domain-specific requirements. He writes: "I really recommend this approach. This feels like the way software development should be." [\[matsen.fredhutch.org\]](https://matsen.fredhutch.org/general/2026/02/10/spec-kit-walkthrough.html) [\[github.com\]](https://github.com/mnriem/spec-kit-dotnet-cli-demo)
|
||||
|
||||
Several other tutorials and guides appeared during the month. An article on *IntuitionLabs* (updated Feb 21) provided a guide to Spec Kit covering the philosophy behind SDD and a walkthrough of the four-phase workflow with examples. A piece by Ry Walker (Feb 22) summarized key aspects of Spec Kit, noting its agent-agnostic design and 71k-star count. Microsoft's Developer Blog post from late 2025 (*"Diving Into Spec-Driven Development with GitHub Spec Kit"* by Den Delimarsky) continued to circulate among new users. [\[intuitionlabs.ai\]](https://intuitionlabs.ai/articles/spec-driven-development-spec-kit) [\[rywalker.com\]](https://rywalker.com/research/github-spec-kit)
|
||||
|
||||
On **Feb 25**, the Cleveland C# .NET User Group hosted a session titled *"Spec Driven Development with GitHub Spec Kit."* The talk was delivered by Microsoft MVP **[Eric Boyd](https://ericboyd.com/)** (Cleveland-based .NET developer; not to be confused with the Microsoft AI Platform VP of the same name). Boyd covered how specs change an AI coding assistant's output, patterns for iterating and refining specs over multiple cycles, and moving from ad-hoc prompting to a repeatable spec-driven workflow. Other groups, including GDG Madison, also listed sessions on spec-driven development in late February and early March. [\[ericboyd.com\]](https://ericboyd.com/events/cleveland-csharp-user-group-february-25-2026-spec-driven-development-sdd-github-spec-kit)
|
||||
|
||||
On GitHub, the **Spec Kit Discussions forum** saw activity around installation troubleshooting, handling multi-feature projects with Spec Kit's branching model, and feature suggestions. One thread discussed how Spec Kit treats each spec as a short-lived artifact tied to a feature branch, which led to discussion about future support for long-running "spec of record" use cases. [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)
|
||||
|
||||
## SDD Ecosystem
|
||||
|
||||
Other spec-driven development tools also saw activity in February.
|
||||
|
||||
AWS **Kiro** released version 0.10 on Feb 18 with two new spec workflows: a **Design-First** mode (starting from architecture/pseudocode to derive requirements) and a **Bugfix** mode (structured root-cause analysis producing a `bugfix.md` spec file). Kiro also added hunk-level code review for AI-generated changes and pre/post task hooks for custom automation. AWS expanded Kiro to GovCloud regions on Feb 17 for government compliance use cases. [\[kiro.dev\]](https://kiro.dev/changelog/)
|
||||
|
||||
**OpenSpec** (by Fission AI), a lightweight SDD framework, reached ~29.3k stars and nearly 2k forks. Its community published guides and comparisons during the month, including *"Spec-Driven Development Made Easy: A Practical Guide with OpenSpec."* OpenSpec emphasizes simplicity and flexibility, integrating with multiple AI coding assistants via YAML configs.
|
||||
|
||||
**Tessl** remained in private beta. As described by Thoughtworks writer Birgitta Boeckeler, Tessl pursues a **spec-as-source** model where specifications are maintained long-term and directly generate code files one-to-one, with generated code labeled as "do not edit." This contrasts with Spec Kit's current approach of creating specs per feature/branch. [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)
|
||||
|
||||
An **arXiv preprint** (January 2026) categorized SDD implementations into three levels: *spec-first*, *spec-anchored*, and *spec-as-source*. Spec Kit was identified as primarily spec-first with elements of spec-anchored. Tech media published reviews including a *Vibe Coding* "GitHub Spec Kit Review (2026)" and a blog post titled *"Putting Spec Kit Through Its Paces: Radical Idea or Reinvented Waterfall?"* which concluded that SDD with AI assistance is more iterative than traditional Waterfall. [\[intuitionlabs.ai\]](https://intuitionlabs.ai/articles/spec-driven-development-spec-kit) [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)
|
||||
|
||||
## Roadmap
|
||||
|
||||
**v0.2.0** was released on March 10, 2026, consolidating the month's work. It includes new extensions (Jira, Azure DevOps, review, sync), support for multiple extension catalogs and community plugins, and additional agent integrations (Tabnine CLI, Kiro CLI). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
Areas under discussion or in progress for future development:
|
||||
|
||||
- **Spec lifecycle management** -- supporting longer-lived specifications that can evolve across multiple iterations, rather than being tied to a single feature branch. Users have raised this in GitHub Discussions, and the concept of "spec-anchored" development is under consideration. [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)
|
||||
- **CI/CD integration** -- incorporating Spec Kit verification (e.g., `speckit.checklist` or `speckit.verify`) into pull request workflows and project management tools. February's Jira and Azure DevOps extensions are a step in this direction. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Continued agent support** -- adding integrations as new AI coding assistants emerge. The project currently supports over 20 agents and has been adding new ones (Kiro CLI, Tabnine CLI) as they become available. [\[github.com\]](https://github.com/github/spec-kit)
|
||||
- **Community ecosystem** -- the open extension model allows external contributors to add functionality directly. February's Jira and Azure DevOps plugins were community-contributed. The Spec Kit README now links to community walkthrough demos for .NET, Spring Boot, and other stacks. [\[github.com\]](https://github.com/github/spec-kit)
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"truststore>=0.10.4",
|
||||
"pyyaml>=6.0",
|
||||
"packaging>=23.0",
|
||||
"pathspec>=0.12.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -154,44 +154,3 @@ EOF
|
||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/templates/packs/<pack-id>/templates/
|
||||
# 3. .specify/extensions/<ext-id>/templates/
|
||||
# 4. .specify/templates/ (core)
|
||||
resolve_template() {
|
||||
local template_name="$1"
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
# Priority 1: Project overrides
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
[ -f "$override" ] && echo "$override" && return 0
|
||||
|
||||
# Priority 2: Installed packs (by directory order)
|
||||
local packs_dir="$base/packs"
|
||||
if [ -d "$packs_dir" ]; then
|
||||
for pack in "$packs_dir"/*/; do
|
||||
[ -d "$pack" ] || continue
|
||||
local candidate="$pack/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates
|
||||
local core="$base/${template_name}.md"
|
||||
[ -f "$core" ] && echo "$core" && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,6 @@ clean_branch_name() {
|
||||
# to searching for repository markers so the workflow still functions in repositories that
|
||||
# were initialised with --no-git.
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
@@ -297,9 +296,9 @@ fi
|
||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
export SPECIFY_FEATURE="$BRANCH_NAME"
|
||||
|
||||
@@ -37,12 +37,12 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
# Copy plan template if it exists
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
|
||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
|
||||
if [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
else
|
||||
echo "Warning: Plan template not found"
|
||||
echo "Warning: Plan template not found at $TEMPLATE"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Antigravity or Generic
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Antigravity or Generic
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
@@ -78,6 +78,7 @@ KIRO_FILE="$REPO_ROOT/AGENTS.md"
|
||||
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$REPO_ROOT/AGENTS.md"
|
||||
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
|
||||
KIMI_FILE="$REPO_ROOT/KIMI.md"
|
||||
|
||||
# Template file
|
||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||
@@ -665,12 +666,15 @@ update_specific_agent() {
|
||||
vibe)
|
||||
update_agent_file "$VIBE_FILE" "Mistral Vibe"
|
||||
;;
|
||||
kimi)
|
||||
update_agent_file "$KIMI_FILE" "Kimi Code"
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -769,6 +773,11 @@ update_all_existing_agents() {
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$KIMI_FILE" ]]; then
|
||||
update_agent_file "$KIMI_FILE" "Kimi Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
# If no agent files exist, create a default Claude file
|
||||
if [[ "$found_agent" == false ]]; then
|
||||
log_info "No existing agent files found, creating default Claude file..."
|
||||
@@ -792,7 +801,7 @@ print_summary() {
|
||||
fi
|
||||
|
||||
echo
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]"
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -135,45 +135,3 @@ function Test-DirHasFiles {
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/templates/packs/<pack-id>/templates/
|
||||
# 3. .specify/extensions/<ext-id>/templates/
|
||||
# 4. .specify/templates/ (core)
|
||||
function Resolve-Template {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Priority 1: Project overrides
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) { return $override }
|
||||
|
||||
# Priority 2: Installed packs (by directory order)
|
||||
$packsDir = Join-Path $base 'packs'
|
||||
if (Test-Path $packsDir) {
|
||||
foreach ($pack in Get-ChildItem -Path $packsDir -Directory -ErrorAction SilentlyContinue) {
|
||||
$candidate = Join-Path $pack.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) { return $core }
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
|
||||
@@ -141,9 +141,6 @@ if (-not $fallbackRoot) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Load common functions (includes Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
try {
|
||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
@@ -253,7 +250,7 @@ if ($branchName.Length -gt $maxBranchLength) {
|
||||
if ($hasGit) {
|
||||
$branchCreated = $false
|
||||
try {
|
||||
git checkout -b $branchName 2>$null | Out-Null
|
||||
git checkout -q -b $branchName 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$branchCreated = $true
|
||||
}
|
||||
@@ -279,9 +276,9 @@ if ($hasGit) {
|
||||
$featureDir = Join-Path $specsDir $branchName
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
if ($template -and (Test-Path $template)) {
|
||||
if (Test-Path $template) {
|
||||
Copy-Item $template $specFile -Force
|
||||
} else {
|
||||
New-Item -ItemType File -Path $specFile | Out-Null
|
||||
|
||||
@@ -32,12 +32,12 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GI
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
# Copy plan template if it exists, otherwise note it or create empty file
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
|
||||
if (Test-Path $template) {
|
||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
} else {
|
||||
Write-Warning "Plan template not found"
|
||||
Write-Warning "Plan template not found at $template"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
|
||||
2. Plan Data Extraction
|
||||
3. Agent File Management (create from template or update existing)
|
||||
4. Content Generation (technology stack, recent changes, timestamp)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, generic)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, generic)
|
||||
|
||||
.PARAMETER AgentType
|
||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
|
||||
#>
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','generic')]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','generic')]
|
||||
[string]$AgentType
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ $KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
|
||||
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
|
||||
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
|
||||
|
||||
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
|
||||
|
||||
@@ -406,8 +407,9 @@ function Update-SpecificAgent {
|
||||
'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' }
|
||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
|
||||
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
|
||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic'; return $false }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic'; return $false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +434,7 @@ function Update-AllExistingAgents {
|
||||
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true }
|
||||
if (-not $found) {
|
||||
Write-Info 'No existing agent files found, creating default Claude file...'
|
||||
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
|
||||
|
||||
@@ -265,6 +265,13 @@ AGENT_CONFIG = {
|
||||
"install_url": "https://github.com/mistralai/mistral-vibe",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"kimi": {
|
||||
"name": "Kimi Code",
|
||||
"folder": ".kimi/",
|
||||
"commands_subdir": "skills", # Kimi uses /skill:<name> with .kimi/skills/<name>/SKILL.md
|
||||
"install_url": "https://code.kimi.com/",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"generic": {
|
||||
"name": "Generic (bring your own agent)",
|
||||
"folder": None, # Set dynamically via --ai-commands-dir
|
||||
@@ -1188,7 +1195,12 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
# SKILL_DESCRIPTIONS lookups work.
|
||||
if command_name.startswith("speckit."):
|
||||
command_name = command_name[len("speckit."):]
|
||||
skill_name = f"speckit-{command_name}"
|
||||
# Kimi CLI discovers skills by directory name and invokes them as
|
||||
# /skill:<name> — use dot separator to match packaging convention.
|
||||
if selected_ai == "kimi":
|
||||
skill_name = f"speckit.{command_name}"
|
||||
else:
|
||||
skill_name = f"speckit-{command_name}"
|
||||
|
||||
# Create skill directory (additive — never removes existing content)
|
||||
skill_dir = skills_dir / skill_name
|
||||
@@ -1272,7 +1284,6 @@ def init(
|
||||
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
||||
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
template: str = typer.Option(None, "--template", help="Install a template pack during initialization (by pack ID)"),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project from the latest template.
|
||||
@@ -1301,7 +1312,6 @@ def init(
|
||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||
specify init --here --ai gemini --ai-skills
|
||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||
specify init my-project --ai claude --template healthcare-compliance # With template pack
|
||||
"""
|
||||
|
||||
show_banner()
|
||||
@@ -1544,27 +1554,6 @@ def init(
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
# Install template pack if specified
|
||||
if template:
|
||||
try:
|
||||
from .templates import TemplatePackManager, TemplateCatalog, TemplateError
|
||||
tmpl_manager = TemplatePackManager(project_path)
|
||||
speckit_ver = get_speckit_version()
|
||||
|
||||
# Try local directory first, then catalog
|
||||
local_path = Path(template).resolve()
|
||||
if local_path.is_dir() and (local_path / "template-pack.yml").exists():
|
||||
tmpl_manager.install_from_directory(local_path, speckit_ver)
|
||||
else:
|
||||
tmpl_catalog = TemplateCatalog(project_path)
|
||||
try:
|
||||
zip_path = tmpl_catalog.download_pack(template)
|
||||
tmpl_manager.install_from_zip(zip_path, speckit_ver)
|
||||
except TemplateError:
|
||||
console.print(f"[yellow]Warning:[/yellow] Template pack '{template}' not found in catalog. Skipping.")
|
||||
except Exception as tmpl_err:
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to install template pack: {tmpl_err}")
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except Exception as e:
|
||||
tracker.error("final", str(e))
|
||||
@@ -1802,13 +1791,6 @@ catalog_app = typer.Typer(
|
||||
)
|
||||
extension_app.add_typer(catalog_app, name="catalog")
|
||||
|
||||
template_app = typer.Typer(
|
||||
name="template",
|
||||
help="Manage spec-kit template packs",
|
||||
add_completion=False,
|
||||
)
|
||||
app.add_typer(template_app, name="template")
|
||||
|
||||
|
||||
def get_speckit_version() -> str:
|
||||
"""Get current spec-kit version."""
|
||||
@@ -1831,227 +1813,6 @@ def get_speckit_version() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ===== Template Pack Commands =====
|
||||
|
||||
|
||||
@template_app.command("list")
|
||||
def template_list():
|
||||
"""List installed template packs."""
|
||||
from .templates import TemplatePackManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = TemplatePackManager(project_root)
|
||||
installed = manager.list_installed()
|
||||
|
||||
if not installed:
|
||||
console.print("[yellow]No template packs installed.[/yellow]")
|
||||
console.print("\nInstall a template pack with:")
|
||||
console.print(" [cyan]specify template add <pack-name>[/cyan]")
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Installed Template Packs:[/bold cyan]\n")
|
||||
for pack in installed:
|
||||
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status}")
|
||||
console.print(f" {pack['description']}")
|
||||
if pack.get("tags"):
|
||||
tags_str = ", ".join(pack["tags"])
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@template_app.command("add")
|
||||
def template_add(
|
||||
pack_id: str = typer.Argument(None, help="Template pack ID to install from catalog"),
|
||||
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
||||
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
||||
):
|
||||
"""Install a template pack."""
|
||||
from .templates import (
|
||||
TemplatePackManager,
|
||||
TemplateCatalog,
|
||||
TemplateError,
|
||||
TemplateValidationError,
|
||||
TemplateCompatibilityError,
|
||||
)
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = TemplatePackManager(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
try:
|
||||
if dev:
|
||||
dev_path = Path(dev).resolve()
|
||||
if not dev_path.exists():
|
||||
console.print(f"[red]Error:[/red] Directory not found: {dev}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing template pack from [cyan]{dev_path}[/cyan]...")
|
||||
manifest = manager.install_from_directory(dev_path, speckit_version)
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
|
||||
elif from_url:
|
||||
console.print(f"Installing template pack from [cyan]{from_url}[/cyan]...")
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "template-pack.zip"
|
||||
try:
|
||||
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||
zip_path.write_bytes(response.read())
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
|
||||
elif pack_id:
|
||||
catalog = TemplateCatalog(project_root)
|
||||
pack_info = catalog.get_pack_info(pack_id)
|
||||
|
||||
if not pack_info:
|
||||
console.print(f"[red]Error:[/red] Template pack '{pack_id}' not found in catalog")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing template pack [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
|
||||
|
||||
try:
|
||||
zip_path = catalog.download_pack(pack_id)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
finally:
|
||||
if 'zip_path' in locals() and zip_path.exists():
|
||||
zip_path.unlink(missing_ok=True)
|
||||
else:
|
||||
console.print("[red]Error:[/red] Specify a template pack ID, --from URL, or --dev path")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except TemplateCompatibilityError as e:
|
||||
console.print(f"[red]Compatibility Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except TemplateValidationError as e:
|
||||
console.print(f"[red]Validation Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except TemplateError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@template_app.command("remove")
|
||||
def template_remove(
|
||||
pack_id: str = typer.Argument(..., help="Template pack ID to remove"),
|
||||
):
|
||||
"""Remove an installed template pack."""
|
||||
from .templates import TemplatePackManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = TemplatePackManager(project_root)
|
||||
|
||||
if not manager.registry.is_installed(pack_id):
|
||||
console.print(f"[red]Error:[/red] Template pack '{pack_id}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if manager.remove(pack_id):
|
||||
console.print(f"[green]✓[/green] Template pack '{pack_id}' removed successfully")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Failed to remove template pack '{pack_id}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@template_app.command("search")
|
||||
def template_search(
|
||||
query: str = typer.Argument(None, help="Search query"),
|
||||
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
|
||||
author: str = typer.Option(None, "--author", help="Filter by author"),
|
||||
):
|
||||
"""Search for template packs in the catalog."""
|
||||
from .templates import TemplateCatalog, TemplateError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalog = TemplateCatalog(project_root)
|
||||
|
||||
try:
|
||||
results = catalog.search(query=query, tag=tag, author=author)
|
||||
except TemplateError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not results:
|
||||
console.print("[yellow]No template packs found matching your criteria.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold cyan]Template Packs ({len(results)} found):[/bold cyan]\n")
|
||||
for pack in results:
|
||||
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
|
||||
console.print(f" {pack.get('description', '')}")
|
||||
if pack.get("tags"):
|
||||
tags_str = ", ".join(pack["tags"])
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@template_app.command("resolve")
|
||||
def template_resolve(
|
||||
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
|
||||
):
|
||||
"""Show which template will be resolved for a given name."""
|
||||
from .templates import TemplateResolver
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
resolver = TemplateResolver(project_root)
|
||||
result = resolver.resolve_with_source(template_name)
|
||||
|
||||
if result:
|
||||
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
||||
console.print(f" [dim](from: {result['source']})[/dim]")
|
||||
else:
|
||||
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
||||
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
||||
|
||||
|
||||
# ===== Extension Commands =====
|
||||
|
||||
|
||||
@extension_app.command("list")
|
||||
def extension_list(
|
||||
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
||||
|
||||
@@ -14,10 +14,12 @@ import zipfile
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
from typing import Optional, Dict, List, Any, Callable, Set
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
import pathspec
|
||||
|
||||
import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
@@ -280,6 +282,70 @@ class ExtensionManager:
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
self.registry = ExtensionRegistry(self.extensions_dir)
|
||||
|
||||
@staticmethod
|
||||
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
||||
"""Load .extensionignore and return an ignore function for shutil.copytree.
|
||||
|
||||
The .extensionignore file uses .gitignore-compatible patterns (one per line).
|
||||
Lines starting with '#' are comments. Blank lines are ignored.
|
||||
The .extensionignore file itself is always excluded.
|
||||
|
||||
Pattern semantics mirror .gitignore:
|
||||
- '*' matches anything except '/'
|
||||
- '**' matches zero or more directories
|
||||
- '?' matches any single character except '/'
|
||||
- Trailing '/' restricts a pattern to directories only
|
||||
- Patterns with '/' (other than trailing) are anchored to the root
|
||||
- '!' negates a previously excluded pattern
|
||||
|
||||
Args:
|
||||
source_dir: Path to the extension source directory
|
||||
|
||||
Returns:
|
||||
An ignore function compatible with shutil.copytree, or None
|
||||
if no .extensionignore file exists.
|
||||
"""
|
||||
ignore_file = source_dir / ".extensionignore"
|
||||
if not ignore_file.exists():
|
||||
return None
|
||||
|
||||
lines: List[str] = ignore_file.read_text().splitlines()
|
||||
|
||||
# Normalise backslashes in patterns so Windows-authored files work
|
||||
normalised: List[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
normalised.append(stripped.replace("\\", "/"))
|
||||
else:
|
||||
# Preserve blanks/comments so pathspec line numbers stay stable
|
||||
normalised.append(line)
|
||||
|
||||
# Always ignore the .extensionignore file itself
|
||||
normalised.append(".extensionignore")
|
||||
|
||||
spec = pathspec.GitIgnoreSpec.from_lines(normalised)
|
||||
|
||||
def _ignore(directory: str, entries: List[str]) -> Set[str]:
|
||||
ignored: Set[str] = set()
|
||||
rel_dir = Path(directory).relative_to(source_dir)
|
||||
for entry in entries:
|
||||
rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry
|
||||
# Normalise to forward slashes for consistent matching
|
||||
rel_path_fwd = rel_path.replace("\\", "/")
|
||||
|
||||
entry_full = Path(directory) / entry
|
||||
if entry_full.is_dir():
|
||||
# Append '/' so directory-only patterns (e.g. tests/) match
|
||||
if spec.match_file(rel_path_fwd + "/"):
|
||||
ignored.add(entry)
|
||||
else:
|
||||
if spec.match_file(rel_path_fwd):
|
||||
ignored.add(entry)
|
||||
return ignored
|
||||
|
||||
return _ignore
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
manifest: ExtensionManifest,
|
||||
@@ -353,7 +419,8 @@ class ExtensionManager:
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
ignore_fn = self._load_extensionignore(source_dir)
|
||||
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
|
||||
|
||||
# Register commands with AI agents
|
||||
registered_commands = {}
|
||||
@@ -635,6 +702,12 @@ class CommandRegistrar:
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"codex": {
|
||||
"dir": ".codex/prompts",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"windsurf": {
|
||||
"dir": ".windsurf/workflows",
|
||||
"format": "markdown",
|
||||
@@ -654,7 +727,7 @@ class CommandRegistrar:
|
||||
"extension": ".md"
|
||||
},
|
||||
"roo": {
|
||||
"dir": ".roo/rules",
|
||||
"dir": ".roo/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
@@ -700,6 +773,12 @@ class CommandRegistrar:
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kimi": {
|
||||
"dir": ".kimi/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -893,6 +972,7 @@ class CommandRegistrar:
|
||||
|
||||
# Write command file
|
||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_file.write_text(output)
|
||||
|
||||
# Generate companion .prompt.md for Copilot agents
|
||||
@@ -904,6 +984,7 @@ class CommandRegistrar:
|
||||
# Register aliases
|
||||
for alias in cmd_info.get("aliases", []):
|
||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(output)
|
||||
# Generate companion .prompt.md for alias too
|
||||
if agent_name == "copilot":
|
||||
|
||||
@@ -1,938 +0,0 @@
|
||||
"""
|
||||
Template Pack Manager for Spec Kit
|
||||
|
||||
Handles installation, removal, and management of Spec Kit template packs.
|
||||
Template packs are self-contained, versioned collections of templates
|
||||
(artifact, command, and script templates) that can be installed to
|
||||
customize the Spec-Driven Development workflow.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
|
||||
class TemplateError(Exception):
|
||||
"""Base exception for template-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateValidationError(TemplateError):
|
||||
"""Raised when template pack manifest validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateCompatibilityError(TemplateError):
|
||||
"""Raised when template pack is incompatible with current environment."""
|
||||
pass
|
||||
|
||||
|
||||
VALID_TEMPLATE_TYPES = {"artifact", "command", "script"}
|
||||
|
||||
|
||||
class TemplatePackManifest:
|
||||
"""Represents and validates a template pack manifest (template-pack.yml)."""
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
REQUIRED_FIELDS = ["schema_version", "template_pack", "requires", "provides"]
|
||||
|
||||
def __init__(self, manifest_path: Path):
|
||||
"""Load and validate template pack manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to template-pack.yml file
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
"""
|
||||
self.path = manifest_path
|
||||
self.data = self._load_yaml(manifest_path)
|
||||
self._validate()
|
||||
|
||||
def _load_yaml(self, path: Path) -> dict:
|
||||
"""Load YAML file safely."""
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise TemplateValidationError(f"Invalid YAML in {path}: {e}")
|
||||
except FileNotFoundError:
|
||||
raise TemplateValidationError(f"Manifest not found: {path}")
|
||||
|
||||
def _validate(self):
|
||||
"""Validate manifest structure and required fields."""
|
||||
# Check required top-level fields
|
||||
for field in self.REQUIRED_FIELDS:
|
||||
if field not in self.data:
|
||||
raise TemplateValidationError(f"Missing required field: {field}")
|
||||
|
||||
# Validate schema version
|
||||
if self.data["schema_version"] != self.SCHEMA_VERSION:
|
||||
raise TemplateValidationError(
|
||||
f"Unsupported schema version: {self.data['schema_version']} "
|
||||
f"(expected {self.SCHEMA_VERSION})"
|
||||
)
|
||||
|
||||
# Validate template_pack metadata
|
||||
pack = self.data["template_pack"]
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
if field not in pack:
|
||||
raise TemplateValidationError(f"Missing template_pack.{field}")
|
||||
|
||||
# Validate pack ID format
|
||||
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template pack ID '{pack['id']}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
)
|
||||
|
||||
# Validate semantic version
|
||||
try:
|
||||
pkg_version.Version(pack["version"])
|
||||
except pkg_version.InvalidVersion:
|
||||
raise TemplateValidationError(f"Invalid version: {pack['version']}")
|
||||
|
||||
# Validate requires section
|
||||
requires = self.data["requires"]
|
||||
if "speckit_version" not in requires:
|
||||
raise TemplateValidationError("Missing requires.speckit_version")
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
if "templates" not in provides or not provides["templates"]:
|
||||
raise TemplateValidationError(
|
||||
"Template pack must provide at least one template"
|
||||
)
|
||||
|
||||
# Validate templates
|
||||
for tmpl in provides["templates"]:
|
||||
if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl:
|
||||
raise TemplateValidationError(
|
||||
"Template missing 'type', 'name', or 'file'"
|
||||
)
|
||||
|
||||
if tmpl["type"] not in VALID_TEMPLATE_TYPES:
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template type '{tmpl['type']}': "
|
||||
f"must be one of {sorted(VALID_TEMPLATE_TYPES)}"
|
||||
)
|
||||
|
||||
# Validate template name format
|
||||
if not re.match(r'^[a-z0-9-]+$', tmpl["name"]):
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template name '{tmpl['name']}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Get template pack ID."""
|
||||
return self.data["template_pack"]["id"]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get template pack name."""
|
||||
return self.data["template_pack"]["name"]
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Get template pack version."""
|
||||
return self.data["template_pack"]["version"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Get template pack description."""
|
||||
return self.data["template_pack"]["description"]
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
"""Get template pack author."""
|
||||
return self.data["template_pack"].get("author", "")
|
||||
|
||||
@property
|
||||
def requires_speckit_version(self) -> str:
|
||||
"""Get required spec-kit version range."""
|
||||
return self.data["requires"]["speckit_version"]
|
||||
|
||||
@property
|
||||
def templates(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of provided templates."""
|
||||
return self.data["provides"]["templates"]
|
||||
|
||||
@property
|
||||
def tags(self) -> List[str]:
|
||||
"""Get template pack tags."""
|
||||
return self.data.get("tags", [])
|
||||
|
||||
def get_hash(self) -> str:
|
||||
"""Calculate SHA256 hash of manifest file."""
|
||||
with open(self.path, 'rb') as f:
|
||||
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
|
||||
|
||||
|
||||
class TemplatePackRegistry:
|
||||
"""Manages the registry of installed template packs."""
|
||||
|
||||
REGISTRY_FILE = ".registry"
|
||||
SCHEMA_VERSION = "1.0"
|
||||
|
||||
def __init__(self, packs_dir: Path):
|
||||
"""Initialize registry.
|
||||
|
||||
Args:
|
||||
packs_dir: Path to .specify/templates/packs/ directory
|
||||
"""
|
||||
self.packs_dir = packs_dir
|
||||
self.registry_path = packs_dir / self.REGISTRY_FILE
|
||||
self.data = self._load()
|
||||
|
||||
def _load(self) -> dict:
|
||||
"""Load registry from disk."""
|
||||
if not self.registry_path.exists():
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"template_packs": {}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"template_packs": {}
|
||||
}
|
||||
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.packs_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, 'w') as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, pack_id: str, metadata: dict):
|
||||
"""Add template pack to registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
metadata: Pack metadata (version, source, etc.)
|
||||
"""
|
||||
self.data["template_packs"][pack_id] = {
|
||||
**metadata,
|
||||
"installed_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
self._save()
|
||||
|
||||
def remove(self, pack_id: str):
|
||||
"""Remove template pack from registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
"""
|
||||
if pack_id in self.data["template_packs"]:
|
||||
del self.data["template_packs"][pack_id]
|
||||
self._save()
|
||||
|
||||
def get(self, pack_id: str) -> Optional[dict]:
|
||||
"""Get template pack metadata from registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
Pack metadata or None if not found
|
||||
"""
|
||||
return self.data["template_packs"].get(pack_id)
|
||||
|
||||
def list(self) -> Dict[str, dict]:
|
||||
"""Get all installed template packs.
|
||||
|
||||
Returns:
|
||||
Dictionary of pack_id -> metadata
|
||||
"""
|
||||
return self.data["template_packs"]
|
||||
|
||||
def is_installed(self, pack_id: str) -> bool:
|
||||
"""Check if template pack is installed.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
True if pack is installed
|
||||
"""
|
||||
return pack_id in self.data["template_packs"]
|
||||
|
||||
|
||||
class TemplatePackManager:
|
||||
"""Manages template pack lifecycle: installation, removal, updates."""
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template pack manager.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root directory
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.packs_dir = self.templates_dir / "packs"
|
||||
self.registry = TemplatePackRegistry(self.packs_dir)
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
manifest: TemplatePackManifest,
|
||||
speckit_version: str
|
||||
) -> bool:
|
||||
"""Check if template pack is compatible with current spec-kit version.
|
||||
|
||||
Args:
|
||||
manifest: Template pack manifest
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
True if compatible
|
||||
|
||||
Raises:
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
current = pkg_version.Version(speckit_version)
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(required)
|
||||
if current not in specifier:
|
||||
raise TemplateCompatibilityError(
|
||||
f"Template pack requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: uv tool install specify-cli --force"
|
||||
)
|
||||
except InvalidSpecifier:
|
||||
raise TemplateCompatibilityError(
|
||||
f"Invalid version specifier: {required}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def install_from_directory(
|
||||
self,
|
||||
source_dir: Path,
|
||||
speckit_version: str,
|
||||
) -> TemplatePackManifest:
|
||||
"""Install template pack from a local directory.
|
||||
|
||||
Args:
|
||||
source_dir: Path to template pack directory
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
Installed template pack manifest
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
manifest_path = source_dir / "template-pack.yml"
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
|
||||
self.check_compatibility(manifest, speckit_version)
|
||||
|
||||
if self.registry.is_installed(manifest.id):
|
||||
raise TemplateError(
|
||||
f"Template pack '{manifest.id}' is already installed. "
|
||||
f"Use 'specify template remove {manifest.id}' first."
|
||||
)
|
||||
|
||||
dest_dir = self.packs_dir / manifest.id
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
|
||||
self.registry.add(manifest.id, {
|
||||
"version": manifest.version,
|
||||
"source": "local",
|
||||
"manifest_hash": manifest.get_hash(),
|
||||
"enabled": True,
|
||||
})
|
||||
|
||||
return manifest
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str
|
||||
) -> TemplatePackManifest:
|
||||
"""Install template pack from ZIP file.
|
||||
|
||||
Args:
|
||||
zip_path: Path to template pack ZIP file
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
Installed template pack manifest
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
temp_path_resolved = temp_path.resolve()
|
||||
for member in zf.namelist():
|
||||
member_path = (temp_path / member).resolve()
|
||||
try:
|
||||
member_path.relative_to(temp_path_resolved)
|
||||
except ValueError:
|
||||
raise TemplateValidationError(
|
||||
f"Unsafe path in ZIP archive: {member} "
|
||||
"(potential path traversal)"
|
||||
)
|
||||
zf.extractall(temp_path)
|
||||
|
||||
pack_dir = temp_path
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
if not manifest_path.exists():
|
||||
subdirs = [d for d in temp_path.iterdir() if d.is_dir()]
|
||||
if len(subdirs) == 1:
|
||||
pack_dir = subdirs[0]
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise TemplateValidationError(
|
||||
"No template-pack.yml found in ZIP file"
|
||||
)
|
||||
|
||||
return self.install_from_directory(pack_dir, speckit_version)
|
||||
|
||||
def remove(self, pack_id: str) -> bool:
|
||||
"""Remove an installed template pack.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
True if pack was removed
|
||||
"""
|
||||
if not self.registry.is_installed(pack_id):
|
||||
return False
|
||||
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
if pack_dir.exists():
|
||||
shutil.rmtree(pack_dir)
|
||||
|
||||
self.registry.remove(pack_id)
|
||||
return True
|
||||
|
||||
def list_installed(self) -> List[Dict[str, Any]]:
|
||||
"""List all installed template packs with metadata.
|
||||
|
||||
Returns:
|
||||
List of template pack metadata dictionaries
|
||||
"""
|
||||
result = []
|
||||
|
||||
for pack_id, metadata in self.registry.list().items():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
try:
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
result.append({
|
||||
"id": pack_id,
|
||||
"name": manifest.name,
|
||||
"version": metadata["version"],
|
||||
"description": manifest.description,
|
||||
"enabled": metadata.get("enabled", True),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"template_count": len(manifest.templates),
|
||||
"tags": manifest.tags,
|
||||
})
|
||||
except TemplateValidationError:
|
||||
result.append({
|
||||
"id": pack_id,
|
||||
"name": pack_id,
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": "⚠️ Corrupted template pack",
|
||||
"enabled": False,
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"template_count": 0,
|
||||
"tags": [],
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_pack(self, pack_id: str) -> Optional[TemplatePackManifest]:
|
||||
"""Get manifest for an installed template pack.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
Template pack manifest or None if not installed
|
||||
"""
|
||||
if not self.registry.is_installed(pack_id):
|
||||
return None
|
||||
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
try:
|
||||
return TemplatePackManifest(manifest_path)
|
||||
except TemplateValidationError:
|
||||
return None
|
||||
|
||||
|
||||
class TemplateCatalog:
|
||||
"""Manages template pack catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.json"
|
||||
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.community.json"
|
||||
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template catalog manager.
|
||||
|
||||
Args:
|
||||
project_root: Root directory of the spec-kit project
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.cache_dir = self.templates_dir / "packs" / ".cache"
|
||||
self.cache_file = self.cache_dir / "catalog.json"
|
||||
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
||||
|
||||
def _validate_catalog_url(self, url: str) -> None:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If URL is invalid or uses non-HTTPS scheme
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise TemplateValidationError(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
raise TemplateValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
|
||||
def get_catalog_url(self) -> str:
|
||||
"""Get the primary catalog URL.
|
||||
|
||||
Returns:
|
||||
URL of the primary catalog
|
||||
"""
|
||||
env_value = os.environ.get("SPECKIT_TEMPLATE_CATALOG_URL")
|
||||
if env_value:
|
||||
catalog_url = env_value.strip()
|
||||
self._validate_catalog_url(catalog_url)
|
||||
return catalog_url
|
||||
return self.DEFAULT_CATALOG_URL
|
||||
|
||||
def is_cache_valid(self) -> bool:
|
||||
"""Check if cached catalog is still valid.
|
||||
|
||||
Returns:
|
||||
True if cache exists and is within cache duration
|
||||
"""
|
||||
if not self.cache_file.exists() or not self.cache_metadata_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age_seconds = (
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""Fetch template pack catalog from URL or cache.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, bypass cache and fetch from network
|
||||
|
||||
Returns:
|
||||
Catalog data dictionary
|
||||
|
||||
Raises:
|
||||
TemplateError: If catalog cannot be fetched
|
||||
"""
|
||||
if not force_refresh and self.is_cache_valid():
|
||||
try:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
with urllib.request.urlopen(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "template_packs" not in catalog_data
|
||||
):
|
||||
raise TemplateError("Invalid template catalog format")
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2)
|
||||
)
|
||||
|
||||
return catalog_data
|
||||
|
||||
except (ImportError, Exception) as e:
|
||||
if isinstance(e, TemplateError):
|
||||
raise
|
||||
raise TemplateError(
|
||||
f"Failed to fetch template catalog from {catalog_url}: {e}"
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search catalog for template packs.
|
||||
|
||||
Args:
|
||||
query: Search query (searches name, description, tags)
|
||||
tag: Filter by specific tag
|
||||
author: Filter by author name
|
||||
|
||||
Returns:
|
||||
List of matching template pack metadata
|
||||
"""
|
||||
try:
|
||||
catalog_data = self.fetch_catalog()
|
||||
except TemplateError:
|
||||
return []
|
||||
|
||||
results = []
|
||||
packs = catalog_data.get("template_packs", {})
|
||||
|
||||
for pack_id, pack_data in packs.items():
|
||||
if author and pack_data.get("author", "").lower() != author.lower():
|
||||
continue
|
||||
|
||||
if tag and tag.lower() not in [
|
||||
t.lower() for t in pack_data.get("tags", [])
|
||||
]:
|
||||
continue
|
||||
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
searchable_text = " ".join(
|
||||
[
|
||||
pack_data.get("name", ""),
|
||||
pack_data.get("description", ""),
|
||||
pack_id,
|
||||
]
|
||||
+ pack_data.get("tags", [])
|
||||
).lower()
|
||||
|
||||
if query_lower not in searchable_text:
|
||||
continue
|
||||
|
||||
results.append({**pack_data, "id": pack_id})
|
||||
|
||||
return results
|
||||
|
||||
def get_pack_info(
|
||||
self, pack_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed information about a specific template pack.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the template pack
|
||||
|
||||
Returns:
|
||||
Pack metadata or None if not found
|
||||
"""
|
||||
try:
|
||||
catalog_data = self.fetch_catalog()
|
||||
except TemplateError:
|
||||
return None
|
||||
|
||||
packs = catalog_data.get("template_packs", {})
|
||||
if pack_id in packs:
|
||||
return {**packs[pack_id], "id": pack_id}
|
||||
return None
|
||||
|
||||
def download_pack(
|
||||
self, pack_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download template pack ZIP from catalog.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the template pack to download
|
||||
target_dir: Directory to save ZIP file (defaults to cache directory)
|
||||
|
||||
Returns:
|
||||
Path to downloaded ZIP file
|
||||
|
||||
Raises:
|
||||
TemplateError: If pack not found or download fails
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
pack_info = self.get_pack_info(pack_id)
|
||||
if not pack_info:
|
||||
raise TemplateError(
|
||||
f"Template pack '{pack_id}' not found in catalog"
|
||||
)
|
||||
|
||||
download_url = pack_info.get("download_url")
|
||||
if not download_url:
|
||||
raise TemplateError(
|
||||
f"Template pack '{pack_id}' has no download URL"
|
||||
)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise TemplateError(
|
||||
f"Template pack download URL must use HTTPS: {download_url}"
|
||||
)
|
||||
|
||||
if target_dir is None:
|
||||
target_dir = self.cache_dir / "downloads"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
version = pack_info.get("version", "unknown")
|
||||
zip_filename = f"{pack_id}-{version}.zip"
|
||||
zip_path = target_dir / zip_filename
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(download_url, timeout=60) as response:
|
||||
zip_data = response.read()
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise TemplateError(
|
||||
f"Failed to download template pack from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise TemplateError(f"Failed to save template pack ZIP: {e}")
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the catalog cache."""
|
||||
if self.cache_file.exists():
|
||||
self.cache_file.unlink()
|
||||
if self.cache_metadata_file.exists():
|
||||
self.cache_metadata_file.unlink()
|
||||
|
||||
|
||||
class TemplateResolver:
|
||||
"""Resolves template names to file paths using a priority stack.
|
||||
|
||||
Resolution order:
|
||||
1. .specify/templates/overrides/ - Project-local overrides
|
||||
2. .specify/templates/packs/<pack-id>/ - Installed template packs
|
||||
3. .specify/extensions/<ext-id>/templates/ - Extension-provided templates
|
||||
4. .specify/templates/ - Core templates (shipped with Spec Kit)
|
||||
"""
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template resolver.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root directory
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.packs_dir = self.templates_dir / "packs"
|
||||
self.overrides_dir = self.templates_dir / "overrides"
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
template_name: str,
|
||||
template_type: str = "artifact",
|
||||
) -> Optional[Path]:
|
||||
"""Resolve a template name to its file path.
|
||||
|
||||
Walks the priority stack and returns the first match.
|
||||
|
||||
Args:
|
||||
template_name: Template name (e.g., "spec-template")
|
||||
template_type: Template type ("artifact", "command", or "script")
|
||||
|
||||
Returns:
|
||||
Path to the resolved template file, or None if not found
|
||||
"""
|
||||
# Determine subdirectory based on template type
|
||||
if template_type == "artifact":
|
||||
subdirs = ["templates", ""]
|
||||
elif template_type == "command":
|
||||
subdirs = ["commands"]
|
||||
elif template_type == "script":
|
||||
subdirs = ["scripts"]
|
||||
else:
|
||||
subdirs = [""]
|
||||
|
||||
# Priority 1: Project-local overrides
|
||||
for subdir in subdirs:
|
||||
if template_type == "script":
|
||||
override = self.overrides_dir / "scripts" / f"{template_name}.sh"
|
||||
elif subdir:
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
else:
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
if override.exists():
|
||||
return override
|
||||
|
||||
# Priority 2: Installed packs (by registry order)
|
||||
if self.packs_dir.exists():
|
||||
registry = TemplatePackRegistry(self.packs_dir)
|
||||
for pack_id in registry.list():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = (
|
||||
pack_dir / subdir / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = pack_dir / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
if self.extensions_dir.exists():
|
||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = (
|
||||
ext_dir / "templates" / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = (
|
||||
ext_dir / "templates" / f"{template_name}.md"
|
||||
)
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Priority 4: Core templates
|
||||
if template_type == "artifact":
|
||||
core = self.templates_dir / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return core
|
||||
elif template_type == "command":
|
||||
core = self.templates_dir / "commands" / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return core
|
||||
|
||||
return None
|
||||
|
||||
def resolve_with_source(
|
||||
self,
|
||||
template_name: str,
|
||||
template_type: str = "artifact",
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Resolve a template name and return source attribution.
|
||||
|
||||
Args:
|
||||
template_name: Template name (e.g., "spec-template")
|
||||
template_type: Template type ("artifact", "command", or "script")
|
||||
|
||||
Returns:
|
||||
Dictionary with 'path' and 'source' keys, or None if not found
|
||||
"""
|
||||
# Priority 1: Project-local overrides
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
if override.exists():
|
||||
return {"path": str(override), "source": "project override"}
|
||||
|
||||
# Priority 2: Installed packs
|
||||
if self.packs_dir.exists():
|
||||
registry = TemplatePackRegistry(self.packs_dir)
|
||||
for pack_id in registry.list():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
# Check templates/ subdirectory first, then root
|
||||
for subdir in ["templates", "commands", "scripts", ""]:
|
||||
if subdir:
|
||||
candidate = (
|
||||
pack_dir / subdir / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = pack_dir / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
meta = registry.get(pack_id)
|
||||
version = meta.get("version", "?") if meta else "?"
|
||||
return {
|
||||
"path": str(candidate),
|
||||
"source": f"{pack_id} v{version}",
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
if self.extensions_dir.exists():
|
||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
candidate = ext_dir / "templates" / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
return {
|
||||
"path": str(candidate),
|
||||
"source": f"extension:{ext_dir.name}",
|
||||
}
|
||||
|
||||
# Priority 4: Core templates
|
||||
core = self.templates_dir / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return {"path": str(core), "source": "core"}
|
||||
|
||||
# Also check commands subdirectory for core
|
||||
core_cmd = self.templates_dir / "commands" / f"{template_name}.md"
|
||||
if core_cmd.exists():
|
||||
return {"path": str(core_cmd), "source": "core"}
|
||||
|
||||
return None
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-09T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.community.json",
|
||||
"template_packs": {}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-09T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.json",
|
||||
"template_packs": {}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
# My Template Pack
|
||||
|
||||
A custom template pack for Spec Kit.
|
||||
|
||||
## Overview
|
||||
|
||||
This template pack provides customized artifact templates for your development workflow.
|
||||
|
||||
## Templates Included
|
||||
|
||||
| Template | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `spec-template` | artifact | Custom feature specification template |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install from local directory (during development)
|
||||
specify template add --dev /path/to/this/directory
|
||||
|
||||
# Install from catalog (after publishing)
|
||||
specify template add my-template-pack
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, templates are automatically resolved by the Spec Kit scripts.
|
||||
When you run `specify specify` or create a new feature, your custom templates
|
||||
will be used instead of the core defaults.
|
||||
|
||||
## Template Types
|
||||
|
||||
- **artifact** — Document scaffolds (spec.md, plan.md, tasks.md, etc.)
|
||||
- **command** — AI agent prompts (the files in `.claude/commands/`, etc.)
|
||||
- **script** — Custom scripts that replace core scripts
|
||||
|
||||
## Development
|
||||
|
||||
1. Edit templates in the `templates/` directory
|
||||
2. Test with: `specify template add --dev .`
|
||||
3. Verify with: `specify template resolve spec-template`
|
||||
|
||||
## Publishing
|
||||
|
||||
See the [Template Publishing Guide](../../docs/TEMPLATE-PUBLISHING-GUIDE.md) for details.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,66 +0,0 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
template_pack:
|
||||
# CUSTOMIZE: Change 'my-template-pack' to your template pack ID (lowercase, hyphen-separated)
|
||||
id: "my-template-pack"
|
||||
|
||||
# CUSTOMIZE: Human-readable name for your template pack
|
||||
name: "My Template Pack"
|
||||
|
||||
# CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z)
|
||||
version: "1.0.0"
|
||||
|
||||
# CUSTOMIZE: Brief description (under 200 characters)
|
||||
description: "Brief description of what your template pack provides"
|
||||
|
||||
# CUSTOMIZE: Your name or organization name
|
||||
author: "Your Name"
|
||||
|
||||
# CUSTOMIZE: GitHub repository URL (create before publishing)
|
||||
repository: "https://github.com/your-org/spec-kit-templates-my-pack"
|
||||
|
||||
# REVIEW: License (MIT is recommended for open source)
|
||||
license: "MIT"
|
||||
|
||||
# Requirements for this template pack
|
||||
requires:
|
||||
# CUSTOMIZE: Minimum spec-kit version required
|
||||
speckit_version: ">=0.1.0"
|
||||
|
||||
# Templates provided by this pack
|
||||
provides:
|
||||
templates:
|
||||
# CUSTOMIZE: Define your artifact templates
|
||||
# Artifact templates are document scaffolds (spec.md, plan.md, etc.)
|
||||
- type: "artifact"
|
||||
name: "spec-template"
|
||||
file: "templates/spec-template.md"
|
||||
description: "Custom feature specification template"
|
||||
replaces: "spec-template" # Which core template this overrides (optional)
|
||||
|
||||
# ADD MORE TEMPLATES: Copy this block for each template
|
||||
# - type: "artifact"
|
||||
# name: "plan-template"
|
||||
# file: "templates/plan-template.md"
|
||||
# description: "Custom plan template"
|
||||
# replaces: "plan-template"
|
||||
|
||||
# Command templates (AI agent prompts)
|
||||
# - type: "command"
|
||||
# name: "specify"
|
||||
# file: "commands/specify.md"
|
||||
# description: "Custom specification command"
|
||||
# replaces: "specify"
|
||||
|
||||
# Script templates
|
||||
# - type: "script"
|
||||
# name: "create-new-feature"
|
||||
# file: "scripts/bash/create-new-feature.sh"
|
||||
# description: "Custom feature creation script"
|
||||
# replaces: "create-new-feature"
|
||||
|
||||
# CUSTOMIZE: Add relevant tags (2-5 recommended)
|
||||
# Used for discovery in catalog
|
||||
tags:
|
||||
- "example"
|
||||
- "template"
|
||||
@@ -1,21 +0,0 @@
|
||||
# Feature Specification
|
||||
|
||||
> Replace this with your actual specification content.
|
||||
|
||||
## Overview
|
||||
|
||||
Brief description of the feature.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Requirement 1
|
||||
- Requirement 2
|
||||
|
||||
## Design
|
||||
|
||||
Describe the design approach.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
@@ -28,6 +28,13 @@ class TestAgentConfigConsistency:
|
||||
assert cfg["kiro-cli"]["dir"] == ".kiro/prompts"
|
||||
assert "q" not in cfg
|
||||
|
||||
def test_extension_registrar_includes_codex(self):
|
||||
"""Extension command registrar should include codex targeting .codex/prompts."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "codex" in cfg
|
||||
assert cfg["codex"]["dir"] == ".codex/prompts"
|
||||
|
||||
def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):
|
||||
"""Bash and PowerShell release scripts should agree on agent key set for Kiro."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
@@ -164,3 +171,58 @@ class TestAgentConfigConsistency:
|
||||
def test_ai_help_includes_tabnine(self):
|
||||
"""CLI help text for --ai should include tabnine."""
|
||||
assert "tabnine" in AI_ASSISTANT_HELP
|
||||
|
||||
# --- Kimi Code CLI consistency checks ---
|
||||
|
||||
def test_kimi_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include kimi with correct folder and commands_subdir."""
|
||||
assert "kimi" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/"
|
||||
assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills"
|
||||
assert AGENT_CONFIG["kimi"]["requires_cli"] is True
|
||||
|
||||
def test_kimi_in_extension_registrar(self):
|
||||
"""Extension command registrar should include kimi using .kimi/skills and SKILL.md."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "kimi" in cfg
|
||||
kimi_cfg = cfg["kimi"]
|
||||
assert kimi_cfg["dir"] == ".kimi/skills"
|
||||
assert kimi_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
def test_kimi_in_release_agent_lists(self):
|
||||
"""Bash and PowerShell release scripts should include kimi in agent lists."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||
|
||||
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
|
||||
assert sh_match is not None
|
||||
sh_agents = sh_match.group(1).split()
|
||||
|
||||
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
|
||||
assert ps_match is not None
|
||||
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
|
||||
|
||||
assert "kimi" in sh_agents
|
||||
assert "kimi" in ps_agents
|
||||
|
||||
def test_kimi_in_powershell_validate_set(self):
|
||||
"""PowerShell update-agent-context script should include 'kimi' in ValidateSet."""
|
||||
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
|
||||
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
|
||||
assert validate_set_match is not None
|
||||
validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1))
|
||||
|
||||
assert "kimi" in validate_set_values
|
||||
|
||||
def test_kimi_in_github_release_output(self):
|
||||
"""GitHub release script should include kimi template packages."""
|
||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "spec-kit-template-kimi-sh-" in gh_release_text
|
||||
assert "spec-kit-template-kimi-ps-" in gh_release_text
|
||||
|
||||
def test_ai_help_includes_kimi(self):
|
||||
"""CLI help text for --ai should include kimi."""
|
||||
assert "kimi" in AI_ASSISTANT_HELP
|
||||
|
||||
@@ -410,8 +410,11 @@ class TestInstallAiSkills:
|
||||
skills_dir = _get_skills_dir(proj, agent_key)
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
assert "speckit-specify" in skill_dirs
|
||||
assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||
# Kimi uses dot-separator (speckit.specify) to match /skill:speckit.* invocation;
|
||||
# all other agents use hyphen-separator (speckit-specify).
|
||||
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
||||
assert expected_skill_name in skill_dirs
|
||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -407,6 +407,11 @@ class TestCommandRegistrar:
|
||||
assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts"
|
||||
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
def test_codex_agent_config_present(self):
|
||||
"""Codex should be mapped to .codex/prompts."""
|
||||
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts"
|
||||
|
||||
def test_parse_frontmatter_valid(self):
|
||||
"""Test parsing valid YAML frontmatter."""
|
||||
content = """---
|
||||
@@ -1598,3 +1603,343 @@ class TestCatalogStack:
|
||||
assert len(results) == 1
|
||||
assert results[0]["_catalog_name"] == "org"
|
||||
assert results[0]["_install_allowed"] is True
|
||||
|
||||
|
||||
class TestExtensionIgnore:
|
||||
"""Test .extensionignore support during extension installation."""
|
||||
|
||||
def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None):
|
||||
"""Helper to create an extension directory with optional extra files and .extensionignore."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ignored-ext"
|
||||
ext_dir.mkdir()
|
||||
|
||||
# Write manifest
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
# Create commands directory with a command file
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
(commands_dir / "hello.md").write_text(
|
||||
"---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n"
|
||||
)
|
||||
|
||||
# Create any extra files/dirs
|
||||
if extra_files:
|
||||
for rel_path, content in extra_files.items():
|
||||
p = ext_dir / rel_path
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if content is None:
|
||||
# Create directory
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
p.write_text(content)
|
||||
|
||||
# Write .extensionignore
|
||||
if ignore_content is not None:
|
||||
(ext_dir / ".extensionignore").write_text(ignore_content)
|
||||
|
||||
return ext_dir
|
||||
|
||||
def test_no_extensionignore(self, temp_dir, valid_manifest_data):
|
||||
"""Without .extensionignore, all files are copied."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"},
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "tests" / "test_foo.py").exists()
|
||||
|
||||
def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data):
|
||||
"""Files matching .extensionignore patterns are excluded."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"README.md": "# Hello",
|
||||
"tests/test_foo.py": "pass",
|
||||
"tests/test_bar.py": "pass",
|
||||
".github/workflows/ci.yml": "on: push",
|
||||
},
|
||||
ignore_content="tests/\n.github/\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# Included
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "extension.yml").exists()
|
||||
assert (dest / "commands" / "hello.md").exists()
|
||||
# Excluded
|
||||
assert not (dest / "tests").exists()
|
||||
assert not (dest / ".github").exists()
|
||||
|
||||
def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data):
|
||||
"""Glob patterns like *.pyc are respected."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"README.md": "# Hello",
|
||||
"helpers.pyc": b"\x00".decode("latin-1"),
|
||||
"commands/cache.pyc": b"\x00".decode("latin-1"),
|
||||
},
|
||||
ignore_content="*.pyc\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "README.md").exists()
|
||||
assert not (dest / "helpers.pyc").exists()
|
||||
assert not (dest / "commands" / "cache.pyc").exists()
|
||||
|
||||
def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data):
|
||||
"""Comments and blank lines in .extensionignore are ignored."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello", "notes.txt": "some notes"},
|
||||
ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "README.md").exists()
|
||||
assert not (dest / "notes.txt").exists()
|
||||
|
||||
def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data):
|
||||
""".extensionignore is never copied to the destination."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
ignore_content="# nothing special here\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "extension.yml").exists()
|
||||
assert not (dest / ".extensionignore").exists()
|
||||
|
||||
def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data):
|
||||
"""Patterns matching relative paths work correctly."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/guide.md": "# Guide",
|
||||
"docs/internal/draft.md": "draft",
|
||||
"README.md": "# Hello",
|
||||
},
|
||||
ignore_content="docs/internal/draft.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "docs" / "guide.md").exists()
|
||||
assert not (dest / "docs" / "internal" / "draft.md").exists()
|
||||
|
||||
def test_extensionignore_dotdot_pattern_is_noop(self, temp_dir, valid_manifest_data):
|
||||
"""Patterns with '..' should not escape the extension root."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello"},
|
||||
ignore_content="../sibling/\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# Everything should still be copied — the '..' pattern matches nothing inside
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "extension.yml").exists()
|
||||
assert (dest / "commands" / "hello.md").exists()
|
||||
|
||||
def test_extensionignore_absolute_path_pattern_is_noop(self, temp_dir, valid_manifest_data):
|
||||
"""Absolute path patterns should not match anything."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello", "passwd": "sensitive"},
|
||||
ignore_content="/etc/passwd\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "passwd").exists()
|
||||
|
||||
def test_extensionignore_empty_file(self, temp_dir, valid_manifest_data):
|
||||
"""An empty .extensionignore should exclude only itself."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello", "notes.txt": "notes"},
|
||||
ignore_content="",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "notes.txt").exists()
|
||||
assert (dest / "extension.yml").exists()
|
||||
# .extensionignore itself is still excluded
|
||||
assert not (dest / ".extensionignore").exists()
|
||||
|
||||
def test_extensionignore_windows_backslash_patterns(self, temp_dir, valid_manifest_data):
|
||||
"""Backslash patterns (Windows-style) are normalised to forward slashes."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/internal/draft.md": "draft",
|
||||
"docs/guide.md": "# Guide",
|
||||
},
|
||||
ignore_content="docs\\internal\\draft.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "docs" / "guide.md").exists()
|
||||
assert not (dest / "docs" / "internal" / "draft.md").exists()
|
||||
|
||||
def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data):
|
||||
"""'*' should NOT match across directory boundaries (gitignore semantics)."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/api.draft.md": "draft",
|
||||
"docs/sub/api.draft.md": "nested draft",
|
||||
},
|
||||
ignore_content="docs/*.draft.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# docs/*.draft.md should only match directly inside docs/, NOT subdirs
|
||||
assert not (dest / "docs" / "api.draft.md").exists()
|
||||
assert (dest / "docs" / "sub" / "api.draft.md").exists()
|
||||
|
||||
def test_extensionignore_doublestar_crosses_directories(self, temp_dir, valid_manifest_data):
|
||||
"""'**' should match across directory boundaries."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/api.draft.md": "draft",
|
||||
"docs/sub/api.draft.md": "nested draft",
|
||||
"docs/guide.md": "guide",
|
||||
},
|
||||
ignore_content="docs/**/*.draft.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert not (dest / "docs" / "api.draft.md").exists()
|
||||
assert not (dest / "docs" / "sub" / "api.draft.md").exists()
|
||||
assert (dest / "docs" / "guide.md").exists()
|
||||
|
||||
def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data):
|
||||
"""'!' negation re-includes a previously excluded file."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/guide.md": "# Guide",
|
||||
"docs/internal.md": "internal",
|
||||
"docs/api.md": "api",
|
||||
},
|
||||
ignore_content="docs/*.md\n!docs/api.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# docs/*.md excludes all .md in docs, but !docs/api.md re-includes it
|
||||
assert not (dest / "docs" / "guide.md").exists()
|
||||
assert not (dest / "docs" / "internal.md").exists()
|
||||
assert (dest / "docs" / "api.md").exists()
|
||||
|
||||
@@ -1,923 +0,0 @@
|
||||
"""
|
||||
Unit tests for the template pack system.
|
||||
|
||||
Tests cover:
|
||||
- Template pack manifest validation
|
||||
- Template pack registry operations
|
||||
- Template pack manager installation/removal
|
||||
- Template catalog search
|
||||
- Template resolver priority stack
|
||||
- Extension-provided templates
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.templates import (
|
||||
TemplatePackManifest,
|
||||
TemplatePackRegistry,
|
||||
TemplatePackManager,
|
||||
TemplateCatalog,
|
||||
TemplateResolver,
|
||||
TemplateError,
|
||||
TemplateValidationError,
|
||||
TemplateCompatibilityError,
|
||||
VALID_TEMPLATE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
# ===== Fixtures =====
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
yield Path(tmpdir)
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_pack_data():
|
||||
"""Valid template pack manifest data."""
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"template_pack": {
|
||||
"id": "test-pack",
|
||||
"name": "Test Template Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "A test template pack",
|
||||
"author": "Test Author",
|
||||
"repository": "https://github.com/test/test-pack",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "artifact",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
"description": "Custom spec template",
|
||||
"replaces": "spec-template",
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["testing", "example"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pack_dir(temp_dir, valid_pack_data):
|
||||
"""Create a complete template pack directory structure."""
|
||||
p_dir = temp_dir / "test-pack"
|
||||
p_dir.mkdir()
|
||||
|
||||
# Write manifest
|
||||
manifest_path = p_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
|
||||
# Create templates directory
|
||||
templates_dir = p_dir / "templates"
|
||||
templates_dir.mkdir()
|
||||
|
||||
# Write template file
|
||||
tmpl_file = templates_dir / "spec-template.md"
|
||||
tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n")
|
||||
|
||||
return p_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir):
|
||||
"""Create a mock spec-kit project directory."""
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
|
||||
# Create .specify directory
|
||||
specify_dir = proj_dir / ".specify"
|
||||
specify_dir.mkdir()
|
||||
|
||||
# Create templates directory with core templates
|
||||
templates_dir = specify_dir / "templates"
|
||||
templates_dir.mkdir()
|
||||
|
||||
# Create core spec-template
|
||||
core_spec = templates_dir / "spec-template.md"
|
||||
core_spec.write_text("# Core Spec Template\n")
|
||||
|
||||
# Create core plan-template
|
||||
core_plan = templates_dir / "plan-template.md"
|
||||
core_plan.write_text("# Core Plan Template\n")
|
||||
|
||||
# Create commands subdirectory
|
||||
commands_dir = templates_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
|
||||
return proj_dir
|
||||
|
||||
|
||||
# ===== TemplatePackManifest Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackManifest:
|
||||
"""Test TemplatePackManifest validation and parsing."""
|
||||
|
||||
def test_valid_manifest(self, pack_dir):
|
||||
"""Test loading a valid manifest."""
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
assert manifest.id == "test-pack"
|
||||
assert manifest.name == "Test Template Pack"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.description == "A test template pack"
|
||||
assert manifest.author == "Test Author"
|
||||
assert manifest.requires_speckit_version == ">=0.1.0"
|
||||
assert len(manifest.templates) == 1
|
||||
assert manifest.tags == ["testing", "example"]
|
||||
|
||||
def test_missing_manifest(self, temp_dir):
|
||||
"""Test that missing manifest raises error."""
|
||||
with pytest.raises(TemplateValidationError, match="Manifest not found"):
|
||||
TemplatePackManifest(temp_dir / "nonexistent.yml")
|
||||
|
||||
def test_invalid_yaml(self, temp_dir):
|
||||
"""Test that invalid YAML raises error."""
|
||||
bad_file = temp_dir / "bad.yml"
|
||||
bad_file.write_text(": invalid: yaml: {{{")
|
||||
with pytest.raises(TemplateValidationError, match="Invalid YAML"):
|
||||
TemplatePackManifest(bad_file)
|
||||
|
||||
def test_missing_schema_version(self, temp_dir, valid_pack_data):
|
||||
"""Test missing schema_version field."""
|
||||
del valid_pack_data["schema_version"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing required field: schema_version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_wrong_schema_version(self, temp_dir, valid_pack_data):
|
||||
"""Test unsupported schema version."""
|
||||
valid_pack_data["schema_version"] = "2.0"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Unsupported schema version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_missing_pack_id(self, temp_dir, valid_pack_data):
|
||||
"""Test missing template_pack.id field."""
|
||||
del valid_pack_data["template_pack"]["id"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing template_pack.id"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_pack_id_format(self, temp_dir, valid_pack_data):
|
||||
"""Test invalid pack ID format."""
|
||||
valid_pack_data["template_pack"]["id"] = "Invalid_ID"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template pack ID"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_version(self, temp_dir, valid_pack_data):
|
||||
"""Test invalid semantic version."""
|
||||
valid_pack_data["template_pack"]["version"] = "not-a-version"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_missing_speckit_version(self, temp_dir, valid_pack_data):
|
||||
"""Test missing requires.speckit_version."""
|
||||
del valid_pack_data["requires"]["speckit_version"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing requires.speckit_version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_no_templates_provided(self, temp_dir, valid_pack_data):
|
||||
"""Test pack with no templates."""
|
||||
valid_pack_data["provides"]["templates"] = []
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="must provide at least one template"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_template_type(self, temp_dir, valid_pack_data):
|
||||
"""Test template with invalid type."""
|
||||
valid_pack_data["provides"]["templates"][0]["type"] = "invalid"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template type"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_valid_template_types(self):
|
||||
"""Test that all expected template types are valid."""
|
||||
assert "artifact" in VALID_TEMPLATE_TYPES
|
||||
assert "command" in VALID_TEMPLATE_TYPES
|
||||
assert "script" in VALID_TEMPLATE_TYPES
|
||||
|
||||
def test_template_missing_required_fields(self, temp_dir, valid_pack_data):
|
||||
"""Test template missing required fields."""
|
||||
valid_pack_data["provides"]["templates"] = [{"type": "artifact"}]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="missing 'type', 'name', or 'file'"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_template_name_format(self, temp_dir, valid_pack_data):
|
||||
"""Test template with invalid name format."""
|
||||
valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template name"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_get_hash(self, pack_dir):
|
||||
"""Test manifest hash calculation."""
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
hash_val = manifest.get_hash()
|
||||
assert hash_val.startswith("sha256:")
|
||||
assert len(hash_val) > 10
|
||||
|
||||
def test_multiple_templates(self, temp_dir, valid_pack_data):
|
||||
"""Test pack with multiple templates of different types."""
|
||||
valid_pack_data["provides"]["templates"] = [
|
||||
{"type": "artifact", "name": "spec-template", "file": "templates/spec-template.md"},
|
||||
{"type": "artifact", "name": "plan-template", "file": "templates/plan-template.md"},
|
||||
{"type": "command", "name": "specify", "file": "commands/specify.md"},
|
||||
{"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"},
|
||||
]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
assert len(manifest.templates) == 4
|
||||
|
||||
|
||||
# ===== TemplatePackRegistry Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackRegistry:
|
||||
"""Test TemplatePackRegistry operations."""
|
||||
|
||||
def test_empty_registry(self, temp_dir):
|
||||
"""Test empty registry initialization."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.list() == {}
|
||||
assert not registry.is_installed("test-pack")
|
||||
|
||||
def test_add_and_get(self, temp_dir):
|
||||
"""Test adding and retrieving a pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("test-pack", {"version": "1.0.0", "source": "local"})
|
||||
assert registry.is_installed("test-pack")
|
||||
|
||||
metadata = registry.get("test-pack")
|
||||
assert metadata is not None
|
||||
assert metadata["version"] == "1.0.0"
|
||||
assert "installed_at" in metadata
|
||||
|
||||
def test_remove(self, temp_dir):
|
||||
"""Test removing a pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("test-pack", {"version": "1.0.0"})
|
||||
assert registry.is_installed("test-pack")
|
||||
|
||||
registry.remove("test-pack")
|
||||
assert not registry.is_installed("test-pack")
|
||||
|
||||
def test_remove_nonexistent(self, temp_dir):
|
||||
"""Test removing a pack that doesn't exist."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
registry.remove("nonexistent") # Should not raise
|
||||
|
||||
def test_list(self, temp_dir):
|
||||
"""Test listing all packs."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("pack-a", {"version": "1.0.0"})
|
||||
registry.add("pack-b", {"version": "2.0.0"})
|
||||
|
||||
all_packs = registry.list()
|
||||
assert len(all_packs) == 2
|
||||
assert "pack-a" in all_packs
|
||||
assert "pack-b" in all_packs
|
||||
|
||||
def test_persistence(self, temp_dir):
|
||||
"""Test that registry data persists across instances."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
|
||||
# Add with first instance
|
||||
registry1 = TemplatePackRegistry(packs_dir)
|
||||
registry1.add("test-pack", {"version": "1.0.0"})
|
||||
|
||||
# Load with second instance
|
||||
registry2 = TemplatePackRegistry(packs_dir)
|
||||
assert registry2.is_installed("test-pack")
|
||||
|
||||
def test_corrupted_registry(self, temp_dir):
|
||||
"""Test recovery from corrupted registry file."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
|
||||
registry_file = packs_dir / ".registry"
|
||||
registry_file.write_text("not valid json{{{")
|
||||
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.list() == {}
|
||||
|
||||
def test_get_nonexistent(self, temp_dir):
|
||||
"""Test getting a nonexistent pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
|
||||
# ===== TemplatePackManager Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackManager:
|
||||
"""Test TemplatePackManager installation and removal."""
|
||||
|
||||
def test_install_from_directory(self, project_dir, pack_dir):
|
||||
"""Test installing a template pack from a directory."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
# Verify files are copied
|
||||
installed_dir = project_dir / ".specify" / "templates" / "packs" / "test-pack"
|
||||
assert installed_dir.exists()
|
||||
assert (installed_dir / "template-pack.yml").exists()
|
||||
assert (installed_dir / "templates" / "spec-template.md").exists()
|
||||
|
||||
def test_install_already_installed(self, project_dir, pack_dir):
|
||||
"""Test installing an already-installed pack raises error."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
with pytest.raises(TemplateError, match="already installed"):
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data):
|
||||
"""Test installing an incompatible pack raises error."""
|
||||
valid_pack_data["requires"]["speckit_version"] = ">=99.0.0"
|
||||
incompat_dir = temp_dir / "incompat-pack"
|
||||
incompat_dir.mkdir()
|
||||
manifest_path = incompat_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
(incompat_dir / "templates").mkdir()
|
||||
(incompat_dir / "templates" / "spec-template.md").write_text("test")
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
with pytest.raises(TemplateCompatibilityError):
|
||||
manager.install_from_directory(incompat_dir, "0.1.5")
|
||||
|
||||
def test_install_from_zip(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from a ZIP file."""
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_zip(zip_path, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from ZIP with nested directory."""
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_zip(zip_path, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
|
||||
def test_install_from_zip_no_manifest(self, project_dir, temp_dir):
|
||||
"""Test installing from ZIP without manifest raises error."""
|
||||
zip_path = temp_dir / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
zf.writestr("readme.txt", "no manifest here")
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
with pytest.raises(TemplateValidationError, match="No template-pack.yml found"):
|
||||
manager.install_from_zip(zip_path, "0.1.5")
|
||||
|
||||
def test_remove(self, project_dir, pack_dir):
|
||||
"""Test removing a template pack."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
result = manager.remove("test-pack")
|
||||
assert result is True
|
||||
assert not manager.registry.is_installed("test-pack")
|
||||
|
||||
installed_dir = project_dir / ".specify" / "templates" / "packs" / "test-pack"
|
||||
assert not installed_dir.exists()
|
||||
|
||||
def test_remove_nonexistent(self, project_dir):
|
||||
"""Test removing a pack that doesn't exist."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
result = manager.remove("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_list_installed(self, project_dir, pack_dir):
|
||||
"""Test listing installed packs."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
installed = manager.list_installed()
|
||||
assert len(installed) == 1
|
||||
assert installed[0]["id"] == "test-pack"
|
||||
assert installed[0]["name"] == "Test Template Pack"
|
||||
assert installed[0]["version"] == "1.0.0"
|
||||
assert installed[0]["template_count"] == 1
|
||||
|
||||
def test_list_installed_empty(self, project_dir):
|
||||
"""Test listing when no packs installed."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
assert manager.list_installed() == []
|
||||
|
||||
def test_get_pack(self, project_dir, pack_dir):
|
||||
"""Test getting a specific installed pack."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
pack = manager.get_pack("test-pack")
|
||||
assert pack is not None
|
||||
assert pack.id == "test-pack"
|
||||
|
||||
def test_get_pack_not_installed(self, project_dir):
|
||||
"""Test getting a non-installed pack returns None."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
assert manager.get_pack("nonexistent") is None
|
||||
|
||||
def test_check_compatibility_valid(self, pack_dir):
|
||||
"""Test compatibility check with valid version."""
|
||||
manager = TemplatePackManager(Path(tempfile.mkdtemp()))
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
assert manager.check_compatibility(manifest, "0.1.5") is True
|
||||
|
||||
def test_check_compatibility_invalid(self, pack_dir):
|
||||
"""Test compatibility check with invalid specifier."""
|
||||
manager = TemplatePackManager(Path(tempfile.mkdtemp()))
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
manifest.data["requires"]["speckit_version"] = "not-a-specifier"
|
||||
with pytest.raises(TemplateCompatibilityError, match="Invalid version specifier"):
|
||||
manager.check_compatibility(manifest, "0.1.5")
|
||||
|
||||
|
||||
# ===== TemplateResolver Tests =====
|
||||
|
||||
|
||||
class TestTemplateResolver:
|
||||
"""Test TemplateResolver priority stack."""
|
||||
|
||||
def test_resolve_core_template(self, project_dir):
|
||||
"""Test resolving a core template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert result.name == "spec-template.md"
|
||||
assert "Core Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_nonexistent(self, project_dir):
|
||||
"""Test resolving a nonexistent template returns None."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("nonexistent-template")
|
||||
assert result is None
|
||||
|
||||
def test_resolve_override_takes_priority(self, project_dir):
|
||||
"""Test that project overrides take priority over core."""
|
||||
# Create override
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override Spec Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Override Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir):
|
||||
"""Test that installed packs take priority over core templates."""
|
||||
# Install the pack
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
|
||||
"""Test that overrides take priority over installed packs."""
|
||||
# Install the pack
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
# Create override
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override Spec Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Override Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_extension_provided_templates(self, project_dir):
|
||||
"""Test resolving templates provided by extensions."""
|
||||
# Create extension with templates
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "custom-template.md"
|
||||
ext_template.write_text("# Extension Custom Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("custom-template")
|
||||
assert result is not None
|
||||
assert "Extension Custom Template" in result.read_text()
|
||||
|
||||
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
|
||||
"""Test that pack templates take priority over extension templates."""
|
||||
# Create extension with templates
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "spec-template.md"
|
||||
ext_template.write_text("# Extension Spec Template\n")
|
||||
|
||||
# Install a pack with the same template
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
# Pack should win over extension
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_with_source_core(self, project_dir):
|
||||
"""Test resolve_with_source for core template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "core"
|
||||
assert "spec-template.md" in result["path"]
|
||||
|
||||
def test_resolve_with_source_override(self, project_dir):
|
||||
"""Test resolve_with_source for override template."""
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "project override"
|
||||
|
||||
def test_resolve_with_source_pack(self, project_dir, pack_dir):
|
||||
"""Test resolve_with_source for pack template."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert "test-pack" in result["source"]
|
||||
assert "v1.0.0" in result["source"]
|
||||
|
||||
def test_resolve_with_source_extension(self, project_dir):
|
||||
"""Test resolve_with_source for extension-provided template."""
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "unique-template.md"
|
||||
ext_template.write_text("# Unique\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("unique-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "extension:my-ext"
|
||||
|
||||
def test_resolve_with_source_not_found(self, project_dir):
|
||||
"""Test resolve_with_source for nonexistent template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_resolve_skips_hidden_extension_dirs(self, project_dir):
|
||||
"""Test that hidden directories in extensions are skipped."""
|
||||
ext_dir = project_dir / ".specify" / "extensions" / ".backup"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "hidden-template.md"
|
||||
ext_template.write_text("# Hidden\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("hidden-template")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===== TemplateCatalog Tests =====
|
||||
|
||||
|
||||
class TestTemplateCatalog:
|
||||
"""Test template catalog functionality."""
|
||||
|
||||
def test_default_catalog_url(self, project_dir):
|
||||
"""Test default catalog URL."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert "githubusercontent.com" in catalog.DEFAULT_CATALOG_URL
|
||||
assert "templates/catalog.json" in catalog.DEFAULT_CATALOG_URL
|
||||
|
||||
def test_community_catalog_url(self, project_dir):
|
||||
"""Test community catalog URL."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert "templates/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL
|
||||
|
||||
def test_cache_validation_no_cache(self, project_dir):
|
||||
"""Test cache validation when no cache exists."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_cache_validation_valid(self, project_dir):
|
||||
"""Test cache validation with valid cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text(json.dumps({
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {},
|
||||
}))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
assert catalog.is_cache_valid() is True
|
||||
|
||||
def test_cache_validation_expired(self, project_dir):
|
||||
"""Test cache validation with expired cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text(json.dumps({
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {},
|
||||
}))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": "2020-01-01T00:00:00+00:00",
|
||||
}))
|
||||
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_cache_validation_corrupted(self, project_dir):
|
||||
"""Test cache validation with corrupted metadata."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text("not json")
|
||||
catalog.cache_metadata_file.write_text("not json")
|
||||
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_clear_cache(self, project_dir):
|
||||
"""Test clearing the cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}")
|
||||
catalog.cache_metadata_file.write_text("{}")
|
||||
|
||||
catalog.clear_cache()
|
||||
|
||||
assert not catalog.cache_file.exists()
|
||||
assert not catalog.cache_metadata_file.exists()
|
||||
|
||||
def test_search_with_cached_data(self, project_dir):
|
||||
"""Test search with cached catalog data."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {
|
||||
"safe-agile": {
|
||||
"name": "SAFe Agile Templates",
|
||||
"description": "SAFe-aligned templates",
|
||||
"author": "agile-community",
|
||||
"version": "1.0.0",
|
||||
"tags": ["safe", "agile"],
|
||||
},
|
||||
"healthcare": {
|
||||
"name": "Healthcare Compliance",
|
||||
"description": "HIPAA-compliant templates",
|
||||
"author": "healthcare-org",
|
||||
"version": "1.0.0",
|
||||
"tags": ["healthcare", "hipaa"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
# Search by query
|
||||
results = catalog.search(query="agile")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "safe-agile"
|
||||
|
||||
# Search by tag
|
||||
results = catalog.search(tag="hipaa")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "healthcare"
|
||||
|
||||
# Search by author
|
||||
results = catalog.search(author="agile-community")
|
||||
assert len(results) == 1
|
||||
|
||||
# Search all
|
||||
results = catalog.search()
|
||||
assert len(results) == 2
|
||||
|
||||
def test_get_pack_info(self, project_dir):
|
||||
"""Test getting info for a specific pack."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {
|
||||
"test-pack": {
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
info = catalog.get_pack_info("test-pack")
|
||||
assert info is not None
|
||||
assert info["name"] == "Test Pack"
|
||||
assert info["id"] == "test-pack"
|
||||
|
||||
assert catalog.get_pack_info("nonexistent") is None
|
||||
|
||||
def test_validate_catalog_url_https(self, project_dir):
|
||||
"""Test that HTTPS URLs are accepted."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog._validate_catalog_url("https://example.com/catalog.json")
|
||||
|
||||
def test_validate_catalog_url_http_rejected(self, project_dir):
|
||||
"""Test that HTTP URLs are rejected."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
with pytest.raises(TemplateValidationError, match="must use HTTPS"):
|
||||
catalog._validate_catalog_url("http://example.com/catalog.json")
|
||||
|
||||
def test_validate_catalog_url_localhost_http_allowed(self, project_dir):
|
||||
"""Test that HTTP is allowed for localhost."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog._validate_catalog_url("http://localhost:8080/catalog.json")
|
||||
catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json")
|
||||
|
||||
def test_env_var_catalog_url(self, project_dir, monkeypatch):
|
||||
"""Test catalog URL from environment variable."""
|
||||
monkeypatch.setenv("SPECKIT_TEMPLATE_CATALOG_URL", "https://custom.example.com/catalog.json")
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json"
|
||||
|
||||
|
||||
# ===== Integration Tests =====
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for complete template pack workflows."""
|
||||
|
||||
def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir):
|
||||
"""Test complete lifecycle: install → resolve → remove."""
|
||||
# Install
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_directory(pack_dir, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
|
||||
# Resolve — pack template should win over core
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
# Remove
|
||||
manager.remove("test-pack")
|
||||
|
||||
# Resolve — should fall back to core
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Core Spec Template" in result.read_text()
|
||||
|
||||
def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir):
|
||||
"""Test the full priority stack: override > pack > extension > core."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
|
||||
# Core should resolve
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "core"
|
||||
|
||||
# Add extension template
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "extension:my-ext"
|
||||
|
||||
# Install pack — should win over extension
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert "test-pack" in result["source"]
|
||||
|
||||
# Add override — should win over pack
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
(overrides_dir / "spec-template.md").write_text("# Override\n")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "project override"
|
||||
|
||||
def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from ZIP and then resolving."""
|
||||
# Create ZIP
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
# Install
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_zip(zip_path, "0.1.5")
|
||||
|
||||
# Resolve
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
Reference in New Issue
Block a user