mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* Stage 6: Complete migration — remove legacy scaffold path (#1924) Remove the legacy GitHub download and offline scaffold code paths. All 26 agents now use the integration system exclusively. Code removal (~1073 lines from __init__.py): - download_template_from_github(), download_and_extract_template() - scaffold_from_core_pack(), _locate_release_script() - install_ai_skills(), _get_skills_dir (restored slim version for presets) - _has_bundled_skills(), _migrate_legacy_kimi_dotted_skills() - AGENT_SKILLS_MIGRATIONS, _handle_agent_skills_migration() - _parse_rate_limit_headers(), _format_rate_limit_error() - Three-way branch in init() collapsed to integration-only Config derivation (single source of truth): - AGENT_CONFIG derived from INTEGRATION_REGISTRY (replaced 180-line dict) - CommandRegistrar.AGENT_CONFIGS derived from INTEGRATION_REGISTRY (replaced 160-line dict) - Backward-compat constants kept for presets/extensions: SKILL_DESCRIPTIONS, NATIVE_SKILLS_AGENTS, DEFAULT_SKILLS_DIR Release pipeline cleanup: - Deleted create-release-packages.sh/.ps1 (948 lines of ZIP packaging) - Deleted create-github-release.sh, generate-release-notes.sh - Deleted simulate-release.sh, get-next-version.sh, update-version.sh - Removed .github/workflows/scripts/ directory entirely - release.yml is now self-contained: check, notes, release all inlined - Install instructions use uv tool install with version tag Test cleanup: - Deleted test_ai_skills.py (tested removed functions) - Deleted test_core_pack_scaffold.py (tested removed scaffold) - Cleaned test_agent_config_consistency.py (removed 19 release-script tests) - Fixed test_branch_numbering.py (removed dead monkeypatches) - Updated auto-promote tests (verify files created, not tip messages) 1089 tests pass, 0 failures, ruff clean. * fix: resolve merge conflicts with #2051 (claude as skills) - Fix circular import: move CommandRegistrar import in claude integration to inside method bodies (was at module level) - Lazy-populate AGENT_CONFIGS via _ensure_configs() to avoid circular import at class definition time - Set claude registrar_config to .claude/commands (extension/preset target) since the integration handles .claude/skills in setup() - Update tests from #2051 to match: registrar_config assertions, remove --integration tip assertions, remove install_ai_skills mocks 1086 tests pass. * fix: properly preserve claude skills migration from #2051 Restore ClaudeIntegration.registrar_config to .claude/skills (not .claude/commands) so extension/preset registrations write to the correct skills directory. Update tests that simulate claude setup to use .claude/skills and check for SKILL.md layout. Some tests still need updating for the full skills path — 10 remaining failures from the #2051 test expectations around the extension/preset skill registration flow. WIP: 1076/1086 pass. * fix: properly handle SKILL.md paths in extension update rollback and tests Fix extension update rollback using _compute_output_name() for SKILL.md agents (converts dots to hyphens in skill directory names). Previously the backup and cleanup code constructed paths with raw command names (e.g. speckit.test-ext.hello/SKILL.md) instead of the correct computed names (speckit-test-ext-hello/SKILL.md). Test fixes for claude skills migration: - Update claude tests to use .claude/skills paths and SKILL.md layout - Use qwen (not claude) for skills-guard tests since claude's agent dir IS the skills dir — creating it triggers command registration - Fix test_extension_command_registered_when_extension_present to check skills path format 1086 tests pass, 0 failures, ruff clean. * fix: address PR review — lazy init, assertions, deprecated flags - _ensure_configs(): catch ImportError (not Exception), don't set _configs_loaded on failure so retries work - Move _ensure_configs() before unregister loop (not inside it) - Module-level try/except catches ImportError specifically - Remove tautology assertion (or True) in test_extensions.py - Strengthen preset provenance assertion to check source: field - Mark --offline, --skip-tls, --debug, --github-token as hidden deprecated no-ops in init() 1086 tests pass. * fix: remove deleted release scripts from pyproject.toml force-include Removes force-include entries for create-release-packages.sh/.ps1 which were deleted but still referenced in [tool.hatch.build].
This commit is contained in:
62
.github/workflows/release.yml
vendored
62
.github/workflows/release.yml
vendored
@@ -27,35 +27,63 @@ jobs:
|
|||||||
- name: Check if release already exists
|
- name: Check if release already exists
|
||||||
id: check_release
|
id: check_release
|
||||||
run: |
|
run: |
|
||||||
chmod +x .github/workflows/scripts/check-release-exists.sh
|
VERSION="${{ steps.version.outputs.tag }}"
|
||||||
.github/workflows/scripts/check-release-exists.sh ${{ steps.version.outputs.tag }}
|
if gh release view "$VERSION" >/dev/null 2>&1; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Release $VERSION already exists, skipping..."
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Release $VERSION does not exist, proceeding..."
|
||||||
|
fi
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create release package variants
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: |
|
|
||||||
chmod +x .github/workflows/scripts/create-release-packages.sh
|
|
||||||
.github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }}
|
|
||||||
|
|
||||||
- name: Generate release notes
|
- name: Generate release notes
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
if: steps.check_release.outputs.exists == 'false'
|
||||||
id: release_notes
|
|
||||||
run: |
|
run: |
|
||||||
chmod +x .github/workflows/scripts/generate-release-notes.sh
|
VERSION="${{ steps.version.outputs.tag }}"
|
||||||
# Get the previous tag for changelog generation
|
VERSION_NO_V=${VERSION#v}
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${{ steps.version.outputs.tag }}^ 2>/dev/null || echo "")
|
|
||||||
# Default to v0.0.0 if no previous tag is found (e.g., first release)
|
# Find previous tag
|
||||||
|
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${VERSION}$" | head -n 1)
|
||||||
if [ -z "$PREVIOUS_TAG" ]; then
|
if [ -z "$PREVIOUS_TAG" ]; then
|
||||||
PREVIOUS_TAG="v0.0.0"
|
PREVIOUS_TAG=""
|
||||||
fi
|
fi
|
||||||
.github/workflows/scripts/generate-release-notes.sh ${{ steps.version.outputs.tag }} "$PREVIOUS_TAG"
|
|
||||||
|
# Get commits since previous tag
|
||||||
|
if [ -z "$PREVIOUS_TAG" ]; then
|
||||||
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
|
if [ "$COMMIT_COUNT" -gt 20 ]; then
|
||||||
|
COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges HEAD~20..HEAD)
|
||||||
|
else
|
||||||
|
COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges)
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges "$PREVIOUS_TAG"..HEAD)
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > release_notes.md << NOTES_EOF
|
||||||
|
## Install
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@${VERSION}
|
||||||
|
specify init my-project
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
NOTES_EOF
|
||||||
|
|
||||||
|
echo "## What's Changed" >> release_notes.md
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
echo "$COMMITS" >> release_notes.md
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
if: steps.check_release.outputs.exists == 'false'
|
||||||
run: |
|
run: |
|
||||||
chmod +x .github/workflows/scripts/create-github-release.sh
|
VERSION="${{ steps.version.outputs.tag }}"
|
||||||
.github/workflows/scripts/create-github-release.sh ${{ steps.version.outputs.tag }}
|
VERSION_NO_V=${VERSION#v}
|
||||||
|
gh release create "$VERSION" \
|
||||||
|
--title "Spec Kit - $VERSION_NO_V" \
|
||||||
|
--notes-file release_notes.md
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# check-release-exists.sh
|
|
||||||
# Check if a GitHub release already exists for the given version
|
|
||||||
# Usage: check-release-exists.sh <version>
|
|
||||||
|
|
||||||
if [[ $# -ne 1 ]]; then
|
|
||||||
echo "Usage: $0 <version>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="$1"
|
|
||||||
|
|
||||||
if gh release view "$VERSION" >/dev/null 2>&1; then
|
|
||||||
echo "exists=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "Release $VERSION already exists, skipping..."
|
|
||||||
else
|
|
||||||
echo "exists=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "Release $VERSION does not exist, proceeding..."
|
|
||||||
fi
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# create-github-release.sh
|
|
||||||
# Create a GitHub release with all template zip files
|
|
||||||
# Usage: create-github-release.sh <version>
|
|
||||||
|
|
||||||
if [[ $# -ne 1 ]]; then
|
|
||||||
echo "Usage: $0 <version>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="$1"
|
|
||||||
|
|
||||||
# Remove 'v' prefix from version for release title
|
|
||||||
VERSION_NO_V=${VERSION#v}
|
|
||||||
|
|
||||||
gh release create "$VERSION" \
|
|
||||||
.genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-claude-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-claude-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-gemini-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-gemini-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-cursor-agent-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-cursor-agent-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-opencode-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-opencode-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-qwen-sh-"$VERSION".zip \
|
|
||||||
.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 \
|
|
||||||
.genreleases/spec-kit-template-kilocode-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-auggie-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-auggie-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-roo-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-roo-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-qodercli-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-qodercli-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-amp-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-amp-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-shai-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-shai-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-tabnine-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-tabnine-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-kiro-cli-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-kiro-cli-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-agy-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-agy-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-bob-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-bob-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-vibe-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-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" \
|
|
||||||
--notes-file release_notes.md
|
|
||||||
@@ -1,560 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
#requires -Version 7.0
|
|
||||||
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Build Spec Kit template release archives for each supported AI assistant and script type.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
create-release-packages.ps1 (workflow-local)
|
|
||||||
Build Spec Kit template release archives for each supported AI assistant and script type.
|
|
||||||
|
|
||||||
.PARAMETER Version
|
|
||||||
Version string with leading 'v' (e.g., v0.2.0)
|
|
||||||
|
|
||||||
.PARAMETER Agents
|
|
||||||
Comma or space separated subset of agents to build (default: all)
|
|
||||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, 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)
|
|
||||||
Valid scripts: sh, ps
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\create-release-packages.ps1 -Version v0.2.0
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\create-release-packages.ps1 -Version v0.2.0 -Agents claude,copilot -Scripts sh
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\create-release-packages.ps1 -Version v0.2.0 -Agents claude -Scripts ps
|
|
||||||
#>
|
|
||||||
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory=$true, Position=0)]
|
|
||||||
[string]$Version,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$Agents = "",
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$Scripts = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
# Validate version format
|
|
||||||
if ($Version -notmatch '^v\d+\.\d+\.\d+$') {
|
|
||||||
Write-Error "Version must look like v0.0.0"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Building release packages for $Version"
|
|
||||||
|
|
||||||
# Create and use .genreleases directory for all build artifacts
|
|
||||||
$GenReleasesDir = ".genreleases"
|
|
||||||
if (Test-Path $GenReleasesDir) {
|
|
||||||
Remove-Item -Path $GenReleasesDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Path $GenReleasesDir -Force | Out-Null
|
|
||||||
|
|
||||||
function Rewrite-Paths {
|
|
||||||
param([string]$Content)
|
|
||||||
|
|
||||||
$Content = $Content -replace '(/?)\bmemory/', '.specify/memory/'
|
|
||||||
$Content = $Content -replace '(/?)\bscripts/', '.specify/scripts/'
|
|
||||||
$Content = $Content -replace '(/?)\btemplates/', '.specify/templates/'
|
|
||||||
return $Content
|
|
||||||
}
|
|
||||||
|
|
||||||
function Generate-Commands {
|
|
||||||
param(
|
|
||||||
[string]$Agent,
|
|
||||||
[string]$Extension,
|
|
||||||
[string]$ArgFormat,
|
|
||||||
[string]$OutputDir,
|
|
||||||
[string]$ScriptVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
|
||||||
|
|
||||||
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
foreach ($template in $templates) {
|
|
||||||
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
|
|
||||||
|
|
||||||
# Read file content and normalize line endings
|
|
||||||
$fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n"
|
|
||||||
|
|
||||||
# Extract description from YAML frontmatter
|
|
||||||
$description = ""
|
|
||||||
if ($fileContent -match '(?m)^description:\s*(.+)$') {
|
|
||||||
$description = $matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract script command from YAML frontmatter
|
|
||||||
$scriptCommand = ""
|
|
||||||
if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") {
|
|
||||||
$scriptCommand = $matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([string]::IsNullOrEmpty($scriptCommand)) {
|
|
||||||
Write-Warning "No script command found for $ScriptVariant in $($template.Name)"
|
|
||||||
$scriptCommand = "(Missing script command for $ScriptVariant)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract agent_script command from YAML frontmatter if present
|
|
||||||
$agentScriptCommand = ""
|
|
||||||
if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") {
|
|
||||||
$agentScriptCommand = $matches[1].Trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Replace {SCRIPT} placeholder with the script command
|
|
||||||
$body = $fileContent -replace '\{SCRIPT\}', $scriptCommand
|
|
||||||
|
|
||||||
# Replace {AGENT_SCRIPT} placeholder with the agent script command if found
|
|
||||||
if (-not [string]::IsNullOrEmpty($agentScriptCommand)) {
|
|
||||||
$body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove the scripts: and agent_scripts: sections from frontmatter
|
|
||||||
$lines = $body -split "`n"
|
|
||||||
$outputLines = @()
|
|
||||||
$inFrontmatter = $false
|
|
||||||
$skipScripts = $false
|
|
||||||
$dashCount = 0
|
|
||||||
|
|
||||||
foreach ($line in $lines) {
|
|
||||||
if ($line -match '^---$') {
|
|
||||||
$outputLines += $line
|
|
||||||
$dashCount++
|
|
||||||
if ($dashCount -eq 1) {
|
|
||||||
$inFrontmatter = $true
|
|
||||||
} else {
|
|
||||||
$inFrontmatter = $false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($inFrontmatter) {
|
|
||||||
if ($line -match '^(scripts|agent_scripts):$') {
|
|
||||||
$skipScripts = $true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ($line -match '^[a-zA-Z].*:' -and $skipScripts) {
|
|
||||||
$skipScripts = $false
|
|
||||||
}
|
|
||||||
if ($skipScripts -and $line -match '^\s+') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$outputLines += $line
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = $outputLines -join "`n"
|
|
||||||
|
|
||||||
# Apply other substitutions
|
|
||||||
$body = $body -replace '\{ARGS\}', $ArgFormat
|
|
||||||
$body = $body -replace '__AGENT__', $Agent
|
|
||||||
$body = Rewrite-Paths -Content $body
|
|
||||||
|
|
||||||
# Generate output file based on extension
|
|
||||||
$outputFile = Join-Path $OutputDir "speckit.$name.$Extension"
|
|
||||||
|
|
||||||
switch ($Extension) {
|
|
||||||
'toml' {
|
|
||||||
$body = $body -replace '\\', '\\'
|
|
||||||
$output = "description = `"$description`"`n`nprompt = `"`"`"`n$body`n`"`"`""
|
|
||||||
Set-Content -Path $outputFile -Value $output -NoNewline
|
|
||||||
}
|
|
||||||
'md' {
|
|
||||||
Set-Content -Path $outputFile -Value $body -NoNewline
|
|
||||||
}
|
|
||||||
'agent.md' {
|
|
||||||
Set-Content -Path $outputFile -Value $body -NoNewline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Generate-CopilotPrompts {
|
|
||||||
param(
|
|
||||||
[string]$AgentsDir,
|
|
||||||
[string]$PromptsDir
|
|
||||||
)
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $PromptsDir -Force | Out-Null
|
|
||||||
|
|
||||||
$agentFiles = Get-ChildItem -Path "$AgentsDir/speckit.*.agent.md" -File -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
foreach ($agentFile in $agentFiles) {
|
|
||||||
$basename = $agentFile.Name -replace '\.agent\.md$', ''
|
|
||||||
$promptFile = Join-Path $PromptsDir "$basename.prompt.md"
|
|
||||||
|
|
||||||
$content = @"
|
|
||||||
---
|
|
||||||
agent: $basename
|
|
||||||
---
|
|
||||||
"@
|
|
||||||
Set-Content -Path $promptFile -Value $content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create skills in <skills_dir>\<name>\SKILL.md format.
|
|
||||||
# Skills use hyphenated names (e.g. speckit-plan).
|
|
||||||
#
|
|
||||||
# Technical debt note:
|
|
||||||
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
|
||||||
# overrides (at minimum: name/description/compatibility/metadata.{author,source}).
|
|
||||||
function New-Skills {
|
|
||||||
param(
|
|
||||||
[string]$SkillsDir,
|
|
||||||
[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${Separator}$name"
|
|
||||||
$skillDir = Join-Path $SkillsDir $skillName
|
|
||||||
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
|
|
||||||
|
|
||||||
$fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n"
|
|
||||||
|
|
||||||
# Extract description
|
|
||||||
$description = "Spec Kit: $name workflow"
|
|
||||||
if ($fileContent -match '(?m)^description:\s*(.+)$') {
|
|
||||||
$description = $matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract script command
|
|
||||||
$scriptCommand = "(Missing script command for $ScriptVariant)"
|
|
||||||
if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") {
|
|
||||||
$scriptCommand = $matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract agent_script command from frontmatter if present
|
|
||||||
$agentScriptCommand = ""
|
|
||||||
if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") {
|
|
||||||
$agentScriptCommand = $matches[1].Trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Replace {SCRIPT}, strip scripts sections, rewrite paths
|
|
||||||
$body = $fileContent -replace '\{SCRIPT\}', $scriptCommand
|
|
||||||
if (-not [string]::IsNullOrEmpty($agentScriptCommand)) {
|
|
||||||
$body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = $body -split "`n"
|
|
||||||
$outputLines = @()
|
|
||||||
$inFrontmatter = $false
|
|
||||||
$skipScripts = $false
|
|
||||||
$dashCount = 0
|
|
||||||
|
|
||||||
foreach ($line in $lines) {
|
|
||||||
if ($line -match '^---$') {
|
|
||||||
$outputLines += $line
|
|
||||||
$dashCount++
|
|
||||||
$inFrontmatter = ($dashCount -eq 1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ($inFrontmatter) {
|
|
||||||
if ($line -match '^(scripts|agent_scripts):$') { $skipScripts = $true; continue }
|
|
||||||
if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { $skipScripts = $false }
|
|
||||||
if ($skipScripts -and $line -match '^\s+') { continue }
|
|
||||||
}
|
|
||||||
$outputLines += $line
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = $outputLines -join "`n"
|
|
||||||
$body = $body -replace '\{ARGS\}', '$ARGUMENTS'
|
|
||||||
$body = $body -replace '__AGENT__', $AgentName
|
|
||||||
$body = Rewrite-Paths -Content $body
|
|
||||||
|
|
||||||
# Strip existing frontmatter, keep only body
|
|
||||||
$templateBody = ""
|
|
||||||
$fmCount = 0
|
|
||||||
$inBody = $false
|
|
||||||
foreach ($line in ($body -split "`n")) {
|
|
||||||
if ($line -match '^---$') {
|
|
||||||
$fmCount++
|
|
||||||
if ($fmCount -eq 2) { $inBody = $true }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ($inBody) { $templateBody += "$line`n" }
|
|
||||||
}
|
|
||||||
|
|
||||||
$skillContent = "---`nname: `"$skillName`"`ndescription: `"$description`"`ncompatibility: `"Requires spec-kit project structure with .specify/ directory`"`nmetadata:`n author: `"github-spec-kit`"`n source: `"templates/commands/$name.md`"`n---`n`n$templateBody"
|
|
||||||
Set-Content -Path (Join-Path $skillDir "SKILL.md") -Value $skillContent -NoNewline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Build-Variant {
|
|
||||||
param(
|
|
||||||
[string]$Agent,
|
|
||||||
[string]$Script
|
|
||||||
)
|
|
||||||
|
|
||||||
$baseDir = Join-Path $GenReleasesDir "sdd-${Agent}-package-${Script}"
|
|
||||||
Write-Host "Building $Agent ($Script) package..."
|
|
||||||
New-Item -ItemType Directory -Path $baseDir -Force | Out-Null
|
|
||||||
|
|
||||||
# Copy base structure but filter scripts by variant
|
|
||||||
$specDir = Join-Path $baseDir ".specify"
|
|
||||||
New-Item -ItemType Directory -Path $specDir -Force | Out-Null
|
|
||||||
|
|
||||||
# Copy memory directory
|
|
||||||
if (Test-Path "memory") {
|
|
||||||
Copy-Item -Path "memory" -Destination $specDir -Recurse -Force
|
|
||||||
Write-Host "Copied memory -> .specify"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only copy the relevant script variant directory
|
|
||||||
if (Test-Path "scripts") {
|
|
||||||
$scriptsDestDir = Join-Path $specDir "scripts"
|
|
||||||
New-Item -ItemType Directory -Path $scriptsDestDir -Force | Out-Null
|
|
||||||
|
|
||||||
switch ($Script) {
|
|
||||||
'sh' {
|
|
||||||
if (Test-Path "scripts/bash") {
|
|
||||||
Copy-Item -Path "scripts/bash" -Destination $scriptsDestDir -Recurse -Force
|
|
||||||
Write-Host "Copied scripts/bash -> .specify/scripts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'ps' {
|
|
||||||
if (Test-Path "scripts/powershell") {
|
|
||||||
Copy-Item -Path "scripts/powershell" -Destination $scriptsDestDir -Recurse -Force
|
|
||||||
Write-Host "Copied scripts/powershell -> .specify/scripts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Get-ChildItem -Path "scripts" -File -ErrorAction SilentlyContinue | ForEach-Object {
|
|
||||||
Copy-Item -Path $_.FullName -Destination $scriptsDestDir -Force
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Copy templates (excluding commands directory and vscode-settings.json)
|
|
||||||
if (Test-Path "templates") {
|
|
||||||
$templatesDestDir = Join-Path $specDir "templates"
|
|
||||||
New-Item -ItemType Directory -Path $templatesDestDir -Force | Out-Null
|
|
||||||
|
|
||||||
Get-ChildItem -Path "templates" -Recurse -File | Where-Object {
|
|
||||||
$_.FullName -notmatch 'templates[/\\]commands[/\\]' -and $_.Name -ne 'vscode-settings.json'
|
|
||||||
} | ForEach-Object {
|
|
||||||
$relativePath = $_.FullName.Substring((Resolve-Path "templates").Path.Length + 1)
|
|
||||||
$destFile = Join-Path $templatesDestDir $relativePath
|
|
||||||
$destFileDir = Split-Path $destFile -Parent
|
|
||||||
New-Item -ItemType Directory -Path $destFileDir -Force | Out-Null
|
|
||||||
Copy-Item -Path $_.FullName -Destination $destFile -Force
|
|
||||||
}
|
|
||||||
Write-Host "Copied templates -> .specify/templates"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate agent-specific command files
|
|
||||||
switch ($Agent) {
|
|
||||||
'claude' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".claude/commands"
|
|
||||||
Generate-Commands -Agent 'claude' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'gemini' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".gemini/commands"
|
|
||||||
Generate-Commands -Agent 'gemini' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
if (Test-Path "agent_templates/gemini/GEMINI.md") {
|
|
||||||
Copy-Item -Path "agent_templates/gemini/GEMINI.md" -Destination (Join-Path $baseDir "GEMINI.md")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'copilot' {
|
|
||||||
$agentsDir = Join-Path $baseDir ".github/agents"
|
|
||||||
Generate-Commands -Agent 'copilot' -Extension 'agent.md' -ArgFormat '$ARGUMENTS' -OutputDir $agentsDir -ScriptVariant $Script
|
|
||||||
|
|
||||||
$promptsDir = Join-Path $baseDir ".github/prompts"
|
|
||||||
Generate-CopilotPrompts -AgentsDir $agentsDir -PromptsDir $promptsDir
|
|
||||||
|
|
||||||
$vscodeDir = Join-Path $baseDir ".vscode"
|
|
||||||
New-Item -ItemType Directory -Path $vscodeDir -Force | Out-Null
|
|
||||||
if (Test-Path "templates/vscode-settings.json") {
|
|
||||||
Copy-Item -Path "templates/vscode-settings.json" -Destination (Join-Path $vscodeDir "settings.json")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'cursor-agent' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".cursor/commands"
|
|
||||||
Generate-Commands -Agent 'cursor-agent' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'qwen' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".qwen/commands"
|
|
||||||
Generate-Commands -Agent 'qwen' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
if (Test-Path "agent_templates/qwen/QWEN.md") {
|
|
||||||
Copy-Item -Path "agent_templates/qwen/QWEN.md" -Destination (Join-Path $baseDir "QWEN.md")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'opencode' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".opencode/command"
|
|
||||||
Generate-Commands -Agent 'opencode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'windsurf' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".windsurf/workflows"
|
|
||||||
Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'junie' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".junie/commands"
|
|
||||||
Generate-Commands -Agent 'junie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'codex' {
|
|
||||||
$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"
|
|
||||||
Generate-Commands -Agent 'kilocode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'auggie' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".augment/commands"
|
|
||||||
Generate-Commands -Agent 'auggie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'roo' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".roo/commands"
|
|
||||||
Generate-Commands -Agent 'roo' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'codebuddy' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".codebuddy/commands"
|
|
||||||
Generate-Commands -Agent 'codebuddy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'amp' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".agents/commands"
|
|
||||||
Generate-Commands -Agent 'amp' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'kiro-cli' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".kiro/prompts"
|
|
||||||
Generate-Commands -Agent 'kiro-cli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'bob' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".bob/commands"
|
|
||||||
Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'qodercli' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".qoder/commands"
|
|
||||||
Generate-Commands -Agent 'qodercli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'shai' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".shai/commands"
|
|
||||||
Generate-Commands -Agent 'shai' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'tabnine' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".tabnine/agent/commands"
|
|
||||||
Generate-Commands -Agent 'tabnine' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
$tabnineTemplate = Join-Path 'agent_templates' 'tabnine/TABNINE.md'
|
|
||||||
if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') }
|
|
||||||
}
|
|
||||||
'agy' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".agent/commands"
|
|
||||||
Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'vibe' {
|
|
||||||
$cmdDir = Join-Path $baseDir ".vibe/prompts"
|
|
||||||
Generate-Commands -Agent 'vibe' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
|
||||||
}
|
|
||||||
'kimi' {
|
|
||||||
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
|
||||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
|
||||||
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi'
|
|
||||||
}
|
|
||||||
'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
|
|
||||||
}
|
|
||||||
default {
|
|
||||||
throw "Unsupported agent '$Agent'."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create zip archive
|
|
||||||
$zipFile = Join-Path $GenReleasesDir "spec-kit-template-${Agent}-${Script}-${Version}.zip"
|
|
||||||
Compress-Archive -Path "$baseDir/*" -DestinationPath $zipFile -Force
|
|
||||||
Write-Host "Created $zipFile"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Define all agents and scripts
|
|
||||||
$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 {
|
|
||||||
param([string]$Value)
|
|
||||||
|
|
||||||
if ([string]::IsNullOrEmpty($Value)) {
|
|
||||||
return @()
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = $Value -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique
|
|
||||||
return $items
|
|
||||||
}
|
|
||||||
|
|
||||||
function Validate-Subset {
|
|
||||||
param(
|
|
||||||
[string]$Type,
|
|
||||||
[string[]]$Allowed,
|
|
||||||
[string[]]$Items
|
|
||||||
)
|
|
||||||
|
|
||||||
$ok = $true
|
|
||||||
foreach ($item in $Items) {
|
|
||||||
if ($item -notin $Allowed) {
|
|
||||||
Write-Error "Unknown $Type '$item' (allowed: $($Allowed -join ', '))"
|
|
||||||
$ok = $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $ok
|
|
||||||
}
|
|
||||||
|
|
||||||
# Determine agent list
|
|
||||||
if (-not [string]::IsNullOrEmpty($Agents)) {
|
|
||||||
$AgentList = Normalize-List -Value $Agents
|
|
||||||
if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) {
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$AgentList = $AllAgents
|
|
||||||
}
|
|
||||||
|
|
||||||
# Determine script list
|
|
||||||
if (-not [string]::IsNullOrEmpty($Scripts)) {
|
|
||||||
$ScriptList = Normalize-List -Value $Scripts
|
|
||||||
if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) {
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$ScriptList = $AllScripts
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Agents: $($AgentList -join ', ')"
|
|
||||||
Write-Host "Scripts: $($ScriptList -join ', ')"
|
|
||||||
|
|
||||||
# Build all variants
|
|
||||||
foreach ($agent in $AgentList) {
|
|
||||||
foreach ($script in $ScriptList) {
|
|
||||||
Build-Variant -Agent $agent -Script $script
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "`nArchives in ${GenReleasesDir}:"
|
|
||||||
Get-ChildItem -Path $GenReleasesDir -Filter "spec-kit-template-*-${Version}.zip" | ForEach-Object {
|
|
||||||
Write-Host " $($_.Name)"
|
|
||||||
}
|
|
||||||
388
.github/workflows/scripts/create-release-packages.sh
vendored
388
.github/workflows/scripts/create-release-packages.sh
vendored
@@ -1,388 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# create-release-packages.sh (workflow-local)
|
|
||||||
# Build Spec Kit template release archives for each supported AI assistant and script type.
|
|
||||||
# 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 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
|
|
||||||
# AGENTS="copilot,gemini" $0 v0.2.0
|
|
||||||
# SCRIPTS=ps $0 v0.2.0
|
|
||||||
|
|
||||||
if [[ $# -ne 1 ]]; then
|
|
||||||
echo "Usage: $0 <version-with-v-prefix>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
NEW_VERSION="$1"
|
|
||||||
if [[ ! $NEW_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
||||||
echo "Version must look like v0.0.0" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building release packages for $NEW_VERSION"
|
|
||||||
|
|
||||||
# Create and use .genreleases directory for all build artifacts
|
|
||||||
# Override via GENRELEASES_DIR env var (e.g. for tests writing to a temp dir)
|
|
||||||
GENRELEASES_DIR="${GENRELEASES_DIR:-.genreleases}"
|
|
||||||
|
|
||||||
# Guard against unsafe GENRELEASES_DIR values before cleaning
|
|
||||||
if [[ -z "$GENRELEASES_DIR" ]]; then
|
|
||||||
echo "GENRELEASES_DIR must not be empty" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
case "$GENRELEASES_DIR" in
|
|
||||||
'/'|'.'|'..')
|
|
||||||
echo "Refusing to use unsafe GENRELEASES_DIR value: $GENRELEASES_DIR" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if [[ "$GENRELEASES_DIR" == *".."* ]]; then
|
|
||||||
echo "Refusing to use GENRELEASES_DIR containing '..' path segments: $GENRELEASES_DIR" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$GENRELEASES_DIR"
|
|
||||||
rm -rf "${GENRELEASES_DIR%/}/"* || true
|
|
||||||
|
|
||||||
rewrite_paths() {
|
|
||||||
sed -E \
|
|
||||||
-e 's@(/?)memory/@.specify/memory/@g' \
|
|
||||||
-e 's@(/?)scripts/@.specify/scripts/@g' \
|
|
||||||
-e 's@(/?)templates/@.specify/templates/@g' \
|
|
||||||
-e 's@\.specify\.specify/@.specify/@g'
|
|
||||||
}
|
|
||||||
|
|
||||||
generate_commands() {
|
|
||||||
local agent=$1 ext=$2 arg_format=$3 output_dir=$4 script_variant=$5
|
|
||||||
mkdir -p "$output_dir"
|
|
||||||
for template in templates/commands/*.md; do
|
|
||||||
[[ -f "$template" ]] || continue
|
|
||||||
local name description script_command agent_script_command body
|
|
||||||
name=$(basename "$template" .md)
|
|
||||||
|
|
||||||
# Normalize line endings
|
|
||||||
file_content=$(tr -d '\r' < "$template")
|
|
||||||
|
|
||||||
# Extract description and script command from YAML frontmatter
|
|
||||||
description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}')
|
|
||||||
script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}')
|
|
||||||
|
|
||||||
if [[ -z $script_command ]]; then
|
|
||||||
echo "Warning: no script command found for $script_variant in $template" >&2
|
|
||||||
script_command="(Missing script command for $script_variant)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract agent_script command from YAML frontmatter if present
|
|
||||||
agent_script_command=$(printf '%s\n' "$file_content" | awk '
|
|
||||||
/^agent_scripts:$/ { in_agent_scripts=1; next }
|
|
||||||
in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ {
|
|
||||||
sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "")
|
|
||||||
print
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 }
|
|
||||||
')
|
|
||||||
|
|
||||||
# Replace {SCRIPT} placeholder with the script command
|
|
||||||
body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g")
|
|
||||||
|
|
||||||
# Replace {AGENT_SCRIPT} placeholder with the agent script command if found
|
|
||||||
if [[ -n $agent_script_command ]]; then
|
|
||||||
body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove the scripts: and agent_scripts: sections from frontmatter while preserving YAML structure
|
|
||||||
body=$(printf '%s\n' "$body" | awk '
|
|
||||||
/^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next }
|
|
||||||
in_frontmatter && /^scripts:$/ { skip_scripts=1; next }
|
|
||||||
in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next }
|
|
||||||
in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 }
|
|
||||||
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
|
||||||
{ print }
|
|
||||||
')
|
|
||||||
|
|
||||||
# Apply other substitutions
|
|
||||||
body=$(printf '%s\n' "$body" | sed "s/{ARGS}/$arg_format/g" | sed "s/__AGENT__/$agent/g" | rewrite_paths)
|
|
||||||
|
|
||||||
case $ext in
|
|
||||||
toml)
|
|
||||||
body=$(printf '%s\n' "$body" | sed 's/\\/\\\\/g')
|
|
||||||
{ echo "description = \"$description\""; echo; echo "prompt = \"\"\""; echo "$body"; echo "\"\"\""; } > "$output_dir/speckit.$name.$ext" ;;
|
|
||||||
md)
|
|
||||||
echo "$body" > "$output_dir/speckit.$name.$ext" ;;
|
|
||||||
agent.md)
|
|
||||||
echo "$body" > "$output_dir/speckit.$name.$ext" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
generate_copilot_prompts() {
|
|
||||||
local agents_dir=$1 prompts_dir=$2
|
|
||||||
mkdir -p "$prompts_dir"
|
|
||||||
|
|
||||||
# Generate a .prompt.md file for each .agent.md file
|
|
||||||
for agent_file in "$agents_dir"/speckit.*.agent.md; do
|
|
||||||
[[ -f "$agent_file" ]] || continue
|
|
||||||
|
|
||||||
local basename=$(basename "$agent_file" .agent.md)
|
|
||||||
local prompt_file="$prompts_dir/${basename}.prompt.md"
|
|
||||||
|
|
||||||
cat > "$prompt_file" <<EOF
|
|
||||||
---
|
|
||||||
agent: ${basename}
|
|
||||||
---
|
|
||||||
EOF
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
|
||||||
# Skills use hyphenated names (e.g. speckit-plan).
|
|
||||||
#
|
|
||||||
# Technical debt note:
|
|
||||||
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
|
||||||
# overrides (at minimum: name/description/compatibility/metadata.{author,source}).
|
|
||||||
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${separator}${name}"
|
|
||||||
local skill_dir="${skills_dir}/${skill_name}"
|
|
||||||
mkdir -p "$skill_dir"
|
|
||||||
|
|
||||||
local file_content
|
|
||||||
file_content=$(tr -d '\r' < "$template")
|
|
||||||
|
|
||||||
# Extract description from frontmatter
|
|
||||||
local description
|
|
||||||
description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}')
|
|
||||||
[[ -z "$description" ]] && description="Spec Kit: ${name} workflow"
|
|
||||||
|
|
||||||
# Extract script command
|
|
||||||
local script_command
|
|
||||||
script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}')
|
|
||||||
[[ -z "$script_command" ]] && script_command="(Missing script command for $script_variant)"
|
|
||||||
|
|
||||||
# Extract agent_script command from frontmatter if present
|
|
||||||
local agent_script_command
|
|
||||||
agent_script_command=$(printf '%s\n' "$file_content" | awk '
|
|
||||||
/^agent_scripts:$/ { in_agent_scripts=1; next }
|
|
||||||
in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ {
|
|
||||||
sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "")
|
|
||||||
print
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 }
|
|
||||||
')
|
|
||||||
|
|
||||||
# Build body: replace placeholders, strip scripts sections, rewrite paths
|
|
||||||
local body
|
|
||||||
body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g")
|
|
||||||
if [[ -n $agent_script_command ]]; then
|
|
||||||
body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g")
|
|
||||||
fi
|
|
||||||
body=$(printf '%s\n' "$body" | awk '
|
|
||||||
/^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next }
|
|
||||||
in_frontmatter && /^scripts:$/ { skip_scripts=1; next }
|
|
||||||
in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next }
|
|
||||||
in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 }
|
|
||||||
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
|
||||||
{ print }
|
|
||||||
')
|
|
||||||
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths)
|
|
||||||
|
|
||||||
# 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')
|
|
||||||
|
|
||||||
{
|
|
||||||
printf -- '---\n'
|
|
||||||
printf 'name: "%s"\n' "$skill_name"
|
|
||||||
printf 'description: "%s"\n' "$description"
|
|
||||||
printf 'compatibility: "%s"\n' "Requires spec-kit project structure with .specify/ directory"
|
|
||||||
printf -- 'metadata:\n'
|
|
||||||
printf ' author: "%s"\n' "github-spec-kit"
|
|
||||||
printf ' source: "%s"\n' "templates/commands/${name}.md"
|
|
||||||
printf -- '---\n\n'
|
|
||||||
printf '%s\n' "$template_body"
|
|
||||||
} > "$skill_dir/SKILL.md"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
build_variant() {
|
|
||||||
local agent=$1 script=$2
|
|
||||||
local base_dir="$GENRELEASES_DIR/sdd-${agent}-package-${script}"
|
|
||||||
echo "Building $agent ($script) package..."
|
|
||||||
mkdir -p "$base_dir"
|
|
||||||
|
|
||||||
# Copy base structure but filter scripts by variant
|
|
||||||
SPEC_DIR="$base_dir/.specify"
|
|
||||||
mkdir -p "$SPEC_DIR"
|
|
||||||
|
|
||||||
[[ -d memory ]] && { cp -r memory "$SPEC_DIR/"; echo "Copied memory -> .specify"; }
|
|
||||||
|
|
||||||
# Only copy the relevant script variant directory
|
|
||||||
if [[ -d scripts ]]; then
|
|
||||||
mkdir -p "$SPEC_DIR/scripts"
|
|
||||||
case $script in
|
|
||||||
sh)
|
|
||||||
[[ -d scripts/bash ]] && { cp -r scripts/bash "$SPEC_DIR/scripts/"; echo "Copied scripts/bash -> .specify/scripts"; }
|
|
||||||
find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true
|
|
||||||
;;
|
|
||||||
ps)
|
|
||||||
[[ -d scripts/powershell ]] && { cp -r scripts/powershell "$SPEC_DIR/scripts/"; echo "Copied scripts/powershell -> .specify/scripts"; }
|
|
||||||
find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
[[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" | while IFS= read -r f; do d="$SPEC_DIR/$(dirname "$f")"; mkdir -p "$d"; cp "$f" "$d/"; done; echo "Copied templates -> .specify/templates"; }
|
|
||||||
|
|
||||||
case $agent in
|
|
||||||
claude)
|
|
||||||
mkdir -p "$base_dir/.claude/commands"
|
|
||||||
generate_commands claude md "\$ARGUMENTS" "$base_dir/.claude/commands" "$script" ;;
|
|
||||||
gemini)
|
|
||||||
mkdir -p "$base_dir/.gemini/commands"
|
|
||||||
generate_commands gemini toml "{{args}}" "$base_dir/.gemini/commands" "$script"
|
|
||||||
[[ -f agent_templates/gemini/GEMINI.md ]] && cp agent_templates/gemini/GEMINI.md "$base_dir/GEMINI.md" ;;
|
|
||||||
copilot)
|
|
||||||
mkdir -p "$base_dir/.github/agents"
|
|
||||||
generate_commands copilot agent.md "\$ARGUMENTS" "$base_dir/.github/agents" "$script"
|
|
||||||
generate_copilot_prompts "$base_dir/.github/agents" "$base_dir/.github/prompts"
|
|
||||||
mkdir -p "$base_dir/.vscode"
|
|
||||||
[[ -f templates/vscode-settings.json ]] && cp templates/vscode-settings.json "$base_dir/.vscode/settings.json"
|
|
||||||
;;
|
|
||||||
cursor-agent)
|
|
||||||
mkdir -p "$base_dir/.cursor/commands"
|
|
||||||
generate_commands cursor-agent md "\$ARGUMENTS" "$base_dir/.cursor/commands" "$script" ;;
|
|
||||||
qwen)
|
|
||||||
mkdir -p "$base_dir/.qwen/commands"
|
|
||||||
generate_commands qwen md "\$ARGUMENTS" "$base_dir/.qwen/commands" "$script"
|
|
||||||
[[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md "$base_dir/QWEN.md" ;;
|
|
||||||
opencode)
|
|
||||||
mkdir -p "$base_dir/.opencode/command"
|
|
||||||
generate_commands opencode md "\$ARGUMENTS" "$base_dir/.opencode/command" "$script" ;;
|
|
||||||
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/.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" ;;
|
|
||||||
auggie)
|
|
||||||
mkdir -p "$base_dir/.augment/commands"
|
|
||||||
generate_commands auggie md "\$ARGUMENTS" "$base_dir/.augment/commands" "$script" ;;
|
|
||||||
roo)
|
|
||||||
mkdir -p "$base_dir/.roo/commands"
|
|
||||||
generate_commands roo md "\$ARGUMENTS" "$base_dir/.roo/commands" "$script" ;;
|
|
||||||
codebuddy)
|
|
||||||
mkdir -p "$base_dir/.codebuddy/commands"
|
|
||||||
generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;;
|
|
||||||
qodercli)
|
|
||||||
mkdir -p "$base_dir/.qoder/commands"
|
|
||||||
generate_commands qodercli md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;;
|
|
||||||
amp)
|
|
||||||
mkdir -p "$base_dir/.agents/commands"
|
|
||||||
generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;;
|
|
||||||
shai)
|
|
||||||
mkdir -p "$base_dir/.shai/commands"
|
|
||||||
generate_commands shai md "\$ARGUMENTS" "$base_dir/.shai/commands" "$script" ;;
|
|
||||||
tabnine)
|
|
||||||
mkdir -p "$base_dir/.tabnine/agent/commands"
|
|
||||||
generate_commands tabnine toml "{{args}}" "$base_dir/.tabnine/agent/commands" "$script"
|
|
||||||
[[ -f agent_templates/tabnine/TABNINE.md ]] && cp agent_templates/tabnine/TABNINE.md "$base_dir/TABNINE.md" ;;
|
|
||||||
kiro-cli)
|
|
||||||
mkdir -p "$base_dir/.kiro/prompts"
|
|
||||||
generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;;
|
|
||||||
agy)
|
|
||||||
mkdir -p "$base_dir/.agent/commands"
|
|
||||||
generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/commands" "$script" ;;
|
|
||||||
bob)
|
|
||||||
mkdir -p "$base_dir/.bob/commands"
|
|
||||||
generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;;
|
|
||||||
vibe)
|
|
||||||
mkdir -p "$base_dir/.vibe/prompts"
|
|
||||||
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
|
||||||
kimi)
|
|
||||||
mkdir -p "$base_dir/.kimi/skills"
|
|
||||||
create_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" ;;
|
|
||||||
esac
|
|
||||||
( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . )
|
|
||||||
echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Determine agent list
|
|
||||||
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)
|
|
||||||
|
|
||||||
validate_subset() {
|
|
||||||
local type=$1; shift
|
|
||||||
local allowed_str="$1"; shift
|
|
||||||
local invalid=0
|
|
||||||
for it in "$@"; do
|
|
||||||
local found=0
|
|
||||||
for a in $allowed_str; do
|
|
||||||
if [[ "$it" == "$a" ]]; then found=1; break; fi
|
|
||||||
done
|
|
||||||
if [[ $found -eq 0 ]]; then
|
|
||||||
echo "Error: unknown $type '$it' (allowed: $allowed_str)" >&2
|
|
||||||
invalid=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return $invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
read_list() { tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?" ":"") $i);out=1}}}END{printf("\n")}'; }
|
|
||||||
|
|
||||||
if [[ -n ${AGENTS:-} ]]; then
|
|
||||||
read -ra AGENT_LIST <<< "$(printf '%s' "$AGENTS" | read_list)"
|
|
||||||
validate_subset agent "${ALL_AGENTS[*]}" "${AGENT_LIST[@]}" || exit 1
|
|
||||||
else
|
|
||||||
AGENT_LIST=("${ALL_AGENTS[@]}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n ${SCRIPTS:-} ]]; then
|
|
||||||
read -ra SCRIPT_LIST <<< "$(printf '%s' "$SCRIPTS" | read_list)"
|
|
||||||
validate_subset script "${ALL_SCRIPTS[*]}" "${SCRIPT_LIST[@]}" || exit 1
|
|
||||||
else
|
|
||||||
SCRIPT_LIST=("${ALL_SCRIPTS[@]}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Agents: ${AGENT_LIST[*]}"
|
|
||||||
echo "Scripts: ${SCRIPT_LIST[*]}"
|
|
||||||
|
|
||||||
for agent in "${AGENT_LIST[@]}"; do
|
|
||||||
for script in "${SCRIPT_LIST[@]}"; do
|
|
||||||
build_variant "$agent" "$script"
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Archives in $GENRELEASES_DIR:"
|
|
||||||
ls -1 "$GENRELEASES_DIR"/spec-kit-template-*-"${NEW_VERSION}".zip
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# generate-release-notes.sh
|
|
||||||
# Generate release notes from git history
|
|
||||||
# Usage: generate-release-notes.sh <new_version> <last_tag>
|
|
||||||
|
|
||||||
if [[ $# -ne 2 ]]; then
|
|
||||||
echo "Usage: $0 <new_version> <last_tag>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
NEW_VERSION="$1"
|
|
||||||
LAST_TAG="$2"
|
|
||||||
|
|
||||||
# Get commits since last tag
|
|
||||||
if [ "$LAST_TAG" = "v0.0.0" ]; then
|
|
||||||
# Check how many commits we have and use that as the limit
|
|
||||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
|
||||||
if [ "$COMMIT_COUNT" -gt 10 ]; then
|
|
||||||
COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~10..HEAD)
|
|
||||||
else
|
|
||||||
COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~$COMMIT_COUNT..HEAD 2>/dev/null || git log --oneline --pretty=format:"- %s")
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
COMMITS=$(git log --oneline --pretty=format:"- %s" $LAST_TAG..HEAD)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create release notes
|
|
||||||
cat > release_notes.md << EOF
|
|
||||||
This is the latest set of releases that you can use with your agent of choice. We recommend using the Specify CLI to scaffold your projects, however you can download these independently and manage them yourself.
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
$COMMITS
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Generated release notes:"
|
|
||||||
cat release_notes.md
|
|
||||||
24
.github/workflows/scripts/get-next-version.sh
vendored
24
.github/workflows/scripts/get-next-version.sh
vendored
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# get-next-version.sh
|
|
||||||
# Calculate the next version based on the latest git tag and output GitHub Actions variables
|
|
||||||
# Usage: get-next-version.sh
|
|
||||||
|
|
||||||
# Get the latest tag, or use v0.0.0 if no tags exist
|
|
||||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
|
||||||
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# Extract version number and increment
|
|
||||||
VERSION=$(echo $LATEST_TAG | sed 's/v//')
|
|
||||||
IFS='.' read -ra VERSION_PARTS <<< "$VERSION"
|
|
||||||
MAJOR=${VERSION_PARTS[0]:-0}
|
|
||||||
MINOR=${VERSION_PARTS[1]:-0}
|
|
||||||
PATCH=${VERSION_PARTS[2]:-0}
|
|
||||||
|
|
||||||
# Increment patch version
|
|
||||||
PATCH=$((PATCH + 1))
|
|
||||||
NEW_VERSION="v$MAJOR.$MINOR.$PATCH"
|
|
||||||
|
|
||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "New version will be: $NEW_VERSION"
|
|
||||||
161
.github/workflows/scripts/simulate-release.sh
vendored
161
.github/workflows/scripts/simulate-release.sh
vendored
@@ -1,161 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# simulate-release.sh
|
|
||||||
# Simulate the release process locally without pushing to GitHub
|
|
||||||
# Usage: simulate-release.sh [version]
|
|
||||||
# If version is omitted, auto-increments patch version
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
echo -e "${BLUE}🧪 Simulating Release Process Locally${NC}"
|
|
||||||
echo "======================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 1: Determine version
|
|
||||||
if [[ -n "${1:-}" ]]; then
|
|
||||||
VERSION="${1#v}"
|
|
||||||
TAG="v$VERSION"
|
|
||||||
echo -e "${GREEN}📝 Using manual version: $VERSION${NC}"
|
|
||||||
else
|
|
||||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
|
||||||
echo -e "${BLUE}Latest tag: $LATEST_TAG${NC}"
|
|
||||||
|
|
||||||
VERSION=$(echo $LATEST_TAG | sed 's/v//')
|
|
||||||
IFS='.' read -ra VERSION_PARTS <<< "$VERSION"
|
|
||||||
MAJOR=${VERSION_PARTS[0]:-0}
|
|
||||||
MINOR=${VERSION_PARTS[1]:-0}
|
|
||||||
PATCH=${VERSION_PARTS[2]:-0}
|
|
||||||
|
|
||||||
PATCH=$((PATCH + 1))
|
|
||||||
VERSION="$MAJOR.$MINOR.$PATCH"
|
|
||||||
TAG="v$VERSION"
|
|
||||||
echo -e "${GREEN}📝 Auto-incremented to: $VERSION${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 2: Check if tag exists
|
|
||||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
|
||||||
echo -e "${RED}❌ Error: Tag $TAG already exists!${NC}"
|
|
||||||
echo " Please use a different version or delete the tag first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo -e "${GREEN}✓ Tag $TAG is available${NC}"
|
|
||||||
|
|
||||||
# Step 3: Backup current state
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}💾 Creating backup of current state...${NC}"
|
|
||||||
BACKUP_DIR=$(mktemp -d)
|
|
||||||
cp pyproject.toml "$BACKUP_DIR/pyproject.toml.bak"
|
|
||||||
cp CHANGELOG.md "$BACKUP_DIR/CHANGELOG.md.bak"
|
|
||||||
echo -e "${GREEN}✓ Backup created at: $BACKUP_DIR${NC}"
|
|
||||||
|
|
||||||
# Step 4: Update pyproject.toml
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}📝 Updating pyproject.toml...${NC}"
|
|
||||||
sed -i.tmp "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml
|
|
||||||
rm -f pyproject.toml.tmp
|
|
||||||
echo -e "${GREEN}✓ Updated pyproject.toml to version $VERSION${NC}"
|
|
||||||
|
|
||||||
# Step 5: Update CHANGELOG.md
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}📝 Updating CHANGELOG.md...${NC}"
|
|
||||||
DATE=$(date +%Y-%m-%d)
|
|
||||||
|
|
||||||
# Get the previous tag to compare commits
|
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [[ -n "$PREVIOUS_TAG" ]]; then
|
|
||||||
echo " Generating changelog from commits since $PREVIOUS_TAG"
|
|
||||||
# Get commits since last tag, format as bullet points
|
|
||||||
COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release")
|
|
||||||
else
|
|
||||||
echo " No previous tag found - this is the first release"
|
|
||||||
COMMITS="- Initial release"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create temp file with new entry
|
|
||||||
{
|
|
||||||
head -n 8 CHANGELOG.md
|
|
||||||
echo ""
|
|
||||||
echo "## [$VERSION] - $DATE"
|
|
||||||
echo ""
|
|
||||||
echo "### Changed"
|
|
||||||
echo ""
|
|
||||||
echo "$COMMITS"
|
|
||||||
echo ""
|
|
||||||
tail -n +9 CHANGELOG.md
|
|
||||||
} > CHANGELOG.md.tmp
|
|
||||||
mv CHANGELOG.md.tmp CHANGELOG.md
|
|
||||||
echo -e "${GREEN}✓ Updated CHANGELOG.md with commits since $PREVIOUS_TAG${NC}"
|
|
||||||
|
|
||||||
# Step 6: Show what would be committed
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}📋 Changes that would be committed:${NC}"
|
|
||||||
git diff pyproject.toml CHANGELOG.md
|
|
||||||
|
|
||||||
# Step 7: Create temporary tag (no push)
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}🏷️ Creating temporary local tag...${NC}"
|
|
||||||
git tag -a "$TAG" -m "Simulated release $TAG" 2>/dev/null || true
|
|
||||||
echo -e "${GREEN}✓ Tag $TAG created locally${NC}"
|
|
||||||
|
|
||||||
# Step 8: Simulate release artifact creation
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}📦 Simulating release package creation...${NC}"
|
|
||||||
echo " (High-level simulation only; packaging script is not executed)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if script exists and is executable
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
if [[ -x "$SCRIPT_DIR/create-release-packages.sh" ]]; then
|
|
||||||
echo -e "${BLUE}In a real release, the following command would be run to create packages:${NC}"
|
|
||||||
echo " $SCRIPT_DIR/create-release-packages.sh \"$TAG\""
|
|
||||||
echo ""
|
|
||||||
echo "This simulation does not enumerate individual package files to avoid"
|
|
||||||
echo "drifting from the actual behavior of create-release-packages.sh."
|
|
||||||
else
|
|
||||||
echo -e "${RED}⚠️ create-release-packages.sh not found or not executable${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 9: Simulate release notes generation
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}📄 Simulating release notes generation...${NC}"
|
|
||||||
echo ""
|
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 $TAG^ 2>/dev/null || echo "")
|
|
||||||
if [[ -n "$PREVIOUS_TAG" ]]; then
|
|
||||||
echo -e "${BLUE}Changes since $PREVIOUS_TAG:${NC}"
|
|
||||||
git log --oneline "$PREVIOUS_TAG".."$TAG" | head -n 10
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo -e "${BLUE}No previous tag found - this would be the first release${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 10: Summary
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}🎉 Simulation Complete!${NC}"
|
|
||||||
echo "======================================"
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}Summary:${NC}"
|
|
||||||
echo " Version: $VERSION"
|
|
||||||
echo " Tag: $TAG"
|
|
||||||
echo " Backup: $BACKUP_DIR"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}⚠️ SIMULATION ONLY - NO CHANGES PUSHED${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}Next steps:${NC}"
|
|
||||||
echo " 1. Review the changes above"
|
|
||||||
echo " 2. To keep changes: git add pyproject.toml CHANGELOG.md && git commit"
|
|
||||||
echo " 3. To discard changes: git checkout pyproject.toml CHANGELOG.md && git tag -d $TAG"
|
|
||||||
echo " 4. To restore from backup: cp $BACKUP_DIR/* ."
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}To run the actual release:${NC}"
|
|
||||||
echo " Go to: https://github.com/github/spec-kit/actions/workflows/release-trigger.yml"
|
|
||||||
echo " Click 'Run workflow' and enter version: $VERSION"
|
|
||||||
echo ""
|
|
||||||
23
.github/workflows/scripts/update-version.sh
vendored
23
.github/workflows/scripts/update-version.sh
vendored
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# update-version.sh
|
|
||||||
# Update version in pyproject.toml (for release artifacts only)
|
|
||||||
# Usage: update-version.sh <version>
|
|
||||||
|
|
||||||
if [[ $# -ne 1 ]]; then
|
|
||||||
echo "Usage: $0 <version>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="$1"
|
|
||||||
|
|
||||||
# Remove 'v' prefix for Python versioning
|
|
||||||
PYTHON_VERSION=${VERSION#v}
|
|
||||||
|
|
||||||
if [ -f "pyproject.toml" ]; then
|
|
||||||
sed -i "s/version = \".*\"/version = \"$PYTHON_VERSION\"/" pyproject.toml
|
|
||||||
echo "Updated pyproject.toml version to $PYTHON_VERSION (for release artifacts only)"
|
|
||||||
else
|
|
||||||
echo "Warning: pyproject.toml not found, skipping version update"
|
|
||||||
fi
|
|
||||||
@@ -41,8 +41,6 @@ packages = ["src/specify_cli"]
|
|||||||
"templates/commands" = "specify_cli/core_pack/commands"
|
"templates/commands" = "specify_cli/core_pack/commands"
|
||||||
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
||||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||||
".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh"
|
|
||||||
".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1"
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
test = [
|
test = [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,18 @@ from copy import deepcopy
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def _build_agent_configs() -> dict[str, Any]:
|
||||||
|
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
|
||||||
|
from specify_cli.integrations import INTEGRATION_REGISTRY
|
||||||
|
configs: dict[str, dict[str, Any]] = {}
|
||||||
|
for key, integration in INTEGRATION_REGISTRY.items():
|
||||||
|
if key == "generic":
|
||||||
|
continue
|
||||||
|
if integration.registrar_config:
|
||||||
|
configs[key] = dict(integration.registrar_config)
|
||||||
|
return configs
|
||||||
|
|
||||||
|
|
||||||
class CommandRegistrar:
|
class CommandRegistrar:
|
||||||
"""Handles registration of commands with AI agents.
|
"""Handles registration of commands with AI agents.
|
||||||
|
|
||||||
@@ -23,159 +35,26 @@ class CommandRegistrar:
|
|||||||
and companion files (e.g. Copilot .prompt.md).
|
and companion files (e.g. Copilot .prompt.md).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Agent configurations with directory, format, and argument placeholder
|
# Derived from INTEGRATION_REGISTRY — single source of truth.
|
||||||
AGENT_CONFIGS = {
|
# Populated lazily via _ensure_configs() on first use.
|
||||||
"claude": {
|
AGENT_CONFIGS: dict[str, dict[str, Any]] = {}
|
||||||
"dir": ".claude/commands",
|
_configs_loaded: bool = False
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
def __init__(self) -> None:
|
||||||
"extension": ".md"
|
self._ensure_configs()
|
||||||
},
|
|
||||||
"gemini": {
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||||
"dir": ".gemini/commands",
|
super().__init_subclass__(**kwargs)
|
||||||
"format": "toml",
|
cls._ensure_configs()
|
||||||
"args": "{{args}}",
|
|
||||||
"extension": ".toml"
|
@classmethod
|
||||||
},
|
def _ensure_configs(cls) -> None:
|
||||||
"copilot": {
|
if not cls._configs_loaded:
|
||||||
"dir": ".github/agents",
|
try:
|
||||||
"format": "markdown",
|
cls.AGENT_CONFIGS = _build_agent_configs()
|
||||||
"args": "$ARGUMENTS",
|
cls._configs_loaded = True
|
||||||
"extension": ".agent.md"
|
except ImportError:
|
||||||
},
|
pass # Circular import during module init; retry on next access
|
||||||
"cursor-agent": {
|
|
||||||
"dir": ".cursor/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"qwen": {
|
|
||||||
"dir": ".qwen/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"opencode": {
|
|
||||||
"dir": ".opencode/command",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"codex": {
|
|
||||||
"dir": ".agents/skills",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": "/SKILL.md",
|
|
||||||
},
|
|
||||||
"windsurf": {
|
|
||||||
"dir": ".windsurf/workflows",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"junie": {
|
|
||||||
"dir": ".junie/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"kilocode": {
|
|
||||||
"dir": ".kilocode/workflows",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"auggie": {
|
|
||||||
"dir": ".augment/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"roo": {
|
|
||||||
"dir": ".roo/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"codebuddy": {
|
|
||||||
"dir": ".codebuddy/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"qodercli": {
|
|
||||||
"dir": ".qoder/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"kiro-cli": {
|
|
||||||
"dir": ".kiro/prompts",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"pi": {
|
|
||||||
"dir": ".pi/prompts",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"amp": {
|
|
||||||
"dir": ".agents/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"shai": {
|
|
||||||
"dir": ".shai/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"tabnine": {
|
|
||||||
"dir": ".tabnine/agent/commands",
|
|
||||||
"format": "toml",
|
|
||||||
"args": "{{args}}",
|
|
||||||
"extension": ".toml"
|
|
||||||
},
|
|
||||||
"bob": {
|
|
||||||
"dir": ".bob/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"kimi": {
|
|
||||||
"dir": ".kimi/skills",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": "/SKILL.md",
|
|
||||||
},
|
|
||||||
"trae": {
|
|
||||||
"dir": ".trae/rules",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"iflow": {
|
|
||||||
"dir": ".iflow/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"vibe": {
|
|
||||||
"dir": ".vibe/prompts",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"agy": {
|
|
||||||
"dir": ".agent/skills",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": "/SKILL.md",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||||
@@ -506,6 +385,7 @@ class CommandRegistrar:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If agent is not supported
|
ValueError: If agent is not supported
|
||||||
"""
|
"""
|
||||||
|
self._ensure_configs()
|
||||||
if agent_name not in self.AGENT_CONFIGS:
|
if agent_name not in self.AGENT_CONFIGS:
|
||||||
raise ValueError(f"Unsupported agent: {agent_name}")
|
raise ValueError(f"Unsupported agent: {agent_name}")
|
||||||
|
|
||||||
@@ -605,6 +485,7 @@ class CommandRegistrar:
|
|||||||
"""
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
|
self._ensure_configs()
|
||||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||||
agent_dir = project_root / agent_config["dir"]
|
agent_dir = project_root / agent_config["dir"]
|
||||||
|
|
||||||
@@ -632,6 +513,7 @@ class CommandRegistrar:
|
|||||||
registered_commands: Dict mapping agent names to command name lists
|
registered_commands: Dict mapping agent names to command name lists
|
||||||
project_root: Path to project root
|
project_root: Path to project root
|
||||||
"""
|
"""
|
||||||
|
self._ensure_configs()
|
||||||
for agent_name, cmd_names in registered_commands.items():
|
for agent_name, cmd_names in registered_commands.items():
|
||||||
if agent_name not in self.AGENT_CONFIGS:
|
if agent_name not in self.AGENT_CONFIGS:
|
||||||
continue
|
continue
|
||||||
@@ -649,3 +531,13 @@ class CommandRegistrar:
|
|||||||
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||||
if prompt_file.exists():
|
if prompt_file.exists():
|
||||||
prompt_file.unlink()
|
prompt_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# Populate AGENT_CONFIGS after class definition.
|
||||||
|
# Catches ImportError from circular imports during module loading;
|
||||||
|
# _configs_loaded stays False so the next explicit access retries.
|
||||||
|
try:
|
||||||
|
CommandRegistrar._ensure_configs()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from typing import Any
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from ...agents import CommandRegistrar
|
|
||||||
from ..base import SkillsIntegration
|
from ..base import SkillsIntegration
|
||||||
from ..manifest import IntegrationManifest
|
from ..manifest import IntegrationManifest
|
||||||
|
|
||||||
@@ -43,15 +42,18 @@ class ClaudeIntegration(SkillsIntegration):
|
|||||||
"description",
|
"description",
|
||||||
f"Spec-kit workflow command: {template_name}",
|
f"Spec-kit workflow command: {template_name}",
|
||||||
)
|
)
|
||||||
skill_frontmatter = CommandRegistrar.build_skill_frontmatter(
|
skill_frontmatter = self._build_skill_fm(
|
||||||
self.key,
|
skill_name, description, f"templates/commands/{template_name}.md"
|
||||||
skill_name,
|
|
||||||
description,
|
|
||||||
f"templates/commands/{template_name}.md",
|
|
||||||
)
|
)
|
||||||
frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip()
|
frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip()
|
||||||
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
|
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
|
||||||
|
|
||||||
|
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
|
||||||
|
from specify_cli.agents import CommandRegistrar
|
||||||
|
return CommandRegistrar.build_skill_frontmatter(
|
||||||
|
self.key, name, description, source
|
||||||
|
)
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
self,
|
self,
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
@@ -83,6 +85,7 @@ class ClaudeIntegration(SkillsIntegration):
|
|||||||
|
|
||||||
script_type = opts.get("script_type", "sh")
|
script_type = opts.get("script_type", "sh")
|
||||||
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
|
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
|
||||||
|
from specify_cli.agents import CommandRegistrar
|
||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
created: list[Path] = []
|
created: list[Path] = []
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ class TestInitIntegrationFlag:
|
|||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "--integration copilot" in result.output
|
|
||||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||||
|
|
||||||
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
|
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
|
||||||
@@ -82,9 +81,11 @@ class TestInitIntegrationFlag:
|
|||||||
|
|
||||||
project = tmp_path / "claude-here-existing"
|
project = tmp_path / "claude-here-existing"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
commands_dir = project / ".claude" / "commands"
|
commands_dir = project / ".claude" / "skills"
|
||||||
commands_dir.mkdir(parents=True)
|
commands_dir.mkdir(parents=True)
|
||||||
command_file = commands_dir / "speckit.specify.md"
|
skill_dir = commands_dir / "speckit-specify"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
command_file = skill_dir / "SKILL.md"
|
||||||
command_file.write_text("# preexisting command\n", encoding="utf-8")
|
command_file.write_text("# preexisting command\n", encoding="utf-8")
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
@@ -98,9 +99,10 @@ class TestInitIntegrationFlag:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert "--integration claude" in result.output
|
|
||||||
assert command_file.exists()
|
assert command_file.exists()
|
||||||
assert command_file.read_text(encoding="utf-8") == "# preexisting command\n"
|
# init replaces skills (not additive); verify the file has valid skill content
|
||||||
|
assert command_file.exists()
|
||||||
|
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
|
||||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
|
||||||
def test_shared_infra_skips_existing_files(self, tmp_path):
|
def test_shared_infra_skips_existing_files(self, tmp_path):
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ class TestAgyAutoPromote:
|
|||||||
"""--ai agy auto-promotes to integration path."""
|
"""--ai agy auto-promotes to integration path."""
|
||||||
|
|
||||||
def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path):
|
def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path):
|
||||||
"""--ai agy (without --ai-skills) should auto-promote to integration."""
|
"""--ai agy should work the same as --integration agy."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "agy"])
|
target = tmp_path / "test-proj"
|
||||||
|
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
||||||
|
|
||||||
assert "--integration agy" in result.output
|
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
|
||||||
|
assert (target / ".agent" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ class MarkdownIntegrationTests:
|
|||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||||
assert f"--integration {self.KEY}" in result.output
|
i = get_integration(self.KEY)
|
||||||
|
cmd_dir = i.commands_dest(project)
|
||||||
|
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||||
|
|
||||||
def test_integration_flag_creates_files(self, tmp_path):
|
def test_integration_flag_creates_files(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|||||||
@@ -261,7 +261,9 @@ class SkillsIntegrationTests:
|
|||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||||
assert f"--integration {self.KEY}" in result.output
|
i = get_integration(self.KEY)
|
||||||
|
skills_dir = i.skills_dest(project)
|
||||||
|
assert skills_dir.is_dir(), f"--ai {self.KEY} did not create skills directory"
|
||||||
|
|
||||||
def test_integration_flag_creates_files(self, tmp_path):
|
def test_integration_flag_creates_files(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|||||||
@@ -226,7 +226,9 @@ class TomlIntegrationTests:
|
|||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||||
assert f"--integration {self.KEY}" in result.output
|
i = get_integration(self.KEY)
|
||||||
|
cmd_dir = i.commands_dest(project)
|
||||||
|
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||||
|
|
||||||
def test_integration_flag_creates_files(self, tmp_path):
|
def test_integration_flag_creates_files(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|||||||
@@ -102,10 +102,6 @@ class TestClaudeIntegration:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert "--integration claude" in result.output
|
|
||||||
assert ".claude/skills" in result.output
|
|
||||||
assert "/speckit-plan" in result.output
|
|
||||||
assert "/speckit.plan" not in result.output
|
|
||||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
assert not (project / ".claude" / "commands").exists()
|
assert not (project / ".claude" / "commands").exists()
|
||||||
|
|
||||||
@@ -189,25 +185,20 @@ class TestClaudeIntegration:
|
|||||||
assert init_options["integration"] == "claude"
|
assert init_options["integration"] == "claude"
|
||||||
|
|
||||||
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
|
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
|
||||||
|
"""Claude init should succeed even without install_ai_skills."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "fail-proj"
|
target = tmp_path / "fail-proj"
|
||||||
|
|
||||||
with patch("specify_cli.ensure_executable_scripts"), \
|
result = runner.invoke(
|
||||||
patch("specify_cli.ensure_constitution_from_template"), \
|
app,
|
||||||
patch("specify_cli.install_ai_skills", return_value=False), \
|
["init", str(target), "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
|
||||||
patch("specify_cli.is_git_repo", return_value=False), \
|
)
|
||||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
|
||||||
result = runner.invoke(
|
|
||||||
app,
|
|
||||||
["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||||
assert not (target / ".claude" / "commands").exists()
|
|
||||||
|
|
||||||
def test_claude_hooks_render_skill_invocation(self, tmp_path):
|
def test_claude_hooks_render_skill_invocation(self, tmp_path):
|
||||||
from specify_cli.extensions import HookExecutor
|
from specify_cli.extensions import HookExecutor
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ class TestCodexAutoPromote:
|
|||||||
"""--ai codex auto-promotes to integration path."""
|
"""--ai codex auto-promotes to integration path."""
|
||||||
|
|
||||||
def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path):
|
def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path):
|
||||||
"""--ai codex (without --ai-skills) should auto-promote to integration."""
|
"""--ai codex should work the same as --integration codex."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "codex"])
|
target = tmp_path / "test-proj"
|
||||||
|
result = runner.invoke(app, ["init", str(target), "--ai", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||||
|
|
||||||
assert "--integration codex" in result.output
|
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
|
||||||
|
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
|||||||
@@ -36,5 +36,4 @@ class TestKiroAlias:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "--integration kiro-cli" in result.output
|
|
||||||
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
|
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Consistency checks for agent configuration across runtime and packaging scripts."""
|
"""Consistency checks for agent configuration across runtime surfaces."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -41,52 +41,6 @@ class TestAgentConfigConsistency:
|
|||||||
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
||||||
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
|
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."""
|
|
||||||
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 "kiro-cli" in sh_agents
|
|
||||||
assert "kiro-cli" in ps_agents
|
|
||||||
assert "shai" in sh_agents
|
|
||||||
assert "shai" in ps_agents
|
|
||||||
assert "agy" in sh_agents
|
|
||||||
assert "agy" in ps_agents
|
|
||||||
assert "q" not in sh_agents
|
|
||||||
assert "q" not in ps_agents
|
|
||||||
|
|
||||||
def test_release_ps_switch_has_shai_and_agy_generation(self):
|
|
||||||
"""PowerShell release builder must generate files for shai and agy agents."""
|
|
||||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert re.search(r"'shai'\s*\{.*?\.shai/commands", ps_text, re.S) is not None
|
|
||||||
assert re.search(r"'agy'\s*\{.*?\.agent/commands", ps_text, re.S) is not None
|
|
||||||
|
|
||||||
def test_release_sh_switch_has_shai_and_agy_generation(self):
|
|
||||||
"""Bash release builder must generate files for shai and agy agents."""
|
|
||||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None
|
|
||||||
assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None
|
|
||||||
|
|
||||||
def test_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):
|
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."""
|
"""CLI help text for --ai should stay in sync with agent config and alias guidance."""
|
||||||
assert "roo" in AI_ASSISTANT_HELP
|
assert "roo" in AI_ASSISTANT_HELP
|
||||||
@@ -102,22 +56,6 @@ class TestAgentConfigConsistency:
|
|||||||
assert "sha256sum -c -" in post_create_text
|
assert "sha256sum -c -" in post_create_text
|
||||||
assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text
|
assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text
|
||||||
|
|
||||||
def test_release_output_targets_kiro_prompt_dir(self):
|
|
||||||
"""Packaging and release scripts should no longer emit amazonq artifacts."""
|
|
||||||
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")
|
|
||||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert ".kiro/prompts" in sh_text
|
|
||||||
assert ".kiro/prompts" in ps_text
|
|
||||||
assert ".amazonq/prompts" not in sh_text
|
|
||||||
assert ".amazonq/prompts" not in ps_text
|
|
||||||
|
|
||||||
assert "spec-kit-template-kiro-cli-sh-" in gh_release_text
|
|
||||||
assert "spec-kit-template-kiro-cli-ps-" in gh_release_text
|
|
||||||
assert "spec-kit-template-q-sh-" not in gh_release_text
|
|
||||||
assert "spec-kit-template-q-ps-" not in gh_release_text
|
|
||||||
|
|
||||||
def test_agent_context_scripts_use_kiro_cli(self):
|
def test_agent_context_scripts_use_kiro_cli(self):
|
||||||
"""Agent context scripts should advertise kiro-cli and not legacy q agent key."""
|
"""Agent context scripts should advertise kiro-cli and not legacy q agent key."""
|
||||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||||
@@ -149,38 +87,6 @@ class TestAgentConfigConsistency:
|
|||||||
assert cfg["args"] == "{{args}}"
|
assert cfg["args"] == "{{args}}"
|
||||||
assert cfg["extension"] == ".toml"
|
assert cfg["extension"] == ".toml"
|
||||||
|
|
||||||
def test_release_agent_lists_include_tabnine(self):
|
|
||||||
"""Bash and PowerShell release scripts should include tabnine 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 "tabnine" in sh_agents
|
|
||||||
assert "tabnine" in ps_agents
|
|
||||||
|
|
||||||
def test_release_scripts_generate_tabnine_toml_commands(self):
|
|
||||||
"""Release scripts should generate TOML commands for tabnine in .tabnine/agent/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 ".tabnine/agent/commands" in sh_text
|
|
||||||
assert ".tabnine/agent/commands" in ps_text
|
|
||||||
assert re.search(r"'tabnine'\s*\{.*?\.tabnine/agent/commands", ps_text, re.S) is not None
|
|
||||||
|
|
||||||
def test_github_release_includes_tabnine_packages(self):
|
|
||||||
"""GitHub release script should include tabnine template packages."""
|
|
||||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "spec-kit-template-tabnine-sh-" in gh_release_text
|
|
||||||
assert "spec-kit-template-tabnine-ps-" in gh_release_text
|
|
||||||
|
|
||||||
def test_agent_context_scripts_include_tabnine(self):
|
def test_agent_context_scripts_include_tabnine(self):
|
||||||
"""Agent context scripts should support tabnine agent type."""
|
"""Agent context scripts should support tabnine agent type."""
|
||||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||||
@@ -213,22 +119,6 @@ class TestAgentConfigConsistency:
|
|||||||
assert kimi_cfg["dir"] == ".kimi/skills"
|
assert kimi_cfg["dir"] == ".kimi/skills"
|
||||||
assert kimi_cfg["extension"] == "/SKILL.md"
|
assert kimi_cfg["extension"] == "/SKILL.md"
|
||||||
|
|
||||||
def test_kimi_in_release_agent_lists(self):
|
|
||||||
"""Bash and PowerShell release scripts should include kimi in agent lists."""
|
|
||||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
|
||||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
|
|
||||||
assert sh_match is not None
|
|
||||||
sh_agents = sh_match.group(1).split()
|
|
||||||
|
|
||||||
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
|
|
||||||
assert ps_match is not None
|
|
||||||
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
|
|
||||||
|
|
||||||
assert "kimi" in sh_agents
|
|
||||||
assert "kimi" in ps_agents
|
|
||||||
|
|
||||||
def test_kimi_in_powershell_validate_set(self):
|
def test_kimi_in_powershell_validate_set(self):
|
||||||
"""PowerShell update-agent-context script should include 'kimi' in ValidateSet."""
|
"""PowerShell update-agent-context script should include 'kimi' in ValidateSet."""
|
||||||
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||||
@@ -239,13 +129,6 @@ class TestAgentConfigConsistency:
|
|||||||
|
|
||||||
assert "kimi" in validate_set_values
|
assert "kimi" in validate_set_values
|
||||||
|
|
||||||
def test_kimi_in_github_release_output(self):
|
|
||||||
"""GitHub release script should include kimi template packages."""
|
|
||||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "spec-kit-template-kimi-sh-" in gh_release_text
|
|
||||||
assert "spec-kit-template-kimi-ps-" in gh_release_text
|
|
||||||
|
|
||||||
def test_ai_help_includes_kimi(self):
|
def test_ai_help_includes_kimi(self):
|
||||||
"""CLI help text for --ai should include kimi."""
|
"""CLI help text for --ai should include kimi."""
|
||||||
assert "kimi" in AI_ASSISTANT_HELP
|
assert "kimi" in AI_ASSISTANT_HELP
|
||||||
@@ -270,38 +153,6 @@ class TestAgentConfigConsistency:
|
|||||||
assert trae_cfg["args"] == "$ARGUMENTS"
|
assert trae_cfg["args"] == "$ARGUMENTS"
|
||||||
assert trae_cfg["extension"] == ".md"
|
assert trae_cfg["extension"] == ".md"
|
||||||
|
|
||||||
def test_trae_in_release_agent_lists(self):
|
|
||||||
"""Bash and PowerShell release scripts should include trae 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 "trae" in sh_agents
|
|
||||||
assert "trae" in ps_agents
|
|
||||||
|
|
||||||
def test_trae_in_release_scripts_generate_commands(self):
|
|
||||||
"""Release scripts should generate markdown commands for trae in .trae/rules."""
|
|
||||||
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 ".trae/rules" in sh_text
|
|
||||||
assert ".trae/rules" in ps_text
|
|
||||||
assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None
|
|
||||||
|
|
||||||
def test_trae_in_github_release_output(self):
|
|
||||||
"""GitHub release script should include trae template packages."""
|
|
||||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "spec-kit-template-trae-sh-" in gh_release_text
|
|
||||||
assert "spec-kit-template-trae-ps-" in gh_release_text
|
|
||||||
|
|
||||||
def test_trae_in_agent_context_scripts(self):
|
def test_trae_in_agent_context_scripts(self):
|
||||||
"""Agent context scripts should support trae agent type."""
|
"""Agent context scripts should support trae agent type."""
|
||||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||||
@@ -347,32 +198,6 @@ class TestAgentConfigConsistency:
|
|||||||
assert pi_cfg["args"] == "$ARGUMENTS"
|
assert pi_cfg["args"] == "$ARGUMENTS"
|
||||||
assert pi_cfg["extension"] == ".md"
|
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):
|
def test_pi_in_powershell_validate_set(self):
|
||||||
"""PowerShell update-agent-context script should include 'pi' in ValidateSet."""
|
"""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")
|
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||||
@@ -383,13 +208,6 @@ class TestAgentConfigConsistency:
|
|||||||
|
|
||||||
assert "pi" in validate_set_values
|
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):
|
def test_agent_context_scripts_include_pi(self):
|
||||||
"""Agent context scripts should support pi agent type."""
|
"""Agent context scripts should support pi agent type."""
|
||||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||||
@@ -422,38 +240,6 @@ class TestAgentConfigConsistency:
|
|||||||
assert cfg["iflow"]["format"] == "markdown"
|
assert cfg["iflow"]["format"] == "markdown"
|
||||||
assert cfg["iflow"]["args"] == "$ARGUMENTS"
|
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):
|
def test_iflow_in_agent_context_scripts(self):
|
||||||
"""Agent context scripts should support iflow agent type."""
|
"""Agent context scripts should support iflow agent type."""
|
||||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -1,994 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for AI agent skills installation.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Skills directory resolution for different agents (_get_skills_dir)
|
|
||||||
- YAML frontmatter parsing and SKILL.md generation (install_ai_skills)
|
|
||||||
- Cleanup of duplicate command files when --ai-skills is used
|
|
||||||
- Missing templates directory handling
|
|
||||||
- Malformed template error handling
|
|
||||||
- CLI validation: --ai-skills requires --ai
|
|
||||||
"""
|
|
||||||
|
|
||||||
import zipfile
|
|
||||||
import pytest
|
|
||||||
import tempfile
|
|
||||||
import shutil
|
|
||||||
import yaml
|
|
||||||
import typer
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import specify_cli
|
|
||||||
from tests.conftest import strip_ansi
|
|
||||||
|
|
||||||
from specify_cli import (
|
|
||||||
_get_skills_dir,
|
|
||||||
_migrate_legacy_kimi_dotted_skills,
|
|
||||||
install_ai_skills,
|
|
||||||
DEFAULT_SKILLS_DIR,
|
|
||||||
SKILL_DESCRIPTIONS,
|
|
||||||
AGENT_CONFIG,
|
|
||||||
app,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Fixtures =====
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_dir():
|
|
||||||
"""Create a temporary directory for tests."""
|
|
||||||
tmpdir = tempfile.mkdtemp()
|
|
||||||
yield Path(tmpdir)
|
|
||||||
shutil.rmtree(tmpdir)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def project_dir(temp_dir):
|
|
||||||
"""Create a mock project directory."""
|
|
||||||
proj_dir = temp_dir / "test-project"
|
|
||||||
proj_dir.mkdir()
|
|
||||||
return proj_dir
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def templates_dir(project_dir):
|
|
||||||
"""Create mock command templates in the project's agent commands directory.
|
|
||||||
|
|
||||||
This simulates what download_and_extract_template() does: it places
|
|
||||||
command .md files into project_path/<agent_folder>/commands/.
|
|
||||||
install_ai_skills() now reads from here instead of from the repo
|
|
||||||
source tree.
|
|
||||||
"""
|
|
||||||
tpl_root = project_dir / ".claude" / "commands"
|
|
||||||
tpl_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Template with valid YAML frontmatter
|
|
||||||
(tpl_root / "speckit.specify.md").write_text(
|
|
||||||
"---\n"
|
|
||||||
"description: Create or update the feature specification.\n"
|
|
||||||
"handoffs:\n"
|
|
||||||
" - label: Build Plan\n"
|
|
||||||
" agent: speckit.plan\n"
|
|
||||||
"scripts:\n"
|
|
||||||
" sh: scripts/bash/create-new-feature.sh\n"
|
|
||||||
"---\n"
|
|
||||||
"\n"
|
|
||||||
"# Specify Command\n"
|
|
||||||
"\n"
|
|
||||||
"Run this to create a spec.\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Template with minimal frontmatter
|
|
||||||
(tpl_root / "speckit.plan.md").write_text(
|
|
||||||
"---\n"
|
|
||||||
"description: Generate implementation plan.\n"
|
|
||||||
"---\n"
|
|
||||||
"\n"
|
|
||||||
"# Plan Command\n"
|
|
||||||
"\n"
|
|
||||||
"Plan body content.\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Template with no frontmatter
|
|
||||||
(tpl_root / "speckit.tasks.md").write_text(
|
|
||||||
"# Tasks Command\n"
|
|
||||||
"\n"
|
|
||||||
"Body without frontmatter.\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Template with empty YAML frontmatter (yaml.safe_load returns None)
|
|
||||||
(tpl_root / "speckit.empty_fm.md").write_text(
|
|
||||||
"---\n"
|
|
||||||
"---\n"
|
|
||||||
"\n"
|
|
||||||
"# Empty Frontmatter Command\n"
|
|
||||||
"\n"
|
|
||||||
"Body with empty frontmatter.\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
return tpl_root
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def commands_dir_claude(project_dir):
|
|
||||||
"""Create a populated .claude/commands directory simulating template extraction."""
|
|
||||||
cmd_dir = project_dir / ".claude" / "commands"
|
|
||||||
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]:
|
|
||||||
(cmd_dir / name).write_text(f"# {name}\nContent here\n")
|
|
||||||
return cmd_dir
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def commands_dir_gemini(project_dir):
|
|
||||||
"""Create a populated .gemini/commands directory (TOML format)."""
|
|
||||||
cmd_dir = project_dir / ".gemini" / "commands"
|
|
||||||
cmd_dir.mkdir(parents=True)
|
|
||||||
for name in ["speckit.specify.toml", "speckit.plan.toml", "speckit.tasks.toml"]:
|
|
||||||
(cmd_dir / name).write_text(f'[command]\nname = "{name}"\n')
|
|
||||||
return cmd_dir
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def commands_dir_qwen(project_dir):
|
|
||||||
"""Create a populated .qwen/commands directory (Markdown format)."""
|
|
||||||
cmd_dir = project_dir / ".qwen" / "commands"
|
|
||||||
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]:
|
|
||||||
(cmd_dir / name).write_text(f"# {name}\nContent here\n")
|
|
||||||
return cmd_dir
|
|
||||||
|
|
||||||
|
|
||||||
# ===== _get_skills_dir Tests =====
|
|
||||||
|
|
||||||
class TestGetSkillsDir:
|
|
||||||
"""Test the _get_skills_dir() helper function."""
|
|
||||||
|
|
||||||
def test_claude_skills_dir(self, project_dir):
|
|
||||||
"""Claude should use .claude/skills/."""
|
|
||||||
result = _get_skills_dir(project_dir, "claude")
|
|
||||||
assert result == project_dir / ".claude" / "skills"
|
|
||||||
|
|
||||||
def test_gemini_skills_dir(self, project_dir):
|
|
||||||
"""Gemini should use .gemini/skills/."""
|
|
||||||
result = _get_skills_dir(project_dir, "gemini")
|
|
||||||
assert result == project_dir / ".gemini" / "skills"
|
|
||||||
|
|
||||||
def test_tabnine_skills_dir(self, project_dir):
|
|
||||||
"""Tabnine should use .tabnine/agent/skills/."""
|
|
||||||
result = _get_skills_dir(project_dir, "tabnine")
|
|
||||||
assert result == project_dir / ".tabnine" / "agent" / "skills"
|
|
||||||
|
|
||||||
def test_copilot_skills_dir(self, project_dir):
|
|
||||||
"""Copilot should use .github/skills/."""
|
|
||||||
result = _get_skills_dir(project_dir, "copilot")
|
|
||||||
assert result == project_dir / ".github" / "skills"
|
|
||||||
|
|
||||||
def test_codex_skills_dir_from_agent_config(self, project_dir):
|
|
||||||
"""Codex should resolve skills directory from AGENT_CONFIG folder."""
|
|
||||||
result = _get_skills_dir(project_dir, "codex")
|
|
||||||
assert result == project_dir / ".agents" / "skills"
|
|
||||||
|
|
||||||
def test_cursor_agent_skills_dir(self, project_dir):
|
|
||||||
"""Cursor should use .cursor/skills/."""
|
|
||||||
result = _get_skills_dir(project_dir, "cursor-agent")
|
|
||||||
assert result == project_dir / ".cursor" / "skills"
|
|
||||||
|
|
||||||
def test_kiro_cli_skills_dir(self, project_dir):
|
|
||||||
"""Kiro CLI should use .kiro/skills/."""
|
|
||||||
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")
|
|
||||||
assert result == project_dir / DEFAULT_SKILLS_DIR
|
|
||||||
|
|
||||||
def test_all_configured_agents_resolve(self, project_dir):
|
|
||||||
"""Every agent in AGENT_CONFIG should resolve to a valid path."""
|
|
||||||
for agent_key in AGENT_CONFIG:
|
|
||||||
result = _get_skills_dir(project_dir, agent_key)
|
|
||||||
assert result is not None
|
|
||||||
assert str(result).startswith(str(project_dir))
|
|
||||||
# Should always end with "skills"
|
|
||||||
assert result.name == "skills"
|
|
||||||
|
|
||||||
class TestKimiLegacySkillMigration:
|
|
||||||
"""Test temporary migration from Kimi dotted skill names to hyphenated names."""
|
|
||||||
|
|
||||||
def test_migrates_legacy_dotted_skill_directory(self, project_dir):
|
|
||||||
skills_dir = project_dir / ".kimi" / "skills"
|
|
||||||
legacy_dir = skills_dir / "speckit.plan"
|
|
||||||
legacy_dir.mkdir(parents=True)
|
|
||||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
|
||||||
|
|
||||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
|
||||||
|
|
||||||
assert migrated == 1
|
|
||||||
assert removed == 0
|
|
||||||
assert not legacy_dir.exists()
|
|
||||||
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
|
|
||||||
|
|
||||||
def test_removes_legacy_dir_when_hyphenated_target_exists_with_same_content(self, project_dir):
|
|
||||||
skills_dir = project_dir / ".kimi" / "skills"
|
|
||||||
legacy_dir = skills_dir / "speckit.plan"
|
|
||||||
legacy_dir.mkdir(parents=True)
|
|
||||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
|
||||||
target_dir = skills_dir / "speckit-plan"
|
|
||||||
target_dir.mkdir(parents=True)
|
|
||||||
(target_dir / "SKILL.md").write_text("legacy")
|
|
||||||
|
|
||||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
|
||||||
|
|
||||||
assert migrated == 0
|
|
||||||
assert removed == 1
|
|
||||||
assert not legacy_dir.exists()
|
|
||||||
assert (target_dir / "SKILL.md").read_text() == "legacy"
|
|
||||||
|
|
||||||
def test_keeps_legacy_dir_when_hyphenated_target_differs(self, project_dir):
|
|
||||||
skills_dir = project_dir / ".kimi" / "skills"
|
|
||||||
legacy_dir = skills_dir / "speckit.plan"
|
|
||||||
legacy_dir.mkdir(parents=True)
|
|
||||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
|
||||||
target_dir = skills_dir / "speckit-plan"
|
|
||||||
target_dir.mkdir(parents=True)
|
|
||||||
(target_dir / "SKILL.md").write_text("new")
|
|
||||||
|
|
||||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
|
||||||
|
|
||||||
assert migrated == 0
|
|
||||||
assert removed == 0
|
|
||||||
assert legacy_dir.exists()
|
|
||||||
assert (legacy_dir / "SKILL.md").read_text() == "legacy"
|
|
||||||
assert (target_dir / "SKILL.md").read_text() == "new"
|
|
||||||
|
|
||||||
def test_keeps_legacy_dir_when_matching_target_but_extra_files_exist(self, project_dir):
|
|
||||||
skills_dir = project_dir / ".kimi" / "skills"
|
|
||||||
legacy_dir = skills_dir / "speckit.plan"
|
|
||||||
legacy_dir.mkdir(parents=True)
|
|
||||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
|
||||||
(legacy_dir / "notes.txt").write_text("custom")
|
|
||||||
target_dir = skills_dir / "speckit-plan"
|
|
||||||
target_dir.mkdir(parents=True)
|
|
||||||
(target_dir / "SKILL.md").write_text("legacy")
|
|
||||||
|
|
||||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
|
||||||
|
|
||||||
assert migrated == 0
|
|
||||||
assert removed == 0
|
|
||||||
assert legacy_dir.exists()
|
|
||||||
assert (legacy_dir / "notes.txt").read_text() == "custom"
|
|
||||||
|
|
||||||
|
|
||||||
# ===== install_ai_skills Tests =====
|
|
||||||
|
|
||||||
class TestInstallAiSkills:
|
|
||||||
"""Test SKILL.md generation and installation logic."""
|
|
||||||
|
|
||||||
def test_skills_installed_with_correct_structure(self, project_dir, templates_dir):
|
|
||||||
"""Verify SKILL.md files have correct agentskills.io structure."""
|
|
||||||
result = install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
|
||||||
assert skills_dir.exists()
|
|
||||||
|
|
||||||
# Check that skill directories were created
|
|
||||||
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
|
|
||||||
assert "speckit-plan" in skill_dirs
|
|
||||||
assert "speckit-specify" in skill_dirs
|
|
||||||
assert "speckit-tasks" in skill_dirs
|
|
||||||
assert "speckit-empty_fm" in skill_dirs
|
|
||||||
|
|
||||||
# Verify SKILL.md content for speckit-specify
|
|
||||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
||||||
assert skill_file.exists()
|
|
||||||
content = skill_file.read_text()
|
|
||||||
|
|
||||||
# Check agentskills.io frontmatter
|
|
||||||
assert content.startswith("---\n")
|
|
||||||
assert "name: speckit-specify" in content
|
|
||||||
assert "description:" in content
|
|
||||||
assert "compatibility:" in content
|
|
||||||
assert "metadata:" in content
|
|
||||||
assert "author: github-spec-kit" in content
|
|
||||||
assert "source: templates/commands/specify.md" in content
|
|
||||||
|
|
||||||
# Check body content is included
|
|
||||||
assert "# Speckit Specify Skill" in content
|
|
||||||
assert "Run this to create a spec." in content
|
|
||||||
|
|
||||||
def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir):
|
|
||||||
"""Generated SKILL.md should contain valid, parseable YAML frontmatter."""
|
|
||||||
install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
|
|
||||||
content = skill_file.read_text()
|
|
||||||
|
|
||||||
# Extract and parse frontmatter
|
|
||||||
assert content.startswith("---\n")
|
|
||||||
parts = content.split("---", 2)
|
|
||||||
assert len(parts) >= 3
|
|
||||||
parsed = yaml.safe_load(parts[1])
|
|
||||||
assert isinstance(parsed, dict)
|
|
||||||
assert "name" in parsed
|
|
||||||
assert parsed["name"] == "speckit-specify"
|
|
||||||
assert "description" in parsed
|
|
||||||
|
|
||||||
def test_empty_yaml_frontmatter(self, project_dir, templates_dir):
|
|
||||||
"""Templates with empty YAML frontmatter (---\\n---) should not crash."""
|
|
||||||
result = install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
skill_file = project_dir / ".claude" / "skills" / "speckit-empty_fm" / "SKILL.md"
|
|
||||||
assert skill_file.exists()
|
|
||||||
content = skill_file.read_text()
|
|
||||||
assert "name: speckit-empty_fm" in content
|
|
||||||
assert "Body with empty frontmatter." in content
|
|
||||||
|
|
||||||
def test_enhanced_descriptions_used_when_available(self, project_dir, templates_dir):
|
|
||||||
"""SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions."""
|
|
||||||
install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
|
|
||||||
content = skill_file.read_text()
|
|
||||||
|
|
||||||
# Parse the generated YAML to compare the description value
|
|
||||||
# (yaml.safe_dump may wrap long strings across multiple lines)
|
|
||||||
parts = content.split("---", 2)
|
|
||||||
parsed = yaml.safe_load(parts[1])
|
|
||||||
|
|
||||||
if "specify" in SKILL_DESCRIPTIONS:
|
|
||||||
assert parsed["description"] == SKILL_DESCRIPTIONS["specify"]
|
|
||||||
|
|
||||||
def test_template_without_frontmatter(self, project_dir, templates_dir):
|
|
||||||
"""Templates without YAML frontmatter should still produce valid skills."""
|
|
||||||
install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md"
|
|
||||||
assert skill_file.exists()
|
|
||||||
content = skill_file.read_text()
|
|
||||||
|
|
||||||
# Should still have valid SKILL.md structure
|
|
||||||
assert "name: speckit-tasks" in content
|
|
||||||
assert "Body without frontmatter." in content
|
|
||||||
|
|
||||||
def test_missing_templates_directory(self, project_dir):
|
|
||||||
"""Returns False when no command templates exist anywhere."""
|
|
||||||
# No .claude/commands/ exists, and __file__ fallback won't find anything
|
|
||||||
fake_init = project_dir / "nonexistent" / "src" / "specify_cli" / "__init__.py"
|
|
||||||
fake_init.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fake_init.touch()
|
|
||||||
|
|
||||||
with patch.object(specify_cli, "__file__", str(fake_init)):
|
|
||||||
result = install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
# Skills directory should not exist
|
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
|
||||||
assert not skills_dir.exists()
|
|
||||||
|
|
||||||
def test_empty_templates_directory(self, project_dir):
|
|
||||||
"""Returns False when commands directory has no .md files."""
|
|
||||||
# Create empty .claude/commands/
|
|
||||||
empty_cmds = project_dir / ".claude" / "commands"
|
|
||||||
empty_cmds.mkdir(parents=True)
|
|
||||||
|
|
||||||
# Block the __file__ fallback so it can't find real templates
|
|
||||||
fake_init = project_dir / "nowhere" / "src" / "specify_cli" / "__init__.py"
|
|
||||||
fake_init.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fake_init.touch()
|
|
||||||
|
|
||||||
with patch.object(specify_cli, "__file__", str(fake_init)):
|
|
||||||
result = install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_malformed_yaml_frontmatter(self, project_dir):
|
|
||||||
"""Malformed YAML in a template should be handled gracefully, not crash."""
|
|
||||||
# Create .claude/commands/ with a broken template
|
|
||||||
cmds_dir = project_dir / ".claude" / "commands"
|
|
||||||
cmds_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
(cmds_dir / "speckit.broken.md").write_text(
|
|
||||||
"---\n"
|
|
||||||
"description: [unclosed bracket\n"
|
|
||||||
" invalid: yaml: content: here\n"
|
|
||||||
"---\n"
|
|
||||||
"\n"
|
|
||||||
"# Broken\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should not raise — errors are caught per-file
|
|
||||||
result = install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
# The broken template should be skipped but not crash the process
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_additive_does_not_overwrite_other_files(self, project_dir, templates_dir):
|
|
||||||
"""Installing skills should not remove non-speckit files in the skills dir."""
|
|
||||||
# Pre-create a custom skill
|
|
||||||
custom_dir = project_dir / ".claude" / "skills" / "my-custom-skill"
|
|
||||||
custom_dir.mkdir(parents=True)
|
|
||||||
custom_file = custom_dir / "SKILL.md"
|
|
||||||
custom_file.write_text("# My Custom Skill\n")
|
|
||||||
|
|
||||||
install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
# Custom skill should still exist
|
|
||||||
assert custom_file.exists()
|
|
||||||
assert custom_file.read_text() == "# My Custom Skill\n"
|
|
||||||
|
|
||||||
def test_return_value(self, project_dir, templates_dir):
|
|
||||||
"""install_ai_skills returns True when skills installed, False otherwise."""
|
|
||||||
assert install_ai_skills(project_dir, "claude") is True
|
|
||||||
|
|
||||||
def test_return_false_when_no_templates(self, project_dir):
|
|
||||||
"""install_ai_skills returns False when no templates found."""
|
|
||||||
fake_init = project_dir / "missing" / "src" / "specify_cli" / "__init__.py"
|
|
||||||
fake_init.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fake_init.touch()
|
|
||||||
|
|
||||||
with patch.object(specify_cli, "__file__", str(fake_init)):
|
|
||||||
assert install_ai_skills(project_dir, "claude") is False
|
|
||||||
|
|
||||||
def test_non_md_commands_dir_falls_back(self, project_dir):
|
|
||||||
"""When extracted commands are .toml (e.g. gemini), fall back to repo templates."""
|
|
||||||
# Simulate gemini template extraction: .gemini/commands/ with .toml files only
|
|
||||||
cmds_dir = project_dir / ".gemini" / "commands"
|
|
||||||
cmds_dir.mkdir(parents=True)
|
|
||||||
(cmds_dir / "speckit.specify.toml").write_text('[command]\nname = "specify"\n')
|
|
||||||
(cmds_dir / "speckit.plan.toml").write_text('[command]\nname = "plan"\n')
|
|
||||||
|
|
||||||
# The __file__ fallback should find the real repo templates/commands/*.md
|
|
||||||
result = install_ai_skills(project_dir, "gemini")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
skills_dir = project_dir / ".gemini" / "skills"
|
|
||||||
assert skills_dir.exists()
|
|
||||||
# Should have installed skills from the fallback .md templates
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
assert len(skill_dirs) >= 1
|
|
||||||
# .toml commands should be untouched
|
|
||||||
assert (cmds_dir / "speckit.specify.toml").exists()
|
|
||||||
|
|
||||||
def test_qwen_md_commands_dir_installs_skills(self, project_dir):
|
|
||||||
"""Qwen now uses Markdown format; skills should install directly from .qwen/commands/."""
|
|
||||||
cmds_dir = project_dir / ".qwen" / "commands"
|
|
||||||
cmds_dir.mkdir(parents=True)
|
|
||||||
(cmds_dir / "speckit.specify.md").write_text(
|
|
||||||
"---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n"
|
|
||||||
)
|
|
||||||
(cmds_dir / "speckit.plan.md").write_text(
|
|
||||||
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(project_dir, "qwen")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
skills_dir = project_dir / ".qwen" / "skills"
|
|
||||||
assert skills_dir.exists()
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
assert len(skill_dirs) >= 1
|
|
||||||
# .md commands should be untouched
|
|
||||||
assert (cmds_dir / "speckit.specify.md").exists()
|
|
||||||
assert (cmds_dir / "speckit.plan.md").exists()
|
|
||||||
|
|
||||||
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."""
|
|
||||||
proj = temp_dir / f"proj-{agent_key}"
|
|
||||||
proj.mkdir()
|
|
||||||
|
|
||||||
# Place .md templates in the agent's commands directory
|
|
||||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
|
||||||
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
|
||||||
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
|
||||||
cmds_dir.mkdir(parents=True)
|
|
||||||
# Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md
|
|
||||||
fname = "speckit.specify.agent.md" if agent_key == "copilot" else "speckit.specify.md"
|
|
||||||
(cmds_dir / fname).write_text(
|
|
||||||
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(proj, agent_key)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
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()]
|
|
||||||
expected_skill_name = "speckit-specify"
|
|
||||||
assert expected_skill_name in skill_dirs
|
|
||||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
|
||||||
|
|
||||||
def test_copilot_ignores_non_speckit_agents(self, project_dir):
|
|
||||||
"""Non-speckit markdown in .github/agents/ must not produce skills."""
|
|
||||||
agents_dir = project_dir / ".github" / "agents"
|
|
||||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(agents_dir / "speckit.plan.agent.md").write_text(
|
|
||||||
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
|
|
||||||
)
|
|
||||||
(agents_dir / "my-custom-agent.agent.md").write_text(
|
|
||||||
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(project_dir, "copilot")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
skills_dir = _get_skills_dir(project_dir, "copilot")
|
|
||||||
assert skills_dir.exists()
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
assert "speckit-plan" in skill_dirs
|
|
||||||
assert "speckit-my-custom-agent.agent" not in skill_dirs
|
|
||||||
assert "speckit-my-custom-agent" not in skill_dirs
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent_key,custom_file", [
|
|
||||||
("claude", "review.md"),
|
|
||||||
("cursor-agent", "deploy.md"),
|
|
||||||
("qwen", "my-workflow.md"),
|
|
||||||
])
|
|
||||||
def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file):
|
|
||||||
"""User-authored command files must not produce skills for any agent."""
|
|
||||||
proj = temp_dir / f"proj-{agent_key}"
|
|
||||||
proj.mkdir()
|
|
||||||
|
|
||||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
|
||||||
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
|
||||||
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
|
||||||
cmds_dir.mkdir(parents=True)
|
|
||||||
(cmds_dir / "speckit.specify.md").write_text(
|
|
||||||
"---\ndescription: Create spec.\n---\n\n# Specify\n\nBody.\n"
|
|
||||||
)
|
|
||||||
(cmds_dir / custom_file).write_text(
|
|
||||||
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(proj, agent_key)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
skills_dir = _get_skills_dir(proj, agent_key)
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
assert "speckit-specify" in skill_dirs
|
|
||||||
custom_stem = Path(custom_file).stem
|
|
||||||
assert f"speckit-{custom_stem}" not in skill_dirs
|
|
||||||
|
|
||||||
def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir):
|
|
||||||
"""Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files."""
|
|
||||||
agents_dir = project_dir / ".github" / "agents"
|
|
||||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
# Only a user-authored agent, no speckit.* templates
|
|
||||||
(agents_dir / "my-custom-agent.agent.md").write_text(
|
|
||||||
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(project_dir, "copilot")
|
|
||||||
|
|
||||||
# Should succeed via fallback to templates/commands/
|
|
||||||
assert result is True
|
|
||||||
skills_dir = _get_skills_dir(project_dir, "copilot")
|
|
||||||
assert skills_dir.exists()
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
# Should have skills from fallback templates, not from the custom agent
|
|
||||||
assert "speckit-plan" in skill_dirs
|
|
||||||
assert not any("my-custom" in d for d in skill_dirs)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent_key", ["claude", "cursor-agent", "qwen"])
|
|
||||||
def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key):
|
|
||||||
"""Fallback to templates/commands/ when agent dir has no speckit.*.md files."""
|
|
||||||
proj = temp_dir / f"proj-{agent_key}"
|
|
||||||
proj.mkdir()
|
|
||||||
|
|
||||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
|
||||||
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
|
||||||
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
|
||||||
cmds_dir.mkdir(parents=True)
|
|
||||||
# Only a user-authored command, no speckit.* templates
|
|
||||||
(cmds_dir / "my-custom-command.md").write_text(
|
|
||||||
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(proj, agent_key)
|
|
||||||
|
|
||||||
# Should succeed via fallback to templates/commands/
|
|
||||||
assert result is True
|
|
||||||
skills_dir = _get_skills_dir(proj, agent_key)
|
|
||||||
assert skills_dir.exists()
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
assert not any("my-custom" in d for d in skill_dirs)
|
|
||||||
|
|
||||||
class TestCommandCoexistence:
|
|
||||||
"""Verify install_ai_skills never touches command files.
|
|
||||||
|
|
||||||
Cleanup of freshly-extracted commands for NEW projects is handled
|
|
||||||
in init(), not in install_ai_skills(). These tests confirm that
|
|
||||||
install_ai_skills leaves existing commands intact.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
|
|
||||||
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
|
|
||||||
# Verify commands exist before (templates_dir adds 4 speckit.* files,
|
|
||||||
# commands_dir_claude overlaps with 3 of them)
|
|
||||||
before = list(commands_dir_claude.glob("speckit.*"))
|
|
||||||
assert len(before) >= 3
|
|
||||||
|
|
||||||
install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
# Commands must still be there — install_ai_skills never touches them
|
|
||||||
remaining = list(commands_dir_claude.glob("speckit.*"))
|
|
||||||
assert len(remaining) == len(before)
|
|
||||||
|
|
||||||
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
|
|
||||||
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""
|
|
||||||
assert len(list(commands_dir_gemini.glob("speckit.*"))) == 3
|
|
||||||
|
|
||||||
install_ai_skills(project_dir, "gemini")
|
|
||||||
|
|
||||||
remaining = list(commands_dir_gemini.glob("speckit.*"))
|
|
||||||
assert len(remaining) == 3
|
|
||||||
|
|
||||||
def test_existing_commands_preserved_qwen(self, project_dir, templates_dir, commands_dir_qwen):
|
|
||||||
"""install_ai_skills must NOT remove pre-existing .qwen/commands files."""
|
|
||||||
assert len(list(commands_dir_qwen.glob("speckit.*"))) == 3
|
|
||||||
|
|
||||||
install_ai_skills(project_dir, "qwen")
|
|
||||||
|
|
||||||
remaining = list(commands_dir_qwen.glob("speckit.*"))
|
|
||||||
assert len(remaining) == 3
|
|
||||||
|
|
||||||
def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude):
|
|
||||||
"""install_ai_skills must not remove the commands directory."""
|
|
||||||
install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
assert commands_dir_claude.exists()
|
|
||||||
|
|
||||||
def test_no_commands_dir_no_error(self, project_dir, templates_dir):
|
|
||||||
"""No error when installing skills — commands dir has templates and is preserved."""
|
|
||||||
result = install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
# Should succeed since templates are in .claude/commands/ via fixture
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Legacy Download Path Tests =====
|
|
||||||
|
|
||||||
class TestLegacyDownloadPath:
|
|
||||||
"""Tests for download_and_extract_template() called directly.
|
|
||||||
|
|
||||||
These test the legacy download/extract code that still exists in
|
|
||||||
__init__.py. They do NOT go through CLI auto-promote.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path):
|
|
||||||
"""Fresh-directory Codex skills init should not leave legacy .codex from archive."""
|
|
||||||
target = tmp_path / "fresh-codex-proj"
|
|
||||||
archive = tmp_path / "codex-template.zip"
|
|
||||||
|
|
||||||
with zipfile.ZipFile(archive, "w") as zf:
|
|
||||||
zf.writestr("template-root/.codex/prompts/speckit.specify.md", "legacy")
|
|
||||||
zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution")
|
|
||||||
|
|
||||||
fake_meta = {
|
|
||||||
"filename": archive.name,
|
|
||||||
"size": archive.stat().st_size,
|
|
||||||
"release": "vtest",
|
|
||||||
"asset_url": "https://example.invalid/template.zip",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)):
|
|
||||||
specify_cli.download_and_extract_template(
|
|
||||||
target,
|
|
||||||
"codex",
|
|
||||||
"sh",
|
|
||||||
is_current_dir=False,
|
|
||||||
skip_legacy_codex_prompts=True,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert target.exists()
|
|
||||||
assert (target / ".specify").exists()
|
|
||||||
assert not (target / ".codex").exists()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("is_current_dir", [False, True])
|
|
||||||
def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path, monkeypatch, is_current_dir):
|
|
||||||
"""Extraction should reject ZIP members escaping the target directory."""
|
|
||||||
target = (tmp_path / "here-proj") if is_current_dir else (tmp_path / "new-proj")
|
|
||||||
if is_current_dir:
|
|
||||||
target.mkdir()
|
|
||||||
monkeypatch.chdir(target)
|
|
||||||
|
|
||||||
archive = tmp_path / "malicious-template.zip"
|
|
||||||
with zipfile.ZipFile(archive, "w") as zf:
|
|
||||||
zf.writestr("../evil.txt", "pwned")
|
|
||||||
zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution")
|
|
||||||
|
|
||||||
fake_meta = {
|
|
||||||
"filename": archive.name,
|
|
||||||
"size": archive.stat().st_size,
|
|
||||||
"release": "vtest",
|
|
||||||
"asset_url": "https://example.invalid/template.zip",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)):
|
|
||||||
with pytest.raises(typer.Exit):
|
|
||||||
specify_cli.download_and_extract_template(
|
|
||||||
target,
|
|
||||||
"codex",
|
|
||||||
"sh",
|
|
||||||
is_current_dir=is_current_dir,
|
|
||||||
skip_legacy_codex_prompts=True,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not (tmp_path / "evil.txt").exists()
|
|
||||||
|
|
||||||
# ===== Skip-If-Exists Tests =====
|
|
||||||
|
|
||||||
class TestSkipIfExists:
|
|
||||||
"""Test that install_ai_skills does not overwrite existing SKILL.md files."""
|
|
||||||
|
|
||||||
def test_existing_skill_not_overwritten(self, project_dir, templates_dir):
|
|
||||||
"""Pre-existing SKILL.md should not be replaced on re-run."""
|
|
||||||
# Pre-create a custom SKILL.md for speckit-specify
|
|
||||||
skill_dir = project_dir / ".claude" / "skills" / "speckit-specify"
|
|
||||||
skill_dir.mkdir(parents=True)
|
|
||||||
custom_content = "# My Custom Specify Skill\nUser-modified content\n"
|
|
||||||
(skill_dir / "SKILL.md").write_text(custom_content)
|
|
||||||
|
|
||||||
result = install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
# The custom SKILL.md should be untouched
|
|
||||||
assert (skill_dir / "SKILL.md").read_text() == custom_content
|
|
||||||
|
|
||||||
# But other skills should still be installed
|
|
||||||
assert result is True
|
|
||||||
assert (project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
||||||
assert (project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md").exists()
|
|
||||||
|
|
||||||
def test_fresh_install_writes_all_skills(self, project_dir, templates_dir):
|
|
||||||
"""On first install (no pre-existing skills), all should be written."""
|
|
||||||
result = install_ai_skills(project_dir, "claude")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
# All 4 templates should produce skills (specify, plan, tasks, empty_fm)
|
|
||||||
assert len(skill_dirs) == 4
|
|
||||||
|
|
||||||
def test_existing_skill_overwritten_when_enabled(self, project_dir, templates_dir):
|
|
||||||
"""When overwrite_existing=True, pre-existing SKILL.md should be replaced."""
|
|
||||||
skill_dir = project_dir / ".claude" / "skills" / "speckit-specify"
|
|
||||||
skill_dir.mkdir(parents=True)
|
|
||||||
custom_content = "# My Custom Specify Skill\nUser-modified content\n"
|
|
||||||
skill_file = skill_dir / "SKILL.md"
|
|
||||||
skill_file.write_text(custom_content)
|
|
||||||
|
|
||||||
result = install_ai_skills(project_dir, "claude", overwrite_existing=True)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
updated_content = skill_file.read_text()
|
|
||||||
assert updated_content != custom_content
|
|
||||||
assert "name: speckit-specify" in updated_content
|
|
||||||
|
|
||||||
|
|
||||||
# ===== SKILL_DESCRIPTIONS Coverage Tests =====
|
|
||||||
|
|
||||||
class TestSkillDescriptions:
|
|
||||||
"""Test SKILL_DESCRIPTIONS constants."""
|
|
||||||
|
|
||||||
def test_all_known_commands_have_descriptions(self):
|
|
||||||
"""All standard spec-kit commands should have enhanced descriptions."""
|
|
||||||
expected_commands = [
|
|
||||||
"specify", "plan", "tasks", "implement", "analyze",
|
|
||||||
"clarify", "constitution", "checklist", "taskstoissues",
|
|
||||||
]
|
|
||||||
for cmd in expected_commands:
|
|
||||||
assert cmd in SKILL_DESCRIPTIONS, f"Missing description for '{cmd}'"
|
|
||||||
assert len(SKILL_DESCRIPTIONS[cmd]) > 20, f"Description for '{cmd}' is too short"
|
|
||||||
|
|
||||||
|
|
||||||
# ===== CLI Validation Tests =====
|
|
||||||
|
|
||||||
class TestCliValidation:
|
|
||||||
"""Test --ai-skills CLI flag validation."""
|
|
||||||
|
|
||||||
def test_ai_skills_without_ai_fails(self, tmp_path):
|
|
||||||
"""--ai-skills without --ai should fail with exit code 1."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"])
|
|
||||||
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "--ai-skills requires --ai" in result.output
|
|
||||||
|
|
||||||
def test_ai_skills_without_ai_shows_usage(self, tmp_path):
|
|
||||||
"""Error message should include usage hint."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"])
|
|
||||||
|
|
||||||
assert "Usage:" in result.output
|
|
||||||
assert "--ai" in result.output
|
|
||||||
|
|
||||||
def test_interactive_agy_without_ai_skills_uses_integration(self, tmp_path, monkeypatch):
|
|
||||||
"""Interactive selector returning agy should auto-promote to integration path."""
|
|
||||||
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 "agy" in options:
|
|
||||||
return "agy"
|
|
||||||
if isinstance(options, (list, tuple)) and "agy" in options:
|
|
||||||
return "agy"
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
target = tmp_path / "test-agy-interactive"
|
|
||||||
result = runner.invoke(app, ["init", str(target), "--no-git"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
# Should NOT raise the old deprecation error
|
|
||||||
assert "Explicit command support was deprecated" not in result.output
|
|
||||||
# Should use integration path (same as --ai agy)
|
|
||||||
assert "agy" in result.output
|
|
||||||
|
|
||||||
def test_interactive_codex_without_ai_skills_uses_integration(self, tmp_path, monkeypatch):
|
|
||||||
"""Interactive selector returning codex should auto-promote to integration path."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
target = tmp_path / "test-codex-interactive"
|
|
||||||
result = runner.invoke(app, ["init", str(target), "--no-git", "--ignore-agent-tools"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
# Should NOT raise the old deprecation error
|
|
||||||
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
|
|
||||||
# Skills should be installed via integration path
|
|
||||||
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_ai_skills_flag_appears_in_help(self):
|
|
||||||
"""--ai-skills should appear in init --help output."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(app, ["init", "--help"])
|
|
||||||
|
|
||||||
plain = strip_ansi(result.output)
|
|
||||||
assert "--ai-skills" in plain
|
|
||||||
assert "skills" in plain.lower()
|
|
||||||
|
|
||||||
def test_q_removed_from_agent_config(self):
|
|
||||||
"""Amazon Q legacy key should not remain in AGENT_CONFIG."""
|
|
||||||
assert "q" not in AGENT_CONFIG
|
|
||||||
assert "kiro-cli" in AGENT_CONFIG
|
|
||||||
|
|
||||||
|
|
||||||
class TestParameterOrderingIssue:
|
|
||||||
"""Test fix for GitHub issue #1641: parameter ordering issues."""
|
|
||||||
|
|
||||||
def test_ai_flag_consuming_here_flag(self):
|
|
||||||
"""--ai without value should not consume --here flag (issue #1641)."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
# This used to fail with "Must specify project name" because --here was consumed by --ai
|
|
||||||
result = runner.invoke(app, ["init", "--ai-skills", "--ai", "--here"])
|
|
||||||
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Invalid value for --ai" in result.output
|
|
||||||
assert "--here" in result.output # Should mention the invalid value
|
|
||||||
|
|
||||||
def test_ai_flag_consuming_ai_skills_flag(self):
|
|
||||||
"""--ai without value should not consume --ai-skills flag."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
# This should fail with helpful error about missing --ai value
|
|
||||||
result = runner.invoke(app, ["init", "--here", "--ai", "--ai-skills"])
|
|
||||||
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Invalid value for --ai" in result.output
|
|
||||||
assert "--ai-skills" in result.output # Should mention the invalid value
|
|
||||||
|
|
||||||
def test_error_message_provides_hint(self):
|
|
||||||
"""Error message should provide helpful hint about missing value."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(app, ["init", "--ai", "--here"])
|
|
||||||
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Hint:" in result.output or "hint" in result.output.lower()
|
|
||||||
assert "forget to provide a value" in result.output.lower()
|
|
||||||
|
|
||||||
def test_error_message_lists_available_agents(self):
|
|
||||||
"""Error message should list available agents."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(app, ["init", "--ai", "--here"])
|
|
||||||
|
|
||||||
assert result.exit_code == 1
|
|
||||||
# Should mention some known agents
|
|
||||||
output_lower = result.output.lower()
|
|
||||||
assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"])
|
|
||||||
|
|
||||||
def test_ai_commands_dir_consuming_flag(self, tmp_path):
|
|
||||||
"""--ai-commands-dir without value should not consume next flag."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "myproject"), "--ai", "generic", "--ai-commands-dir", "--here"])
|
|
||||||
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Invalid value for --ai-commands-dir" in result.output
|
|
||||||
assert "--here" in result.output
|
|
||||||
@@ -30,18 +30,13 @@ class TestSaveBranchNumbering:
|
|||||||
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||||
assert saved["branch_numbering"] == "sequential"
|
assert saved["branch_numbering"] == "sequential"
|
||||||
|
|
||||||
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path, monkeypatch):
|
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
def _fake_download(project_path, *args, **kwargs):
|
|
||||||
Path(project_path).mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
|
||||||
|
|
||||||
project_dir = tmp_path / "proj"
|
project_dir = tmp_path / "proj"
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
||||||
@@ -56,34 +51,24 @@ class TestBranchNumberingValidation:
|
|||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar"])
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Invalid --branch-numbering" in result.output
|
assert "Invalid --branch-numbering" in result.output
|
||||||
|
|
||||||
def test_valid_branch_numbering_sequential(self, tmp_path: Path, monkeypatch):
|
def test_valid_branch_numbering_sequential(self, tmp_path: Path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
def _fake_download(project_path, *args, **kwargs):
|
|
||||||
Path(project_path).mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|
||||||
def test_valid_branch_numbering_timestamp(self, tmp_path: Path, monkeypatch):
|
def test_valid_branch_numbering_timestamp(self, tmp_path: Path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
def _fake_download(project_path, *args, **kwargs):
|
|
||||||
Path(project_path).mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|||||||
@@ -1,613 +0,0 @@
|
|||||||
"""
|
|
||||||
Validation tests for offline/air-gapped scaffolding (PR #1803).
|
|
||||||
|
|
||||||
For every supported AI agent (except "generic") the scaffold output is verified
|
|
||||||
against invariants and compared byte-for-byte with the canonical output produced
|
|
||||||
by create-release-packages.sh.
|
|
||||||
|
|
||||||
Since scaffold_from_core_pack() now invokes the release script at runtime, the
|
|
||||||
parity test (section 9) runs the script independently and compares the results
|
|
||||||
to ensure the integration is correct.
|
|
||||||
|
|
||||||
Per-agent invariants verified
|
|
||||||
──────────────────────────────
|
|
||||||
• Command files are written to the directory declared in AGENT_CONFIG
|
|
||||||
• File count matches the number of source templates
|
|
||||||
• Extension is correct: .toml (TOML agents), .agent.md (copilot), .md (rest)
|
|
||||||
• No unresolved placeholders remain ({SCRIPT}, {ARGS}, __AGENT__)
|
|
||||||
• Argument token is correct: {{args}} for TOML agents, $ARGUMENTS for others
|
|
||||||
• Path rewrites applied: scripts/ → .specify/scripts/ etc.
|
|
||||||
• TOML files have "description" and "prompt" fields
|
|
||||||
• Markdown files have parseable YAML frontmatter
|
|
||||||
• Copilot: companion speckit.*.prompt.md files are generated in prompts/
|
|
||||||
• .specify/scripts/ contains at least one script file
|
|
||||||
• .specify/templates/ contains at least one template file
|
|
||||||
|
|
||||||
Parity invariant
|
|
||||||
────────────────
|
|
||||||
Every file produced by scaffold_from_core_pack() must be byte-for-byte
|
|
||||||
identical to the same file in the ZIP produced by the release script.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tomllib
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from specify_cli import (
|
|
||||||
AGENT_CONFIG,
|
|
||||||
_TOML_AGENTS,
|
|
||||||
_locate_core_pack,
|
|
||||||
scaffold_from_core_pack,
|
|
||||||
)
|
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).parent.parent
|
|
||||||
_RELEASE_SCRIPT = _REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh"
|
|
||||||
|
|
||||||
|
|
||||||
def _find_bash() -> str | None:
|
|
||||||
"""Return the path to a usable bash on this machine, or None."""
|
|
||||||
# Prefer PATH lookup so non-standard install locations (Nix, CI) are found.
|
|
||||||
on_path = shutil.which("bash")
|
|
||||||
if on_path:
|
|
||||||
return on_path
|
|
||||||
candidates = [
|
|
||||||
"/opt/homebrew/bin/bash",
|
|
||||||
"/usr/local/bin/bash",
|
|
||||||
"/bin/bash",
|
|
||||||
"/usr/bin/bash",
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[candidate, "--version"],
|
|
||||||
capture_output=True, text=True, timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return candidate
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _run_release_script(agent: str, script_type: str, bash: str, output_dir: Path) -> Path:
|
|
||||||
"""Run create-release-packages.sh for *agent*/*script_type* and return the
|
|
||||||
path to the generated ZIP. *output_dir* receives the build artifacts so
|
|
||||||
the repo working tree stays clean."""
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["AGENTS"] = agent
|
|
||||||
env["SCRIPTS"] = script_type
|
|
||||||
env["GENRELEASES_DIR"] = str(output_dir)
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
[bash, str(_RELEASE_SCRIPT), "v0.0.0"],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
cwd=str(_REPO_ROOT),
|
|
||||||
env=env,
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
pytest.fail(
|
|
||||||
f"Release script failed with exit code {result.returncode}\n"
|
|
||||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
|
||||||
)
|
|
||||||
|
|
||||||
zip_pattern = f"spec-kit-template-{agent}-{script_type}-v0.0.0.zip"
|
|
||||||
zip_path = output_dir / zip_pattern
|
|
||||||
if not zip_path.exists():
|
|
||||||
pytest.fail(
|
|
||||||
f"Release script did not produce expected ZIP: {zip_path}\n"
|
|
||||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
|
||||||
)
|
|
||||||
return zip_path
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Number of source command templates (one per .md file in templates/commands/)
|
|
||||||
|
|
||||||
|
|
||||||
def _commands_dir() -> Path:
|
|
||||||
"""Return the command templates directory (source-checkout or core_pack)."""
|
|
||||||
core = _locate_core_pack()
|
|
||||||
if core and (core / "commands").is_dir():
|
|
||||||
return core / "commands"
|
|
||||||
# Source-checkout fallback
|
|
||||||
repo_root = Path(__file__).parent.parent
|
|
||||||
return repo_root / "templates" / "commands"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_source_template_stems() -> list[str]:
|
|
||||||
"""Return the stems of source command template files (e.g. ['specify', 'plan', ...])."""
|
|
||||||
return sorted(p.stem for p in _commands_dir().glob("*.md"))
|
|
||||||
|
|
||||||
|
|
||||||
def _expected_cmd_dir(project_path: Path, agent: str) -> Path:
|
|
||||||
"""Return the expected command-files directory for a given agent."""
|
|
||||||
cfg = AGENT_CONFIG[agent]
|
|
||||||
folder = (cfg.get("folder") or "").rstrip("/")
|
|
||||||
subdir = cfg.get("commands_subdir", "commands")
|
|
||||||
if folder:
|
|
||||||
return project_path / folder / subdir
|
|
||||||
return project_path / ".speckit" / subdir
|
|
||||||
|
|
||||||
|
|
||||||
# Agents whose commands are laid out as <skills_dir>/<name>/SKILL.md.
|
|
||||||
# Maps agent -> separator used in skill directory names.
|
|
||||||
_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "-"}
|
|
||||||
|
|
||||||
|
|
||||||
def _expected_ext(agent: str) -> str:
|
|
||||||
if agent in _TOML_AGENTS:
|
|
||||||
return "toml"
|
|
||||||
if agent == "copilot":
|
|
||||||
return "agent.md"
|
|
||||||
if agent in _SKILL_AGENTS:
|
|
||||||
return "SKILL.md"
|
|
||||||
return "md"
|
|
||||||
|
|
||||||
|
|
||||||
def _list_command_files(cmd_dir: Path, agent: str) -> list[Path]:
|
|
||||||
"""List generated command files, handling skills-based directory layouts."""
|
|
||||||
if agent in _SKILL_AGENTS:
|
|
||||||
sep = _SKILL_AGENTS[agent]
|
|
||||||
return sorted(cmd_dir.glob(f"speckit{sep}*/SKILL.md"))
|
|
||||||
ext = _expected_ext(agent)
|
|
||||||
return sorted(cmd_dir.glob(f"speckit.*.{ext}"))
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_relative_files(root: Path) -> dict[str, bytes]:
|
|
||||||
"""Walk *root* and return {relative_posix_path: file_bytes}."""
|
|
||||||
result: dict[str, bytes] = {}
|
|
||||||
for p in root.rglob("*"):
|
|
||||||
if p.is_file():
|
|
||||||
result[p.relative_to(root).as_posix()] = p.read_bytes()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def source_template_stems() -> list[str]:
|
|
||||||
return _get_source_template_stems()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def scaffolded_sh(tmp_path_factory):
|
|
||||||
"""Session-scoped cache: scaffold once per agent with script_type='sh'."""
|
|
||||||
cache = {}
|
|
||||||
def _get(agent: str) -> Path:
|
|
||||||
if agent not in cache:
|
|
||||||
project = tmp_path_factory.mktemp(f"scaffold_sh_{agent}")
|
|
||||||
ok = scaffold_from_core_pack(project, agent, "sh")
|
|
||||||
assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'"
|
|
||||||
cache[agent] = project
|
|
||||||
return cache[agent]
|
|
||||||
return _get
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def scaffolded_ps(tmp_path_factory):
|
|
||||||
"""Session-scoped cache: scaffold once per agent with script_type='ps'."""
|
|
||||||
cache = {}
|
|
||||||
def _get(agent: str) -> Path:
|
|
||||||
if agent not in cache:
|
|
||||||
project = tmp_path_factory.mktemp(f"scaffold_ps_{agent}")
|
|
||||||
ok = scaffold_from_core_pack(project, agent, "ps")
|
|
||||||
assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'"
|
|
||||||
cache[agent] = project
|
|
||||||
return cache[agent]
|
|
||||||
return _get
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Parametrize over all agents except "generic"
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_TESTABLE_AGENTS = [a for a in AGENT_CONFIG if a != "generic"]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Bundled scaffold — directory structure
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_scaffold_creates_specify_scripts(agent, scaffolded_sh):
|
|
||||||
"""scaffold_from_core_pack copies at least one script into .specify/scripts/."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
||||||
assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'"
|
|
||||||
assert any(scripts_dir.iterdir()), f".specify/scripts/bash/ is empty for agent '{agent}'"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_scaffold_creates_specify_templates(agent, scaffolded_sh):
|
|
||||||
"""scaffold_from_core_pack copies at least one page template into .specify/templates/."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
tpl_dir = project / ".specify" / "templates"
|
|
||||||
assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'"
|
|
||||||
assert any(tpl_dir.iterdir()), ".specify/templates/ is empty"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_scaffold_command_dir_location(agent, scaffolded_sh):
|
|
||||||
"""Command files land in the directory declared by AGENT_CONFIG."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
assert cmd_dir.is_dir(), (
|
|
||||||
f"Command dir '{cmd_dir.relative_to(project)}' not created for agent '{agent}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Bundled scaffold — file count
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_scaffold_command_file_count(agent, scaffolded_sh, source_template_stems):
|
|
||||||
"""One command file is generated per source template for every agent."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
generated = _list_command_files(cmd_dir, agent)
|
|
||||||
|
|
||||||
if cmd_dir.is_dir():
|
|
||||||
dir_listing = list(cmd_dir.iterdir())
|
|
||||||
else:
|
|
||||||
dir_listing = f"<command dir missing: {cmd_dir}>"
|
|
||||||
|
|
||||||
assert len(generated) == len(source_template_stems), (
|
|
||||||
f"Agent '{agent}': expected {len(source_template_stems)} command files "
|
|
||||||
f"({_expected_ext(agent)}), found {len(generated)}. Dir: {dir_listing}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_scaffold_command_file_names(agent, scaffolded_sh, source_template_stems):
|
|
||||||
"""Each source template stem maps to a corresponding speckit.<stem>.<ext> file."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
for stem in source_template_stems:
|
|
||||||
if agent in _SKILL_AGENTS:
|
|
||||||
sep = _SKILL_AGENTS[agent]
|
|
||||||
expected = cmd_dir / f"speckit{sep}{stem}" / "SKILL.md"
|
|
||||||
else:
|
|
||||||
ext = _expected_ext(agent)
|
|
||||||
expected = cmd_dir / f"speckit.{stem}.{ext}"
|
|
||||||
assert expected.is_file(), (
|
|
||||||
f"Agent '{agent}': expected file '{expected.name}' not found in '{cmd_dir}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Bundled scaffold — content invariants
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_no_unresolved_script_placeholder(agent, scaffolded_sh):
|
|
||||||
"""{SCRIPT} must not appear in any generated command file."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
for f in cmd_dir.rglob("*"):
|
|
||||||
if f.is_file():
|
|
||||||
content = f.read_text(encoding="utf-8")
|
|
||||||
assert "{SCRIPT}" not in content, (
|
|
||||||
f"Unresolved {{SCRIPT}} in '{f.relative_to(project)}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_no_unresolved_agent_placeholder(agent, scaffolded_sh):
|
|
||||||
"""__AGENT__ must not appear in any generated command file."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
for f in cmd_dir.rglob("*"):
|
|
||||||
if f.is_file():
|
|
||||||
content = f.read_text(encoding="utf-8")
|
|
||||||
assert "__AGENT__" not in content, (
|
|
||||||
f"Unresolved __AGENT__ in '{f.relative_to(project)}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_no_unresolved_args_placeholder(agent, scaffolded_sh):
|
|
||||||
"""{ARGS} must not appear in any generated command file (replaced with agent-specific token)."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
for f in cmd_dir.rglob("*"):
|
|
||||||
if f.is_file():
|
|
||||||
content = f.read_text(encoding="utf-8")
|
|
||||||
assert "{ARGS}" not in content, (
|
|
||||||
f"Unresolved {{ARGS}} in '{f.relative_to(project)}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Build a set of template stems that actually contain {ARGS} in their source.
|
|
||||||
_TEMPLATES_WITH_ARGS: frozenset[str] = frozenset(
|
|
||||||
p.stem
|
|
||||||
for p in _commands_dir().glob("*.md")
|
|
||||||
if "{ARGS}" in p.read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_argument_token_format(agent, scaffolded_sh):
|
|
||||||
"""For templates that carry an {ARGS} token:
|
|
||||||
- TOML agents must emit {{args}}
|
|
||||||
- Markdown agents must emit $ARGUMENTS
|
|
||||||
Templates without {ARGS} (e.g. implement, plan) are skipped.
|
|
||||||
"""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
|
|
||||||
for f in _list_command_files(cmd_dir, agent):
|
|
||||||
# Recover the stem from the file path
|
|
||||||
if agent in _SKILL_AGENTS:
|
|
||||||
sep = _SKILL_AGENTS[agent]
|
|
||||||
stem = f.parent.name.removeprefix(f"speckit{sep}")
|
|
||||||
else:
|
|
||||||
ext = _expected_ext(agent)
|
|
||||||
stem = f.name.removeprefix("speckit.").removesuffix(f".{ext}")
|
|
||||||
if stem not in _TEMPLATES_WITH_ARGS:
|
|
||||||
continue # this template has no argument token
|
|
||||||
|
|
||||||
content = f.read_text(encoding="utf-8")
|
|
||||||
if agent in _TOML_AGENTS:
|
|
||||||
assert "{{args}}" in content, (
|
|
||||||
f"TOML agent '{agent}': expected '{{{{args}}}}' in '{f.name}'"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
assert "$ARGUMENTS" in content, (
|
|
||||||
f"Markdown agent '{agent}': expected '$ARGUMENTS' in '{f.name}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_path_rewrites_applied(agent, scaffolded_sh):
|
|
||||||
"""Bare scripts/ and templates/ paths must be rewritten to .specify/ variants.
|
|
||||||
|
|
||||||
YAML frontmatter 'source:' metadata fields are excluded — they reference
|
|
||||||
the original template path for provenance, not a runtime path.
|
|
||||||
"""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
for f in cmd_dir.rglob("*"):
|
|
||||||
if not f.is_file():
|
|
||||||
continue
|
|
||||||
content = f.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
# Strip YAML frontmatter before checking — source: metadata is not a runtime path
|
|
||||||
body = content
|
|
||||||
if content.startswith("---"):
|
|
||||||
parts = content.split("---", 2)
|
|
||||||
if len(parts) >= 3:
|
|
||||||
body = parts[2]
|
|
||||||
|
|
||||||
# Should not contain bare (non-.specify/) script paths
|
|
||||||
assert not re.search(r'(?<!\.specify/)scripts/', body), (
|
|
||||||
f"Bare scripts/ path found in '{f.relative_to(project)}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
assert not re.search(r'(?<!\.specify/)templates/', body), (
|
|
||||||
f"Bare templates/ path found in '{f.relative_to(project)}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. TOML format checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", sorted(_TOML_AGENTS))
|
|
||||||
def test_toml_format_valid(agent, scaffolded_sh):
|
|
||||||
"""TOML agents: every command file must have description and prompt fields."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
for f in cmd_dir.glob("speckit.*.toml"):
|
|
||||||
content = f.read_text(encoding="utf-8")
|
|
||||||
assert 'description = "' in content, (
|
|
||||||
f"Missing 'description' in '{f.name}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
assert 'prompt = """' in content, (
|
|
||||||
f"Missing 'prompt' block in '{f.name}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Markdown frontmatter checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_MARKDOWN_AGENTS = [a for a in _TESTABLE_AGENTS if a not in _TOML_AGENTS]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _MARKDOWN_AGENTS)
|
|
||||||
def test_markdown_has_frontmatter(agent, scaffolded_sh):
|
|
||||||
"""Markdown agents: every command file must start with valid YAML frontmatter."""
|
|
||||||
project = scaffolded_sh(agent)
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
for f in _list_command_files(cmd_dir, agent):
|
|
||||||
content = f.read_text(encoding="utf-8")
|
|
||||||
assert content.startswith("---"), (
|
|
||||||
f"No YAML frontmatter in '{f.name}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
parts = content.split("---", 2)
|
|
||||||
assert len(parts) >= 3, f"Incomplete frontmatter in '{f.name}'"
|
|
||||||
fm = yaml.safe_load(parts[1])
|
|
||||||
assert fm is not None, f"Empty frontmatter in '{f.name}'"
|
|
||||||
assert "description" in fm, (
|
|
||||||
f"'description' key missing from frontmatter in '{f.name}' for agent '{agent}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Copilot-specific: companion .prompt.md files
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_copilot_companion_prompt_files(scaffolded_sh, source_template_stems):
|
|
||||||
"""Copilot: a speckit.<stem>.prompt.md companion is created for every .agent.md file."""
|
|
||||||
project = scaffolded_sh("copilot")
|
|
||||||
|
|
||||||
prompts_dir = project / ".github" / "prompts"
|
|
||||||
assert prompts_dir.is_dir(), ".github/prompts/ not created for copilot"
|
|
||||||
|
|
||||||
for stem in source_template_stems:
|
|
||||||
prompt_file = prompts_dir / f"speckit.{stem}.prompt.md"
|
|
||||||
assert prompt_file.is_file(), (
|
|
||||||
f"Companion prompt file '{prompt_file.name}' missing for copilot"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_copilot_prompt_file_content(scaffolded_sh, source_template_stems):
|
|
||||||
"""Copilot companion .prompt.md files must reference their parent .agent.md."""
|
|
||||||
project = scaffolded_sh("copilot")
|
|
||||||
|
|
||||||
prompts_dir = project / ".github" / "prompts"
|
|
||||||
for stem in source_template_stems:
|
|
||||||
f = prompts_dir / f"speckit.{stem}.prompt.md"
|
|
||||||
content = f.read_text(encoding="utf-8")
|
|
||||||
assert f"agent: speckit.{stem}" in content, (
|
|
||||||
f"Companion '{f.name}' does not reference 'speckit.{stem}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7. PowerShell script variant
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_scaffold_powershell_variant(agent, scaffolded_ps, source_template_stems):
|
|
||||||
"""scaffold_from_core_pack with script_type='ps' creates correct files."""
|
|
||||||
project = scaffolded_ps(agent)
|
|
||||||
|
|
||||||
scripts_dir = project / ".specify" / "scripts" / "powershell"
|
|
||||||
assert scripts_dir.is_dir(), f".specify/scripts/powershell/ missing for '{agent}'"
|
|
||||||
assert any(scripts_dir.iterdir()), ".specify/scripts/powershell/ is empty"
|
|
||||||
|
|
||||||
cmd_dir = _expected_cmd_dir(project, agent)
|
|
||||||
generated = _list_command_files(cmd_dir, agent)
|
|
||||||
assert len(generated) == len(source_template_stems)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Parity: bundled vs. real create-release-packages.sh ZIP
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def release_script_trees(tmp_path_factory):
|
|
||||||
"""Session-scoped cache: run release script once per (agent, script_type)."""
|
|
||||||
cache: dict[tuple[str, str], dict[str, bytes]] = {}
|
|
||||||
bash = _find_bash()
|
|
||||||
|
|
||||||
def _get(agent: str, script_type: str) -> dict[str, bytes] | None:
|
|
||||||
if bash is None:
|
|
||||||
return None
|
|
||||||
key = (agent, script_type)
|
|
||||||
if key not in cache:
|
|
||||||
tmp = tmp_path_factory.mktemp(f"release_{agent}_{script_type}")
|
|
||||||
gen_dir = tmp / "genreleases"
|
|
||||||
gen_dir.mkdir()
|
|
||||||
zip_path = _run_release_script(agent, script_type, bash, gen_dir)
|
|
||||||
extracted = tmp / "extracted"
|
|
||||||
extracted.mkdir()
|
|
||||||
with zipfile.ZipFile(zip_path) as zf:
|
|
||||||
zf.extractall(extracted)
|
|
||||||
cache[key] = _collect_relative_files(extracted)
|
|
||||||
return cache[key]
|
|
||||||
return _get
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("script_type", ["sh", "ps"])
|
|
||||||
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
|
|
||||||
def test_parity_bundled_vs_release_script(agent, script_type, scaffolded_sh, scaffolded_ps, release_script_trees):
|
|
||||||
"""scaffold_from_core_pack() file tree is identical to the ZIP produced by
|
|
||||||
create-release-packages.sh for every agent and script type.
|
|
||||||
|
|
||||||
This is the true end-to-end parity check: the Python offline path must
|
|
||||||
produce exactly the same artifacts as the canonical shell release script.
|
|
||||||
|
|
||||||
Both sides are session-cached: each agent/script_type combination is
|
|
||||||
scaffolded and release-scripted only once across all tests.
|
|
||||||
"""
|
|
||||||
script_tree = release_script_trees(agent, script_type)
|
|
||||||
if script_tree is None:
|
|
||||||
pytest.skip("bash required to run create-release-packages.sh")
|
|
||||||
|
|
||||||
# Reuse session-cached scaffold output
|
|
||||||
if script_type == "sh":
|
|
||||||
bundled_dir = scaffolded_sh(agent)
|
|
||||||
else:
|
|
||||||
bundled_dir = scaffolded_ps(agent)
|
|
||||||
|
|
||||||
bundled_tree = _collect_relative_files(bundled_dir)
|
|
||||||
|
|
||||||
only_bundled = set(bundled_tree) - set(script_tree)
|
|
||||||
only_script = set(script_tree) - set(bundled_tree)
|
|
||||||
|
|
||||||
assert not only_bundled, (
|
|
||||||
f"Agent '{agent}' ({script_type}): files only in bundled output (not in release ZIP):\n "
|
|
||||||
+ "\n ".join(sorted(only_bundled))
|
|
||||||
)
|
|
||||||
assert not only_script, (
|
|
||||||
f"Agent '{agent}' ({script_type}): files only in release ZIP (not in bundled output):\n "
|
|
||||||
+ "\n ".join(sorted(only_script))
|
|
||||||
)
|
|
||||||
|
|
||||||
for name in bundled_tree:
|
|
||||||
assert bundled_tree[name] == script_tree[name], (
|
|
||||||
f"Agent '{agent}' ({script_type}): file '{name}' content differs between "
|
|
||||||
f"bundled output and release script ZIP"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Section 10 – pyproject.toml force-include covers all template files
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_pyproject_force_include_covers_all_templates():
|
|
||||||
"""Every file in templates/ (excluding commands/) must be listed in
|
|
||||||
pyproject.toml's [tool.hatch.build.targets.wheel.force-include] section.
|
|
||||||
|
|
||||||
This prevents new template files from being silently omitted from the
|
|
||||||
wheel, which would break ``specify init --offline``.
|
|
||||||
"""
|
|
||||||
templates_dir = _REPO_ROOT / "templates"
|
|
||||||
# Collect all files directly in templates/ (not in subdirectories like commands/)
|
|
||||||
repo_template_files = sorted(
|
|
||||||
f.name for f in templates_dir.iterdir()
|
|
||||||
if f.is_file()
|
|
||||||
)
|
|
||||||
assert repo_template_files, "Expected at least one template file in templates/"
|
|
||||||
|
|
||||||
pyproject_path = _REPO_ROOT / "pyproject.toml"
|
|
||||||
with open(pyproject_path, "rb") as f:
|
|
||||||
pyproject = tomllib.load(f)
|
|
||||||
force_include = pyproject.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}).get("force-include", {})
|
|
||||||
|
|
||||||
missing = [
|
|
||||||
name for name in repo_template_files
|
|
||||||
if f"templates/{name}" not in force_include
|
|
||||||
]
|
|
||||||
assert not missing, (
|
|
||||||
"Template files not listed in pyproject.toml force-include "
|
|
||||||
"(offline scaffolding will miss them):\n "
|
|
||||||
+ "\n ".join(missing)
|
|
||||||
)
|
|
||||||
@@ -41,14 +41,14 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool
|
|||||||
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
||||||
"""Create and return the expected skills directory for the given agent."""
|
"""Create and return the expected skills directory for the given agent."""
|
||||||
# Match the logic in _get_skills_dir() from specify_cli
|
# Match the logic in _get_skills_dir() from specify_cli
|
||||||
from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR
|
from specify_cli import AGENT_CONFIG
|
||||||
|
|
||||||
agent_config = AGENT_CONFIG.get(ai, {})
|
agent_config = AGENT_CONFIG.get(ai, {})
|
||||||
agent_folder = agent_config.get("folder", "")
|
agent_folder = agent_config.get("folder", "")
|
||||||
if agent_folder:
|
if agent_folder:
|
||||||
skills_dir = project_root / agent_folder.rstrip("/") / "skills"
|
skills_dir = project_root / agent_folder.rstrip("/") / "skills"
|
||||||
else:
|
else:
|
||||||
skills_dir = project_root / DEFAULT_SKILLS_DIR
|
skills_dir = project_root / ".agents" / "skills"
|
||||||
|
|
||||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return skills_dir
|
return skills_dir
|
||||||
|
|||||||
@@ -1017,7 +1017,7 @@ $ARGUMENTS
|
|||||||
def test_register_commands_for_claude(self, extension_dir, project_dir):
|
def test_register_commands_for_claude(self, extension_dir, project_dir):
|
||||||
"""Test registering commands for Claude agent."""
|
"""Test registering commands for Claude agent."""
|
||||||
# Create .claude directory
|
# Create .claude directory
|
||||||
claude_dir = project_dir / ".claude" / "commands"
|
claude_dir = project_dir / ".claude" / "skills"
|
||||||
claude_dir.mkdir(parents=True)
|
claude_dir.mkdir(parents=True)
|
||||||
|
|
||||||
ExtensionManager(project_dir) # Initialize manager (side effects only)
|
ExtensionManager(project_dir) # Initialize manager (side effects only)
|
||||||
@@ -1034,13 +1034,12 @@ $ARGUMENTS
|
|||||||
assert "speckit.test-ext.hello" in registered
|
assert "speckit.test-ext.hello" in registered
|
||||||
|
|
||||||
# Check command file was created
|
# Check command file was created
|
||||||
cmd_file = claude_dir / "speckit.test-ext.hello.md"
|
cmd_file = claude_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
assert cmd_file.exists()
|
assert cmd_file.exists()
|
||||||
|
|
||||||
content = cmd_file.read_text()
|
content = cmd_file.read_text()
|
||||||
assert "description: Test hello command" in content
|
assert "description: Test hello command" in content
|
||||||
assert "<!-- Extension: test-ext -->" in content
|
assert "test-ext" in content
|
||||||
assert "<!-- Config: .specify/extensions/test-ext/ -->" in content
|
|
||||||
|
|
||||||
def test_command_with_aliases(self, project_dir, temp_dir):
|
def test_command_with_aliases(self, project_dir, temp_dir):
|
||||||
"""Test registering a command with aliases."""
|
"""Test registering a command with aliases."""
|
||||||
@@ -1078,7 +1077,7 @@ $ARGUMENTS
|
|||||||
(ext_dir / "commands").mkdir()
|
(ext_dir / "commands").mkdir()
|
||||||
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest")
|
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest")
|
||||||
|
|
||||||
claude_dir = project_dir / ".claude" / "commands"
|
claude_dir = project_dir / ".claude" / "skills"
|
||||||
claude_dir.mkdir(parents=True)
|
claude_dir.mkdir(parents=True)
|
||||||
|
|
||||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||||
@@ -1088,8 +1087,8 @@ $ARGUMENTS
|
|||||||
assert len(registered) == 2
|
assert len(registered) == 2
|
||||||
assert "speckit.ext-alias.cmd" in registered
|
assert "speckit.ext-alias.cmd" in registered
|
||||||
assert "speckit.ext-alias.shortcut" in registered
|
assert "speckit.ext-alias.shortcut" in registered
|
||||||
assert (claude_dir / "speckit.ext-alias.cmd.md").exists()
|
assert (claude_dir / "speckit-ext-alias-cmd" / "SKILL.md").exists()
|
||||||
assert (claude_dir / "speckit.ext-alias.shortcut.md").exists()
|
assert (claude_dir / "speckit-ext-alias-shortcut" / "SKILL.md").exists()
|
||||||
|
|
||||||
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
|
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
|
||||||
"""Codex skill cleanup should use the same mapped names as registration."""
|
"""Codex skill cleanup should use the same mapped names as registration."""
|
||||||
@@ -1466,7 +1465,7 @@ Then {AGENT_SCRIPT}
|
|||||||
|
|
||||||
content = cmd_file.read_text()
|
content = cmd_file.read_text()
|
||||||
assert "description: Test hello command" in content
|
assert "description: Test hello command" in content
|
||||||
assert "<!-- Extension: test-ext -->" in content
|
assert "test-ext" in content
|
||||||
|
|
||||||
def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
|
def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
|
||||||
"""Test that companion .prompt.md files are created in .github/prompts/."""
|
"""Test that companion .prompt.md files are created in .github/prompts/."""
|
||||||
@@ -1541,7 +1540,7 @@ Then {AGENT_SCRIPT}
|
|||||||
|
|
||||||
def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):
|
def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):
|
||||||
"""Test that non-copilot agents do NOT create .prompt.md files."""
|
"""Test that non-copilot agents do NOT create .prompt.md files."""
|
||||||
claude_dir = project_dir / ".claude" / "commands"
|
claude_dir = project_dir / ".claude" / "skills"
|
||||||
claude_dir.mkdir(parents=True)
|
claude_dir.mkdir(parents=True)
|
||||||
|
|
||||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
@@ -1592,7 +1591,7 @@ class TestIntegration:
|
|||||||
def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
|
def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
|
||||||
"""Test complete installation and removal workflow."""
|
"""Test complete installation and removal workflow."""
|
||||||
# Create Claude directory
|
# Create Claude directory
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True)
|
(project_dir / ".claude" / "skills").mkdir(parents=True)
|
||||||
|
|
||||||
manager = ExtensionManager(project_dir)
|
manager = ExtensionManager(project_dir)
|
||||||
|
|
||||||
@@ -1610,7 +1609,7 @@ class TestIntegration:
|
|||||||
assert installed[0]["id"] == "test-ext"
|
assert installed[0]["id"] == "test-ext"
|
||||||
|
|
||||||
# Verify command registered
|
# Verify command registered
|
||||||
cmd_file = project_dir / ".claude" / "commands" / "speckit.test-ext.hello.md"
|
cmd_file = project_dir / ".claude" / "skills" / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
assert cmd_file.exists()
|
assert cmd_file.exists()
|
||||||
|
|
||||||
# Verify registry has registered commands (now a dict keyed by agent)
|
# Verify registry has registered commands (now a dict keyed by agent)
|
||||||
@@ -3008,7 +3007,7 @@ class TestExtensionUpdateCLI:
|
|||||||
project_dir = tmp_path / "project"
|
project_dir = tmp_path / "project"
|
||||||
project_dir.mkdir()
|
project_dir.mkdir()
|
||||||
(project_dir / ".specify").mkdir()
|
(project_dir / ".specify").mkdir()
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True)
|
(project_dir / ".claude" / "skills").mkdir(parents=True)
|
||||||
|
|
||||||
manager = ExtensionManager(project_dir)
|
manager = ExtensionManager(project_dir)
|
||||||
v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True)
|
v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True)
|
||||||
@@ -3057,7 +3056,7 @@ class TestExtensionUpdateCLI:
|
|||||||
project_dir = tmp_path / "project"
|
project_dir = tmp_path / "project"
|
||||||
project_dir.mkdir()
|
project_dir.mkdir()
|
||||||
(project_dir / ".specify").mkdir()
|
(project_dir / ".specify").mkdir()
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True)
|
(project_dir / ".claude" / "skills").mkdir(parents=True)
|
||||||
|
|
||||||
manager = ExtensionManager(project_dir)
|
manager = ExtensionManager(project_dir)
|
||||||
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
|
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
|
||||||
@@ -3068,14 +3067,16 @@ class TestExtensionUpdateCLI:
|
|||||||
|
|
||||||
registered_commands = backup_registry_entry.get("registered_commands", {})
|
registered_commands = backup_registry_entry.get("registered_commands", {})
|
||||||
command_files = []
|
command_files = []
|
||||||
registrar = CommandRegistrar()
|
from specify_cli.agents import CommandRegistrar as AgentRegistrar
|
||||||
|
agent_registrar = AgentRegistrar()
|
||||||
for agent_name, cmd_names in registered_commands.items():
|
for agent_name, cmd_names in registered_commands.items():
|
||||||
if agent_name not in registrar.AGENT_CONFIGS:
|
if agent_name not in agent_registrar.AGENT_CONFIGS:
|
||||||
continue
|
continue
|
||||||
agent_cfg = registrar.AGENT_CONFIGS[agent_name]
|
agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name]
|
||||||
commands_dir = project_dir / agent_cfg["dir"]
|
commands_dir = project_dir / agent_cfg["dir"]
|
||||||
for cmd_name in cmd_names:
|
for cmd_name in cmd_names:
|
||||||
cmd_path = commands_dir / f"{cmd_name}{agent_cfg['extension']}"
|
output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg)
|
||||||
|
cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}"
|
||||||
command_files.append(cmd_path)
|
command_files.append(cmd_path)
|
||||||
|
|
||||||
assert command_files, "Expected at least one registered command file"
|
assert command_files, "Expected at least one registered command file"
|
||||||
|
|||||||
@@ -1772,19 +1772,20 @@ class TestSelfTestPreset:
|
|||||||
assert "preset:self-test" in content
|
assert "preset:self-test" in content
|
||||||
|
|
||||||
def test_self_test_registers_commands_for_claude(self, project_dir):
|
def test_self_test_registers_commands_for_claude(self, project_dir):
|
||||||
"""Test that installing self-test registers commands in .claude/commands/."""
|
"""Test that installing self-test registers skills in .claude/skills/."""
|
||||||
# Create Claude agent directory to simulate Claude being set up
|
# Create Claude skills directory to simulate Claude being set up
|
||||||
claude_dir = project_dir / ".claude" / "commands"
|
claude_dir = project_dir / ".claude" / "skills"
|
||||||
claude_dir.mkdir(parents=True)
|
claude_dir.mkdir(parents=True)
|
||||||
|
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||||
|
|
||||||
# Check the command was registered
|
# Check the skill was registered
|
||||||
cmd_file = claude_dir / "speckit.specify.md"
|
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
|
||||||
assert cmd_file.exists(), "Command not registered in .claude/commands/"
|
assert cmd_file.exists(), "Skill not registered in .claude/skills/"
|
||||||
content = cmd_file.read_text()
|
content = cmd_file.read_text()
|
||||||
assert "preset:self-test" in content
|
assert "self-test" in content
|
||||||
|
assert "source:" in content # skill frontmatter includes metadata.source
|
||||||
|
|
||||||
def test_self_test_registers_commands_for_gemini(self, project_dir):
|
def test_self_test_registers_commands_for_gemini(self, project_dir):
|
||||||
"""Test that installing self-test registers commands in .gemini/commands/ as TOML."""
|
"""Test that installing self-test registers commands in .gemini/commands/ as TOML."""
|
||||||
@@ -1804,13 +1805,13 @@ class TestSelfTestPreset:
|
|||||||
|
|
||||||
def test_self_test_unregisters_commands_on_remove(self, project_dir):
|
def test_self_test_unregisters_commands_on_remove(self, project_dir):
|
||||||
"""Test that removing self-test cleans up registered commands."""
|
"""Test that removing self-test cleans up registered commands."""
|
||||||
claude_dir = project_dir / ".claude" / "commands"
|
claude_dir = project_dir / ".claude" / "skills"
|
||||||
claude_dir.mkdir(parents=True)
|
claude_dir.mkdir(parents=True)
|
||||||
|
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||||
|
|
||||||
cmd_file = claude_dir / "speckit.specify.md"
|
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
|
||||||
assert cmd_file.exists()
|
assert cmd_file.exists()
|
||||||
|
|
||||||
manager.remove("self-test")
|
manager.remove("self-test")
|
||||||
@@ -1826,7 +1827,7 @@ class TestSelfTestPreset:
|
|||||||
|
|
||||||
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
|
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
|
||||||
"""Test that extension command overrides are skipped if the extension isn't installed."""
|
"""Test that extension command overrides are skipped if the extension isn't installed."""
|
||||||
claude_dir = project_dir / ".claude" / "commands"
|
claude_dir = project_dir / ".claude" / "skills"
|
||||||
claude_dir.mkdir(parents=True)
|
claude_dir.mkdir(parents=True)
|
||||||
|
|
||||||
preset_dir = temp_dir / "ext-override-preset"
|
preset_dir = temp_dir / "ext-override-preset"
|
||||||
@@ -1869,7 +1870,7 @@ class TestSelfTestPreset:
|
|||||||
|
|
||||||
def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir):
|
def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir):
|
||||||
"""Test that extension command overrides ARE registered when the extension is installed."""
|
"""Test that extension command overrides ARE registered when the extension is installed."""
|
||||||
claude_dir = project_dir / ".claude" / "commands"
|
claude_dir = project_dir / ".claude" / "skills"
|
||||||
claude_dir.mkdir(parents=True)
|
claude_dir.mkdir(parents=True)
|
||||||
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True)
|
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True)
|
||||||
|
|
||||||
@@ -1905,8 +1906,8 @@ class TestSelfTestPreset:
|
|||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
manager.install_from_directory(preset_dir, "0.1.5")
|
manager.install_from_directory(preset_dir, "0.1.5")
|
||||||
|
|
||||||
cmd_file = claude_dir / "speckit.fakeext.cmd.md"
|
cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
||||||
assert cmd_file.exists(), "Command not registered despite extension being present"
|
assert cmd_file.exists(), "Skill not registered despite extension being present"
|
||||||
|
|
||||||
|
|
||||||
# ===== Init Options and Skills Tests =====
|
# ===== Init Options and Skills Tests =====
|
||||||
@@ -1964,7 +1965,7 @@ class TestPresetSkills:
|
|||||||
self._create_skill(skills_dir, "speckit-specify")
|
self._create_skill(skills_dir, "speckit-specify")
|
||||||
|
|
||||||
# Also create the claude commands dir so commands get registered
|
# Also create the claude commands dir so commands get registered
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Install self-test preset (has a command override for speckit.specify)
|
# Install self-test preset (has a command override for speckit.specify)
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
@@ -1983,12 +1984,10 @@ class TestPresetSkills:
|
|||||||
|
|
||||||
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||||
"""When --ai-skills was NOT used, preset install should not touch skills."""
|
"""When --ai-skills was NOT used, preset install should not touch skills."""
|
||||||
self._write_init_options(project_dir, ai="claude", ai_skills=False)
|
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".qwen" / "skills"
|
||||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||||
|
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||||
@@ -2019,18 +2018,16 @@ class TestPresetSkills:
|
|||||||
|
|
||||||
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
|
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
|
||||||
"""When no init-options.json exists, preset install should not touch skills."""
|
"""When no init-options.json exists, preset install should not touch skills."""
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".qwen" / "skills"
|
||||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||||
|
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||||
|
|
||||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||||
content = skill_file.read_text()
|
file_content = skill_file.read_text()
|
||||||
assert "untouched" in content
|
assert "untouched" in file_content
|
||||||
|
|
||||||
def test_skill_restored_on_preset_remove(self, project_dir, temp_dir):
|
def test_skill_restored_on_preset_remove(self, project_dir, temp_dir):
|
||||||
"""When a preset is removed, skills should be restored from core templates."""
|
"""When a preset is removed, skills should be restored from core templates."""
|
||||||
@@ -2038,7 +2035,7 @@ class TestPresetSkills:
|
|||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".claude" / "skills"
|
||||||
self._create_skill(skills_dir, "speckit-specify")
|
self._create_skill(skills_dir, "speckit-specify")
|
||||||
|
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Set up core command template in the project so restoration works
|
# Set up core command template in the project so restoration works
|
||||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||||
@@ -2068,7 +2065,7 @@ class TestPresetSkills:
|
|||||||
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
|
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".claude" / "skills"
|
||||||
self._create_skill(skills_dir, "speckit-specify", body="old")
|
self._create_skill(skills_dir, "speckit-specify", body="old")
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||||
core_cmds.mkdir(parents=True, exist_ok=True)
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -2094,13 +2091,11 @@ class TestPresetSkills:
|
|||||||
|
|
||||||
def test_skill_not_overridden_when_skill_path_is_file(self, project_dir):
|
def test_skill_not_overridden_when_skill_path_is_file(self, project_dir):
|
||||||
"""Preset install should skip non-directory skill targets."""
|
"""Preset install should skip non-directory skill targets."""
|
||||||
self._write_init_options(project_dir, ai="claude")
|
self._write_init_options(project_dir, ai="qwen")
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".qwen" / "skills"
|
||||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(skills_dir / "speckit-specify").write_text("not-a-directory")
|
(skills_dir / "speckit-specify").write_text("not-a-directory")
|
||||||
|
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||||
@@ -2114,8 +2109,6 @@ class TestPresetSkills:
|
|||||||
self._write_init_options(project_dir, ai="claude")
|
self._write_init_options(project_dir, ai="claude")
|
||||||
# Don't create skills dir — simulate --ai-skills never created them
|
# Don't create skills dir — simulate --ai-skills never created them
|
||||||
|
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||||
@@ -2516,16 +2509,15 @@ class TestPresetSkills:
|
|||||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
init_options.write_text("[]")
|
init_options.write_text("[]")
|
||||||
|
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".qwen" / "skills"
|
||||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||||
|
|
||||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||||
assert "untouched" in content
|
assert "untouched" in skill_content
|
||||||
|
|
||||||
|
|
||||||
class TestPresetSetPriority:
|
class TestPresetSetPriority:
|
||||||
|
|||||||
Reference in New Issue
Block a user