mirror of
https://github.com/github/spec-kit.git
synced 2026-03-19 20:03:07 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2559d7025 | ||
|
|
f85944aafe | ||
|
|
34171efcef | ||
|
|
c8af730b14 | ||
|
|
a4b60aca7f | ||
|
|
2f25e2d575 | ||
|
|
7484eb521a | ||
|
|
2bf655e261 | ||
|
|
f6794685b6 | ||
|
|
333a76535b | ||
|
|
6d0b84ab5b | ||
|
|
497b5885e1 | ||
|
|
33c83a6162 | ||
|
|
f97c8e95a6 | ||
|
|
cfd99ad499 | ||
|
|
96712e1cdf | ||
|
|
2e55bdd3f2 | ||
|
|
eecb723663 | ||
|
|
1a21bdef01 | ||
|
|
f21eb71990 | ||
|
|
b471b5e6f3 |
@@ -51,6 +51,14 @@ echo -e "\n🤖 Installing OpenCode CLI..."
|
||||
run_command "npm install -g opencode-ai@latest"
|
||||
echo "✅ Done"
|
||||
|
||||
echo -e "\n🤖 Installing Junie CLI..."
|
||||
run_command "npm install -g @jetbrains/junie-cli@latest"
|
||||
echo "✅ Done"
|
||||
|
||||
echo -e "\n🤖 Installing Pi Coding Agent..."
|
||||
run_command "npm install -g @mariozechner/pi-coding-agent@latest"
|
||||
echo "✅ Done"
|
||||
|
||||
echo -e "\n🤖 Installing Kiro CLI..."
|
||||
# https://kiro.dev/docs/cli/
|
||||
KIRO_INSTALLER_URL="https://kiro.dev/install.sh"
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, IBM Bob, Antigravity
|
||||
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -7,7 +7,7 @@ contact_links:
|
||||
url: https://github.com/github/spec-kit/blob/main/README.md
|
||||
about: Read the Spec Kit documentation and guides
|
||||
- name: 🛠️ Extension Development Guide
|
||||
url: https://github.com/manfredseee/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
|
||||
url: https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
|
||||
about: Learn how to develop and publish Spec Kit extensions
|
||||
- name: 🤝 Contributing Guide
|
||||
url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md
|
||||
|
||||
169
.github/ISSUE_TEMPLATE/preset_submission.yml
vendored
Normal file
169
.github/ISSUE_TEMPLATE/preset_submission.yml
vendored
Normal file
@@ -0,0 +1,169 @@
|
||||
name: Preset Submission
|
||||
description: Submit your preset to the Spec Kit preset catalog
|
||||
title: "[Preset]: Add "
|
||||
labels: ["preset-submission", "enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for contributing a preset! This template helps you submit your preset to the community catalog.
|
||||
|
||||
**Before submitting:**
|
||||
- Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)
|
||||
- Ensure your preset has a valid `preset.yml` manifest
|
||||
- Create a GitHub release with a version tag (e.g., v1.0.0)
|
||||
- Test installation from the release archive: `specify preset add --from <download-url>`
|
||||
|
||||
- type: input
|
||||
id: preset-id
|
||||
attributes:
|
||||
label: Preset ID
|
||||
description: Unique preset identifier (lowercase with hyphens only)
|
||||
placeholder: "e.g., healthcare-compliance"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: preset-name
|
||||
attributes:
|
||||
label: Preset Name
|
||||
description: Human-readable preset name
|
||||
placeholder: "e.g., Healthcare Compliance"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Semantic version number
|
||||
placeholder: "e.g., 1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Brief description of what your preset does (under 200 characters)
|
||||
placeholder: Enforces HIPAA-compliant spec workflows with audit templates and compliance checklists
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: author
|
||||
attributes:
|
||||
label: Author
|
||||
description: Your name or organization
|
||||
placeholder: "e.g., John Doe or Acme Corp"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: repository
|
||||
attributes:
|
||||
label: Repository URL
|
||||
description: GitHub repository URL for your preset
|
||||
placeholder: "https://github.com/your-org/spec-kit-your-preset"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: download-url
|
||||
attributes:
|
||||
label: Download URL
|
||||
description: URL to the GitHub release archive for your preset (e.g., https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip)
|
||||
placeholder: "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: license
|
||||
attributes:
|
||||
label: License
|
||||
description: Open source license type
|
||||
placeholder: "e.g., MIT, Apache-2.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: speckit-version
|
||||
attributes:
|
||||
label: Required Spec Kit Version
|
||||
description: Minimum Spec Kit version required
|
||||
placeholder: "e.g., >=0.3.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: templates-provided
|
||||
attributes:
|
||||
label: Templates Provided
|
||||
description: List the template overrides your preset provides
|
||||
placeholder: |
|
||||
- spec-template.md — adds compliance section
|
||||
- plan-template.md — includes audit checkpoints
|
||||
- checklist-template.md — HIPAA compliance checklist
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: commands-provided
|
||||
attributes:
|
||||
label: Commands Provided (optional)
|
||||
description: List any command overrides your preset provides
|
||||
placeholder: |
|
||||
- speckit.specify.md — customized for compliance workflows
|
||||
|
||||
- type: textarea
|
||||
id: tags
|
||||
attributes:
|
||||
label: Tags
|
||||
description: 2-5 relevant tags (lowercase, separated by commas)
|
||||
placeholder: "compliance, healthcare, hipaa, audit"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: features
|
||||
attributes:
|
||||
label: Key Features
|
||||
description: List the main features and capabilities of your preset
|
||||
placeholder: |
|
||||
- HIPAA-compliant spec templates
|
||||
- Audit trail checklists
|
||||
- Compliance review workflow
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: testing
|
||||
attributes:
|
||||
label: Testing Checklist
|
||||
description: Confirm that your preset has been tested
|
||||
options:
|
||||
- label: Preset installs successfully via `specify preset add`
|
||||
required: true
|
||||
- label: Template resolution works correctly after installation
|
||||
required: true
|
||||
- label: Documentation is complete and accurate
|
||||
required: true
|
||||
- label: Tested on at least one real project
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: requirements
|
||||
attributes:
|
||||
label: Submission Requirements
|
||||
description: Verify your preset meets all requirements
|
||||
options:
|
||||
- label: Valid `preset.yml` manifest included
|
||||
required: true
|
||||
- label: README.md with description and usage instructions
|
||||
required: true
|
||||
- label: LICENSE file included
|
||||
required: true
|
||||
- label: GitHub release created with version tag
|
||||
required: true
|
||||
- label: Preset ID follows naming conventions (lowercase-with-hyphens)
|
||||
required: true
|
||||
8
.github/workflows/release-trigger.yml
vendored
8
.github/workflows/release-trigger.yml
vendored
@@ -86,8 +86,10 @@ jobs:
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
# Get the previous tag to compare commits
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
# Get the previous tag by sorting all version tags numerically
|
||||
# (git describe --tags only finds tags reachable from HEAD,
|
||||
# which misses tags on unmerged release branches)
|
||||
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | head -n 1)
|
||||
|
||||
echo "Generating changelog from commits..."
|
||||
if [[ -n "$PREVIOUS_TAG" ]]; then
|
||||
@@ -104,7 +106,7 @@ jobs:
|
||||
echo ""
|
||||
echo "## [${{ steps.version.outputs.version }}] - $DATE"
|
||||
echo ""
|
||||
echo "### Changed"
|
||||
echo "### Changes"
|
||||
echo ""
|
||||
echo "$COMMITS"
|
||||
echo ""
|
||||
|
||||
@@ -30,6 +30,8 @@ gh release create "$VERSION" \
|
||||
.genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-junie-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-junie-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-codex-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-codex-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \
|
||||
@@ -60,6 +62,10 @@ gh release create "$VERSION" \
|
||||
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-trae-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-trae-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-pi-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-pi-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-iflow-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-iflow-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, kimi, trae, generic
|
||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, junie, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, iflow, generic
|
||||
|
||||
.PARAMETER Scripts
|
||||
Comma or space separated subset of script types to build (default: both)
|
||||
@@ -201,20 +201,22 @@ 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 {
|
||||
# Create skills in <skills_dir>\<name>\SKILL.md format.
|
||||
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||
# current dotted-name exception (e.g. speckit.plan).
|
||||
function New-Skills {
|
||||
param(
|
||||
[string]$SkillsDir,
|
||||
[string]$ScriptVariant
|
||||
[string]$ScriptVariant,
|
||||
[string]$AgentName,
|
||||
[string]$Separator = '-'
|
||||
)
|
||||
|
||||
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($template in $templates) {
|
||||
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
|
||||
$skillName = "speckit.$name"
|
||||
$skillName = "speckit${Separator}$name"
|
||||
$skillDir = Join-Path $SkillsDir $skillName
|
||||
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
|
||||
|
||||
@@ -267,7 +269,7 @@ function New-KimiSkills {
|
||||
|
||||
$body = $outputLines -join "`n"
|
||||
$body = $body -replace '\{ARGS\}', '$ARGUMENTS'
|
||||
$body = $body -replace '__AGENT__', 'kimi'
|
||||
$body = $body -replace '__AGENT__', $AgentName
|
||||
$body = Rewrite-Paths -Content $body
|
||||
|
||||
# Strip existing frontmatter, keep only body
|
||||
@@ -395,9 +397,14 @@ function Build-Variant {
|
||||
$cmdDir = Join-Path $baseDir ".windsurf/workflows"
|
||||
Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'junie' {
|
||||
$cmdDir = Join-Path $baseDir ".junie/commands"
|
||||
Generate-Commands -Agent 'junie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'codex' {
|
||||
$cmdDir = Join-Path $baseDir ".codex/prompts"
|
||||
Generate-Commands -Agent 'codex' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
$skillsDir = Join-Path $baseDir ".agents/skills"
|
||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-'
|
||||
}
|
||||
'kilocode' {
|
||||
$cmdDir = Join-Path $baseDir ".kilocode/workflows"
|
||||
@@ -452,13 +459,21 @@ function Build-Variant {
|
||||
'kimi' {
|
||||
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||
New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script
|
||||
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'
|
||||
}
|
||||
'trae' {
|
||||
$rulesDir = Join-Path $baseDir ".trae/rules"
|
||||
New-Item -ItemType Directory -Force -Path $rulesDir | Out-Null
|
||||
Generate-Commands -Agent 'trae' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $rulesDir -ScriptVariant $Script
|
||||
}
|
||||
'pi' {
|
||||
$cmdDir = Join-Path $baseDir ".pi/prompts"
|
||||
Generate-Commands -Agent 'pi' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'iflow' {
|
||||
$cmdDir = Join-Path $baseDir ".iflow/commands"
|
||||
Generate-Commands -Agent 'iflow' -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
|
||||
@@ -475,7 +490,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', 'kimi', 'trae', 'generic')
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'junie', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'iflow', 'generic')
|
||||
$AllScripts = @('sh', 'ps')
|
||||
|
||||
function Normalize-List {
|
||||
|
||||
@@ -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 kimi trae generic (default: all)
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all)
|
||||
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
||||
# Examples:
|
||||
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
||||
@@ -121,18 +121,20 @@ 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() {
|
||||
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
||||
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||
# current dotted-name exception (e.g. speckit.plan).
|
||||
create_skills() {
|
||||
local skills_dir="$1"
|
||||
local script_variant="$2"
|
||||
local agent_name="$3"
|
||||
local separator="${4:-"-"}"
|
||||
|
||||
for template in templates/commands/*.md; do
|
||||
[[ -f "$template" ]] || continue
|
||||
local name
|
||||
name=$(basename "$template" .md)
|
||||
local skill_name="speckit.${name}"
|
||||
local skill_name="speckit${separator}${name}"
|
||||
local skill_dir="${skills_dir}/${skill_name}"
|
||||
mkdir -p "$skill_dir"
|
||||
|
||||
@@ -175,9 +177,9 @@ create_kimi_skills() {
|
||||
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
||||
{ print }
|
||||
')
|
||||
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed 's/__AGENT__/kimi/g' | rewrite_paths)
|
||||
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths)
|
||||
|
||||
# Strip existing frontmatter and prepend Kimi frontmatter
|
||||
# Strip existing frontmatter and prepend skills frontmatter.
|
||||
local template_body
|
||||
template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found')
|
||||
|
||||
@@ -248,9 +250,12 @@ build_variant() {
|
||||
windsurf)
|
||||
mkdir -p "$base_dir/.windsurf/workflows"
|
||||
generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;;
|
||||
junie)
|
||||
mkdir -p "$base_dir/.junie/commands"
|
||||
generate_commands junie md "\$ARGUMENTS" "$base_dir/.junie/commands" "$script" ;;
|
||||
codex)
|
||||
mkdir -p "$base_dir/.codex/prompts"
|
||||
generate_commands codex md "\$ARGUMENTS" "$base_dir/.codex/prompts" "$script" ;;
|
||||
mkdir -p "$base_dir/.agents/skills"
|
||||
create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;;
|
||||
kilocode)
|
||||
mkdir -p "$base_dir/.kilocode/workflows"
|
||||
generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;;
|
||||
@@ -290,10 +295,16 @@ build_variant() {
|
||||
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" ;;
|
||||
create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
|
||||
trae)
|
||||
mkdir -p "$base_dir/.trae/rules"
|
||||
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
||||
pi)
|
||||
mkdir -p "$base_dir/.pi/prompts"
|
||||
generate_commands pi md "\$ARGUMENTS" "$base_dir/.pi/prompts" "$script" ;;
|
||||
iflow)
|
||||
mkdir -p "$base_dir/.iflow/commands"
|
||||
generate_commands iflow md "\$ARGUMENTS" "$base_dir/.iflow/commands" "$script" ;;
|
||||
generic)
|
||||
mkdir -p "$base_dir/.speckit/commands"
|
||||
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
||||
@@ -303,7 +314,7 @@ 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 kimi trae generic)
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic)
|
||||
ALL_SCRIPTS=(sh ps)
|
||||
|
||||
norm_list() {
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@@ -33,11 +33,12 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
||||
| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor 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 |
|
||||
| **Codex CLI** | `.codex/prompts/` | Markdown | `codex` | Codex CLI |
|
||||
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
|
||||
| **Kilo Code** | `.kilocode/rules/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
|
||||
| **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI |
|
||||
| **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE |
|
||||
| **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains |
|
||||
| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
|
||||
| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI |
|
||||
| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE |
|
||||
| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI |
|
||||
| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI |
|
||||
| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI |
|
||||
@@ -45,6 +46,8 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
||||
| **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) |
|
||||
| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent |
|
||||
| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) |
|
||||
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
||||
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
|
||||
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
||||
@@ -85,7 +88,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), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular)
|
||||
- Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli, pi), `"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
|
||||
@@ -316,6 +319,7 @@ Require a command-line tool to be installed:
|
||||
- **Cursor**: `cursor-agent` CLI
|
||||
- **Qwen Code**: `qwen` CLI
|
||||
- **opencode**: `opencode` CLI
|
||||
- **Junie**: `junie` CLI
|
||||
- **Kiro CLI**: `kiro-cli` CLI
|
||||
- **CodeBuddy CLI**: `codebuddy` CLI
|
||||
- **Qoder CLI**: `qodercli` CLI
|
||||
@@ -323,6 +327,7 @@ Require a command-line tool to be installed:
|
||||
- **SHAI**: `shai` CLI
|
||||
- **Tabnine CLI**: `tabnine` CLI
|
||||
- **Kimi Code**: `kimi` CLI
|
||||
- **Pi Coding Agent**: `pi` CLI
|
||||
|
||||
### IDE-Based Agents
|
||||
|
||||
@@ -336,7 +341,7 @@ Work within integrated development environments:
|
||||
|
||||
### Markdown Format
|
||||
|
||||
Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen
|
||||
Used by: Claude, Cursor, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi
|
||||
|
||||
**Standard format:**
|
||||
|
||||
@@ -374,6 +379,10 @@ Command content with {SCRIPT} and {{args}} placeholders.
|
||||
## Directory Conventions
|
||||
|
||||
- **CLI agents**: Usually `.<agent-name>/commands/`
|
||||
- **Common prompt-based exceptions**:
|
||||
- Codex: `.codex/prompts/`
|
||||
- Kiro CLI: `.kiro/prompts/`
|
||||
- Pi: `.pi/prompts/`
|
||||
- **IDE agents**: Follow IDE-specific patterns:
|
||||
- Copilot: `.github/agents/`
|
||||
- Cursor: `.cursor/commands/`
|
||||
|
||||
91
CHANGELOG.md
91
CHANGELOG.md
@@ -1,18 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
## [0.3.2] - 2026-03-19
|
||||
|
||||
Recent changes to the Specify CLI and templates are documented here.
|
||||
### Changes
|
||||
|
||||
- Add conduct extension to community catalog (#1908)
|
||||
- feat(extensions): add verify-tasks extension to community catalog (#1871)
|
||||
- feat(presets): add enable/disable toggle and update semantics (#1891)
|
||||
- feat: add iFlow CLI support (#1875)
|
||||
- feat(commands): wire before/after hook events into specify and plan templates (#1886)
|
||||
- docs(catalog): add speckit-utils to community catalog (#1896)
|
||||
- docs: Add Extensions & Presets section to README (#1898)
|
||||
- chore: update DocGuard extension to v0.9.11 (#1899)
|
||||
- Update cognitive-squad catalog entry — Triadic Model, full lifecycle (#1884)
|
||||
- feat: register spec-kit-iterate extension (#1887)
|
||||
- fix(scripts): add explicit positional binding to PowerShell create-new-feature params (#1885)
|
||||
- fix(scripts): encode residual JSON control chars as \uXXXX instead of stripping (#1872)
|
||||
- chore: update DocGuard extension to v0.9.10 (#1890)
|
||||
- Feature/spec kit add pi coding agent pullrequest (#1853)
|
||||
- feat: register spec-kit-learn extension (#1883)
|
||||
|
||||
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.3.1] - 2026-03-17
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878)
|
||||
- fix(ai-skills): exclude non-speckit copilot agent markdown from skill… (#1867)
|
||||
- fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867)
|
||||
- feat: add Trae IDE support as a new agent (#1817)
|
||||
- feat(cli): polite deep merge for settings.json and support JSONC (#1874)
|
||||
- feat(extensions,presets): add priority-based resolution ordering (#1855)
|
||||
@@ -25,57 +39,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- fix(extensions): show extension ID in list output (#1843)
|
||||
- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)
|
||||
- feat: Add DocGuard CDD enforcement extension to community catalog (#1838)
|
||||
- chore: bump version to 0.3.0 (#1839)
|
||||
- feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
|
||||
- fix: match 'Last updated' timestamp with or without bold markers (#1836)
|
||||
- Add specify doctor command for project health diagnostics (#1828)
|
||||
- fix: harden bash scripts against shell injection and improve robustness (#1809)
|
||||
- fix: clean up command templates (specify, analyze) (#1810)
|
||||
- fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730)
|
||||
- fix(cli): deprecate explicit command support for agy (#1798) (#1808)
|
||||
- Add /selftest.extension core extension to test other extensions (#1758)
|
||||
- feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776)
|
||||
- Add Java brownfield walkthrough to community walkthroughs (#1820)
|
||||
- chore: bump version to 0.2.1 (#1813)
|
||||
- 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.3.0] - 2026-03-13
|
||||
@@ -358,19 +321,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- chore(deps): bump actions/stale from 9 to 10 (#1623)
|
||||
- feat: add dependabot configuration for pip and GitHub Actions updates (#1622)
|
||||
|
||||
## [0.0.97] - 2026-02-18
|
||||
|
||||
- Remove Maintainers section from README.md (#1618)
|
||||
|
||||
## [0.0.96] - 2026-02-17
|
||||
|
||||
- fix: typo in plan-template.md (#1446)
|
||||
|
||||
## [0.0.95] - 2026-02-12
|
||||
|
||||
- Feat: add a new agent: Google Anti Gravity (#1220)
|
||||
|
||||
## [0.0.94] - 2026-02-11
|
||||
|
||||
- Add stale workflow for 180-day inactive issues and PRs (#1594)
|
||||
|
||||
122
README.md
122
README.md
@@ -25,6 +25,7 @@
|
||||
- [🚶 Community Walkthroughs](#-community-walkthroughs)
|
||||
- [🤖 Supported AI Agents](#-supported-ai-agents)
|
||||
- [🔧 Specify CLI Reference](#-specify-cli-reference)
|
||||
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
|
||||
- [📚 Core Philosophy](#-core-philosophy)
|
||||
- [🌟 Development Phases](#-development-phases)
|
||||
- [🎯 Experimental Goals](#-experimental-goals)
|
||||
@@ -98,7 +99,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c
|
||||
|
||||
### 2. Establish project principles
|
||||
|
||||
Launch your AI assistant in the project directory. The `/speckit.*` commands are available in the assistant.
|
||||
Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||
|
||||
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
||||
|
||||
@@ -172,7 +173,7 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
|
||||
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
|
||||
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
|
||||
| [Codex CLI](https://github.com/openai/codex) | ✅ | |
|
||||
| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-<command>`. |
|
||||
| [Cursor](https://cursor.sh/) | ✅ | |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
|
||||
@@ -180,13 +181,16 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
| [Jules](https://jules.google.com/) | ✅ | |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | |
|
||||
| [opencode](https://opencode.ai/) | ✅ | |
|
||||
| [Pi Coding Agent](https://pi.dev) | ✅ | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
|
||||
| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | |
|
||||
| [Roo Code](https://roocode.com/) | ✅ | |
|
||||
| [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/) | ✅ | |
|
||||
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | |
|
||||
| [Windsurf](https://windsurf.com/) | ✅ | |
|
||||
| [Junie](https://junie.jetbrains.com/) | ✅ | |
|
||||
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
|
||||
| [Trae](https://www.trae.ai/) | ✅ | |
|
||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||
@@ -197,27 +201,27 @@ The `specify` command supports the following options:
|
||||
|
||||
### Commands
|
||||
|
||||
| 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`, `kimi`) |
|
||||
| Command | Description |
|
||||
| ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `init` | Initialize a new Specify project from the latest template |
|
||||
| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, etc.) |
|
||||
|
||||
### `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`, `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 |
|
||||
| `--no-git` | Flag | Skip git repository initialization |
|
||||
| `--here` | Flag | Initialize project in the current directory instead of creating a new one |
|
||||
| `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) |
|
||||
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
|
||||
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
||||
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
|
||||
| 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 (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, 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 |
|
||||
| `--no-git` | Flag | Skip git repository initialization |
|
||||
| `--here` | Flag | Initialize project in the current directory instead of creating a new one |
|
||||
| `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) |
|
||||
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
|
||||
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
||||
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -252,6 +256,12 @@ specify init my-project --ai vibe
|
||||
# Initialize with IBM Bob support
|
||||
specify init my-project --ai bob
|
||||
|
||||
# Initialize with Pi Coding Agent support
|
||||
specify init my-project --ai pi
|
||||
|
||||
# Initialize with Codex CLI support
|
||||
specify init my-project --ai codex --ai-skills
|
||||
|
||||
# Initialize with Antigravity support
|
||||
specify init my-project --ai agy --ai-skills
|
||||
|
||||
@@ -292,7 +302,9 @@ specify check
|
||||
|
||||
### Available Slash Commands
|
||||
|
||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development:
|
||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development.
|
||||
|
||||
For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`.
|
||||
|
||||
#### Core Commands
|
||||
|
||||
@@ -322,6 +334,68 @@ Additional commands for enhanced quality and validation:
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.<br/>\*\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. |
|
||||
|
||||
## 🧩 Making Spec Kit Your Own: Extensions & Presets
|
||||
|
||||
Spec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments:
|
||||
|
||||
```mermaid
|
||||
block-beta
|
||||
columns 1
|
||||
overrides["⬆ Highest priority\nProject-Local Overrides\n.specify/templates/overrides/"]
|
||||
presets["Presets — Customize core & extensions\n.specify/presets/<preset-id>/templates/"]
|
||||
extensions["Extensions — Add new capabilities\n.specify/extensions/<ext-id>/templates/"]
|
||||
core["Spec Kit Core — Built-in SDD commands & templates\n.specify/templates/\n⬇ Lowest priority"]
|
||||
|
||||
style overrides fill:transparent,stroke:#999
|
||||
style presets fill:transparent,stroke:#4a9eda
|
||||
style extensions fill:transparent,stroke:#4a9e4a
|
||||
style core fill:transparent,stroke:#e6a817
|
||||
```
|
||||
|
||||
**Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. **Commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. If no overrides or customizations exist, Spec Kit uses its core defaults.
|
||||
|
||||
### Extensions — Add New Capabilities
|
||||
|
||||
Use **extensions** when you need functionality that goes beyond Spec Kit's core. Extensions introduce new commands and templates — for example, adding domain-specific workflows that are not covered by the built-in SDD commands, integrating with external tools, or adding entirely new development phases. They expand *what Spec Kit can do*.
|
||||
|
||||
```bash
|
||||
# Search available extensions
|
||||
specify extension search
|
||||
|
||||
# Install an extension
|
||||
specify extension add <extension-name>
|
||||
```
|
||||
|
||||
For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics.
|
||||
|
||||
See the [Extensions README](./extensions/README.md) for the full guide, the complete community catalog, and how to build and publish your own.
|
||||
|
||||
### Presets — Customize Existing Workflows
|
||||
|
||||
Use **presets** when you want to change *how* Spec Kit works without adding new capabilities. Presets override the templates and commands that ship with the core *and* with installed extensions — for example, enforcing a compliance-oriented spec format, using domain-specific terminology, or applying organizational standards to plans and tasks. They customize the artifacts and instructions that Spec Kit and its extensions produce.
|
||||
|
||||
```bash
|
||||
# Search available presets
|
||||
specify preset search
|
||||
|
||||
# Install a preset
|
||||
specify preset add <preset-name>
|
||||
```
|
||||
|
||||
For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering.
|
||||
|
||||
See the [Presets README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own.
|
||||
|
||||
### When to Use Which
|
||||
|
||||
| Goal | Use |
|
||||
| --- | --- |
|
||||
| Add a brand-new command or workflow | Extension |
|
||||
| Customize the format of specs, plans, or tasks | Preset |
|
||||
| Integrate an external tool or service | Extension |
|
||||
| Enforce organizational or regulatory standards | Preset |
|
||||
| Ship reusable domain-specific templates | Either — presets for template overrides, extensions for templates bundled with new commands |
|
||||
|
||||
## 📚 Core Philosophy
|
||||
|
||||
Spec-Driven Development is a structured process that emphasizes:
|
||||
@@ -416,11 +490,11 @@ specify init <project_name> --ai copilot
|
||||
|
||||
# Or in current directory:
|
||||
specify init . --ai claude
|
||||
specify init . --ai codex
|
||||
specify init . --ai codex --ai-skills
|
||||
|
||||
# or use --here flag
|
||||
specify init --here --ai claude
|
||||
specify init --here --ai codex
|
||||
specify init --here --ai codex --ai-skills
|
||||
|
||||
# Force merge into a non-empty current directory
|
||||
specify init . --force --ai claude
|
||||
@@ -429,7 +503,7 @@ specify init . --force --ai claude
|
||||
specify init --here --force --ai claude
|
||||
```
|
||||
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --ai claude --ignore-agent-tools
|
||||
|
||||
13
SUPPORT.md
13
SUPPORT.md
@@ -1,18 +1,17 @@
|
||||
# Support
|
||||
|
||||
## How to file issues and get help
|
||||
## How to get help
|
||||
|
||||
This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue.
|
||||
Please search existing [issues](https://github.com/github/spec-kit/issues) and [discussions](https://github.com/github/spec-kit/discussions) before creating new ones to avoid duplicates.
|
||||
|
||||
For help or questions about using this project, please:
|
||||
|
||||
- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports, feature requests, or questions about the Spec-Driven Development methodology
|
||||
- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process
|
||||
- Review the [README](./README.md) for getting started instructions and troubleshooting tips
|
||||
- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process
|
||||
- Ask in [GitHub Discussions](https://github.com/github/spec-kit/discussions) for questions about using Spec Kit or the Spec-Driven Development methodology
|
||||
- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports and feature requests
|
||||
|
||||
## Project Status
|
||||
|
||||
**Spec Kit** is under active development and maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support, feature requests, and community questions in a timely manner.
|
||||
**Spec Kit** is under active development and maintained by GitHub staff and the community. We will do our best to respond to support, feature requests, and community questions as time permits.
|
||||
|
||||
## GitHub Support Policy
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Prerequisites
|
||||
|
||||
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli) or [Gemini CLI](https://github.com/google-gemini/gemini-cli)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||
- [uv](https://docs.astral.sh/uv/) for package management
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
@@ -35,6 +35,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <project_name
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai gemini
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai copilot
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai codebuddy
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai pi
|
||||
```
|
||||
|
||||
### Specify Script Type (Shell vs PowerShell)
|
||||
|
||||
@@ -289,8 +289,9 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a
|
||||
|
||||
```bash
|
||||
ls -la .claude/commands/ # Claude Code
|
||||
ls -la .gemini/commands/ # Gemini
|
||||
ls -la .cursor/commands/ # Cursor
|
||||
ls -la .gemini/commands/ # Gemini
|
||||
ls -la .cursor/commands/ # Cursor
|
||||
ls -la .pi/prompts/ # Pi Coding Agent
|
||||
```
|
||||
|
||||
3. **Check agent-specific setup:**
|
||||
@@ -398,7 +399,7 @@ The `specify` CLI tool is used for:
|
||||
- **Upgrades:** `specify init --here --force` to update templates and commands
|
||||
- **Diagnostics:** `specify check` to verify tool installation
|
||||
|
||||
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again.
|
||||
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again.
|
||||
|
||||
**If your agent isn't recognizing slash commands:**
|
||||
|
||||
@@ -410,6 +411,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
|
||||
|
||||
# For Claude
|
||||
ls -la .claude/commands/
|
||||
|
||||
# For Pi
|
||||
ls -la .pi/prompts/
|
||||
```
|
||||
|
||||
2. **Restart your IDE/editor completely** (not just reload window)
|
||||
|
||||
@@ -53,7 +53,7 @@ provides:
|
||||
required: boolean # Default: false
|
||||
|
||||
hooks: # Optional, event hooks
|
||||
event_name: # e.g., "after_tasks", "after_implement"
|
||||
event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement"
|
||||
command: string # Command to execute
|
||||
optional: boolean # Default: true
|
||||
prompt: string # Prompt text for optional hooks
|
||||
@@ -108,7 +108,7 @@ defaults: # Optional, default configuration values
|
||||
#### `hooks`
|
||||
|
||||
- **Type**: object
|
||||
- **Keys**: Event names (e.g., `after_tasks`, `after_implement`, `before_commit`)
|
||||
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_commit`)
|
||||
- **Description**: Hooks that execute at lifecycle events
|
||||
- **Events**: Defined by core spec-kit commands
|
||||
|
||||
@@ -551,10 +551,16 @@ hooks:
|
||||
|
||||
Standard events (defined by core):
|
||||
|
||||
- `before_specify` - Before specification generation
|
||||
- `after_specify` - After specification generation
|
||||
- `before_plan` - Before implementation planning
|
||||
- `after_plan` - After implementation planning
|
||||
- `before_tasks` - Before task generation
|
||||
- `after_tasks` - After task generation
|
||||
- `before_implement` - Before implementation
|
||||
- `after_implement` - After implementation
|
||||
- `before_commit` - Before git commit
|
||||
- `after_commit` - After git commit
|
||||
- `before_commit` - Before git commit *(planned - not yet wired into core templates)*
|
||||
- `after_commit` - After git commit *(planned - not yet wired into core templates)*
|
||||
|
||||
### Hook Configuration
|
||||
|
||||
|
||||
@@ -387,6 +387,9 @@ settings:
|
||||
auto_execute_hooks: true
|
||||
|
||||
# Hook configuration
|
||||
# Available events: before_specify, after_specify, before_plan, after_plan,
|
||||
# before_tasks, after_tasks, before_implement, after_implement
|
||||
# Planned (not yet wired into core templates): before_commit, after_commit
|
||||
hooks:
|
||||
after_tasks:
|
||||
- extension: jira
|
||||
|
||||
@@ -70,25 +70,34 @@ specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/ta
|
||||
|
||||
The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json):
|
||||
|
||||
| Extension | Purpose | URL |
|
||||
|-----------|---------|-----|
|
||||
| Archive Extension | Archive merged features into main project memory. | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Cognitive Squad | 19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | [understanding](https://github.com/Testimonial/understanding) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
**Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress
|
||||
|
||||
**Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
|
||||
|
||||
## Adding Your Extension
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-19T12:08:20Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"archive": {
|
||||
@@ -108,7 +108,7 @@
|
||||
"cognitive-squad": {
|
||||
"name": "Cognitive Squad",
|
||||
"id": "cognitive-squad",
|
||||
"description": "19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop",
|
||||
"description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing",
|
||||
"author": "Testimonial",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip",
|
||||
@@ -133,29 +133,59 @@
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 7,
|
||||
"commands": 10,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"ai-agents",
|
||||
"cognitive",
|
||||
"pre-code",
|
||||
"analysis",
|
||||
"full-lifecycle",
|
||||
"verification",
|
||||
"multi-agent"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-16T00:00:00Z"
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"conduct": {
|
||||
"name": "Conduct Extension",
|
||||
"id": "conduct",
|
||||
"description": "Executes a single spec-kit phase via sub-agent delegation to reduce context pollution.",
|
||||
"author": "twbrandon7",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/twbrandon7/spec-kit-conduct-ext",
|
||||
"homepage": "https://github.com/twbrandon7/spec-kit-conduct-ext",
|
||||
"documentation": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/README.md",
|
||||
"changelog": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.3.1"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"conduct",
|
||||
"workflow",
|
||||
"automation"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-19T12:08:20Z",
|
||||
"updated_at": "2026-03-19T12:08:20Z"
|
||||
},
|
||||
"docguard": {
|
||||
"name": "DocGuard \u2014 CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies.",
|
||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies.",
|
||||
"author": "raccioly",
|
||||
"version": "0.9.8",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.8/spec-kit-docguard-v0.9.8.zip",
|
||||
"version": "0.9.11",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.11/spec-kit-docguard-v0.9.11.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
@@ -189,7 +219,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-03-15T20:00:00Z"
|
||||
"updated_at": "2026-03-18T18:53:31Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
@@ -253,6 +283,36 @@
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-03-06T00:00:00Z"
|
||||
},
|
||||
"iterate": {
|
||||
"name": "Iterate",
|
||||
"id": "iterate",
|
||||
"description": "Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building",
|
||||
"author": "Vianca Martinez",
|
||||
"version": "2.0.0",
|
||||
"download_url": "https://github.com/imviancagrace/spec-kit-iterate/archive/refs/tags/v2.0.0.zip",
|
||||
"repository": "https://github.com/imviancagrace/spec-kit-iterate",
|
||||
"homepage": "https://github.com/imviancagrace/spec-kit-iterate",
|
||||
"documentation": "https://github.com/imviancagrace/spec-kit-iterate/blob/main/README.md",
|
||||
"changelog": "https://github.com/imviancagrace/spec-kit-iterate/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"iteration",
|
||||
"change-management",
|
||||
"spec-maintenance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-17T00:00:00Z",
|
||||
"updated_at": "2026-03-17T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Integration",
|
||||
"id": "jira",
|
||||
@@ -422,6 +482,38 @@
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-03-06T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
"id": "speckit-utils",
|
||||
"description": "Resume interrupted workflows, validate project health, and verify spec-to-task traceability.",
|
||||
"author": "mvanhorn",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/mvanhorn/speckit-utils/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/mvanhorn/speckit-utils",
|
||||
"homepage": "https://github.com/mvanhorn/speckit-utils",
|
||||
"documentation": "https://github.com/mvanhorn/speckit-utils/blob/main/README.md",
|
||||
"changelog": "https://github.com/mvanhorn/speckit-utils/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"resume",
|
||||
"doctor",
|
||||
"validate",
|
||||
"workflow",
|
||||
"health-check"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
"id": "sync",
|
||||
@@ -559,6 +651,37 @@
|
||||
"created_at": "2026-02-20T00:00:00Z",
|
||||
"updated_at": "2026-02-22T00:00:00Z"
|
||||
},
|
||||
"learn": {
|
||||
"name": "Learning Extension",
|
||||
"id": "learn",
|
||||
"description": "Generate educational guides from implementations and enhance clarifications with mentoring context.",
|
||||
"author": "Vianca Martinez",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/imviancagrace/spec-kit-learn",
|
||||
"homepage": "https://github.com/imviancagrace/spec-kit-learn",
|
||||
"documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md",
|
||||
"changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"learning",
|
||||
"education",
|
||||
"mentoring",
|
||||
"knowledge-transfer"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-17T00:00:00Z",
|
||||
"updated_at": "2026-03-17T00:00:00Z"
|
||||
},
|
||||
"verify": {
|
||||
"name": "Verify Extension",
|
||||
"id": "verify",
|
||||
@@ -590,6 +713,37 @@
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-03-03T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
"id": "verify-tasks",
|
||||
"description": "Detect phantom completions: tasks marked [X] in tasks.md with no real implementation.",
|
||||
"author": "Dave Sharpe",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/datastone-inc/spec-kit-verify-tasks/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/datastone-inc/spec-kit-verify-tasks",
|
||||
"homepage": "https://github.com/datastone-inc/spec-kit-verify-tasks",
|
||||
"documentation": "https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/README.md",
|
||||
"changelog": "https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"verification",
|
||||
"quality",
|
||||
"phantom-completion",
|
||||
"tasks"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-16T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,15 @@ When Spec Kit needs a template (e.g. `spec-template`), it walks a resolution sta
|
||||
|
||||
If no preset is installed, core templates are used — exactly the same behavior as before presets existed.
|
||||
|
||||
Template resolution happens **at runtime** — although preset files are copied into `.specify/presets/<id>/` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location.
|
||||
|
||||
For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
## Command Overrides
|
||||
|
||||
Presets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions).
|
||||
|
||||
When a preset includes `type: "command"` entries, the commands are automatically registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up.
|
||||
Unlike templates, command overrides are applied **at install time**. When a preset includes `type: "command"` entries, the commands are registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -171,9 +171,21 @@ json_escape() {
|
||||
s="${s//$'\r'/\\r}"
|
||||
s="${s//$'\b'/\\b}"
|
||||
s="${s//$'\f'/\\f}"
|
||||
# Strip remaining control characters (U+0000–U+001F) not individually escaped above
|
||||
s=$(printf '%s' "$s" | tr -d '\000-\007\013\016-\037')
|
||||
printf '%s' "$s"
|
||||
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
|
||||
# (U+0000/NUL cannot appear in bash strings and is excluded.)
|
||||
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
|
||||
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
|
||||
local LC_ALL=C
|
||||
local i char code
|
||||
for (( i=0; i<${#s}; i++ )); do
|
||||
char="${s:$i:1}"
|
||||
printf -v code '%d' "'$char" 2>/dev/null || code=256
|
||||
if (( code >= 1 && code <= 31 )); then
|
||||
printf '\\u%04x' "$code"
|
||||
else
|
||||
printf '%s' "$char"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
|
||||
@@ -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, Kimi Code, Antigravity or Generic
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, 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|kimi|trae|generic
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
@@ -68,12 +68,13 @@ CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc"
|
||||
QWEN_FILE="$REPO_ROOT/QWEN.md"
|
||||
AGENTS_FILE="$REPO_ROOT/AGENTS.md"
|
||||
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
|
||||
JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md"
|
||||
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
|
||||
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
||||
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
||||
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
||||
QODER_FILE="$REPO_ROOT/QODER.md"
|
||||
# AMP, Kiro CLI, and IBM Bob all share AGENTS.md — use AGENTS_FILE to avoid
|
||||
# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid
|
||||
# updating the same file multiple times.
|
||||
AMP_FILE="$AGENTS_FILE"
|
||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||
@@ -84,6 +85,7 @@ BOB_FILE="$AGENTS_FILE"
|
||||
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
|
||||
KIMI_FILE="$REPO_ROOT/KIMI.md"
|
||||
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
|
||||
IFLOW_FILE="$REPO_ROOT/IFLOW.md"
|
||||
|
||||
# Template file
|
||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||
@@ -637,6 +639,9 @@ update_specific_agent() {
|
||||
windsurf)
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1
|
||||
;;
|
||||
junie)
|
||||
update_agent_file "$JUNIE_FILE" "Junie" || return 1
|
||||
;;
|
||||
kilocode)
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1
|
||||
;;
|
||||
@@ -679,12 +684,18 @@ update_specific_agent() {
|
||||
trae)
|
||||
update_agent_file "$TRAE_FILE" "Trae" || return 1
|
||||
;;
|
||||
pi)
|
||||
update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1
|
||||
;;
|
||||
iflow)
|
||||
update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1
|
||||
;;
|
||||
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|kimi|trae|generic"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -733,6 +744,7 @@ update_all_existing_agents() {
|
||||
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
|
||||
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
|
||||
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
|
||||
_update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false
|
||||
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
|
||||
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
|
||||
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
|
||||
@@ -744,6 +756,7 @@ update_all_existing_agents() {
|
||||
_update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false
|
||||
_update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false
|
||||
_update_if_new "$TRAE_FILE" "Trae" || _all_ok=false
|
||||
_update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false
|
||||
|
||||
# If no agent files exist, create a default Claude file
|
||||
if [[ "$_found_agent" == false ]]; then
|
||||
@@ -770,7 +783,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|kimi|trae|generic]"
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
param(
|
||||
[switch]$Json,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[int]$Number = 0,
|
||||
[switch]$Help,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
@@ -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, kimi, trae, generic)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, 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','kimi','trae','generic')]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','iflow','generic')]
|
||||
[string]$AgentType
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@ $CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc'
|
||||
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md'
|
||||
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md'
|
||||
$JUNIE_FILE = Join-Path $REPO_ROOT '.junie/AGENTS.md'
|
||||
$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md'
|
||||
$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md'
|
||||
$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md'
|
||||
@@ -65,6 +66,7 @@ $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'
|
||||
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md'
|
||||
$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md'
|
||||
|
||||
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
|
||||
|
||||
@@ -396,6 +398,7 @@ function Update-SpecificAgent {
|
||||
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
|
||||
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
|
||||
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
|
||||
'junie' { Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie' }
|
||||
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
|
||||
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
||||
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
||||
@@ -410,8 +413,10 @@ function Update-SpecificAgent {
|
||||
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
|
||||
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
|
||||
'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' }
|
||||
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
|
||||
'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' }
|
||||
'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|kimi|trae|generic'; return $false }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic'; return $false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +430,7 @@ function Update-AllExistingAgents {
|
||||
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $JUNIE_FILE) { if (-not (Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
|
||||
@@ -438,6 +444,7 @@ function Update-AllExistingAgents {
|
||||
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 (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $IFLOW_FILE) { if (-not (Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI')) { $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 }
|
||||
@@ -452,7 +459,7 @@ function Print-Summary {
|
||||
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
|
||||
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
|
||||
Write-Host ''
|
||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]'
|
||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]'
|
||||
}
|
||||
|
||||
function Main {
|
||||
|
||||
@@ -31,7 +31,6 @@ import sys
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import shlex
|
||||
import json
|
||||
import json5
|
||||
import stat
|
||||
@@ -172,8 +171,8 @@ AGENT_CONFIG = {
|
||||
},
|
||||
"codex": {
|
||||
"name": "Codex CLI",
|
||||
"folder": ".codex/",
|
||||
"commands_subdir": "prompts", # Special: uses prompts/ not commands/
|
||||
"folder": ".agents/",
|
||||
"commands_subdir": "skills", # Codex now uses project skills directly
|
||||
"install_url": "https://github.com/openai/codex",
|
||||
"requires_cli": True,
|
||||
},
|
||||
@@ -184,6 +183,13 @@ AGENT_CONFIG = {
|
||||
"install_url": None, # IDE-based
|
||||
"requires_cli": False,
|
||||
},
|
||||
"junie": {
|
||||
"name": "Junie",
|
||||
"folder": ".junie/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": "https://junie.jetbrains.com/",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"kilocode": {
|
||||
"name": "Kilo Code",
|
||||
"folder": ".kilocode/",
|
||||
@@ -282,6 +288,20 @@ AGENT_CONFIG = {
|
||||
"install_url": None, # IDE-based
|
||||
"requires_cli": False,
|
||||
},
|
||||
"pi": {
|
||||
"name": "Pi Coding Agent",
|
||||
"folder": ".pi/",
|
||||
"commands_subdir": "prompts",
|
||||
"install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"iflow": {
|
||||
"name": "iFlow CLI",
|
||||
"folder": ".iflow/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": "https://docs.iflow.cn/en/cli/quickstart",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"generic": {
|
||||
"name": "Generic (bring your own agent)",
|
||||
"folder": None, # Set dynamically via --ai-commands-dir
|
||||
@@ -1197,6 +1217,9 @@ AGENT_SKILLS_DIR_OVERRIDES = {
|
||||
# Default skills directory for agents not in AGENT_CONFIG
|
||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
||||
|
||||
# Agents whose downloaded template already contains skills in the final layout.
|
||||
NATIVE_SKILLS_AGENTS = {"codex", "kimi"}
|
||||
|
||||
# Enhanced descriptions for each spec-kit command skill
|
||||
SKILL_DESCRIPTIONS = {
|
||||
"specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.",
|
||||
@@ -1325,8 +1348,6 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
command_name = command_name[len("speckit."):]
|
||||
if command_name.endswith(".agent"):
|
||||
command_name = command_name[:-len(".agent")]
|
||||
# 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:
|
||||
@@ -1402,20 +1423,45 @@ 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.
|
||||
def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
|
||||
"""Return True when a native-skills agent has spec-kit bundled skills."""
|
||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||
if not skills_dir.is_dir():
|
||||
return False
|
||||
|
||||
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.")
|
||||
pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
|
||||
return any(skills_dir.glob(pattern))
|
||||
|
||||
|
||||
AGENT_SKILLS_MIGRATIONS = {
|
||||
"agy": {
|
||||
"error": "Explicit command support was deprecated in Antigravity version 1.20.5.",
|
||||
"usage": "specify init <project> --ai agy --ai-skills",
|
||||
"interactive_note": (
|
||||
"'agy' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
|
||||
"automatically for compatibility (explicit .agent/commands usage is deprecated)."
|
||||
),
|
||||
},
|
||||
"codex": {
|
||||
"error": (
|
||||
"Custom prompt-based spec-kit initialization is deprecated for Codex CLI; "
|
||||
"use agent skills instead."
|
||||
),
|
||||
"usage": "specify init <project> --ai codex --ai-skills",
|
||||
"interactive_note": (
|
||||
"'codex' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
|
||||
"automatically for compatibility (.agents/skills is the recommended Codex layout)."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _handle_agent_skills_migration(console: Console, agent_key: str) -> None:
|
||||
"""Print a fail-fast migration error for agents that now require skills."""
|
||||
migration = AGENT_SKILLS_MIGRATIONS[agent_key]
|
||||
console.print(f"\n[red]Error:[/red] {migration['error']}")
|
||||
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")
|
||||
console.print(f"[yellow]Usage:[/yellow] {migration['usage']}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@app.command()
|
||||
@@ -1453,7 +1499,7 @@ def init(
|
||||
specify init . --ai claude # Initialize in current directory
|
||||
specify init . # Initialize in current directory (interactive AI selection)
|
||||
specify init --here --ai claude # Alternative syntax for current directory
|
||||
specify init --here --ai codex
|
||||
specify init --here --ai codex --ai-skills
|
||||
specify init --here --ai codebuddy
|
||||
specify init --here --ai vibe # Initialize with Mistral Vibe support
|
||||
specify init --here
|
||||
@@ -1543,24 +1589,16 @@ def init(
|
||||
"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
|
||||
# Agents that have moved from explicit commands/prompts to agent skills.
|
||||
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
|
||||
# If 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.
|
||||
# Preserve fail-fast behavior only for explicit '--ai <agent>' without skills.
|
||||
if ai_assistant:
|
||||
_handle_agy_deprecation(console)
|
||||
_handle_agent_skills_migration(console, selected_ai)
|
||||
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)."
|
||||
)
|
||||
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
|
||||
|
||||
# Validate --ai-commands-dir usage
|
||||
if selected_ai == "generic":
|
||||
@@ -1684,28 +1722,41 @@ def init(
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
if ai_skills:
|
||||
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
|
||||
if selected_ai in NATIVE_SKILLS_AGENTS:
|
||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||
if not _has_bundled_skills(project_path, selected_ai):
|
||||
raise RuntimeError(
|
||||
f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, "
|
||||
"but none were found. Re-run with an up-to-date template."
|
||||
)
|
||||
if tracker:
|
||||
tracker.start("ai-skills")
|
||||
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
|
||||
else:
|
||||
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
|
||||
|
||||
# When --ai-skills is used on a NEW project and skills were
|
||||
# successfully installed, remove the command files that the
|
||||
# template archive just created. Skills replace commands, so
|
||||
# keeping both would be confusing. For --here on an existing
|
||||
# repo we leave pre-existing commands untouched to avoid a
|
||||
# breaking change. We only delete AFTER skills succeed so the
|
||||
# project always has at least one of {commands, skills}.
|
||||
if skills_ok and not here:
|
||||
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
|
||||
agent_folder = agent_cfg.get("folder", "")
|
||||
commands_subdir = agent_cfg.get("commands_subdir", "commands")
|
||||
if agent_folder:
|
||||
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
|
||||
if cmds_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(cmds_dir)
|
||||
except OSError:
|
||||
# Best-effort cleanup: skills are already installed,
|
||||
# so leaving stale commands is non-fatal.
|
||||
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
|
||||
# When --ai-skills is used on a NEW project and skills were
|
||||
# successfully installed, remove the command files that the
|
||||
# template archive just created. Skills replace commands, so
|
||||
# keeping both would be confusing. For --here on an existing
|
||||
# repo we leave pre-existing commands untouched to avoid a
|
||||
# breaking change. We only delete AFTER skills succeed so the
|
||||
# project always has at least one of {commands, skills}.
|
||||
if skills_ok and not here:
|
||||
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
|
||||
agent_folder = agent_cfg.get("folder", "")
|
||||
commands_subdir = agent_cfg.get("commands_subdir", "commands")
|
||||
if agent_folder:
|
||||
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
|
||||
if cmds_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(cmds_dir)
|
||||
except OSError:
|
||||
# Best-effort cleanup: skills are already installed,
|
||||
# so leaving stale commands is non-fatal.
|
||||
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
|
||||
|
||||
if not no_git:
|
||||
tracker.start("git")
|
||||
@@ -1829,38 +1880,48 @@ def init(
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
# Add Codex-specific setup step if needed
|
||||
if selected_ai == "codex":
|
||||
codex_path = project_path / ".codex"
|
||||
quoted_path = shlex.quote(str(codex_path))
|
||||
if os.name == "nt": # Windows
|
||||
cmd = f"setx CODEX_HOME {quoted_path}"
|
||||
else: # Unix-like systems
|
||||
cmd = f"export CODEX_HOME={quoted_path}"
|
||||
|
||||
steps_lines.append(f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]")
|
||||
if selected_ai == "codex" and ai_skills:
|
||||
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||
step_num += 1
|
||||
|
||||
steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:")
|
||||
codex_skill_mode = selected_ai == "codex" and ai_skills
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
native_skill_mode = codex_skill_mode or kimi_skill_mode
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
steps_lines.append(" 2.1 [cyan]/speckit.constitution[/] - Establish project principles")
|
||||
steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification")
|
||||
steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan")
|
||||
steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks")
|
||||
steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation")
|
||||
def _display_cmd(name: str) -> str:
|
||||
if codex_skill_mode:
|
||||
return f"$speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
return f"/skill:speckit.{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
|
||||
|
||||
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
|
||||
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
|
||||
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
|
||||
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
|
||||
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
|
||||
|
||||
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
|
||||
console.print()
|
||||
console.print(steps_panel)
|
||||
|
||||
enhancement_intro = (
|
||||
"Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||
if native_skill_mode
|
||||
else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||
)
|
||||
enhancement_lines = [
|
||||
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
|
||||
enhancement_intro,
|
||||
"",
|
||||
"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
|
||||
"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
||||
"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
|
||||
f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
|
||||
f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
|
||||
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
|
||||
]
|
||||
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
|
||||
enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
|
||||
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2))
|
||||
console.print()
|
||||
console.print(enhancements_panel)
|
||||
|
||||
@@ -2405,6 +2466,89 @@ def preset_set_priority(
|
||||
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
||||
|
||||
|
||||
@preset_app.command("enable")
|
||||
def preset_enable(
|
||||
pack_id: str = typer.Argument(help="Preset ID to enable"),
|
||||
):
|
||||
"""Enable a disabled preset."""
|
||||
from .presets import PresetManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
# Check if we're in a spec-kit project
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = PresetManager(project_root)
|
||||
|
||||
# Check if preset is installed
|
||||
if not manager.registry.is_installed(pack_id):
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get current metadata
|
||||
metadata = manager.registry.get(pack_id)
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if metadata.get("enabled", True):
|
||||
console.print(f"[yellow]Preset '{pack_id}' is already enabled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Enable the preset
|
||||
manager.registry.update(pack_id, {"enabled": True})
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{pack_id}' enabled")
|
||||
console.print("\nTemplates from this preset will now be included in resolution.")
|
||||
console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]")
|
||||
|
||||
|
||||
@preset_app.command("disable")
|
||||
def preset_disable(
|
||||
pack_id: str = typer.Argument(help="Preset ID to disable"),
|
||||
):
|
||||
"""Disable a preset without removing it."""
|
||||
from .presets import PresetManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
# Check if we're in a spec-kit project
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = PresetManager(project_root)
|
||||
|
||||
# Check if preset is installed
|
||||
if not manager.registry.is_installed(pack_id):
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get current metadata
|
||||
metadata = manager.registry.get(pack_id)
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not metadata.get("enabled", True):
|
||||
console.print(f"[yellow]Preset '{pack_id}' is already disabled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Disable the preset
|
||||
manager.registry.update(pack_id, {"enabled": False})
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{pack_id}' disabled")
|
||||
console.print("\nTemplates from this preset will be skipped during resolution.")
|
||||
console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]")
|
||||
console.print(f"To re-enable: specify preset enable {pack_id}")
|
||||
|
||||
|
||||
# ===== Preset Catalog Commands =====
|
||||
|
||||
|
||||
@@ -3841,8 +3985,7 @@ def extension_enable(
|
||||
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
metadata["enabled"] = True
|
||||
manager.registry.update(extension_id, metadata)
|
||||
manager.registry.update(extension_id, {"enabled": True})
|
||||
|
||||
# Enable hooks in extensions.yml
|
||||
config = hook_executor.get_project_config()
|
||||
@@ -3889,8 +4032,7 @@ def extension_disable(
|
||||
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
metadata["enabled"] = False
|
||||
manager.registry.update(extension_id, metadata)
|
||||
manager.registry.update(extension_id, {"enabled": False})
|
||||
|
||||
# Disable hooks in extensions.yml
|
||||
config = hook_executor.get_project_config()
|
||||
|
||||
@@ -9,6 +9,7 @@ command files into agent-specific directories in the correct format.
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
|
||||
import platform
|
||||
import yaml
|
||||
|
||||
|
||||
@@ -59,13 +60,19 @@ class CommandRegistrar:
|
||||
"extension": ".md"
|
||||
},
|
||||
"codex": {
|
||||
"dir": ".codex/prompts",
|
||||
"dir": ".agents/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
},
|
||||
"windsurf": {
|
||||
"dir": ".windsurf/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"windsurf": {
|
||||
"dir": ".windsurf/workflows",
|
||||
"junie": {
|
||||
"dir": ".junie/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
@@ -106,6 +113,12 @@ class CommandRegistrar:
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"pi": {
|
||||
"dir": ".pi/prompts",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"amp": {
|
||||
"dir": ".agents/commands",
|
||||
"format": "markdown",
|
||||
@@ -134,13 +147,19 @@ class CommandRegistrar:
|
||||
"dir": ".kimi/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md"
|
||||
"extension": "/SKILL.md",
|
||||
},
|
||||
"trae": {
|
||||
"dir": ".trae/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"iflow": {
|
||||
"dir": ".iflow/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +189,9 @@ class CommandRegistrar:
|
||||
except yaml.YAMLError:
|
||||
frontmatter = {}
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
return frontmatter, body
|
||||
|
||||
@staticmethod
|
||||
@@ -197,11 +219,14 @@ class CommandRegistrar:
|
||||
Returns:
|
||||
Modified frontmatter with adjusted paths
|
||||
"""
|
||||
if "scripts" in frontmatter:
|
||||
for key in frontmatter["scripts"]:
|
||||
script_path = frontmatter["scripts"][key]
|
||||
if script_path.startswith("../../scripts/"):
|
||||
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
||||
for script_key in ("scripts", "agent_scripts"):
|
||||
scripts = frontmatter.get(script_key)
|
||||
if not isinstance(scripts, dict):
|
||||
continue
|
||||
|
||||
for key, script_path in scripts.items():
|
||||
if isinstance(script_path, str) and script_path.startswith("../../scripts/"):
|
||||
scripts[key] = f".specify/scripts/{script_path[14:]}"
|
||||
return frontmatter
|
||||
|
||||
def render_markdown_command(
|
||||
@@ -258,6 +283,95 @@ class CommandRegistrar:
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
def render_skill_command(
|
||||
self,
|
||||
agent_name: str,
|
||||
skill_name: str,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
source_id: str,
|
||||
source_file: str,
|
||||
project_root: Path,
|
||||
) -> str:
|
||||
"""Render a command override as a SKILL.md file.
|
||||
|
||||
SKILL-target agents should receive the same skills-oriented
|
||||
frontmatter shape used elsewhere in the project instead of the
|
||||
original command frontmatter.
|
||||
"""
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
if agent_name == "codex":
|
||||
body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)
|
||||
|
||||
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
|
||||
skill_frontmatter = {
|
||||
"name": skill_name,
|
||||
"description": description,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"{source_id}:{source_file}",
|
||||
},
|
||||
}
|
||||
return self.render_frontmatter(skill_frontmatter) + "\n" + body
|
||||
|
||||
@staticmethod
|
||||
def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str:
|
||||
"""Resolve script placeholders for Codex skill overrides.
|
||||
|
||||
This intentionally scopes the fix to Codex, which is the newly
|
||||
migrated runtime path in this PR. Existing Kimi behavior is left
|
||||
unchanged for now.
|
||||
"""
|
||||
try:
|
||||
from . import load_init_options
|
||||
except ImportError:
|
||||
return body
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
scripts = frontmatter.get("scripts", {}) or {}
|
||||
agent_scripts = frontmatter.get("agent_scripts", {}) or {}
|
||||
if not isinstance(scripts, dict):
|
||||
scripts = {}
|
||||
if not isinstance(agent_scripts, dict):
|
||||
agent_scripts = {}
|
||||
|
||||
script_variant = load_init_options(project_root).get("script")
|
||||
if script_variant not in {"sh", "ps"}:
|
||||
fallback_order = []
|
||||
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
|
||||
secondary_variant = "sh" if default_variant == "ps" else "ps"
|
||||
|
||||
if default_variant in scripts or default_variant in agent_scripts:
|
||||
fallback_order.append(default_variant)
|
||||
if secondary_variant in scripts or secondary_variant in agent_scripts:
|
||||
fallback_order.append(secondary_variant)
|
||||
|
||||
for key in scripts:
|
||||
if key not in fallback_order:
|
||||
fallback_order.append(key)
|
||||
for key in agent_scripts:
|
||||
if key not in fallback_order:
|
||||
fallback_order.append(key)
|
||||
|
||||
script_variant = fallback_order[0] if fallback_order else None
|
||||
|
||||
script_command = scripts.get(script_variant) if script_variant else None
|
||||
if script_command:
|
||||
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||
body = body.replace("{SCRIPT}", script_command)
|
||||
|
||||
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
|
||||
if agent_script_command:
|
||||
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
|
||||
|
||||
return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex")
|
||||
|
||||
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||
"""Convert argument placeholder format.
|
||||
|
||||
@@ -271,6 +385,18 @@ class CommandRegistrar:
|
||||
"""
|
||||
return content.replace(from_placeholder, to_placeholder)
|
||||
|
||||
@staticmethod
|
||||
def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:
|
||||
"""Compute the on-disk command or skill name for an agent."""
|
||||
if agent_config["extension"] != "/SKILL.md":
|
||||
return cmd_name
|
||||
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
|
||||
return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}"
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -322,14 +448,20 @@ class CommandRegistrar:
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
|
||||
if agent_config["format"] == "markdown":
|
||||
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
|
||||
if agent_config["extension"] == "/SKILL.md":
|
||||
output = self.render_skill_command(
|
||||
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||
elif agent_config["format"] == "toml":
|
||||
output = self.render_toml_command(frontmatter, body, source_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_file.write_text(output, encoding="utf-8")
|
||||
|
||||
@@ -339,9 +471,15 @@ class CommandRegistrar:
|
||||
registered.append(cmd_name)
|
||||
|
||||
for alias in cmd_info.get("aliases", []):
|
||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
||||
alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
|
||||
alias_output = output
|
||||
if agent_config["extension"] == "/SKILL.md":
|
||||
alias_output = self.render_skill_command(
|
||||
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
|
||||
)
|
||||
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(output, encoding="utf-8")
|
||||
alias_file.write_text(alias_output, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, alias)
|
||||
registered.append(alias)
|
||||
@@ -384,7 +522,7 @@ class CommandRegistrar:
|
||||
results = {}
|
||||
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
agent_dir = project_root / agent_config["dir"].split("/")[0]
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
@@ -418,7 +556,8 @@ class CommandRegistrar:
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
|
||||
|
||||
@@ -222,7 +222,17 @@ class ExtensionRegistry:
|
||||
|
||||
try:
|
||||
with open(self.registry_path, 'r') as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
# Validate loaded data is a dict (handles corrupted registry files)
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"extensions": {}
|
||||
}
|
||||
# Normalize extensions field (handles corrupted extensions value)
|
||||
if not isinstance(data.get("extensions"), dict):
|
||||
data["extensions"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
# Corrupted or missing registry, start fresh
|
||||
return {
|
||||
@@ -244,7 +254,7 @@ class ExtensionRegistry:
|
||||
metadata: Extension metadata (version, source, etc.)
|
||||
"""
|
||||
self.data["extensions"][extension_id] = {
|
||||
**metadata,
|
||||
**copy.deepcopy(metadata),
|
||||
"installed_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
self._save()
|
||||
@@ -267,15 +277,16 @@ class ExtensionRegistry:
|
||||
Raises:
|
||||
KeyError: If extension is not installed
|
||||
"""
|
||||
if extension_id not in self.data["extensions"]:
|
||||
extensions = self.data.get("extensions")
|
||||
if not isinstance(extensions, dict) or extension_id not in 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]
|
||||
existing = extensions[extension_id]
|
||||
# Handle corrupted registry entries (e.g., string/list instead of dict)
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
# Merge: existing fields preserved, new fields override
|
||||
merged = {**existing, **metadata}
|
||||
# Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation)
|
||||
merged = {**existing, **copy.deepcopy(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:
|
||||
@@ -283,7 +294,7 @@ class ExtensionRegistry:
|
||||
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
|
||||
extensions[extension_id] = merged
|
||||
self._save()
|
||||
|
||||
def restore(self, extension_id: str, metadata: dict):
|
||||
@@ -296,8 +307,16 @@ class ExtensionRegistry:
|
||||
Args:
|
||||
extension_id: Extension ID
|
||||
metadata: Complete extension metadata including installed_at
|
||||
|
||||
Raises:
|
||||
ValueError: If metadata is None or not a dict
|
||||
"""
|
||||
self.data["extensions"][extension_id] = dict(metadata)
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
raise ValueError(f"Cannot restore '{extension_id}': metadata must be a dict")
|
||||
# Ensure extensions dict exists (handle corrupted registry)
|
||||
if not isinstance(self.data.get("extensions"), dict):
|
||||
self.data["extensions"] = {}
|
||||
self.data["extensions"][extension_id] = copy.deepcopy(metadata)
|
||||
self._save()
|
||||
|
||||
def remove(self, extension_id: str):
|
||||
@@ -306,8 +325,11 @@ class ExtensionRegistry:
|
||||
Args:
|
||||
extension_id: Extension ID
|
||||
"""
|
||||
if extension_id in self.data["extensions"]:
|
||||
del self.data["extensions"][extension_id]
|
||||
extensions = self.data.get("extensions")
|
||||
if not isinstance(extensions, dict):
|
||||
return
|
||||
if extension_id in extensions:
|
||||
del extensions[extension_id]
|
||||
self._save()
|
||||
|
||||
def get(self, extension_id: str) -> Optional[dict]:
|
||||
@@ -320,21 +342,49 @@ class ExtensionRegistry:
|
||||
extension_id: Extension ID
|
||||
|
||||
Returns:
|
||||
Deep copy of extension metadata, or None if not found
|
||||
Deep copy of extension metadata, or None if not found or corrupted
|
||||
"""
|
||||
entry = self.data["extensions"].get(extension_id)
|
||||
return copy.deepcopy(entry) if entry is not None else None
|
||||
extensions = self.data.get("extensions")
|
||||
if not isinstance(extensions, dict):
|
||||
return None
|
||||
entry = extensions.get(extension_id)
|
||||
# Return None for missing or corrupted (non-dict) entries
|
||||
if entry is None or not isinstance(entry, dict):
|
||||
return None
|
||||
return copy.deepcopy(entry)
|
||||
|
||||
def list(self) -> Dict[str, dict]:
|
||||
"""Get all installed extensions.
|
||||
"""Get all installed extensions with valid metadata.
|
||||
|
||||
Returns a deep copy of the extensions mapping to prevent callers
|
||||
from accidentally mutating nested internal registry state.
|
||||
Returns a deep copy of extensions with dict metadata only.
|
||||
Corrupted entries (non-dict values) are filtered out.
|
||||
|
||||
Returns:
|
||||
Dictionary of extension_id -> metadata (deep copies)
|
||||
Dictionary of extension_id -> metadata (deep copies), empty dict if corrupted
|
||||
"""
|
||||
return copy.deepcopy(self.data["extensions"])
|
||||
extensions = self.data.get("extensions", {}) or {}
|
||||
if not isinstance(extensions, dict):
|
||||
return {}
|
||||
# Filter to only valid dict entries to match type contract
|
||||
return {
|
||||
ext_id: copy.deepcopy(meta)
|
||||
for ext_id, meta in extensions.items()
|
||||
if isinstance(meta, dict)
|
||||
}
|
||||
|
||||
def keys(self) -> set:
|
||||
"""Get all extension IDs including corrupted entries.
|
||||
|
||||
Lightweight method that returns IDs without deep-copying metadata.
|
||||
Use this when you only need to check which extensions are tracked.
|
||||
|
||||
Returns:
|
||||
Set of extension IDs (includes corrupted entries)
|
||||
"""
|
||||
extensions = self.data.get("extensions", {}) or {}
|
||||
if not isinstance(extensions, dict):
|
||||
return set()
|
||||
return set(extensions.keys())
|
||||
|
||||
def is_installed(self, extension_id: str) -> bool:
|
||||
"""Check if extension is installed.
|
||||
@@ -343,17 +393,23 @@ class ExtensionRegistry:
|
||||
extension_id: Extension ID
|
||||
|
||||
Returns:
|
||||
True if extension is installed
|
||||
True if extension is installed, False if not or registry corrupted
|
||||
"""
|
||||
return extension_id in self.data["extensions"]
|
||||
extensions = self.data.get("extensions")
|
||||
if not isinstance(extensions, dict):
|
||||
return False
|
||||
return extension_id in extensions
|
||||
|
||||
def list_by_priority(self) -> List[tuple]:
|
||||
def list_by_priority(self, include_disabled: bool = False) -> List[tuple]:
|
||||
"""Get all installed extensions sorted by priority.
|
||||
|
||||
Lower priority number = higher precedence (checked first).
|
||||
Extensions with equal priority are sorted alphabetically by ID
|
||||
for deterministic ordering.
|
||||
|
||||
Args:
|
||||
include_disabled: If True, include disabled extensions. Default False.
|
||||
|
||||
Returns:
|
||||
List of (extension_id, metadata_copy) tuples sorted by priority.
|
||||
Metadata is deep-copied to prevent accidental mutation.
|
||||
@@ -365,6 +421,9 @@ class ExtensionRegistry:
|
||||
for ext_id, meta in extensions.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
# Skip disabled extensions unless explicitly requested
|
||||
if not include_disabled and not meta.get("enabled", True):
|
||||
continue
|
||||
metadata_copy = copy.deepcopy(meta)
|
||||
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
|
||||
sortable_extensions.append((ext_id, metadata_copy))
|
||||
@@ -633,7 +692,7 @@ class ExtensionManager:
|
||||
|
||||
# Get registered commands before removal
|
||||
metadata = self.registry.get(extension_id)
|
||||
registered_commands = metadata.get("registered_commands", {})
|
||||
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
||||
|
||||
extension_dir = self.extensions_dir / extension_id
|
||||
|
||||
|
||||
@@ -238,7 +238,17 @@ class PresetRegistry:
|
||||
|
||||
try:
|
||||
with open(self.registry_path, 'r') as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
# Validate loaded data is a dict (handles corrupted registry files)
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"presets": {}
|
||||
}
|
||||
# Normalize presets field (handles corrupted presets value)
|
||||
if not isinstance(data.get("presets"), dict):
|
||||
data["presets"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
@@ -259,7 +269,7 @@ class PresetRegistry:
|
||||
metadata: Pack metadata (version, source, etc.)
|
||||
"""
|
||||
self.data["presets"][pack_id] = {
|
||||
**metadata,
|
||||
**copy.deepcopy(metadata),
|
||||
"installed_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
self._save()
|
||||
@@ -270,8 +280,11 @@ class PresetRegistry:
|
||||
Args:
|
||||
pack_id: Preset ID
|
||||
"""
|
||||
if pack_id in self.data["presets"]:
|
||||
del self.data["presets"][pack_id]
|
||||
packs = self.data.get("presets")
|
||||
if not isinstance(packs, dict):
|
||||
return
|
||||
if pack_id in packs:
|
||||
del packs[pack_id]
|
||||
self._save()
|
||||
|
||||
def update(self, pack_id: str, updates: dict):
|
||||
@@ -288,14 +301,15 @@ class PresetRegistry:
|
||||
Raises:
|
||||
KeyError: If preset is not installed
|
||||
"""
|
||||
if pack_id not in self.data["presets"]:
|
||||
packs = self.data.get("presets")
|
||||
if not isinstance(packs, dict) or pack_id not in packs:
|
||||
raise KeyError(f"Preset '{pack_id}' not found in registry")
|
||||
existing = self.data["presets"][pack_id]
|
||||
existing = packs[pack_id]
|
||||
# Handle corrupted registry entries (e.g., string/list instead of dict)
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
# Merge: existing fields preserved, new fields override
|
||||
merged = {**existing, **updates}
|
||||
# Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation)
|
||||
merged = {**existing, **copy.deepcopy(updates)}
|
||||
# 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:
|
||||
@@ -303,35 +317,95 @@ class PresetRegistry:
|
||||
else:
|
||||
# If not present in existing, explicitly remove from merged if caller provided it
|
||||
merged.pop("installed_at", None)
|
||||
self.data["presets"][pack_id] = merged
|
||||
packs[pack_id] = merged
|
||||
self._save()
|
||||
|
||||
def restore(self, pack_id: str, metadata: dict):
|
||||
"""Restore preset 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:
|
||||
pack_id: Preset ID
|
||||
metadata: Complete preset metadata including installed_at
|
||||
|
||||
Raises:
|
||||
ValueError: If metadata is None or not a dict
|
||||
"""
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
raise ValueError(f"Cannot restore '{pack_id}': metadata must be a dict")
|
||||
# Ensure presets dict exists (handle corrupted registry)
|
||||
if not isinstance(self.data.get("presets"), dict):
|
||||
self.data["presets"] = {}
|
||||
self.data["presets"][pack_id] = copy.deepcopy(metadata)
|
||||
self._save()
|
||||
|
||||
def get(self, pack_id: str) -> Optional[dict]:
|
||||
"""Get preset metadata from registry.
|
||||
|
||||
Returns a deep copy to prevent callers from accidentally mutating
|
||||
nested internal registry state without going through the write path.
|
||||
|
||||
Args:
|
||||
pack_id: Preset ID
|
||||
|
||||
Returns:
|
||||
Pack metadata or None if not found
|
||||
Deep copy of preset metadata, or None if not found or corrupted
|
||||
"""
|
||||
return self.data["presets"].get(pack_id)
|
||||
packs = self.data.get("presets")
|
||||
if not isinstance(packs, dict):
|
||||
return None
|
||||
entry = packs.get(pack_id)
|
||||
# Return None for missing or corrupted (non-dict) entries
|
||||
if entry is None or not isinstance(entry, dict):
|
||||
return None
|
||||
return copy.deepcopy(entry)
|
||||
|
||||
def list(self) -> Dict[str, dict]:
|
||||
"""Get all installed presets.
|
||||
"""Get all installed presets with valid metadata.
|
||||
|
||||
Returns a deep copy of presets with dict metadata only.
|
||||
Corrupted entries (non-dict values) are filtered out.
|
||||
|
||||
Returns:
|
||||
Dictionary of pack_id -> metadata
|
||||
Dictionary of pack_id -> metadata (deep copies), empty dict if corrupted
|
||||
"""
|
||||
return self.data["presets"]
|
||||
packs = self.data.get("presets", {}) or {}
|
||||
if not isinstance(packs, dict):
|
||||
return {}
|
||||
# Filter to only valid dict entries to match type contract
|
||||
return {
|
||||
pack_id: copy.deepcopy(meta)
|
||||
for pack_id, meta in packs.items()
|
||||
if isinstance(meta, dict)
|
||||
}
|
||||
|
||||
def list_by_priority(self) -> List[tuple]:
|
||||
def keys(self) -> set:
|
||||
"""Get all preset IDs including corrupted entries.
|
||||
|
||||
Lightweight method that returns IDs without deep-copying metadata.
|
||||
Use this when you only need to check which presets are tracked.
|
||||
|
||||
Returns:
|
||||
Set of preset IDs (includes corrupted entries)
|
||||
"""
|
||||
packs = self.data.get("presets", {}) or {}
|
||||
if not isinstance(packs, dict):
|
||||
return set()
|
||||
return set(packs.keys())
|
||||
|
||||
def list_by_priority(self, include_disabled: bool = False) -> List[tuple]:
|
||||
"""Get all installed presets sorted by priority.
|
||||
|
||||
Lower priority number = higher precedence (checked first).
|
||||
Presets with equal priority are sorted alphabetically by ID
|
||||
for deterministic ordering.
|
||||
|
||||
Args:
|
||||
include_disabled: If True, include disabled presets. Default False.
|
||||
|
||||
Returns:
|
||||
List of (pack_id, metadata_copy) tuples sorted by priority.
|
||||
Metadata is deep-copied to prevent accidental mutation.
|
||||
@@ -343,6 +417,9 @@ class PresetRegistry:
|
||||
for pack_id, meta in packs.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
# Skip disabled presets unless explicitly requested
|
||||
if not include_disabled and not meta.get("enabled", True):
|
||||
continue
|
||||
metadata_copy = copy.deepcopy(meta)
|
||||
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
|
||||
sortable_packs.append((pack_id, metadata_copy))
|
||||
@@ -358,9 +435,12 @@ class PresetRegistry:
|
||||
pack_id: Preset ID
|
||||
|
||||
Returns:
|
||||
True if pack is installed
|
||||
True if pack is installed, False if not or registry corrupted
|
||||
"""
|
||||
return pack_id in self.data["presets"]
|
||||
packs = self.data.get("presets")
|
||||
if not isinstance(packs, dict):
|
||||
return False
|
||||
return pack_id in packs
|
||||
|
||||
|
||||
class PresetManager:
|
||||
@@ -566,8 +646,6 @@ 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:
|
||||
@@ -1466,12 +1544,20 @@ class PresetResolver:
|
||||
return []
|
||||
|
||||
registry = ExtensionRegistry(self.extensions_dir)
|
||||
registered_extensions = registry.list_by_priority()
|
||||
registered_extension_ids = {ext_id for ext_id, _ in registered_extensions}
|
||||
# Use keys() to track ALL extensions (including corrupted entries) without deep copy
|
||||
# This prevents corrupted entries from being picked up as "unregistered" dirs
|
||||
registered_extension_ids = registry.keys()
|
||||
|
||||
# Get all registered extensions including disabled; we filter disabled manually below
|
||||
all_registered = registry.list_by_priority(include_disabled=True)
|
||||
|
||||
all_extensions: list[tuple[int, str, dict | None]] = []
|
||||
|
||||
for ext_id, metadata in registered_extensions:
|
||||
# Only include enabled extensions in the result
|
||||
for ext_id, metadata in all_registered:
|
||||
# Skip disabled extensions
|
||||
if not metadata.get("enabled", True):
|
||||
continue
|
||||
priority = normalize_priority(metadata.get("priority") if metadata else None)
|
||||
all_extensions.append((priority, ext_id, metadata))
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter to only hooks where `enabled: true`
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
@@ -174,7 +174,7 @@ Note: This command assumes a complete task breakdown exists in tasks.md. If task
|
||||
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter to only hooks where `enabled: true`
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
|
||||
@@ -24,6 +24,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before planning)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
@@ -41,6 +75,35 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
@@ -21,6 +21,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before specification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
@@ -176,6 +210,35 @@ Given that feature description, do this:
|
||||
|
||||
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
|
||||
|
||||
8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
@@ -28,7 +28,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter to only hooks where `enabled: true`
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
@@ -100,7 +100,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter to only hooks where `enabled: true`
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
|
||||
@@ -29,11 +29,17 @@ class TestAgentConfigConsistency:
|
||||
assert "q" not in cfg
|
||||
|
||||
def test_extension_registrar_includes_codex(self):
|
||||
"""Extension command registrar should include codex targeting .codex/prompts."""
|
||||
"""Extension command registrar should include codex targeting .agents/skills."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "codex" in cfg
|
||||
assert cfg["codex"]["dir"] == ".codex/prompts"
|
||||
assert cfg["codex"]["dir"] == ".agents/skills"
|
||||
assert cfg["codex"]["extension"] == "/SKILL.md"
|
||||
|
||||
def test_runtime_codex_uses_native_skills(self):
|
||||
"""Codex runtime config should point at .agents/skills."""
|
||||
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
||||
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
|
||||
|
||||
def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):
|
||||
"""Bash and PowerShell release scripts should agree on agent key set for Kiro."""
|
||||
@@ -71,6 +77,16 @@ class TestAgentConfigConsistency:
|
||||
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_release_scripts_generate_codex_skills(self):
|
||||
"""Release scripts should generate Codex skills in .agents/skills."""
|
||||
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")
|
||||
|
||||
assert ".agents/skills" in sh_text
|
||||
assert ".agents/skills" in ps_text
|
||||
assert re.search(r"codex\)\s*\n.*?create_skills.*?\.agents/skills.*?\"-\"", sh_text, re.S) is not None
|
||||
assert re.search(r"'codex'\s*\{.*?\.agents/skills.*?New-Skills.*?-Separator '-'", ps_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."""
|
||||
assert "roo" in AI_ASSISTANT_HELP
|
||||
@@ -309,3 +325,145 @@ class TestAgentConfigConsistency:
|
||||
def test_ai_help_includes_trae(self):
|
||||
"""CLI help text for --ai should include trae."""
|
||||
assert "trae" in AI_ASSISTANT_HELP
|
||||
|
||||
# --- Pi Coding Agent consistency checks ---
|
||||
|
||||
def test_pi_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include pi with correct folder and commands_subdir."""
|
||||
assert "pi" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["pi"]["folder"] == ".pi/"
|
||||
assert AGENT_CONFIG["pi"]["commands_subdir"] == "prompts"
|
||||
assert AGENT_CONFIG["pi"]["requires_cli"] is True
|
||||
assert AGENT_CONFIG["pi"]["install_url"] is not None
|
||||
|
||||
def test_pi_in_extension_registrar(self):
|
||||
"""Extension command registrar should include pi using .pi/prompts."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "pi" in cfg
|
||||
pi_cfg = cfg["pi"]
|
||||
assert pi_cfg["dir"] == ".pi/prompts"
|
||||
assert pi_cfg["format"] == "markdown"
|
||||
assert pi_cfg["args"] == "$ARGUMENTS"
|
||||
assert pi_cfg["extension"] == ".md"
|
||||
|
||||
def test_pi_in_release_agent_lists(self):
|
||||
"""Bash and PowerShell release scripts should include pi 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 "pi" in sh_agents
|
||||
assert "pi" in ps_agents
|
||||
|
||||
def test_release_scripts_generate_pi_prompt_templates(self):
|
||||
"""Release scripts should generate Markdown prompt templates for pi in .pi/prompts."""
|
||||
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")
|
||||
|
||||
assert ".pi/prompts" in sh_text
|
||||
assert ".pi/prompts" in ps_text
|
||||
assert re.search(r"pi\)\s*\n.*?\.pi/prompts", sh_text, re.S) is not None
|
||||
assert re.search(r"'pi'\s*\{.*?\.pi/prompts", ps_text, re.S) is not None
|
||||
|
||||
def test_pi_in_powershell_validate_set(self):
|
||||
"""PowerShell update-agent-context script should include 'pi' 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 "pi" in validate_set_values
|
||||
|
||||
def test_pi_in_github_release_output(self):
|
||||
"""GitHub release script should include pi template packages."""
|
||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "spec-kit-template-pi-sh-" in gh_release_text
|
||||
assert "spec-kit-template-pi-ps-" in gh_release_text
|
||||
|
||||
def test_agent_context_scripts_include_pi(self):
|
||||
"""Agent context scripts should support pi agent type."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
|
||||
assert "pi" in bash_text
|
||||
assert "Pi Coding Agent" in bash_text
|
||||
assert "pi" in pwsh_text
|
||||
assert "Pi Coding Agent" in pwsh_text
|
||||
|
||||
def test_ai_help_includes_pi(self):
|
||||
"""CLI help text for --ai should include pi."""
|
||||
assert "pi" in AI_ASSISTANT_HELP
|
||||
|
||||
# --- iFlow CLI consistency checks ---
|
||||
|
||||
def test_iflow_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include iflow with correct folder and commands_subdir."""
|
||||
assert "iflow" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["iflow"]["folder"] == ".iflow/"
|
||||
assert AGENT_CONFIG["iflow"]["commands_subdir"] == "commands"
|
||||
assert AGENT_CONFIG["iflow"]["requires_cli"] is True
|
||||
|
||||
def test_iflow_in_extension_registrar(self):
|
||||
"""Extension command registrar should include iflow targeting .iflow/commands."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "iflow" in cfg
|
||||
assert cfg["iflow"]["dir"] == ".iflow/commands"
|
||||
assert cfg["iflow"]["format"] == "markdown"
|
||||
assert cfg["iflow"]["args"] == "$ARGUMENTS"
|
||||
|
||||
def test_iflow_in_release_agent_lists(self):
|
||||
"""Bash and PowerShell release scripts should include iflow 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 "iflow" in sh_agents
|
||||
assert "iflow" in ps_agents
|
||||
|
||||
def test_iflow_in_release_scripts_build_variant(self):
|
||||
"""Release scripts should generate Markdown commands for iflow in .iflow/commands."""
|
||||
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")
|
||||
|
||||
assert ".iflow/commands" in sh_text
|
||||
assert ".iflow/commands" in ps_text
|
||||
assert re.search(r"'iflow'\s*\{.*?\.iflow/commands", ps_text, re.S) is not None
|
||||
|
||||
def test_iflow_in_github_release_output(self):
|
||||
"""GitHub release script should include iflow template packages."""
|
||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "spec-kit-template-iflow-sh-" in gh_release_text
|
||||
assert "spec-kit-template-iflow-ps-" in gh_release_text
|
||||
|
||||
def test_iflow_in_agent_context_scripts(self):
|
||||
"""Agent context scripts should support iflow agent type."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
|
||||
assert "iflow" in bash_text
|
||||
assert "IFLOW_FILE" in bash_text
|
||||
assert "iflow" in pwsh_text
|
||||
assert "IFLOW_FILE" in pwsh_text
|
||||
|
||||
def test_ai_help_includes_iflow(self):
|
||||
"""CLI help text for --ai should include iflow."""
|
||||
assert "iflow" in AI_ASSISTANT_HELP
|
||||
|
||||
@@ -182,6 +182,11 @@ class TestGetSkillsDir:
|
||||
result = _get_skills_dir(project_dir, "kiro-cli")
|
||||
assert result == project_dir / ".kiro" / "skills"
|
||||
|
||||
def test_pi_skills_dir(self, project_dir):
|
||||
"""Pi should use .pi/skills/."""
|
||||
result = _get_skills_dir(project_dir, "pi")
|
||||
assert result == project_dir / ".pi" / "skills"
|
||||
|
||||
def test_unknown_agent_uses_default(self, project_dir):
|
||||
"""Unknown agents should fall back to DEFAULT_SKILLS_DIR."""
|
||||
result = _get_skills_dir(project_dir, "nonexistent-agent")
|
||||
@@ -422,6 +427,27 @@ class TestInstallAiSkills:
|
||||
assert (cmds_dir / "speckit.specify.md").exists()
|
||||
assert (cmds_dir / "speckit.plan.md").exists()
|
||||
|
||||
def test_pi_prompt_dir_installs_skills(self, project_dir):
|
||||
"""Pi should install skills directly from .pi/prompts/."""
|
||||
prompts_dir = project_dir / ".pi" / "prompts"
|
||||
prompts_dir.mkdir(parents=True)
|
||||
(prompts_dir / "speckit.specify.md").write_text(
|
||||
"---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n"
|
||||
)
|
||||
(prompts_dir / "speckit.plan.md").write_text(
|
||||
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "pi")
|
||||
|
||||
assert result is True
|
||||
skills_dir = project_dir / ".pi" / "skills"
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
assert len(skill_dirs) >= 1
|
||||
assert (prompts_dir / "speckit.specify.md").exists()
|
||||
assert (prompts_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."""
|
||||
@@ -445,8 +471,7 @@ 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()]
|
||||
# Kimi uses dot-separator (speckit.specify) to match /skill:speckit.* invocation;
|
||||
# all other agents use hyphen-separator (speckit-specify).
|
||||
# Kimi uses dotted skill names; other agents use hyphen-separated names.
|
||||
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()
|
||||
@@ -668,6 +693,82 @@ class TestNewProjectCommandSkip:
|
||||
prompts_dir = target / ".kiro" / "prompts"
|
||||
assert not prompts_dir.exists()
|
||||
|
||||
def test_codex_native_skills_preserved_without_conversion(self, tmp_path):
|
||||
"""Codex should keep bundled .agents/skills and skip install_ai_skills conversion."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "new-codex-proj"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills") as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_skills.assert_not_called()
|
||||
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
|
||||
"""Codex native skills init should fail if bundled skills are missing."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "missing-codex-skills"
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills") as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
mock_skills.assert_not_called()
|
||||
assert "Expected bundled agent skills" in result.output
|
||||
|
||||
def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):
|
||||
"""Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "foreign-codex-skills"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
skill_dir = project_path / ".agents" / "skills" / "other-tool"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills") as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
mock_skills.assert_not_called()
|
||||
assert "Expected bundled agent skills" in result.output
|
||||
|
||||
def test_commands_preserved_when_skills_fail(self, tmp_path):
|
||||
"""If skills fail, commands should NOT be removed (safety net)."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -811,6 +912,17 @@ class TestCliValidation:
|
||||
assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
|
||||
assert "--ai-skills" in result.output
|
||||
|
||||
def test_codex_without_ai_skills_fails(self):
|
||||
"""--ai codex without --ai-skills should fail with exit code 1."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" 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
|
||||
@@ -853,6 +965,72 @@ class TestCliValidation:
|
||||
assert result.exit_code == 0
|
||||
assert "Explicit command support was deprecated" not in result.output
|
||||
|
||||
def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch):
|
||||
"""Interactive selector returning codex without --ai-skills should automatically enable --ai-skills."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
def _fake_select_with_arrows(*args, **kwargs):
|
||||
options = kwargs.get("options")
|
||||
if options is None and len(args) >= 1:
|
||||
options = args[0]
|
||||
|
||||
if isinstance(options, dict) and "codex" in options:
|
||||
return "codex"
|
||||
if isinstance(options, (list, tuple)) and "codex" in options:
|
||||
return "codex"
|
||||
|
||||
if isinstance(options, dict) and options:
|
||||
return next(iter(options.keys()))
|
||||
if isinstance(options, (list, tuple)) and options:
|
||||
return options[0]
|
||||
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
|
||||
|
||||
def _fake_download(*args, **kwargs):
|
||||
project_path = Path(args[0])
|
||||
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
|
||||
assert ".agents/skills" in result.output
|
||||
assert "$speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
|
||||
"""Kimi next-steps guidance should display /skill:speckit.* usage."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
def _fake_download(*args, **kwargs):
|
||||
project_path = Path(args[0])
|
||||
skill_dir = project_path / ".kimi" / "skills" / "speckit.specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "/skill:speckit.constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" 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
|
||||
|
||||
@@ -420,6 +420,48 @@ class TestExtensionRegistry:
|
||||
assert registry.is_installed("test-ext")
|
||||
assert registry.get("test-ext")["version"] == "1.0.0"
|
||||
|
||||
def test_restore_rejects_none_metadata(self, temp_dir):
|
||||
"""Test restore() raises ValueError for None metadata."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
with pytest.raises(ValueError, match="metadata must be a dict"):
|
||||
registry.restore("test-ext", None)
|
||||
|
||||
def test_restore_rejects_non_dict_metadata(self, temp_dir):
|
||||
"""Test restore() raises ValueError for non-dict metadata."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
with pytest.raises(ValueError, match="metadata must be a dict"):
|
||||
registry.restore("test-ext", "not-a-dict")
|
||||
|
||||
with pytest.raises(ValueError, match="metadata must be a dict"):
|
||||
registry.restore("test-ext", ["list", "not", "dict"])
|
||||
|
||||
def test_restore_uses_deep_copy(self, temp_dir):
|
||||
"""Test restore() deep copies metadata to prevent mutation."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
original_metadata = {
|
||||
"version": "1.0.0",
|
||||
"nested": {"key": "original"},
|
||||
}
|
||||
registry.restore("test-ext", original_metadata)
|
||||
|
||||
# Mutate the original metadata after restore
|
||||
original_metadata["version"] = "MUTATED"
|
||||
original_metadata["nested"]["key"] = "MUTATED"
|
||||
|
||||
# Registry should have the original values
|
||||
stored = registry.get("test-ext")
|
||||
assert stored["version"] == "1.0.0"
|
||||
assert stored["nested"]["key"] == "original"
|
||||
|
||||
def test_get_returns_deep_copy(self, temp_dir):
|
||||
"""Test that get() returns deep copies for nested structures."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
@@ -439,6 +481,26 @@ class TestExtensionRegistry:
|
||||
internal = registry.data["extensions"]["test-ext"]
|
||||
assert internal["registered_commands"] == {"claude": ["cmd1"]}
|
||||
|
||||
def test_get_returns_none_for_corrupted_entry(self, temp_dir):
|
||||
"""Test that get() returns None for corrupted (non-dict) entries."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
# Directly corrupt the registry with non-dict entries
|
||||
registry.data["extensions"]["corrupted-string"] = "not a dict"
|
||||
registry.data["extensions"]["corrupted-list"] = ["not", "a", "dict"]
|
||||
registry.data["extensions"]["corrupted-int"] = 42
|
||||
registry._save()
|
||||
|
||||
# All corrupted entries should return None
|
||||
assert registry.get("corrupted-string") is None
|
||||
assert registry.get("corrupted-list") is None
|
||||
assert registry.get("corrupted-int") is None
|
||||
# Non-existent should also return None
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
def test_list_returns_deep_copy(self, temp_dir):
|
||||
"""Test that list() returns deep copies for nested structures."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
@@ -458,6 +520,20 @@ class TestExtensionRegistry:
|
||||
internal = registry.data["extensions"]["test-ext"]
|
||||
assert internal["registered_commands"] == {"claude": ["cmd1"]}
|
||||
|
||||
def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):
|
||||
"""Test that list() returns empty dict when extensions is not a dict."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
# Corrupt the registry - extensions is a list instead of dict
|
||||
registry.data["extensions"] = ["not", "a", "dict"]
|
||||
registry._save()
|
||||
|
||||
# list() should return empty dict, not crash
|
||||
result = registry.list()
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ===== ExtensionManager Tests =====
|
||||
|
||||
@@ -589,9 +665,19 @@ class TestCommandRegistrar:
|
||||
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
def test_codex_agent_config_present(self):
|
||||
"""Codex should be mapped to .codex/prompts."""
|
||||
"""Codex should be mapped to .agents/skills."""
|
||||
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts"
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".agents/skills"
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["extension"] == "/SKILL.md"
|
||||
|
||||
def test_pi_agent_config_present(self):
|
||||
"""Pi should be mapped to .pi/prompts."""
|
||||
assert "pi" in CommandRegistrar.AGENT_CONFIGS
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS["pi"]
|
||||
assert cfg["dir"] == ".pi/prompts"
|
||||
assert cfg["format"] == "markdown"
|
||||
assert cfg["args"] == "$ARGUMENTS"
|
||||
assert cfg["extension"] == ".md"
|
||||
|
||||
def test_qwen_agent_config_is_markdown(self):
|
||||
"""Qwen should use Markdown format with $ARGUMENTS (not TOML)."""
|
||||
@@ -632,6 +718,21 @@ $ARGUMENTS
|
||||
assert frontmatter == {}
|
||||
assert body == content
|
||||
|
||||
def test_parse_frontmatter_non_mapping_returns_empty_dict(self):
|
||||
"""Non-mapping YAML frontmatter should not crash downstream renderers."""
|
||||
content = """---
|
||||
- item1
|
||||
- item2
|
||||
---
|
||||
|
||||
# Command body
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
assert frontmatter == {}
|
||||
assert "Command body" in body
|
||||
|
||||
def test_render_frontmatter(self):
|
||||
"""Test rendering frontmatter to YAML."""
|
||||
frontmatter = {
|
||||
@@ -723,6 +824,299 @@ $ARGUMENTS
|
||||
assert (claude_dir / "speckit.alias.cmd.md").exists()
|
||||
assert (claude_dir / "speckit.shortcut.md").exists()
|
||||
|
||||
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
|
||||
"""Codex skill cleanup should use the same mapped names as registration."""
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
(skills_dir / "speckit-specify").mkdir(parents=True)
|
||||
(skills_dir / "speckit-specify" / "SKILL.md").write_text("body")
|
||||
(skills_dir / "speckit-shortcut").mkdir(parents=True)
|
||||
(skills_dir / "speckit-shortcut" / "SKILL.md").write_text("body")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registrar.unregister_commands(
|
||||
{"codex": ["speckit.specify", "speckit.shortcut"]},
|
||||
project_dir,
|
||||
)
|
||||
|
||||
assert not (skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||
assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists()
|
||||
|
||||
def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):
|
||||
"""A Codex project under .agents/skills should not implicitly activate Amp."""
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
||||
|
||||
assert "codex" in registered
|
||||
assert "amp" not in registered
|
||||
assert not (project_dir / ".agents" / "commands").exists()
|
||||
|
||||
def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir):
|
||||
"""Codex SKILL.md output should use skills-oriented frontmatter."""
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-test.hello" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "name: speckit-test.hello" in content
|
||||
assert "description: Test hello command" in content
|
||||
assert "compatibility:" in content
|
||||
assert "metadata:" in content
|
||||
assert "source: test-ext:commands/hello.md" in content
|
||||
assert "<!-- Extension:" not in content
|
||||
|
||||
def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||
"""Codex SKILL.md overrides should resolve script placeholders."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ext-scripted"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-scripted",
|
||||
"name": "Scripted Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.test.plan",
|
||||
"file": "commands/plan.md",
|
||||
"description": "Scripted command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"""---
|
||||
description: "Scripted command"
|
||||
scripts:
|
||||
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||
agent_scripts:
|
||||
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||
ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
Then {AGENT_SCRIPT}
|
||||
Agent __AGENT__
|
||||
"""
|
||||
)
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text('{"ai":"codex","ai_skills":true,"script":"sh"}')
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-test.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{AGENT_SCRIPT}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||
|
||||
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
|
||||
"""Codex alias skills should render their own matching `name:` frontmatter."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ext-alias-skill"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-alias-skill",
|
||||
"name": "Alias Skill Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.alias.cmd",
|
||||
"file": "commands/cmd.md",
|
||||
"aliases": ["speckit.shortcut"],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Alias skill\n---\n\nBody\n")
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
primary = skills_dir / "speckit-alias.cmd" / "SKILL.md"
|
||||
alias = skills_dir / "speckit-shortcut" / "SKILL.md"
|
||||
|
||||
assert primary.exists()
|
||||
assert alias.exists()
|
||||
assert "name: speckit-alias.cmd" in primary.read_text()
|
||||
assert "name: speckit-shortcut" in alias.read_text()
|
||||
|
||||
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Codex placeholder substitution should still work without init-options.json."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ext-script-fallback"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-script-fallback",
|
||||
"name": "Script fallback",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.fallback.plan",
|
||||
"file": "commands/plan.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"""---
|
||||
description: "Fallback scripted command"
|
||||
scripts:
|
||||
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||
agent_scripts:
|
||||
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
Then {AGENT_SCRIPT}
|
||||
"""
|
||||
)
|
||||
|
||||
# Intentionally do NOT create .specify/init-options.json
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{AGENT_SCRIPT}" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||
|
||||
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Without init metadata, Windows fallback should prefer ps scripts over sh."""
|
||||
import yaml
|
||||
|
||||
monkeypatch.setattr("specify_cli.agents.platform.system", lambda: "Windows")
|
||||
|
||||
ext_dir = temp_dir / "ext-script-windows-fallback"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-script-windows-fallback",
|
||||
"name": "Script fallback windows",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.windows.plan",
|
||||
"file": "commands/plan.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"""---
|
||||
description: "Windows fallback scripted command"
|
||||
scripts:
|
||||
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||
agent_scripts:
|
||||
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||
ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
Then {AGENT_SCRIPT}
|
||||
"""
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-windows.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
|
||||
assert ".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex" in content
|
||||
assert ".specify/scripts/bash/setup-plan.sh" not in content
|
||||
|
||||
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Copilot agent with .agent.md extension."""
|
||||
# Create .github/agents directory (Copilot project)
|
||||
@@ -2500,6 +2894,40 @@ class TestExtensionPriority:
|
||||
assert [item[0] for item in result] == ["ext-high", "ext-invalid"]
|
||||
assert result[1][1]["priority"] == 10
|
||||
|
||||
def test_list_by_priority_excludes_disabled(self, temp_dir):
|
||||
"""Test that list_by_priority excludes disabled extensions by default."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
|
||||
registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
|
||||
registry.add("ext-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True
|
||||
|
||||
# Default: exclude disabled
|
||||
by_priority = registry.list_by_priority()
|
||||
ext_ids = [p[0] for p in by_priority]
|
||||
assert "ext-enabled" in ext_ids
|
||||
assert "ext-default" in ext_ids
|
||||
assert "ext-disabled" not in ext_ids
|
||||
|
||||
def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
|
||||
"""Test that list_by_priority includes disabled extensions when requested."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
|
||||
registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
|
||||
|
||||
# Include disabled
|
||||
by_priority = registry.list_by_priority(include_disabled=True)
|
||||
ext_ids = [p[0] for p in by_priority]
|
||||
assert "ext-enabled" in ext_ids
|
||||
assert "ext-disabled" in ext_ids
|
||||
# Disabled ext has lower priority number, so it comes first when included
|
||||
assert ext_ids[0] == "ext-disabled"
|
||||
|
||||
def test_install_with_priority(self, extension_dir, project_dir):
|
||||
"""Test that install_from_directory stores priority."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -2541,8 +2969,8 @@ class TestExtensionPriority:
|
||||
assert updated["priority"] == 5 # Preserved
|
||||
assert updated["enabled"] is False # Updated
|
||||
|
||||
def test_resolve_uses_unregistered_extension_dirs_when_registry_partially_corrupted(self, project_dir):
|
||||
"""Resolution scans unregistered extension dirs after valid registry entries."""
|
||||
def test_corrupted_extension_entry_not_picked_up_as_unregistered(self, project_dir):
|
||||
"""Corrupted registry entries are still tracked and NOT picked up as unregistered."""
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
|
||||
valid_dir = extensions_dir / "valid-ext" / "templates"
|
||||
@@ -2555,20 +2983,21 @@ class TestExtensionPriority:
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("valid-ext", {"version": "1.0.0", "priority": 10})
|
||||
# Corrupt the entry - should still be tracked, not picked up as unregistered
|
||||
registry.data["extensions"]["broken-ext"] = "corrupted"
|
||||
registry._save()
|
||||
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
# Corrupted extension templates should NOT be resolved
|
||||
resolved = resolver.resolve("target-template")
|
||||
sourced = resolver.resolve_with_source("target-template")
|
||||
assert resolved is None
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.name == "target-template.md"
|
||||
assert "Broken Target" in resolved.read_text()
|
||||
assert sourced is not None
|
||||
assert sourced["source"] == "extension:broken-ext (unregistered)"
|
||||
# Valid extension template should still resolve
|
||||
valid_resolved = resolver.resolve("other-template")
|
||||
assert valid_resolved is not None
|
||||
assert "Valid" in valid_resolved.read_text()
|
||||
|
||||
|
||||
class TestExtensionPriorityCLI:
|
||||
|
||||
@@ -369,6 +369,172 @@ class TestPresetRegistry:
|
||||
registry = PresetRegistry(packs_dir)
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
def test_restore(self, temp_dir):
|
||||
"""Test restore() preserves timestamps exactly."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
# Create original entry with a specific timestamp
|
||||
original_metadata = {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"installed_at": "2025-01-15T10:30:00+00:00",
|
||||
"enabled": True,
|
||||
}
|
||||
registry.restore("test-pack", original_metadata)
|
||||
|
||||
# Verify exact restoration
|
||||
restored = registry.get("test-pack")
|
||||
assert restored["installed_at"] == "2025-01-15T10:30:00+00:00"
|
||||
assert restored["version"] == "1.0.0"
|
||||
assert restored["enabled"] is True
|
||||
|
||||
def test_restore_rejects_none_metadata(self, temp_dir):
|
||||
"""Test restore() raises ValueError for None metadata."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
with pytest.raises(ValueError, match="metadata must be a dict"):
|
||||
registry.restore("test-pack", None)
|
||||
|
||||
def test_restore_rejects_non_dict_metadata(self, temp_dir):
|
||||
"""Test restore() raises ValueError for non-dict metadata."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
with pytest.raises(ValueError, match="metadata must be a dict"):
|
||||
registry.restore("test-pack", "not-a-dict")
|
||||
|
||||
with pytest.raises(ValueError, match="metadata must be a dict"):
|
||||
registry.restore("test-pack", ["list", "not", "dict"])
|
||||
|
||||
def test_restore_uses_deep_copy(self, temp_dir):
|
||||
"""Test restore() deep copies metadata to prevent mutation."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
original_metadata = {
|
||||
"version": "1.0.0",
|
||||
"nested": {"key": "original"},
|
||||
}
|
||||
registry.restore("test-pack", original_metadata)
|
||||
|
||||
# Mutate the original metadata after restore
|
||||
original_metadata["version"] = "MUTATED"
|
||||
original_metadata["nested"]["key"] = "MUTATED"
|
||||
|
||||
# Registry should have the original values
|
||||
stored = registry.get("test-pack")
|
||||
assert stored["version"] == "1.0.0"
|
||||
assert stored["nested"]["key"] == "original"
|
||||
|
||||
def test_get_returns_deep_copy(self, temp_dir):
|
||||
"""Test that get() returns a deep copy to prevent mutation."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}})
|
||||
|
||||
# Get and mutate the returned copy
|
||||
metadata = registry.get("test-pack")
|
||||
metadata["version"] = "MUTATED"
|
||||
metadata["nested"]["key"] = "MUTATED"
|
||||
|
||||
# Original should be unchanged
|
||||
fresh = registry.get("test-pack")
|
||||
assert fresh["version"] == "1.0.0"
|
||||
assert fresh["nested"]["key"] == "original"
|
||||
|
||||
def test_get_returns_none_for_corrupted_entry(self, temp_dir):
|
||||
"""Test that get() returns None for corrupted (non-dict) entries."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
# Directly corrupt the registry with non-dict entries
|
||||
registry.data["presets"]["corrupted-string"] = "not a dict"
|
||||
registry.data["presets"]["corrupted-list"] = ["not", "a", "dict"]
|
||||
registry.data["presets"]["corrupted-int"] = 42
|
||||
registry._save()
|
||||
|
||||
# All corrupted entries should return None
|
||||
assert registry.get("corrupted-string") is None
|
||||
assert registry.get("corrupted-list") is None
|
||||
assert registry.get("corrupted-int") is None
|
||||
# Non-existent should also return None
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
def test_list_returns_deep_copy(self, temp_dir):
|
||||
"""Test that list() returns deep copies to prevent mutation."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}})
|
||||
|
||||
# Get list and mutate
|
||||
all_packs = registry.list()
|
||||
all_packs["test-pack"]["version"] = "MUTATED"
|
||||
all_packs["test-pack"]["nested"]["key"] = "MUTATED"
|
||||
|
||||
# Original should be unchanged
|
||||
fresh = registry.get("test-pack")
|
||||
assert fresh["version"] == "1.0.0"
|
||||
assert fresh["nested"]["key"] == "original"
|
||||
|
||||
def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):
|
||||
"""Test that list() returns empty dict when presets is not a dict."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
# Corrupt the registry - presets is a list instead of dict
|
||||
registry.data["presets"] = ["not", "a", "dict"]
|
||||
registry._save()
|
||||
|
||||
# list() should return empty dict, not crash
|
||||
result = registry.list()
|
||||
assert result == {}
|
||||
|
||||
def test_list_by_priority_excludes_disabled(self, temp_dir):
|
||||
"""Test that list_by_priority excludes disabled presets by default."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
|
||||
registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
|
||||
registry.add("pack-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True
|
||||
|
||||
# Default: exclude disabled
|
||||
by_priority = registry.list_by_priority()
|
||||
pack_ids = [p[0] for p in by_priority]
|
||||
assert "pack-enabled" in pack_ids
|
||||
assert "pack-default" in pack_ids
|
||||
assert "pack-disabled" not in pack_ids
|
||||
|
||||
def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
|
||||
"""Test that list_by_priority includes disabled presets when requested."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
|
||||
registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
|
||||
|
||||
# Include disabled
|
||||
by_priority = registry.list_by_priority(include_disabled=True)
|
||||
pack_ids = [p[0] for p in by_priority]
|
||||
assert "pack-enabled" in pack_ids
|
||||
assert "pack-disabled" in pack_ids
|
||||
# Disabled pack has lower priority number, so it comes first when included
|
||||
assert pack_ids[0] == "pack-disabled"
|
||||
|
||||
|
||||
# ===== PresetManager Tests =====
|
||||
|
||||
@@ -707,6 +873,44 @@ class TestPresetResolver:
|
||||
assert result is not None
|
||||
assert "Extension Custom Template" in result.read_text()
|
||||
|
||||
def test_resolve_disabled_extension_templates_skipped(self, project_dir):
|
||||
"""Test that disabled extension templates are not resolved."""
|
||||
# Create extension with templates
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "disabled-template.md"
|
||||
ext_template.write_text("# Disabled Extension Template\n")
|
||||
|
||||
# Register extension as disabled
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
ext_registry = ExtensionRegistry(extensions_dir)
|
||||
ext_registry.add("disabled-ext", {"version": "1.0.0", "priority": 1, "enabled": False})
|
||||
|
||||
# Template should NOT be resolved because extension is disabled
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("disabled-template")
|
||||
assert result is None, "Disabled extension template should not be resolved"
|
||||
|
||||
def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir):
|
||||
"""Test that disabled extensions are not picked up via unregistered dir scan."""
|
||||
# Create extension directory with templates
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "test-disabled-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "unique-disabled-template.md"
|
||||
ext_template.write_text("# Should Not Resolve\n")
|
||||
|
||||
# Register the extension but disable it
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
ext_registry = ExtensionRegistry(extensions_dir)
|
||||
ext_registry.add("test-disabled-ext", {"version": "1.0.0", "enabled": False})
|
||||
|
||||
# Verify the template is NOT resolved (even though the directory exists)
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("unique-disabled-template")
|
||||
assert result is None, "Disabled extension should not be picked up as unregistered"
|
||||
|
||||
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
|
||||
"""Test that pack templates take priority over extension templates."""
|
||||
# Create extension with templates
|
||||
@@ -2001,3 +2205,189 @@ class TestPresetPriorityBackwardsCompatibility:
|
||||
"legacy-pack",
|
||||
"low-priority-pack",
|
||||
]
|
||||
|
||||
|
||||
class TestPresetEnableDisable:
|
||||
"""Test preset enable/disable CLI commands."""
|
||||
|
||||
def test_disable_preset(self, project_dir, pack_dir):
|
||||
"""Test disable command sets enabled=False."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
# Verify initially enabled
|
||||
assert manager.registry.get("test-pack").get("enabled", True) is True
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "disable", "test-pack"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "disabled" in result.output.lower()
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = PresetManager(project_dir)
|
||||
assert manager2.registry.get("test-pack")["enabled"] is False
|
||||
|
||||
def test_enable_preset(self, project_dir, pack_dir):
|
||||
"""Test enable command sets enabled=True."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset and disable it
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
manager.registry.update("test-pack", {"enabled": False})
|
||||
|
||||
# Verify disabled
|
||||
assert manager.registry.get("test-pack")["enabled"] is False
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "enable", "test-pack"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "enabled" in result.output.lower()
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = PresetManager(project_dir)
|
||||
assert manager2.registry.get("test-pack")["enabled"] is True
|
||||
|
||||
def test_disable_already_disabled(self, project_dir, pack_dir):
|
||||
"""Test disable on already disabled preset shows warning."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset and disable it
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
manager.registry.update("test-pack", {"enabled": False})
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "disable", "test-pack"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already disabled" in result.output.lower()
|
||||
|
||||
def test_enable_already_enabled(self, project_dir, pack_dir):
|
||||
"""Test enable on already enabled preset shows warning."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset (enabled by default)
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "enable", "test-pack"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already enabled" in result.output.lower()
|
||||
|
||||
def test_disable_not_installed(self, project_dir):
|
||||
"""Test disable fails for non-installed preset."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "disable", "nonexistent"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "not installed" in result.output.lower()
|
||||
|
||||
def test_enable_not_installed(self, project_dir):
|
||||
"""Test enable fails for non-installed preset."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "enable", "nonexistent"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "not installed" in result.output.lower()
|
||||
|
||||
def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir):
|
||||
"""Test that disabled presets are excluded from template resolution."""
|
||||
# Install preset with a template
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
# Create a template in the preset directory
|
||||
preset_template = project_dir / ".specify" / "presets" / "test-pack" / "templates" / "test-template.md"
|
||||
preset_template.parent.mkdir(parents=True, exist_ok=True)
|
||||
preset_template.write_text("# Template from test-pack")
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
|
||||
# Template should be found when enabled
|
||||
result = resolver.resolve("test-template", "template")
|
||||
assert result is not None
|
||||
assert "test-pack" in str(result)
|
||||
|
||||
# Disable the preset
|
||||
manager.registry.update("test-pack", {"enabled": False})
|
||||
|
||||
# Template should NOT be found when disabled
|
||||
resolver2 = PresetResolver(project_dir)
|
||||
result2 = resolver2.resolve("test-template", "template")
|
||||
assert result2 is None
|
||||
|
||||
def test_enable_corrupted_registry_entry(self, project_dir, pack_dir):
|
||||
"""Test enable fails gracefully for corrupted registry entry."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset then corrupt the registry entry
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
manager.registry.data["presets"]["test-pack"] = "corrupted-string"
|
||||
manager.registry._save()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "enable", "test-pack"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "corrupted state" in result.output.lower()
|
||||
|
||||
def test_disable_corrupted_registry_entry(self, project_dir, pack_dir):
|
||||
"""Test disable fails gracefully for corrupted registry entry."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset then corrupt the registry entry
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
manager.registry.data["presets"]["test-pack"] = "corrupted-string"
|
||||
manager.registry._save()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "disable", "test-pack"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "corrupted state" in result.output.lower()
|
||||
|
||||
Reference in New Issue
Block a user