mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 02:43:08 +00:00
Compare commits
18 Commits
f5f8311415
...
13a46dd8b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13a46dd8b2 | ||
|
|
1a0f8b17ea | ||
|
|
db66637f8d | ||
|
|
f7fbda53d2 | ||
|
|
7259652c9e | ||
|
|
d8bc72f1cf | ||
|
|
017e1c4c2f | ||
|
|
7562664fd1 | ||
|
|
976c9981a4 | ||
|
|
d3fc056743 | ||
|
|
58ce653908 | ||
|
|
82f8a13f83 | ||
|
|
0f1cbd74fe | ||
|
|
ec60c5b2fe | ||
|
|
e56d37db8c | ||
|
|
33e853e9c9 | ||
|
|
929fab5d98 | ||
|
|
56095f06d2 |
@@ -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" \
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
.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)
|
||||
@@ -201,6 +201,93 @@ 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,
|
||||
@@ -241,7 +328,6 @@ 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
|
||||
}
|
||||
@@ -281,11 +367,9 @@ function Build-Variant {
|
||||
$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") {
|
||||
@@ -298,7 +382,7 @@ function Build-Variant {
|
||||
}
|
||||
'qwen' {
|
||||
$cmdDir = Join-Path $baseDir ".qwen/commands"
|
||||
Generate-Commands -Agent 'qwen' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
Generate-Commands -Agent 'qwen' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
if (Test-Path "agent_templates/qwen/QWEN.md") {
|
||||
Copy-Item -Path "agent_templates/qwen/QWEN.md" -Destination (Join-Path $baseDir "QWEN.md")
|
||||
}
|
||||
@@ -358,17 +442,22 @@ function Build-Variant {
|
||||
if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') }
|
||||
}
|
||||
'agy' {
|
||||
$cmdDir = Join-Path $baseDir ".agent/workflows"
|
||||
$cmdDir = Join-Path $baseDir ".agent/commands"
|
||||
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'."
|
||||
}
|
||||
@@ -381,7 +470,7 @@ 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 {
|
||||
@@ -391,7 +480,6 @@ function Normalize-List {
|
||||
return @()
|
||||
}
|
||||
|
||||
# Split by comma or space and remove duplicates while preserving order
|
||||
$items = $Input -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique
|
||||
return $items
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -113,7 +113,6 @@ generate_copilot_prompts() {
|
||||
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,6 +121,76 @@ 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}"
|
||||
@@ -140,12 +209,10 @@ build_variant() {
|
||||
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
|
||||
@@ -153,11 +220,6 @@ build_variant() {
|
||||
|
||||
[[ -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)
|
||||
mkdir -p "$base_dir/.claude/commands"
|
||||
@@ -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"
|
||||
;;
|
||||
@@ -180,7 +240,7 @@ build_variant() {
|
||||
generate_commands cursor-agent md "\$ARGUMENTS" "$base_dir/.cursor/commands" "$script" ;;
|
||||
qwen)
|
||||
mkdir -p "$base_dir/.qwen/commands"
|
||||
generate_commands qwen toml "{{args}}" "$base_dir/.qwen/commands" "$script"
|
||||
generate_commands qwen md "\$ARGUMENTS" "$base_dir/.qwen/commands" "$script"
|
||||
[[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md "$base_dir/QWEN.md" ;;
|
||||
opencode)
|
||||
mkdir -p "$base_dir/.opencode/command"
|
||||
@@ -220,14 +280,17 @@ build_variant() {
|
||||
mkdir -p "$base_dir/.kiro/prompts"
|
||||
generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;;
|
||||
agy)
|
||||
mkdir -p "$base_dir/.agent/workflows"
|
||||
generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/workflows" "$script" ;;
|
||||
mkdir -p "$base_dir/.agent/commands"
|
||||
generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/commands" "$script" ;;
|
||||
bob)
|
||||
mkdir -p "$base_dir/.bob/commands"
|
||||
generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;;
|
||||
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")}'
|
||||
}
|
||||
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -10,10 +10,6 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
|
||||
|
||||
---
|
||||
|
||||
## General practices
|
||||
|
||||
- Any changes to `__init__.py` for the Specify CLI require a version rev in `pyproject.toml` and addition of entries to `CHANGELOG.md`.
|
||||
|
||||
## Adding New Agent Support
|
||||
|
||||
This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow.
|
||||
@@ -35,7 +31,7 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
||||
| **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI |
|
||||
| **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code |
|
||||
| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI |
|
||||
| **Qwen Code** | `.qwen/commands/` | TOML | `qwen` | Alibaba's Qwen Code CLI |
|
||||
| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI |
|
||||
| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI |
|
||||
| **Codex CLI** | `.codex/commands/` | Markdown | `codex` | Codex CLI |
|
||||
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
|
||||
@@ -48,6 +44,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 |
|
||||
|
||||
@@ -87,7 +84,7 @@ This eliminates the need for special-case mappings throughout the codebase.
|
||||
- `folder`: Directory where agent-specific files are stored (relative to project root)
|
||||
- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`)
|
||||
- Most agents use `"commands"` (e.g., `.claude/commands/`)
|
||||
- Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode, agy), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular)
|
||||
- Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular)
|
||||
- This field enables `--ai-skills` to locate command templates correctly for skill generation
|
||||
- `install_url`: Installation documentation URL (set to `None` for IDE-based agents)
|
||||
- `requires_cli`: Whether the agent requires a CLI tool check during initialization
|
||||
@@ -324,6 +321,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 +335,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, Qwen
|
||||
|
||||
**Standard format:**
|
||||
|
||||
@@ -362,7 +360,7 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders.
|
||||
|
||||
### TOML Format
|
||||
|
||||
Used by: Gemini, Qwen, Tabnine
|
||||
Used by: Gemini, Tabnine
|
||||
|
||||
```toml
|
||||
description = "Command description"
|
||||
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -7,7 +7,7 @@ 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-10
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -28,10 +28,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations
|
||||
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
|
||||
|
||||
## [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)
|
||||
- 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.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)
|
||||
|
||||
13
README.md
13
README.md
@@ -154,7 +154,9 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
|
||||
- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.
|
||||
|
||||
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
|
||||
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
|
||||
|
||||
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
|
||||
|
||||
## 🤖 Supported AI Agents
|
||||
|
||||
@@ -179,8 +181,9 @@ 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/) | ✅ | |
|
||||
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
|
||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||
|
||||
## 🔧 Specify CLI Reference
|
||||
@@ -192,14 +195,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 |
|
||||
@@ -245,7 +248,7 @@ specify init my-project --ai vibe
|
||||
specify init my-project --ai bob
|
||||
|
||||
# Initialize with Antigravity support
|
||||
specify init my-project --ai agy
|
||||
specify init my-project --ai agy --ai-skills
|
||||
|
||||
# Initialize with an unsupported agent (generic / bring your own agent)
|
||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# RFC: Spec Kit Extension System
|
||||
|
||||
**Status**: Draft
|
||||
**Status**: Implemented
|
||||
**Author**: Stats Perform Engineering
|
||||
**Created**: 2026-01-28
|
||||
**Updated**: 2026-01-28
|
||||
**Updated**: 2026-03-11
|
||||
|
||||
---
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
13. [Security Considerations](#security-considerations)
|
||||
14. [Migration Strategy](#migration-strategy)
|
||||
15. [Implementation Phases](#implementation-phases)
|
||||
16. [Open Questions](#open-questions)
|
||||
17. [Appendices](#appendices)
|
||||
16. [Resolved Questions](#resolved-questions)
|
||||
17. [Open Questions (Remaining)](#open-questions-remaining)
|
||||
18. [Appendices](#appendices)
|
||||
|
||||
---
|
||||
|
||||
@@ -1504,203 +1505,225 @@ AI agent registers both names, so old scripts work.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Extension System (Week 1-2)
|
||||
### Phase 1: Core Extension System ✅ COMPLETED
|
||||
|
||||
**Goal**: Basic extension infrastructure
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
- [ ] Extension manifest schema (`extension.yml`)
|
||||
- [ ] Extension directory structure
|
||||
- [ ] CLI commands:
|
||||
- [ ] `specify extension list`
|
||||
- [ ] `specify extension add` (from URL)
|
||||
- [ ] `specify extension remove`
|
||||
- [ ] Extension registry (`.specify/extensions/.registry`)
|
||||
- [ ] Command registration (Claude only initially)
|
||||
- [ ] Basic validation (manifest schema, compatibility)
|
||||
- [ ] Documentation (extension development guide)
|
||||
- [x] Extension manifest schema (`extension.yml`)
|
||||
- [x] Extension directory structure
|
||||
- [x] CLI commands:
|
||||
- [x] `specify extension list`
|
||||
- [x] `specify extension add` (from URL and local `--dev`)
|
||||
- [x] `specify extension remove`
|
||||
- [x] Extension registry (`.specify/extensions/.registry`)
|
||||
- [x] Command registration (Claude and 15+ other agents)
|
||||
- [x] Basic validation (manifest schema, compatibility)
|
||||
- [x] Documentation (extension development guide)
|
||||
|
||||
**Testing**:
|
||||
|
||||
- [ ] Unit tests for manifest parsing
|
||||
- [ ] Integration test: Install dummy extension
|
||||
- [ ] Integration test: Register commands with Claude
|
||||
- [x] Unit tests for manifest parsing
|
||||
- [x] Integration test: Install dummy extension
|
||||
- [x] Integration test: Register commands with Claude
|
||||
|
||||
### Phase 2: Jira Extension (Week 3)
|
||||
### Phase 2: Jira Extension ✅ COMPLETED
|
||||
|
||||
**Goal**: First production extension
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
- [ ] Create `spec-kit-jira` repository
|
||||
- [ ] Port Jira functionality to extension
|
||||
- [ ] Create `jira-config.yml` template
|
||||
- [ ] Commands:
|
||||
- [ ] `specstoissues.md`
|
||||
- [ ] `discover-fields.md`
|
||||
- [ ] `sync-status.md`
|
||||
- [ ] Helper scripts
|
||||
- [ ] Documentation (README, configuration guide, examples)
|
||||
- [ ] Release v1.0.0
|
||||
- [x] Create `spec-kit-jira` repository
|
||||
- [x] Port Jira functionality to extension
|
||||
- [x] Create `jira-config.yml` template
|
||||
- [x] Commands:
|
||||
- [x] `specstoissues.md`
|
||||
- [x] `discover-fields.md`
|
||||
- [x] `sync-status.md`
|
||||
- [x] Helper scripts
|
||||
- [x] Documentation (README, configuration guide, examples)
|
||||
- [x] Release v3.0.0
|
||||
|
||||
**Testing**:
|
||||
|
||||
- [ ] Test on `eng-msa-ts` project
|
||||
- [ ] Verify spec→Epic, phase→Story, task→Issue mapping
|
||||
- [ ] Test configuration loading and validation
|
||||
- [ ] Test custom field application
|
||||
- [x] Test on `eng-msa-ts` project
|
||||
- [x] Verify spec→Epic, phase→Story, task→Issue mapping
|
||||
- [x] Test configuration loading and validation
|
||||
- [x] Test custom field application
|
||||
|
||||
### Phase 3: Extension Catalog (Week 4)
|
||||
### Phase 3: Extension Catalog ✅ COMPLETED
|
||||
|
||||
**Goal**: Discovery and distribution
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
- [ ] Central catalog (`extensions/catalog.json` in spec-kit repo)
|
||||
- [ ] Catalog fetch and parsing
|
||||
- [ ] CLI commands:
|
||||
- [ ] `specify extension search`
|
||||
- [ ] `specify extension info`
|
||||
- [ ] Catalog publishing process (GitHub Action)
|
||||
- [ ] Documentation (how to publish extensions)
|
||||
- [x] Central catalog (`extensions/catalog.json` in spec-kit repo)
|
||||
- [x] Community catalog (`extensions/catalog.community.json`)
|
||||
- [x] Catalog fetch and parsing with multi-catalog support
|
||||
- [x] CLI commands:
|
||||
- [x] `specify extension search`
|
||||
- [x] `specify extension info`
|
||||
- [x] `specify extension catalog list`
|
||||
- [x] `specify extension catalog add`
|
||||
- [x] `specify extension catalog remove`
|
||||
- [x] Documentation (how to publish extensions)
|
||||
|
||||
**Testing**:
|
||||
|
||||
- [ ] Test catalog fetch
|
||||
- [ ] Test extension search/filtering
|
||||
- [ ] Test catalog caching
|
||||
- [x] Test catalog fetch
|
||||
- [x] Test extension search/filtering
|
||||
- [x] Test catalog caching
|
||||
- [x] Test multi-catalog merge with priority
|
||||
|
||||
### Phase 4: Advanced Features (Week 5-6)
|
||||
### Phase 4: Advanced Features ✅ COMPLETED
|
||||
|
||||
**Goal**: Hooks, updates, multi-agent support
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
- [ ] Hook system (`hooks` in extension.yml)
|
||||
- [ ] Hook registration and execution
|
||||
- [ ] Project extensions config (`.specify/extensions.yml`)
|
||||
- [ ] CLI commands:
|
||||
- [ ] `specify extension update`
|
||||
- [ ] `specify extension enable/disable`
|
||||
- [ ] Command registration for multiple agents (Gemini, Copilot)
|
||||
- [ ] Extension update notifications
|
||||
- [ ] Configuration layer resolution (project, local, env)
|
||||
- [x] Hook system (`hooks` in extension.yml)
|
||||
- [x] Hook registration and execution
|
||||
- [x] Project extensions config (`.specify/extensions.yml`)
|
||||
- [x] CLI commands:
|
||||
- [x] `specify extension update` (with atomic backup/restore)
|
||||
- [x] `specify extension enable/disable`
|
||||
- [x] Command registration for multiple agents (15+ agents including Claude, Copilot, Gemini, Cursor, etc.)
|
||||
- [x] Extension update notifications (version comparison)
|
||||
- [x] Configuration layer resolution (project, local, env)
|
||||
|
||||
**Additional features implemented beyond original RFC**:
|
||||
|
||||
- [x] **Display name resolution**: All commands accept extension display names in addition to IDs
|
||||
- [x] **Ambiguous name handling**: User-friendly tables when multiple extensions match a name
|
||||
- [x] **Atomic update with rollback**: Full backup of extension dir, commands, hooks, and registry with automatic rollback on failure
|
||||
- [x] **Pre-install ID validation**: Validates extension ID from ZIP before installing (security)
|
||||
- [x] **Enabled state preservation**: Disabled extensions stay disabled after update
|
||||
- [x] **Registry update/restore methods**: Clean API for enable/disable and rollback operations
|
||||
- [x] **Catalog error fallback**: `extension info` falls back to local info when catalog unavailable
|
||||
- [x] **`_install_allowed` flag**: Discovery-only catalogs can't be used for installation
|
||||
- [x] **Cache invalidation**: Cache invalidated when `SPECKIT_CATALOG_URL` changes
|
||||
|
||||
**Testing**:
|
||||
|
||||
- [ ] Test hooks in core commands
|
||||
- [ ] Test extension updates (preserve config)
|
||||
- [ ] Test multi-agent registration
|
||||
- [x] Test hooks in core commands
|
||||
- [x] Test extension updates (preserve config)
|
||||
- [x] Test multi-agent registration
|
||||
- [x] Test atomic rollback on update failure
|
||||
- [x] Test enabled state preservation
|
||||
- [x] Test display name resolution
|
||||
|
||||
### Phase 5: Polish & Documentation (Week 7)
|
||||
### Phase 5: Polish & Documentation ✅ COMPLETED
|
||||
|
||||
**Goal**: Production ready
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
- [ ] Comprehensive documentation:
|
||||
- [ ] User guide (installing/using extensions)
|
||||
- [ ] Extension development guide
|
||||
- [ ] Extension API reference
|
||||
- [ ] Migration guide (core → extension)
|
||||
- [ ] Error messages and validation improvements
|
||||
- [ ] CLI help text updates
|
||||
- [ ] Example extension template (cookiecutter)
|
||||
- [ ] Blog post / announcement
|
||||
- [ ] Video tutorial
|
||||
- [x] Comprehensive documentation:
|
||||
- [x] User guide (EXTENSION-USER-GUIDE.md)
|
||||
- [x] Extension development guide (EXTENSION-DEV-GUIDE.md)
|
||||
- [x] Extension API reference (EXTENSION-API-REFERENCE.md)
|
||||
- [x] Error messages and validation improvements
|
||||
- [x] CLI help text updates
|
||||
|
||||
**Testing**:
|
||||
|
||||
- [ ] End-to-end testing on multiple projects
|
||||
- [ ] Community beta testing
|
||||
- [ ] Performance testing (large projects)
|
||||
- [x] End-to-end testing on multiple projects
|
||||
- [x] 163 unit tests passing
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
## Resolved Questions
|
||||
|
||||
### 1. Extension Namespace
|
||||
The following questions from the original RFC have been resolved during implementation:
|
||||
|
||||
### 1. Extension Namespace ✅ RESOLVED
|
||||
|
||||
**Question**: Should extension commands use namespace prefix?
|
||||
|
||||
**Options**:
|
||||
**Decision**: **Option C** - Both prefixed and aliases are supported. Commands use `speckit.{extension}.{command}` as canonical name, with optional aliases defined in manifest.
|
||||
|
||||
- A) Prefixed: `/speckit.jira.specstoissues` (explicit, avoids conflicts)
|
||||
- B) Short alias: `/jira.specstoissues` (shorter, less verbose)
|
||||
- C) Both: Register both names, prefer prefixed in docs
|
||||
|
||||
**Recommendation**: C (both), prefixed is canonical
|
||||
**Implementation**: The `aliases` field in `extension.yml` allows extensions to register additional command names.
|
||||
|
||||
---
|
||||
|
||||
### 2. Config File Location
|
||||
### 2. Config File Location ✅ RESOLVED
|
||||
|
||||
**Question**: Where should extension configs live?
|
||||
|
||||
**Options**:
|
||||
**Decision**: **Option A** - Extension directory (`.specify/extensions/{ext-id}/{ext-id}-config.yml`). This keeps extensions self-contained and easier to manage.
|
||||
|
||||
- A) Extension directory: `.specify/extensions/jira/jira-config.yml` (encapsulated)
|
||||
- B) Root level: `.specify/jira-config.yml` (more visible)
|
||||
- C) Unified: `.specify/extensions.yml` (all extension configs in one file)
|
||||
|
||||
**Recommendation**: A (extension directory), cleaner separation
|
||||
**Implementation**: Each extension has its own config file within its directory, with layered resolution (defaults → project → local → env vars).
|
||||
|
||||
---
|
||||
|
||||
### 3. Command File Format
|
||||
### 3. Command File Format ✅ RESOLVED
|
||||
|
||||
**Question**: Should extensions use universal format or agent-specific?
|
||||
|
||||
**Options**:
|
||||
**Decision**: **Option A** - Universal Markdown format. Extensions write commands once, CLI converts to agent-specific format during registration.
|
||||
|
||||
- A) Universal Markdown: Extensions write once, CLI converts per-agent
|
||||
- B) Agent-specific: Extensions provide separate files for each agent
|
||||
- C) Hybrid: Universal default, agent-specific overrides
|
||||
|
||||
**Recommendation**: A (universal), reduces duplication
|
||||
**Implementation**: `CommandRegistrar` class handles conversion to 15+ agent formats (Claude, Copilot, Gemini, Cursor, etc.).
|
||||
|
||||
---
|
||||
|
||||
### 4. Hook Execution Model
|
||||
### 4. Hook Execution Model ✅ RESOLVED
|
||||
|
||||
**Question**: How should hooks execute?
|
||||
|
||||
**Options**:
|
||||
**Decision**: **Option A** - Hooks are registered in `.specify/extensions.yml` and executed by the AI agent when it sees the hook trigger. Hook state (enabled/disabled) is managed per-extension.
|
||||
|
||||
- A) AI agent interprets: Core commands output `EXECUTE_COMMAND: name`
|
||||
- B) CLI executes: Core commands call `specify extension hook after_tasks`
|
||||
- C) Agent built-in: Extension system built into AI agent (Claude SDK)
|
||||
|
||||
**Recommendation**: A initially (simpler), move to C long-term
|
||||
**Implementation**: `HookExecutor` class manages hook registration and state in `extensions.yml`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Extension Distribution
|
||||
### 5. Extension Distribution ✅ RESOLVED
|
||||
|
||||
**Question**: How should extensions be packaged?
|
||||
|
||||
**Options**:
|
||||
**Decision**: **Option A** - ZIP archives downloaded from GitHub releases (via catalog `download_url`). Local development uses `--dev` flag with directory path.
|
||||
|
||||
- A) ZIP archives: Downloaded from GitHub releases
|
||||
- B) Git repos: Cloned directly (`git clone`)
|
||||
- C) Python packages: Installable via `uv tool install`
|
||||
|
||||
**Recommendation**: A (ZIP), simpler for non-Python extensions in future
|
||||
**Implementation**: `ExtensionManager.install_from_zip()` handles ZIP extraction and validation.
|
||||
|
||||
---
|
||||
|
||||
### 6. Multi-Version Support
|
||||
### 6. Multi-Version Support ✅ RESOLVED
|
||||
|
||||
**Question**: Can multiple versions of same extension coexist?
|
||||
|
||||
**Decision**: **Option A** - Single version only. Updates replace the existing version with atomic rollback on failure.
|
||||
|
||||
**Implementation**: `extension update` performs atomic backup/restore to ensure safe updates.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions (Remaining)
|
||||
|
||||
### 1. Sandboxing / Permissions (Future)
|
||||
|
||||
**Question**: Should extensions declare required permissions?
|
||||
|
||||
**Options**:
|
||||
|
||||
- A) Single version: Only one version installed at a time
|
||||
- B) Multi-version: Side-by-side versions (`.specify/extensions/jira@1.0/`, `.specify/extensions/jira@2.0/`)
|
||||
- C) Per-branch: Different branches use different versions
|
||||
- A) No sandboxing (current): Extensions run with same privileges as AI agent
|
||||
- B) Permission declarations: Extensions declare `filesystem:read`, `network:external`, etc.
|
||||
- C) Opt-in sandboxing: Organizations can enable permission enforcement
|
||||
|
||||
**Recommendation**: A initially (simpler), consider B in future if needed
|
||||
**Status**: Deferred to future version. Currently using trust-based model where users trust extension authors.
|
||||
|
||||
---
|
||||
|
||||
### 2. Package Signatures (Future)
|
||||
|
||||
**Question**: Should extensions be cryptographically signed?
|
||||
|
||||
**Options**:
|
||||
|
||||
- A) No signatures (current): Trust based on catalog source
|
||||
- B) GPG/Sigstore signatures: Verify package integrity
|
||||
- C) Catalog-level verification: Catalog maintainers verify packages
|
||||
|
||||
**Status**: Deferred to future version. `checksum` field is available in catalog schema but not enforced.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-02-03T00:00:00Z",
|
||||
"updated_at": "2026-03-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
|
||||
"extensions": {}
|
||||
"extensions": {
|
||||
"selftest": {
|
||||
"name": "Spec Kit Self-Test Utility",
|
||||
"id": "selftest",
|
||||
"version": "1.0.0",
|
||||
"description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip",
|
||||
"tags": [
|
||||
"testing",
|
||||
"core",
|
||||
"utility"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
69
extensions/selftest/commands/selftest.md
Normal file
69
extensions/selftest/commands/selftest.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
description: "Validate the lifecycle of an extension from the catalog."
|
||||
---
|
||||
|
||||
# Extension Self-Test: `$ARGUMENTS`
|
||||
|
||||
This command drives a self-test simulating the developer experience with the `$ARGUMENTS` extension.
|
||||
|
||||
## Goal
|
||||
|
||||
Validate the end-to-end lifecycle (discovery, installation, registration) for the extension: `$ARGUMENTS`.
|
||||
If `$ARGUMENTS` is empty, you must tell the user to provide an extension name, for example: `/speckit.selftest.extension linear`.
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1: Catalog Discovery Validation
|
||||
|
||||
Check if the extension exists in the Spec Kit catalog.
|
||||
Execute this command and verify that it completes successfully and that the returned extension ID exactly matches `$ARGUMENTS`. If the command fails or the ID does not match `$ARGUMENTS`, fail the test.
|
||||
|
||||
```bash
|
||||
specify extension info "$ARGUMENTS"
|
||||
```
|
||||
|
||||
### Step 2: Simulate Installation
|
||||
|
||||
First, try to add the extension to the current workspace configuration directly. If the catalog provides the extension as `install_allowed: false` (discovery-only), this step is *expected* to fail.
|
||||
|
||||
```bash
|
||||
specify extension add "$ARGUMENTS"
|
||||
```
|
||||
|
||||
Then, simulate adding the extension by installing it from its catalog download URL, which should bypass the restriction.
|
||||
Obtain the extension's `download_url` from the catalog metadata (for example, via a catalog info command or UI), then run:
|
||||
|
||||
```bash
|
||||
specify extension add "$ARGUMENTS" --from "<download_url>"
|
||||
```
|
||||
|
||||
### Step 3: Registration Verification
|
||||
|
||||
Once the `add` command completes, verify the installation by checking the project configuration.
|
||||
Use terminal tools (like `cat`) to verify that the following file contains a record for `$ARGUMENTS`.
|
||||
|
||||
```bash
|
||||
cat .specify/extensions/.registry/$ARGUMENTS.json
|
||||
```
|
||||
|
||||
### Step 4: Verification Report
|
||||
|
||||
Analyze the standard output of the three steps.
|
||||
Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user.
|
||||
|
||||
Example output format:
|
||||
```text
|
||||
============================= test session starts ==============================
|
||||
collected 3 items
|
||||
|
||||
test_selftest_discovery.py::test_catalog_search [PASS/FAIL]
|
||||
Details: [Provide execution result of specify extension search]
|
||||
|
||||
test_selftest_installation.py::test_extension_add [PASS/FAIL]
|
||||
Details: [Provide execution result of specify extension add]
|
||||
|
||||
test_selftest_registration.py::test_config_verification [PASS/FAIL]
|
||||
Details: [Provide execution result of registry record verification]
|
||||
|
||||
============================== [X] passed in ... ==============================
|
||||
```
|
||||
16
extensions/selftest/extension.yml
Normal file
16
extensions/selftest/extension.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
schema_version: "1.0"
|
||||
extension:
|
||||
id: selftest
|
||||
name: Spec Kit Self-Test Utility
|
||||
version: 1.0.0
|
||||
description: Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
requires:
|
||||
speckit_version: ">=0.2.0"
|
||||
provides:
|
||||
commands:
|
||||
- name: speckit.selftest.extension
|
||||
file: commands/selftest.md
|
||||
description: Validate the lifecycle of an extension from the catalog.
|
||||
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)
|
||||
@@ -225,6 +225,8 @@ except Exception:
|
||||
local core="$base/${template_name}.md"
|
||||
[ -f "$core" ] && echo "$core" && return 0
|
||||
|
||||
return 1
|
||||
# Return success with empty output so callers using set -e don't abort;
|
||||
# callers check [ -n "$TEMPLATE" ] to detect "not found".
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -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]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -163,7 +163,7 @@ function Resolve-Template {
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Sort-Object { if ($_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -253,7 +253,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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -247,7 +247,7 @@ AGENT_CONFIG = {
|
||||
"agy": {
|
||||
"name": "Antigravity",
|
||||
"folder": ".agent/",
|
||||
"commands_subdir": "workflows", # Special: uses workflows/ not commands/
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None, # IDE-based
|
||||
"requires_cli": 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
|
||||
@@ -1218,6 +1225,11 @@ 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."):]
|
||||
# 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)
|
||||
@@ -1288,6 +1300,22 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
return installed_count > 0 or skipped_count > 0
|
||||
|
||||
|
||||
def _handle_agy_deprecation(console: Console) -> None:
|
||||
"""
|
||||
Print the deprecation error for the Antigravity (agy) agent and exit.
|
||||
|
||||
Context for future AI reviewers (e.g., GitHub Copilot):
|
||||
- Prior to Antigravity v1.20.5, users could rely on explicit agent command definitions generated by this tool.
|
||||
- v1.20.5 removed this "command support" in favor of "Agent Skills".
|
||||
- `specify_cli` handles this by throwing a fail-fast deprecation error if `--ai agy` is
|
||||
invoked without the accompanying `--ai-skills` flag. Users are instructed to add
|
||||
the skills flag to generate agent skills templates instead.
|
||||
"""
|
||||
console.print("\n[red]Error:[/red] Explicit command support was deprecated in Antigravity version 1.20.5.")
|
||||
console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.")
|
||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai agy --ai-skills")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
@@ -1399,6 +1427,49 @@ def init(
|
||||
console.print(error_panel)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_assistant:
|
||||
if ai_assistant not in AGENT_CONFIG:
|
||||
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
selected_ai = ai_assistant
|
||||
else:
|
||||
# Create options dict for selection (agent_key: display_name)
|
||||
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
||||
selected_ai = select_with_arrows(
|
||||
ai_choices,
|
||||
"Choose your AI assistant:",
|
||||
"copilot"
|
||||
)
|
||||
|
||||
# [DEPRECATION NOTICE: Antigravity (agy)]
|
||||
# As of Antigravity v1.20.5, traditional CLI "command" support was fully removed
|
||||
# in favor of "Agent Skills" (SKILL.md files under <agent_folder>/skills/<skill_name>/).
|
||||
# Because 'specify_cli' historically populated .agent/commands/, we now must explicitly
|
||||
# enforce the `--ai-skills` flag for `agy` to ensure valid template generation.
|
||||
if selected_ai == "agy" and not ai_skills:
|
||||
# If agy was selected interactively (no --ai provided), automatically enable
|
||||
# ai_skills so the agent remains usable without requiring an extra flag.
|
||||
# Preserve deprecation behavior only for explicit '--ai agy' without skills.
|
||||
if ai_assistant:
|
||||
_handle_agy_deprecation(console)
|
||||
else:
|
||||
ai_skills = True
|
||||
console.print(
|
||||
"\n[yellow]Note:[/yellow] 'agy' was selected interactively; "
|
||||
"enabling [cyan]--ai-skills[/cyan] automatically for compatibility "
|
||||
"(explicit .agent/commands usage is deprecated)."
|
||||
)
|
||||
|
||||
# Validate --ai-commands-dir usage
|
||||
if selected_ai == "generic":
|
||||
if not ai_commands_dir:
|
||||
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic")
|
||||
console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]")
|
||||
raise typer.Exit(1)
|
||||
elif ai_commands_dir:
|
||||
console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')")
|
||||
raise typer.Exit(1)
|
||||
|
||||
current_dir = Path.cwd()
|
||||
|
||||
setup_lines = [
|
||||
@@ -1419,30 +1490,6 @@ def init(
|
||||
if not should_init_git:
|
||||
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
|
||||
|
||||
if ai_assistant:
|
||||
if ai_assistant not in AGENT_CONFIG:
|
||||
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
selected_ai = ai_assistant
|
||||
else:
|
||||
# Create options dict for selection (agent_key: display_name)
|
||||
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
||||
selected_ai = select_with_arrows(
|
||||
ai_choices,
|
||||
"Choose your AI assistant:",
|
||||
"copilot"
|
||||
)
|
||||
|
||||
# Validate --ai-commands-dir usage
|
||||
if selected_ai == "generic":
|
||||
if not ai_commands_dir:
|
||||
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic")
|
||||
console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]")
|
||||
raise typer.Exit(1)
|
||||
elif ai_commands_dir:
|
||||
console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not ignore_agent_tools:
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config and agent_config["requires_cli"]:
|
||||
@@ -2375,6 +2422,126 @@ def preset_catalog_remove(
|
||||
# ===== Extension Commands =====
|
||||
|
||||
|
||||
def _resolve_installed_extension(
|
||||
argument: str,
|
||||
installed_extensions: list,
|
||||
command_name: str = "command",
|
||||
allow_not_found: bool = False,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Resolve an extension argument (ID or display name) to an installed extension.
|
||||
|
||||
Args:
|
||||
argument: Extension ID or display name provided by user
|
||||
installed_extensions: List of installed extension dicts from manager.list_installed()
|
||||
command_name: Name of the command for error messages (e.g., "enable", "disable")
|
||||
allow_not_found: If True, return (None, None) when not found instead of raising
|
||||
|
||||
Returns:
|
||||
Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found
|
||||
|
||||
Raises:
|
||||
typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous
|
||||
"""
|
||||
from rich.table import Table
|
||||
|
||||
# First, try exact ID match
|
||||
for ext in installed_extensions:
|
||||
if ext["id"] == argument:
|
||||
return (ext["id"], ext["name"])
|
||||
|
||||
# If not found by ID, try display name match
|
||||
name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()]
|
||||
|
||||
if len(name_matches) == 1:
|
||||
# Unique display-name match
|
||||
return (name_matches[0]["id"], name_matches[0]["name"])
|
||||
elif len(name_matches) > 1:
|
||||
# Ambiguous display-name match
|
||||
console.print(
|
||||
f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
|
||||
"Multiple installed extensions share this name:"
|
||||
)
|
||||
table = Table(title="Matching extensions")
|
||||
table.add_column("ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Name", style="white")
|
||||
table.add_column("Version", style="green")
|
||||
for ext in name_matches:
|
||||
table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", "")))
|
||||
console.print(table)
|
||||
console.print("\nPlease rerun using the extension ID:")
|
||||
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
# No match by ID or display name
|
||||
if allow_not_found:
|
||||
return (None, None)
|
||||
console.print(f"[red]Error:[/red] Extension '{argument}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _resolve_catalog_extension(
|
||||
argument: str,
|
||||
catalog,
|
||||
command_name: str = "info",
|
||||
) -> tuple[Optional[dict], Optional[Exception]]:
|
||||
"""Resolve an extension argument (ID or display name) from the catalog.
|
||||
|
||||
Args:
|
||||
argument: Extension ID or display name provided by user
|
||||
catalog: ExtensionCatalog instance
|
||||
command_name: Name of the command for error messages
|
||||
|
||||
Returns:
|
||||
Tuple of (extension_info, catalog_error)
|
||||
- If found: (ext_info_dict, None)
|
||||
- If catalog error: (None, error)
|
||||
- If not found: (None, None)
|
||||
"""
|
||||
from rich.table import Table
|
||||
from .extensions import ExtensionError
|
||||
|
||||
try:
|
||||
# First try by ID
|
||||
ext_info = catalog.get_extension_info(argument)
|
||||
if ext_info:
|
||||
return (ext_info, None)
|
||||
|
||||
# Try by display name - search using argument as query, then filter for exact match
|
||||
search_results = catalog.search(query=argument)
|
||||
name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
|
||||
|
||||
if len(name_matches) == 1:
|
||||
return (name_matches[0], None)
|
||||
elif len(name_matches) > 1:
|
||||
# Ambiguous display-name match in catalog
|
||||
console.print(
|
||||
f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
|
||||
"Multiple catalog extensions share this name:"
|
||||
)
|
||||
table = Table(title="Matching extensions")
|
||||
table.add_column("ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Name", style="white")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("Catalog", style="dim")
|
||||
for ext in name_matches:
|
||||
table.add_row(
|
||||
ext.get("id", ""),
|
||||
ext.get("name", ""),
|
||||
str(ext.get("version", "")),
|
||||
ext.get("_catalog_name", ""),
|
||||
)
|
||||
console.print(table)
|
||||
console.print("\nPlease rerun using the extension ID:")
|
||||
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Not found
|
||||
return (None, None)
|
||||
|
||||
except ExtensionError as e:
|
||||
return (None, e)
|
||||
|
||||
|
||||
@extension_app.command("list")
|
||||
def extension_list(
|
||||
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
||||
@@ -2673,8 +2840,11 @@ def extension_add(
|
||||
# Install from catalog
|
||||
catalog = ExtensionCatalog(project_root)
|
||||
|
||||
# Check if extension exists in catalog
|
||||
ext_info = catalog.get_extension_info(extension)
|
||||
# Check if extension exists in catalog (supports both ID and display name)
|
||||
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
|
||||
if catalog_error:
|
||||
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
|
||||
raise typer.Exit(1)
|
||||
if not ext_info:
|
||||
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
|
||||
console.print("\nSearch available extensions:")
|
||||
@@ -2694,9 +2864,10 @@ def extension_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Download extension ZIP
|
||||
# Download extension ZIP (use resolved ID, not original argument which may be display name)
|
||||
extension_id = ext_info['id']
|
||||
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
|
||||
zip_path = catalog.download_extension(extension)
|
||||
zip_path = catalog.download_extension(extension_id)
|
||||
|
||||
try:
|
||||
# Install from downloaded ZIP
|
||||
@@ -2729,7 +2900,7 @@ def extension_add(
|
||||
|
||||
@extension_app.command("remove")
|
||||
def extension_remove(
|
||||
extension: str = typer.Argument(help="Extension ID to remove"),
|
||||
extension: str = typer.Argument(help="Extension ID or name to remove"),
|
||||
keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation"),
|
||||
):
|
||||
@@ -2747,25 +2918,19 @@ def extension_remove(
|
||||
|
||||
manager = ExtensionManager(project_root)
|
||||
|
||||
# Check if extension is installed
|
||||
if not manager.registry.is_installed(extension):
|
||||
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
# Resolve extension ID from argument (handles ambiguous names)
|
||||
installed = manager.list_installed()
|
||||
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
|
||||
|
||||
# Get extension info
|
||||
ext_manifest = manager.get_extension(extension)
|
||||
if ext_manifest:
|
||||
ext_name = ext_manifest.name
|
||||
cmd_count = len(ext_manifest.commands)
|
||||
else:
|
||||
ext_name = extension
|
||||
cmd_count = 0
|
||||
# Get extension info for command count
|
||||
ext_manifest = manager.get_extension(extension_id)
|
||||
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
|
||||
|
||||
# Confirm removal
|
||||
if not force:
|
||||
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
||||
console.print(f" • {cmd_count} commands from AI agent")
|
||||
console.print(f" • Extension directory: .specify/extensions/{extension}/")
|
||||
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
|
||||
if not keep_config:
|
||||
console.print(" • Config files (will be backed up)")
|
||||
console.print()
|
||||
@@ -2776,15 +2941,15 @@ def extension_remove(
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Remove extension
|
||||
success = manager.remove(extension, keep_config=keep_config)
|
||||
success = manager.remove(extension_id, keep_config=keep_config)
|
||||
|
||||
if success:
|
||||
console.print(f"\n[green]✓[/green] Extension '{ext_name}' removed successfully")
|
||||
console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully")
|
||||
if keep_config:
|
||||
console.print(f"\nConfig files preserved in .specify/extensions/{extension}/")
|
||||
console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/")
|
||||
else:
|
||||
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/")
|
||||
console.print(f"\nTo reinstall: specify extension add {extension}")
|
||||
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/")
|
||||
console.print(f"\nTo reinstall: specify extension add {extension_id}")
|
||||
else:
|
||||
console.print("[red]Error:[/red] Failed to remove extension")
|
||||
raise typer.Exit(1)
|
||||
@@ -2882,7 +3047,7 @@ def extension_info(
|
||||
extension: str = typer.Argument(help="Extension ID or name"),
|
||||
):
|
||||
"""Show detailed information about an extension."""
|
||||
from .extensions import ExtensionCatalog, ExtensionManager, ExtensionError
|
||||
from .extensions import ExtensionCatalog, ExtensionManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
@@ -2895,15 +3060,74 @@ def extension_info(
|
||||
|
||||
catalog = ExtensionCatalog(project_root)
|
||||
manager = ExtensionManager(project_root)
|
||||
installed = manager.list_installed()
|
||||
|
||||
try:
|
||||
ext_info = catalog.get_extension_info(extension)
|
||||
# Try to resolve from installed extensions first (by ID or name)
|
||||
# Use allow_not_found=True since the extension may be catalog-only
|
||||
resolved_installed_id, resolved_installed_name = _resolve_installed_extension(
|
||||
extension, installed, "info", allow_not_found=True
|
||||
)
|
||||
|
||||
if not ext_info:
|
||||
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
|
||||
# Try catalog lookup (with error handling)
|
||||
# If we resolved an installed extension by display name, use its ID for catalog lookup
|
||||
# to ensure we get the correct catalog entry (not a different extension with same name)
|
||||
lookup_key = resolved_installed_id if resolved_installed_id else extension
|
||||
ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info")
|
||||
|
||||
# Case 1: Found in catalog - show full catalog info
|
||||
if ext_info:
|
||||
_print_extension_info(ext_info, manager)
|
||||
return
|
||||
|
||||
# Case 2: Installed locally but catalog lookup failed or not in catalog
|
||||
if resolved_installed_id:
|
||||
# Get local manifest info
|
||||
ext_manifest = manager.get_extension(resolved_installed_id)
|
||||
metadata = manager.registry.get(resolved_installed_id)
|
||||
|
||||
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})")
|
||||
console.print(f"ID: {resolved_installed_id}")
|
||||
console.print()
|
||||
|
||||
if ext_manifest:
|
||||
console.print(f"{ext_manifest.description}")
|
||||
console.print()
|
||||
# Author is optional in extension.yml, safely retrieve it
|
||||
author = ext_manifest.data.get("extension", {}).get("author")
|
||||
if author:
|
||||
console.print(f"[dim]Author:[/dim] {author}")
|
||||
console.print()
|
||||
|
||||
if ext_manifest.commands:
|
||||
console.print("[bold]Commands:[/bold]")
|
||||
for cmd in ext_manifest.commands:
|
||||
console.print(f" • {cmd['name']}: {cmd.get('description', '')}")
|
||||
console.print()
|
||||
|
||||
# Show catalog status
|
||||
if catalog_error:
|
||||
console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}")
|
||||
console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]")
|
||||
else:
|
||||
console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)")
|
||||
|
||||
console.print()
|
||||
console.print("[green]✓ Installed[/green]")
|
||||
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
||||
return
|
||||
|
||||
# Case 3: Not found anywhere
|
||||
if catalog_error:
|
||||
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
|
||||
console.print("\nTry again when online, or use the extension ID directly.")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Extension '{extension}' not found")
|
||||
console.print("\nTry: specify extension search")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _print_extension_info(ext_info: dict, manager):
|
||||
"""Print formatted extension info from catalog data."""
|
||||
# Header
|
||||
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
||||
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
||||
@@ -2995,18 +3219,22 @@ def extension_info(
|
||||
f"with install_allowed: true to enable installation."
|
||||
)
|
||||
|
||||
except ExtensionError as e:
|
||||
console.print(f"\n[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@extension_app.command("update")
|
||||
def extension_update(
|
||||
extension: str = typer.Argument(None, help="Extension ID to update (or all)"),
|
||||
extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"),
|
||||
):
|
||||
"""Update extension(s) to latest version."""
|
||||
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError
|
||||
from .extensions import (
|
||||
ExtensionManager,
|
||||
ExtensionCatalog,
|
||||
ExtensionError,
|
||||
ValidationError,
|
||||
CommandRegistrar,
|
||||
HookExecutor,
|
||||
)
|
||||
from packaging import version as pkg_version
|
||||
import shutil
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
@@ -3019,18 +3247,17 @@ def extension_update(
|
||||
|
||||
manager = ExtensionManager(project_root)
|
||||
catalog = ExtensionCatalog(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
try:
|
||||
# Get list of extensions to update
|
||||
installed = manager.list_installed()
|
||||
if extension:
|
||||
# Update specific extension
|
||||
if not manager.registry.is_installed(extension):
|
||||
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
extensions_to_update = [extension]
|
||||
# Update specific extension - resolve ID from argument (handles ambiguous names)
|
||||
extension_id, _ = _resolve_installed_extension(extension, installed, "update")
|
||||
extensions_to_update = [extension_id]
|
||||
else:
|
||||
# Update all extensions
|
||||
installed = manager.list_installed()
|
||||
extensions_to_update = [ext["id"] for ext in installed]
|
||||
|
||||
if not extensions_to_update:
|
||||
@@ -3044,7 +3271,16 @@ def extension_update(
|
||||
for ext_id in extensions_to_update:
|
||||
# Get installed version
|
||||
metadata = manager.registry.get(ext_id)
|
||||
if metadata is None or "version" not in metadata:
|
||||
console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)")
|
||||
continue
|
||||
try:
|
||||
installed_version = pkg_version.Version(metadata["version"])
|
||||
except pkg_version.InvalidVersion:
|
||||
console.print(
|
||||
f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get catalog info
|
||||
ext_info = catalog.get_extension_info(ext_id)
|
||||
@@ -3052,12 +3288,24 @@ def extension_update(
|
||||
console.print(f"⚠ {ext_id}: Not found in catalog (skipping)")
|
||||
continue
|
||||
|
||||
# Check if installation is allowed from this catalog
|
||||
if not ext_info.get("_install_allowed", True):
|
||||
console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)")
|
||||
continue
|
||||
|
||||
try:
|
||||
catalog_version = pkg_version.Version(ext_info["version"])
|
||||
except pkg_version.InvalidVersion:
|
||||
console.print(
|
||||
f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)"
|
||||
)
|
||||
continue
|
||||
|
||||
if catalog_version > installed_version:
|
||||
updates_available.append(
|
||||
{
|
||||
"id": ext_id,
|
||||
"name": ext_info.get("name", ext_id), # Display name for status messages
|
||||
"installed": str(installed_version),
|
||||
"available": str(catalog_version),
|
||||
"download_url": ext_info.get("download_url"),
|
||||
@@ -3083,25 +3331,288 @@ def extension_update(
|
||||
console.print("Cancelled")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Perform updates
|
||||
# Perform updates with atomic backup/restore
|
||||
console.print()
|
||||
updated_extensions = []
|
||||
failed_updates = []
|
||||
registrar = CommandRegistrar()
|
||||
hook_executor = HookExecutor(project_root)
|
||||
|
||||
for update in updates_available:
|
||||
ext_id = update["id"]
|
||||
console.print(f"📦 Updating {ext_id}...")
|
||||
extension_id = update["id"]
|
||||
ext_name = update["name"] # Use display name for user-facing messages
|
||||
console.print(f"📦 Updating {ext_name}...")
|
||||
|
||||
# TODO: Implement download and reinstall from URL
|
||||
# For now, just show message
|
||||
console.print(
|
||||
"[yellow]Note:[/yellow] Automatic update not yet implemented. "
|
||||
"Please update manually:"
|
||||
# Backup paths
|
||||
backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
|
||||
backup_ext_dir = backup_base / "extension"
|
||||
backup_commands_dir = backup_base / "commands"
|
||||
backup_config_dir = backup_base / "config"
|
||||
|
||||
# Store backup state
|
||||
backup_registry_entry = None
|
||||
backup_hooks = None # None means no hooks key in config; {} means hooks key existed
|
||||
backed_up_command_files = {}
|
||||
|
||||
try:
|
||||
# 1. Backup registry entry (always, even if extension dir doesn't exist)
|
||||
backup_registry_entry = manager.registry.get(extension_id)
|
||||
|
||||
# 2. Backup extension directory
|
||||
extension_dir = manager.extensions_dir / extension_id
|
||||
if extension_dir.exists():
|
||||
backup_base.mkdir(parents=True, exist_ok=True)
|
||||
if backup_ext_dir.exists():
|
||||
shutil.rmtree(backup_ext_dir)
|
||||
shutil.copytree(extension_dir, backup_ext_dir)
|
||||
|
||||
# Backup config files separately so they can be restored
|
||||
# after a successful install (install_from_directory clears dest dir).
|
||||
config_files = list(extension_dir.glob("*-config.yml")) + list(
|
||||
extension_dir.glob("*-config.local.yml")
|
||||
)
|
||||
console.print(f" specify extension remove {ext_id} --keep-config")
|
||||
console.print(f" specify extension add {ext_id}")
|
||||
for cfg_file in config_files:
|
||||
backup_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(cfg_file, backup_config_dir / cfg_file.name)
|
||||
|
||||
console.print(
|
||||
"\n[cyan]Tip:[/cyan] Automatic updates will be available in a future version"
|
||||
# 3. Backup command files for all agents
|
||||
registered_commands = backup_registry_entry.get("registered_commands", {})
|
||||
for agent_name, cmd_names in registered_commands.items():
|
||||
if agent_name not in registrar.AGENT_CONFIGS:
|
||||
continue
|
||||
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name
|
||||
backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(cmd_file, backup_cmd_path)
|
||||
backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
|
||||
|
||||
# Also backup copilot prompt files
|
||||
if agent_name == "copilot":
|
||||
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||
if prompt_file.exists():
|
||||
backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
|
||||
backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(prompt_file, backup_prompt_path)
|
||||
backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
|
||||
|
||||
# 4. Backup hooks from extensions.yml
|
||||
# Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore)
|
||||
# Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension
|
||||
config = hook_executor.get_project_config()
|
||||
if "hooks" in config:
|
||||
backup_hooks = {} # Config has hooks key - preserve this fact
|
||||
for hook_name, hook_list in config["hooks"].items():
|
||||
ext_hooks = [h for h in hook_list if h.get("extension") == extension_id]
|
||||
if ext_hooks:
|
||||
backup_hooks[hook_name] = ext_hooks
|
||||
|
||||
# 5. Download new version
|
||||
zip_path = catalog.download_extension(extension_id)
|
||||
try:
|
||||
# 6. Validate extension ID from ZIP BEFORE modifying installation
|
||||
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
import yaml
|
||||
manifest_data = None
|
||||
namelist = zf.namelist()
|
||||
|
||||
# First try root-level extension.yml
|
||||
if "extension.yml" in namelist:
|
||||
with zf.open("extension.yml") as f:
|
||||
manifest_data = yaml.safe_load(f) or {}
|
||||
else:
|
||||
# Look for extension.yml in a single top-level subdirectory
|
||||
# (e.g., "repo-name-branch/extension.yml")
|
||||
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
|
||||
if len(manifest_paths) == 1:
|
||||
with zf.open(manifest_paths[0]) as f:
|
||||
manifest_data = yaml.safe_load(f) or {}
|
||||
|
||||
if manifest_data is None:
|
||||
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
|
||||
|
||||
zip_extension_id = manifest_data.get("extension", {}).get("id")
|
||||
if zip_extension_id != extension_id:
|
||||
raise ValueError(
|
||||
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
|
||||
)
|
||||
|
||||
# 7. Remove old extension (handles command file cleanup and registry removal)
|
||||
manager.remove(extension_id, keep_config=True)
|
||||
|
||||
# 8. Install new version
|
||||
_ = manager.install_from_zip(zip_path, speckit_version)
|
||||
|
||||
# Restore user config files from backup after successful install.
|
||||
new_extension_dir = manager.extensions_dir / extension_id
|
||||
if backup_config_dir.exists() and new_extension_dir.exists():
|
||||
for cfg_file in backup_config_dir.iterdir():
|
||||
if cfg_file.is_file():
|
||||
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
||||
|
||||
# 9. Restore metadata from backup (installed_at, enabled state)
|
||||
if backup_registry_entry:
|
||||
# Copy current registry entry to avoid mutating internal
|
||||
# registry state before explicit restore().
|
||||
current_metadata = manager.registry.get(extension_id)
|
||||
if current_metadata is None:
|
||||
raise RuntimeError(
|
||||
f"Registry entry for '{extension_id}' missing after install — update incomplete"
|
||||
)
|
||||
new_metadata = dict(current_metadata)
|
||||
|
||||
# Preserve the original installation timestamp
|
||||
if "installed_at" in backup_registry_entry:
|
||||
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
||||
|
||||
# If extension was disabled before update, disable it again
|
||||
if not backup_registry_entry.get("enabled", True):
|
||||
new_metadata["enabled"] = False
|
||||
|
||||
# Use restore() instead of update() because update() always
|
||||
# preserves the existing installed_at, ignoring our override
|
||||
manager.registry.restore(extension_id, new_metadata)
|
||||
|
||||
# Also disable hooks in extensions.yml if extension was disabled
|
||||
if not backup_registry_entry.get("enabled", True):
|
||||
config = hook_executor.get_project_config()
|
||||
if "hooks" in config:
|
||||
for hook_name in config["hooks"]:
|
||||
for hook in config["hooks"][hook_name]:
|
||||
if hook.get("extension") == extension_id:
|
||||
hook["enabled"] = False
|
||||
hook_executor.save_project_config(config)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
# 10. Clean up backup on success
|
||||
if backup_base.exists():
|
||||
shutil.rmtree(backup_base)
|
||||
|
||||
console.print(f" [green]✓[/green] Updated to v{update['available']}")
|
||||
updated_extensions.append(ext_name)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
console.print(f" [red]✗[/red] Failed: {e}")
|
||||
failed_updates.append((ext_name, str(e)))
|
||||
|
||||
# Rollback on failure
|
||||
console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...")
|
||||
|
||||
try:
|
||||
# Restore extension directory
|
||||
# Only perform destructive rollback if backup exists (meaning we
|
||||
# actually modified the extension). This avoids deleting a valid
|
||||
# installation when failure happened before changes were made.
|
||||
extension_dir = manager.extensions_dir / extension_id
|
||||
if backup_ext_dir.exists():
|
||||
if extension_dir.exists():
|
||||
shutil.rmtree(extension_dir)
|
||||
shutil.copytree(backup_ext_dir, extension_dir)
|
||||
|
||||
# Remove any NEW command files created by failed install
|
||||
# (files that weren't in the original backup)
|
||||
try:
|
||||
new_registry_entry = manager.registry.get(extension_id)
|
||||
if new_registry_entry is None:
|
||||
new_registered_commands = {}
|
||||
else:
|
||||
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
||||
for agent_name, cmd_names in new_registered_commands.items():
|
||||
if agent_name not in registrar.AGENT_CONFIGS:
|
||||
continue
|
||||
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
# Delete if it exists and wasn't in our backup
|
||||
if cmd_file.exists() and str(cmd_file) not in backed_up_command_files:
|
||||
cmd_file.unlink()
|
||||
|
||||
# Also handle copilot prompt files
|
||||
if agent_name == "copilot":
|
||||
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||
if prompt_file.exists() and str(prompt_file) not in backed_up_command_files:
|
||||
prompt_file.unlink()
|
||||
except KeyError:
|
||||
pass # No new registry entry exists, nothing to clean up
|
||||
|
||||
# Restore backed up command files
|
||||
for original_path, backup_path in backed_up_command_files.items():
|
||||
backup_file = Path(backup_path)
|
||||
if backup_file.exists():
|
||||
original_file = Path(original_path)
|
||||
original_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(backup_file, original_file)
|
||||
|
||||
# Restore hooks in extensions.yml
|
||||
# - backup_hooks=None means original config had no "hooks" key
|
||||
# - backup_hooks={} or {...} means config had hooks key
|
||||
config = hook_executor.get_project_config()
|
||||
if "hooks" in config:
|
||||
modified = False
|
||||
|
||||
if backup_hooks is None:
|
||||
# Original config had no "hooks" key; remove it entirely
|
||||
del config["hooks"]
|
||||
modified = True
|
||||
else:
|
||||
# Remove any hooks for this extension added by failed install
|
||||
for hook_name, hooks_list in config["hooks"].items():
|
||||
original_len = len(hooks_list)
|
||||
config["hooks"][hook_name] = [
|
||||
h for h in hooks_list
|
||||
if h.get("extension") != extension_id
|
||||
]
|
||||
if len(config["hooks"][hook_name]) != original_len:
|
||||
modified = True
|
||||
|
||||
# Add back the backed up hooks if any
|
||||
if backup_hooks:
|
||||
for hook_name, hooks in backup_hooks.items():
|
||||
if hook_name not in config["hooks"]:
|
||||
config["hooks"][hook_name] = []
|
||||
config["hooks"][hook_name].extend(hooks)
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
hook_executor.save_project_config(config)
|
||||
|
||||
# Restore registry entry (use restore() since entry was removed)
|
||||
if backup_registry_entry:
|
||||
manager.registry.restore(extension_id, backup_registry_entry)
|
||||
|
||||
console.print(" [green]✓[/green] Rollback successful")
|
||||
# Clean up backup directory only on successful rollback
|
||||
if backup_base.exists():
|
||||
shutil.rmtree(backup_base)
|
||||
except Exception as rollback_error:
|
||||
console.print(f" [red]✗[/red] Rollback failed: {rollback_error}")
|
||||
console.print(f" [dim]Backup preserved at: {backup_base}[/dim]")
|
||||
|
||||
# Summary
|
||||
console.print()
|
||||
if updated_extensions:
|
||||
console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)")
|
||||
if failed_updates:
|
||||
console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):")
|
||||
for ext_name, error in failed_updates:
|
||||
console.print(f" • {ext_name}: {error}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except ValidationError as e:
|
||||
console.print(f"\n[red]Validation Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except ExtensionError as e:
|
||||
console.print(f"\n[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
@@ -3109,7 +3620,7 @@ def extension_update(
|
||||
|
||||
@extension_app.command("enable")
|
||||
def extension_enable(
|
||||
extension: str = typer.Argument(help="Extension ID to enable"),
|
||||
extension: str = typer.Argument(help="Extension ID or name to enable"),
|
||||
):
|
||||
"""Enable a disabled extension."""
|
||||
from .extensions import ExtensionManager, HookExecutor
|
||||
@@ -3126,34 +3637,38 @@ def extension_enable(
|
||||
manager = ExtensionManager(project_root)
|
||||
hook_executor = HookExecutor(project_root)
|
||||
|
||||
if not manager.registry.is_installed(extension):
|
||||
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
# Resolve extension ID from argument (handles ambiguous names)
|
||||
installed = manager.list_installed()
|
||||
extension_id, display_name = _resolve_installed_extension(extension, installed, "enable")
|
||||
|
||||
# Update registry
|
||||
metadata = manager.registry.get(extension)
|
||||
metadata = manager.registry.get(extension_id)
|
||||
if metadata is None:
|
||||
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if metadata.get("enabled", True):
|
||||
console.print(f"[yellow]Extension '{extension}' is already enabled[/yellow]")
|
||||
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
metadata["enabled"] = True
|
||||
manager.registry.add(extension, metadata)
|
||||
manager.registry.update(extension_id, metadata)
|
||||
|
||||
# Enable hooks in extensions.yml
|
||||
config = hook_executor.get_project_config()
|
||||
if "hooks" in config:
|
||||
for hook_name in config["hooks"]:
|
||||
for hook in config["hooks"][hook_name]:
|
||||
if hook.get("extension") == extension:
|
||||
if hook.get("extension") == extension_id:
|
||||
hook["enabled"] = True
|
||||
hook_executor.save_project_config(config)
|
||||
|
||||
console.print(f"[green]✓[/green] Extension '{extension}' enabled")
|
||||
console.print(f"[green]✓[/green] Extension '{display_name}' enabled")
|
||||
|
||||
|
||||
@extension_app.command("disable")
|
||||
def extension_disable(
|
||||
extension: str = typer.Argument(help="Extension ID to disable"),
|
||||
extension: str = typer.Argument(help="Extension ID or name to disable"),
|
||||
):
|
||||
"""Disable an extension without removing it."""
|
||||
from .extensions import ExtensionManager, HookExecutor
|
||||
@@ -3170,31 +3685,35 @@ def extension_disable(
|
||||
manager = ExtensionManager(project_root)
|
||||
hook_executor = HookExecutor(project_root)
|
||||
|
||||
if not manager.registry.is_installed(extension):
|
||||
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
# Resolve extension ID from argument (handles ambiguous names)
|
||||
installed = manager.list_installed()
|
||||
extension_id, display_name = _resolve_installed_extension(extension, installed, "disable")
|
||||
|
||||
# Update registry
|
||||
metadata = manager.registry.get(extension)
|
||||
metadata = manager.registry.get(extension_id)
|
||||
if metadata is None:
|
||||
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not metadata.get("enabled", True):
|
||||
console.print(f"[yellow]Extension '{extension}' is already disabled[/yellow]")
|
||||
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
metadata["enabled"] = False
|
||||
manager.registry.add(extension, metadata)
|
||||
manager.registry.update(extension_id, metadata)
|
||||
|
||||
# Disable hooks in extensions.yml
|
||||
config = hook_executor.get_project_config()
|
||||
if "hooks" in config:
|
||||
for hook_name in config["hooks"]:
|
||||
for hook in config["hooks"][hook_name]:
|
||||
if hook.get("extension") == extension:
|
||||
if hook.get("extension") == extension_id:
|
||||
hook["enabled"] = False
|
||||
hook_executor.save_project_config(config)
|
||||
|
||||
console.print(f"[green]✓[/green] Extension '{extension}' disabled")
|
||||
console.print(f"[green]✓[/green] Extension '{display_name}' disabled")
|
||||
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
||||
console.print(f"To re-enable: specify extension enable {extension}")
|
||||
console.print(f"To re-enable: specify extension enable {extension_id}")
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -48,9 +48,9 @@ class CommandRegistrar:
|
||||
},
|
||||
"qwen": {
|
||||
"dir": ".qwen/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"opencode": {
|
||||
"dir": ".opencode/command",
|
||||
@@ -71,19 +71,19 @@ class CommandRegistrar:
|
||||
"extension": ".md"
|
||||
},
|
||||
"kilocode": {
|
||||
"dir": ".kilocode/rules",
|
||||
"dir": ".kilocode/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"auggie": {
|
||||
"dir": ".augment/rules",
|
||||
"dir": ".augment/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"roo": {
|
||||
"dir": ".roo/rules",
|
||||
"dir": ".roo/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
@@ -129,6 +129,12 @@ class CommandRegistrar:
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kimi": {
|
||||
"dir": ".kimi/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +324,7 @@ class CommandRegistrar:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_file.write_text(output, encoding="utf-8")
|
||||
|
||||
if agent_name == "copilot":
|
||||
@@ -327,6 +334,7 @@ class CommandRegistrar:
|
||||
|
||||
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, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, alias)
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any, Callable, Set
|
||||
@@ -228,6 +229,54 @@ class ExtensionRegistry:
|
||||
}
|
||||
self._save()
|
||||
|
||||
def update(self, extension_id: str, metadata: dict):
|
||||
"""Update extension metadata in registry, merging with existing entry.
|
||||
|
||||
Merges the provided metadata with the existing entry, preserving any
|
||||
fields not specified in the new metadata. The installed_at timestamp
|
||||
is always preserved from the original entry.
|
||||
|
||||
Use this method instead of add() when updating existing extension
|
||||
metadata (e.g., enabling/disabling) to preserve the original
|
||||
installation timestamp and other existing fields.
|
||||
|
||||
Args:
|
||||
extension_id: Extension ID
|
||||
metadata: Extension metadata fields to update (merged with existing)
|
||||
|
||||
Raises:
|
||||
KeyError: If extension is not installed
|
||||
"""
|
||||
if extension_id not in self.data["extensions"]:
|
||||
raise KeyError(f"Extension '{extension_id}' is not installed")
|
||||
# Merge new metadata with existing, preserving original installed_at
|
||||
existing = self.data["extensions"][extension_id]
|
||||
# Merge: existing fields preserved, new fields override
|
||||
merged = {**existing, **metadata}
|
||||
# Always preserve original installed_at based on key existence, not truthiness,
|
||||
# to handle cases where the field exists but may be falsy (legacy/corruption)
|
||||
if "installed_at" in existing:
|
||||
merged["installed_at"] = existing["installed_at"]
|
||||
else:
|
||||
# If not present in existing, explicitly remove from merged if caller provided it
|
||||
merged.pop("installed_at", None)
|
||||
self.data["extensions"][extension_id] = merged
|
||||
self._save()
|
||||
|
||||
def restore(self, extension_id: str, metadata: dict):
|
||||
"""Restore extension metadata to registry without modifying timestamps.
|
||||
|
||||
Use this method for rollback scenarios where you have a complete backup
|
||||
of the registry entry (including installed_at) and want to restore it
|
||||
exactly as it was.
|
||||
|
||||
Args:
|
||||
extension_id: Extension ID
|
||||
metadata: Complete extension metadata including installed_at
|
||||
"""
|
||||
self.data["extensions"][extension_id] = dict(metadata)
|
||||
self._save()
|
||||
|
||||
def remove(self, extension_id: str):
|
||||
"""Remove extension from registry.
|
||||
|
||||
@@ -241,21 +290,28 @@ class ExtensionRegistry:
|
||||
def get(self, extension_id: str) -> Optional[dict]:
|
||||
"""Get extension metadata from registry.
|
||||
|
||||
Returns a deep copy to prevent callers from accidentally mutating
|
||||
nested internal registry state without going through the write path.
|
||||
|
||||
Args:
|
||||
extension_id: Extension ID
|
||||
|
||||
Returns:
|
||||
Extension metadata or None if not found
|
||||
Deep copy of extension metadata, or None if not found
|
||||
"""
|
||||
return self.data["extensions"].get(extension_id)
|
||||
entry = self.data["extensions"].get(extension_id)
|
||||
return copy.deepcopy(entry) if entry is not None else None
|
||||
|
||||
def list(self) -> Dict[str, dict]:
|
||||
"""Get all installed extensions.
|
||||
|
||||
Returns a deep copy of the extensions mapping to prevent callers
|
||||
from accidentally mutating nested internal registry state.
|
||||
|
||||
Returns:
|
||||
Dictionary of extension_id -> metadata
|
||||
Dictionary of extension_id -> metadata (deep copies)
|
||||
"""
|
||||
return self.data["extensions"]
|
||||
return copy.deepcopy(self.data["extensions"])
|
||||
|
||||
def is_installed(self, extension_id: str) -> bool:
|
||||
"""Check if extension is installed.
|
||||
@@ -584,7 +640,7 @@ class ExtensionManager:
|
||||
result.append({
|
||||
"id": ext_id,
|
||||
"name": manifest.name,
|
||||
"version": metadata["version"],
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": manifest.description,
|
||||
"enabled": metadata.get("enabled", True),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
@@ -683,7 +739,10 @@ class CommandRegistrar:
|
||||
return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
|
||||
def _render_toml_command(self, frontmatter, body, ext_id):
|
||||
return self._registrar.render_toml_command(frontmatter, body, ext_id)
|
||||
# Preserve extension-specific context comments for backward compatibility
|
||||
base = self._registrar.render_toml_command(frontmatter, body, ext_id)
|
||||
context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n"
|
||||
return base.rstrip("\n") + "\n" + context_lines
|
||||
|
||||
def register_commands_for_agent(
|
||||
self,
|
||||
@@ -779,12 +838,13 @@ class ExtensionCatalog:
|
||||
config_path: Path to extension-catalogs.yml
|
||||
|
||||
Returns:
|
||||
Ordered list of CatalogEntry objects, or None if file doesn't exist
|
||||
or contains no valid catalog entries.
|
||||
Ordered list of CatalogEntry objects, or None if file doesn't exist.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any catalog entry has an invalid URL,
|
||||
the file cannot be parsed, or a priority value is invalid.
|
||||
the file cannot be parsed, a priority value is invalid,
|
||||
or the file exists but contains no valid catalog entries
|
||||
(fail-closed for security).
|
||||
"""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
@@ -796,12 +856,17 @@ class ExtensionCatalog:
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
return None
|
||||
# File exists but has no catalogs key or empty list - fail closed
|
||||
raise ValidationError(
|
||||
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
|
||||
f"Remove the file to use built-in defaults, or add valid catalog entries."
|
||||
)
|
||||
if not isinstance(catalogs_data, list):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
|
||||
)
|
||||
entries: List[CatalogEntry] = []
|
||||
skipped_entries: List[int] = []
|
||||
for idx, item in enumerate(catalogs_data):
|
||||
if not isinstance(item, dict):
|
||||
raise ValidationError(
|
||||
@@ -809,6 +874,7 @@ class ExtensionCatalog:
|
||||
)
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
skipped_entries.append(idx)
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
@@ -831,7 +897,14 @@ class ExtensionCatalog:
|
||||
description=str(item.get("description", "")),
|
||||
))
|
||||
entries.sort(key=lambda e: e.priority)
|
||||
return entries if entries else None
|
||||
if not entries:
|
||||
# All entries were invalid (missing URLs) - fail closed for security
|
||||
raise ValidationError(
|
||||
f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs "
|
||||
f"(entries at indices {skipped_entries} were skipped). "
|
||||
f"Each catalog entry must have a 'url' field."
|
||||
)
|
||||
return entries
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
"""Get the ordered list of active catalogs.
|
||||
|
||||
@@ -139,6 +139,15 @@ class PresetManifest:
|
||||
f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}"
|
||||
)
|
||||
|
||||
# Validate file path safety: must be relative, no parent traversal
|
||||
file_path = tmpl["file"]
|
||||
normalized = os.path.normpath(file_path)
|
||||
if os.path.isabs(normalized) or normalized.startswith(".."):
|
||||
raise PresetValidationError(
|
||||
f"Invalid template file path '{file_path}': "
|
||||
"must be a relative path within the preset directory"
|
||||
)
|
||||
|
||||
# Validate template name format
|
||||
if tmpl["type"] == "command":
|
||||
# Commands use dot notation (e.g. speckit.specify)
|
||||
@@ -364,7 +373,7 @@ class PresetManager:
|
||||
|
||||
Scans the preset's templates for type "command", reads each command
|
||||
file, and writes it to every detected agent directory using the
|
||||
CommandRegistrar from the extensions module.
|
||||
CommandRegistrar from the agents module.
|
||||
|
||||
Args:
|
||||
manifest: Preset manifest
|
||||
@@ -424,7 +433,7 @@ class PresetManager:
|
||||
|
||||
Reads ``.specify/init-options.json`` to determine whether skills
|
||||
are enabled and which agent was selected, then delegates to
|
||||
``_get_skills_dir()`` for the concrete path.
|
||||
the module-level ``_get_skills_dir()`` helper for the concrete path.
|
||||
|
||||
Returns:
|
||||
The skills directory ``Path``, or ``None`` if skills were not
|
||||
@@ -473,15 +482,33 @@ class PresetManager:
|
||||
if not command_templates:
|
||||
return []
|
||||
|
||||
# Filter out extension command overrides if the extension isn't installed,
|
||||
# matching the same logic used by _register_commands().
|
||||
extensions_dir = self.project_root / ".specify" / "extensions"
|
||||
filtered = []
|
||||
for cmd in command_templates:
|
||||
parts = cmd["name"].split(".")
|
||||
if len(parts) >= 3 and parts[0] == "speckit":
|
||||
ext_id = parts[1]
|
||||
if not (extensions_dir / ext_id).is_dir():
|
||||
continue
|
||||
filtered.append(cmd)
|
||||
|
||||
if not filtered:
|
||||
return []
|
||||
|
||||
skills_dir = self._get_skills_dir()
|
||||
if not skills_dir:
|
||||
return []
|
||||
|
||||
from . import SKILL_DESCRIPTIONS
|
||||
from . import SKILL_DESCRIPTIONS, load_init_options
|
||||
|
||||
opts = load_init_options(self.project_root)
|
||||
selected_ai = opts.get("ai", "")
|
||||
|
||||
written: List[str] = []
|
||||
|
||||
for cmd_tmpl in command_templates:
|
||||
for cmd_tmpl in filtered:
|
||||
cmd_name = cmd_tmpl["name"]
|
||||
cmd_file_rel = cmd_tmpl["file"]
|
||||
source_file = preset_dir / cmd_file_rel
|
||||
@@ -492,6 +519,11 @@ class PresetManager:
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
# 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.{short_name}"
|
||||
else:
|
||||
skill_name = f"speckit-{short_name}"
|
||||
|
||||
# Only overwrite if the skill already exists (i.e. --ai-skills was used)
|
||||
@@ -573,6 +605,8 @@ class PresetManager:
|
||||
short_name = skill_name
|
||||
if short_name.startswith("speckit-"):
|
||||
short_name = short_name[len("speckit-"):]
|
||||
elif short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
|
||||
skill_subdir = skills_dir / skill_name
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
@@ -896,6 +930,10 @@ class PresetCatalog:
|
||||
raise PresetValidationError(
|
||||
f"Failed to read catalog config {config_path}: {e}"
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise PresetValidationError(
|
||||
f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}"
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
return None
|
||||
@@ -1326,11 +1364,11 @@ class PresetCatalog:
|
||||
raise PresetError(f"Failed to save preset 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()
|
||||
"""Clear all catalog cache files, including per-URL hashed caches."""
|
||||
if self.cache_dir.exists():
|
||||
for f in self.cache_dir.iterdir():
|
||||
if f.is_file() and f.name.startswith("catalog"):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class PresetResolver:
|
||||
|
||||
@@ -129,7 +129,7 @@ Given that feature description, do this:
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 6
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 7
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
@@ -178,8 +178,6 @@ Given that feature description, do this:
|
||||
|
||||
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
|
||||
@@ -62,7 +62,14 @@ class TestAgentConfigConsistency:
|
||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||
|
||||
assert re.search(r"'shai'\s*\{.*?\.shai/commands", ps_text, re.S) is not None
|
||||
assert re.search(r"'agy'\s*\{.*?\.agent/workflows", ps_text, re.S) is not None
|
||||
assert re.search(r"'agy'\s*\{.*?\.agent/commands", ps_text, re.S) is not None
|
||||
|
||||
def test_release_sh_switch_has_shai_and_agy_generation(self):
|
||||
"""Bash release builder must generate files for shai and agy agents."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None
|
||||
assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None
|
||||
|
||||
def test_init_ai_help_includes_roo_and_kiro_alias(self):
|
||||
"""CLI help text for --ai should stay in sync with agent config and alias guidance."""
|
||||
@@ -171,3 +178,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
|
||||
|
||||
@@ -132,6 +132,16 @@ def commands_dir_gemini(project_dir):
|
||||
return cmd_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def commands_dir_qwen(project_dir):
|
||||
"""Create a populated .qwen/commands directory (Markdown format)."""
|
||||
cmd_dir = project_dir / ".qwen" / "commands"
|
||||
cmd_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]:
|
||||
(cmd_dir / name).write_text(f"# {name}\nContent here\n")
|
||||
return cmd_dir
|
||||
|
||||
|
||||
# ===== _get_skills_dir Tests =====
|
||||
|
||||
class TestGetSkillsDir:
|
||||
@@ -390,6 +400,28 @@ class TestInstallAiSkills:
|
||||
# .toml commands should be untouched
|
||||
assert (cmds_dir / "speckit.specify.toml").exists()
|
||||
|
||||
def test_qwen_md_commands_dir_installs_skills(self, project_dir):
|
||||
"""Qwen now uses Markdown format; skills should install directly from .qwen/commands/."""
|
||||
cmds_dir = project_dir / ".qwen" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
(cmds_dir / "speckit.specify.md").write_text(
|
||||
"---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n"
|
||||
)
|
||||
(cmds_dir / "speckit.plan.md").write_text(
|
||||
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "qwen")
|
||||
|
||||
assert result is True
|
||||
skills_dir = project_dir / ".qwen" / "skills"
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
assert len(skill_dirs) >= 1
|
||||
# .md commands should be untouched
|
||||
assert (cmds_dir / "speckit.specify.md").exists()
|
||||
assert (cmds_dir / "speckit.plan.md").exists()
|
||||
|
||||
@pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"])
|
||||
def test_skills_install_for_all_agents(self, temp_dir, agent_key):
|
||||
"""install_ai_skills should produce skills for every configured agent."""
|
||||
@@ -410,8 +442,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()
|
||||
|
||||
|
||||
|
||||
@@ -443,6 +478,15 @@ class TestCommandCoexistence:
|
||||
remaining = list(commands_dir_gemini.glob("speckit.*"))
|
||||
assert len(remaining) == 3
|
||||
|
||||
def test_existing_commands_preserved_qwen(self, project_dir, templates_dir, commands_dir_qwen):
|
||||
"""install_ai_skills must NOT remove pre-existing .qwen/commands files."""
|
||||
assert len(list(commands_dir_qwen.glob("speckit.*"))) == 3
|
||||
|
||||
install_ai_skills(project_dir, "qwen")
|
||||
|
||||
remaining = list(commands_dir_qwen.glob("speckit.*"))
|
||||
assert len(remaining) == 3
|
||||
|
||||
def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude):
|
||||
"""install_ai_skills must not remove the commands directory."""
|
||||
install_ai_skills(project_dir, "claude")
|
||||
@@ -658,6 +702,59 @@ class TestCliValidation:
|
||||
assert "Usage:" in result.output
|
||||
assert "--ai" in result.output
|
||||
|
||||
def test_agy_without_ai_skills_fails(self):
|
||||
"""--ai agy without --ai-skills should fail with exit code 1."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai", "agy"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
|
||||
assert "--ai-skills" in result.output
|
||||
|
||||
def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):
|
||||
"""Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
# Mock select_with_arrows to simulate the user picking 'agy' for AI,
|
||||
# and return a deterministic default for any other prompts to avoid
|
||||
# calling the real interactive implementation.
|
||||
def _fake_select_with_arrows(*args, **kwargs):
|
||||
options = kwargs.get("options")
|
||||
if options is None and len(args) >= 1:
|
||||
options = args[0]
|
||||
|
||||
# If the options include 'agy', simulate selecting it.
|
||||
if isinstance(options, dict) and "agy" in options:
|
||||
return "agy"
|
||||
if isinstance(options, (list, tuple)) and "agy" in options:
|
||||
return "agy"
|
||||
|
||||
# For any other prompt, return a deterministic, non-interactive default:
|
||||
# pick the first option if available.
|
||||
if isinstance(options, dict) and options:
|
||||
return next(iter(options.keys()))
|
||||
if isinstance(options, (list, tuple)) and options:
|
||||
return options[0]
|
||||
|
||||
# If no options are provided, fall back to None (should not occur in normal use).
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
|
||||
|
||||
# Mock download_and_extract_template to prevent real HTTP downloads during testing
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None)
|
||||
# We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe?
|
||||
runner = CliRunner()
|
||||
# Create temp dir to avoid directory already exists errors or whatever
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(app, ["init", "test-proj", "--no-git"])
|
||||
|
||||
# Interactive selection should NOT raise the deprecation error!
|
||||
assert result.exit_code == 0
|
||||
assert "Explicit command support was deprecated" not in result.output
|
||||
|
||||
def test_ai_skills_flag_appears_in_help(self):
|
||||
"""--ai-skills should appear in init --help output."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -277,6 +277,135 @@ class TestExtensionRegistry:
|
||||
assert registry2.is_installed("test-ext")
|
||||
assert registry2.get("test-ext")["version"] == "1.0.0"
|
||||
|
||||
def test_update_preserves_installed_at(self, temp_dir):
|
||||
"""Test that update() preserves the original installed_at timestamp."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("test-ext", {"version": "1.0.0", "enabled": True})
|
||||
|
||||
# Get original installed_at
|
||||
original_data = registry.get("test-ext")
|
||||
original_installed_at = original_data["installed_at"]
|
||||
|
||||
# Update with new metadata
|
||||
registry.update("test-ext", {"version": "2.0.0", "enabled": False})
|
||||
|
||||
# Verify installed_at is preserved
|
||||
updated_data = registry.get("test-ext")
|
||||
assert updated_data["installed_at"] == original_installed_at
|
||||
assert updated_data["version"] == "2.0.0"
|
||||
assert updated_data["enabled"] is False
|
||||
|
||||
def test_update_merges_with_existing(self, temp_dir):
|
||||
"""Test that update() merges new metadata with existing fields."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("test-ext", {
|
||||
"version": "1.0.0",
|
||||
"enabled": True,
|
||||
"registered_commands": {"claude": ["cmd1", "cmd2"]},
|
||||
})
|
||||
|
||||
# Update with partial metadata (only enabled field)
|
||||
registry.update("test-ext", {"enabled": False})
|
||||
|
||||
# Verify existing fields are preserved
|
||||
updated_data = registry.get("test-ext")
|
||||
assert updated_data["enabled"] is False
|
||||
assert updated_data["version"] == "1.0.0" # Preserved
|
||||
assert updated_data["registered_commands"] == {"claude": ["cmd1", "cmd2"]} # Preserved
|
||||
|
||||
def test_update_raises_for_missing_extension(self, temp_dir):
|
||||
"""Test that update() raises KeyError for non-installed extension."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
with pytest.raises(KeyError, match="not installed"):
|
||||
registry.update("nonexistent-ext", {"enabled": False})
|
||||
|
||||
def test_restore_overwrites_completely(self, temp_dir):
|
||||
"""Test that restore() overwrites the registry entry completely."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("test-ext", {"version": "2.0.0", "enabled": True})
|
||||
|
||||
# Restore with complete backup data
|
||||
backup_data = {
|
||||
"version": "1.0.0",
|
||||
"enabled": False,
|
||||
"installed_at": "2024-01-01T00:00:00+00:00",
|
||||
"registered_commands": {"claude": ["old-cmd"]},
|
||||
}
|
||||
registry.restore("test-ext", backup_data)
|
||||
|
||||
# Verify entry is exactly as restored
|
||||
restored_data = registry.get("test-ext")
|
||||
assert restored_data == backup_data
|
||||
|
||||
def test_restore_can_recreate_removed_entry(self, temp_dir):
|
||||
"""Test that restore() can recreate an entry after remove()."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("test-ext", {"version": "1.0.0"})
|
||||
|
||||
# Save backup and remove
|
||||
backup = registry.get("test-ext").copy()
|
||||
registry.remove("test-ext")
|
||||
assert not registry.is_installed("test-ext")
|
||||
|
||||
# Restore should recreate the entry
|
||||
registry.restore("test-ext", backup)
|
||||
assert registry.is_installed("test-ext")
|
||||
assert registry.get("test-ext")["version"] == "1.0.0"
|
||||
|
||||
def test_get_returns_deep_copy(self, temp_dir):
|
||||
"""Test that get() returns deep copies for nested structures."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
metadata = {
|
||||
"version": "1.0.0",
|
||||
"registered_commands": {"claude": ["cmd1"]},
|
||||
}
|
||||
registry.add("test-ext", metadata)
|
||||
|
||||
fetched = registry.get("test-ext")
|
||||
fetched["registered_commands"]["claude"].append("cmd2")
|
||||
|
||||
# Internal registry must remain unchanged.
|
||||
internal = registry.data["extensions"]["test-ext"]
|
||||
assert internal["registered_commands"] == {"claude": ["cmd1"]}
|
||||
|
||||
def test_list_returns_deep_copy(self, temp_dir):
|
||||
"""Test that list() returns deep copies for nested structures."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
metadata = {
|
||||
"version": "1.0.0",
|
||||
"registered_commands": {"claude": ["cmd1"]},
|
||||
}
|
||||
registry.add("test-ext", metadata)
|
||||
|
||||
listed = registry.list()
|
||||
listed["test-ext"]["registered_commands"]["claude"].append("cmd2")
|
||||
|
||||
# Internal registry must remain unchanged.
|
||||
internal = registry.data["extensions"]["test-ext"]
|
||||
assert internal["registered_commands"] == {"claude": ["cmd1"]}
|
||||
|
||||
|
||||
# ===== ExtensionManager Tests =====
|
||||
|
||||
@@ -412,6 +541,15 @@ class TestCommandRegistrar:
|
||||
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts"
|
||||
|
||||
def test_qwen_agent_config_is_markdown(self):
|
||||
"""Qwen should use Markdown format with $ARGUMENTS (not TOML)."""
|
||||
assert "qwen" in CommandRegistrar.AGENT_CONFIGS
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS["qwen"]
|
||||
assert cfg["dir"] == ".qwen/commands"
|
||||
assert cfg["format"] == "markdown"
|
||||
assert cfg["args"] == "$ARGUMENTS"
|
||||
assert cfg["extension"] == ".md"
|
||||
|
||||
def test_parse_frontmatter_valid(self):
|
||||
"""Test parsing valid YAML frontmatter."""
|
||||
content = """---
|
||||
@@ -1402,8 +1540,8 @@ class TestCatalogStack:
|
||||
with pytest.raises(ValidationError, match="HTTPS"):
|
||||
catalog.get_active_catalogs()
|
||||
|
||||
def test_empty_project_config_falls_back_to_defaults(self, temp_dir):
|
||||
"""Empty catalogs list in config falls back to default stack."""
|
||||
def test_empty_project_config_raises_error(self, temp_dir):
|
||||
"""Empty catalogs list in config raises ValidationError (fail-closed for security)."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
@@ -1412,11 +1550,32 @@ class TestCatalogStack:
|
||||
yaml_module.dump({"catalogs": []}, f)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
entries = catalog.get_active_catalogs()
|
||||
|
||||
# Falls back to default stack
|
||||
assert len(entries) == 2
|
||||
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
|
||||
# Fail-closed: empty config should raise, not fall back to defaults
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
assert "contains no 'catalogs' entries" in str(exc_info.value)
|
||||
|
||||
def test_catalog_entries_without_urls_raises_error(self, temp_dir):
|
||||
"""Catalog entries without URLs raise ValidationError (fail-closed for security)."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump({
|
||||
"catalogs": [
|
||||
{"name": "no-url-catalog", "priority": 1},
|
||||
{"name": "another-no-url", "description": "Also missing URL"},
|
||||
]
|
||||
}, f)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Fail-closed: entries without URLs should raise, not fall back to defaults
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
assert "none have valid URLs" in str(exc_info.value)
|
||||
|
||||
# --- _load_catalog_config ---
|
||||
|
||||
@@ -1943,3 +2102,238 @@ class TestExtensionIgnore:
|
||||
assert not (dest / "docs" / "guide.md").exists()
|
||||
assert not (dest / "docs" / "internal.md").exists()
|
||||
assert (dest / "docs" / "api.md").exists()
|
||||
|
||||
|
||||
class TestExtensionAddCLI:
|
||||
"""CLI integration tests for extension add command."""
|
||||
|
||||
def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path):
|
||||
"""extension add by display name should use resolved ID for download_extension()."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Create project structure
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
(project_dir / ".specify" / "extensions").mkdir(parents=True)
|
||||
|
||||
# Mock catalog that returns extension by display name
|
||||
mock_catalog = MagicMock()
|
||||
mock_catalog.get_extension_info.return_value = None # ID lookup fails
|
||||
mock_catalog.search.return_value = [
|
||||
{
|
||||
"id": "acme-jira-integration",
|
||||
"name": "Jira Integration",
|
||||
"version": "1.0.0",
|
||||
"description": "Jira integration extension",
|
||||
"_install_allowed": True,
|
||||
}
|
||||
]
|
||||
|
||||
# Track what ID was passed to download_extension
|
||||
download_called_with = []
|
||||
def mock_download(extension_id):
|
||||
download_called_with.append(extension_id)
|
||||
# Return a path that will fail install (we just want to verify the ID)
|
||||
raise ExtensionError("Mock download - checking ID was resolved")
|
||||
|
||||
mock_catalog.download_extension.side_effect = mock_download
|
||||
|
||||
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
|
||||
patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "Jira Integration"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code != 0, (
|
||||
f"Expected non-zero exit code since mock download raises, got {result.exit_code}"
|
||||
)
|
||||
|
||||
# Verify download_extension was called with the resolved ID, not the display name
|
||||
assert len(download_called_with) == 1
|
||||
assert download_called_with[0] == "acme-jira-integration", (
|
||||
f"Expected download_extension to be called with resolved ID 'acme-jira-integration', "
|
||||
f"but was called with '{download_called_with[0]}'"
|
||||
)
|
||||
|
||||
|
||||
class TestExtensionUpdateCLI:
|
||||
"""CLI integration tests for extension update command."""
|
||||
|
||||
@staticmethod
|
||||
def _create_extension_source(base_dir: Path, version: str, include_config: bool = False) -> Path:
|
||||
"""Create a minimal extension source directory for install tests."""
|
||||
import yaml
|
||||
|
||||
ext_dir = base_dir / f"test-ext-{version}"
|
||||
ext_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": version,
|
||||
"description": "A test extension",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.test.hello",
|
||||
"file": "commands/hello.md",
|
||||
"description": "Test command",
|
||||
}
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"after_tasks": {
|
||||
"command": "speckit.test.hello",
|
||||
"optional": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
(ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False))
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir(exist_ok=True)
|
||||
(commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n")
|
||||
if include_config:
|
||||
(ext_dir / "linear-config.yml").write_text("custom: true\nvalue: original\n")
|
||||
return ext_dir
|
||||
|
||||
@staticmethod
|
||||
def _create_catalog_zip(zip_path: Path, version: str):
|
||||
"""Create a minimal ZIP that passes extension_update ID validation."""
|
||||
import zipfile
|
||||
import yaml
|
||||
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": version,
|
||||
"description": "A test extension",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"commands": [{"name": "speckit.test.hello", "file": "commands/hello.md"}]},
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("extension.yml", yaml.dump(manifest, sort_keys=False))
|
||||
|
||||
def test_update_success_preserves_installed_at(self, tmp_path):
|
||||
"""Successful update should keep original installed_at and apply new version."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
(project_dir / ".claude" / "commands").mkdir(parents=True)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True)
|
||||
manager.install_from_directory(v1_dir, "0.1.0")
|
||||
original_installed_at = manager.registry.get("test-ext")["installed_at"]
|
||||
original_config_content = (
|
||||
project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml"
|
||||
).read_text()
|
||||
|
||||
zip_path = tmp_path / "test-ext-update.zip"
|
||||
self._create_catalog_zip(zip_path, "2.0.0")
|
||||
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
|
||||
|
||||
def fake_install_from_zip(self_obj, _zip_path, speckit_version):
|
||||
return self_obj.install_from_directory(v2_dir, speckit_version)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(ExtensionCatalog, "get_extension_info", return_value={
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "2.0.0",
|
||||
"_install_allowed": True,
|
||||
}), \
|
||||
patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \
|
||||
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
updated = ExtensionManager(project_dir).registry.get("test-ext")
|
||||
assert updated["version"] == "2.0.0"
|
||||
assert updated["installed_at"] == original_installed_at
|
||||
restored_config_content = (
|
||||
project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml"
|
||||
).read_text()
|
||||
assert restored_config_content == original_config_content
|
||||
|
||||
def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path):
|
||||
"""Failed update should restore original registry, hooks, and command files."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
import yaml
|
||||
|
||||
runner = CliRunner()
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
(project_dir / ".claude" / "commands").mkdir(parents=True)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
|
||||
manager.install_from_directory(v1_dir, "0.1.0")
|
||||
|
||||
backup_registry_entry = manager.registry.get("test-ext")
|
||||
hooks_before = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text())
|
||||
|
||||
registered_commands = backup_registry_entry.get("registered_commands", {})
|
||||
command_files = []
|
||||
registrar = CommandRegistrar()
|
||||
for agent_name, cmd_names in registered_commands.items():
|
||||
if agent_name not in registrar.AGENT_CONFIGS:
|
||||
continue
|
||||
agent_cfg = registrar.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_dir / agent_cfg["dir"]
|
||||
for cmd_name in cmd_names:
|
||||
cmd_path = commands_dir / f"{cmd_name}{agent_cfg['extension']}"
|
||||
command_files.append(cmd_path)
|
||||
|
||||
assert command_files, "Expected at least one registered command file"
|
||||
for cmd_file in command_files:
|
||||
assert cmd_file.exists(), f"Expected command file to exist before update: {cmd_file}"
|
||||
|
||||
zip_path = tmp_path / "test-ext-update.zip"
|
||||
self._create_catalog_zip(zip_path, "2.0.0")
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(ExtensionCatalog, "get_extension_info", return_value={
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "2.0.0",
|
||||
"_install_allowed": True,
|
||||
}), \
|
||||
patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \
|
||||
patch.object(ExtensionManager, "install_from_zip", side_effect=RuntimeError("install failed")):
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
|
||||
restored_entry = ExtensionManager(project_dir).registry.get("test-ext")
|
||||
assert restored_entry == backup_registry_entry
|
||||
|
||||
hooks_after = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text())
|
||||
assert hooks_after == hooks_before
|
||||
|
||||
for cmd_file in command_files:
|
||||
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}"
|
||||
|
||||
Reference in New Issue
Block a user