mirror of
https://github.com/github/spec-kit.git
synced 2026-03-26 15:23:09 +00:00
Compare commits
23 Commits
v0.3.2
...
copilot/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7396683bfb | ||
|
|
a5466f08de | ||
|
|
363b7dcab7 | ||
|
|
4f1b63a65a | ||
|
|
17810c8e85 | ||
|
|
5e49ec6936 | ||
|
|
312c37be25 | ||
|
|
086421daf1 | ||
|
|
4f83308540 | ||
|
|
e9c464db14 | ||
|
|
b72a5850fe | ||
|
|
a351c826ee | ||
|
|
6223d10d84 | ||
|
|
bf33980426 | ||
|
|
a7606c0f14 | ||
|
|
7d9361c716 | ||
|
|
191f33213c | ||
|
|
65ecd5321d | ||
|
|
d2559d7025 | ||
|
|
f85944aafe | ||
|
|
34171efcef | ||
|
|
c8af730b14 | ||
|
|
a4b60aca7f |
@@ -51,6 +51,10 @@ echo -e "\n🤖 Installing OpenCode CLI..."
|
|||||||
run_command "npm install -g opencode-ai@latest"
|
run_command "npm install -g opencode-ai@latest"
|
||||||
echo "✅ Done"
|
echo "✅ Done"
|
||||||
|
|
||||||
|
echo -e "\n🤖 Installing Junie CLI..."
|
||||||
|
run_command "npm install -g @jetbrains/junie-cli@latest"
|
||||||
|
echo "✅ Done"
|
||||||
|
|
||||||
echo -e "\n🤖 Installing Pi Coding Agent..."
|
echo -e "\n🤖 Installing Pi Coding Agent..."
|
||||||
run_command "npm install -g @mariozechner/pi-coding-agent@latest"
|
run_command "npm install -g @mariozechner/pi-coding-agent@latest"
|
||||||
echo "✅ Done"
|
echo "✅ Done"
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||||
|
|
||||||
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, IBM Bob, Antigravity
|
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: agent-name
|
id: agent-name
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -7,7 +7,7 @@ contact_links:
|
|||||||
url: https://github.com/github/spec-kit/blob/main/README.md
|
url: https://github.com/github/spec-kit/blob/main/README.md
|
||||||
about: Read the Spec Kit documentation and guides
|
about: Read the Spec Kit documentation and guides
|
||||||
- name: 🛠️ Extension Development Guide
|
- name: 🛠️ Extension Development Guide
|
||||||
url: https://github.com/manfredseee/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
|
url: https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
|
||||||
about: Learn how to develop and publish Spec Kit extensions
|
about: Learn how to develop and publish Spec Kit extensions
|
||||||
- name: 🤝 Contributing Guide
|
- name: 🤝 Contributing Guide
|
||||||
url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md
|
url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md
|
||||||
|
|||||||
169
.github/ISSUE_TEMPLATE/preset_submission.yml
vendored
Normal file
169
.github/ISSUE_TEMPLATE/preset_submission.yml
vendored
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
name: Preset Submission
|
||||||
|
description: Submit your preset to the Spec Kit preset catalog
|
||||||
|
title: "[Preset]: Add "
|
||||||
|
labels: ["preset-submission", "enhancement", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for contributing a preset! This template helps you submit your preset to the community catalog.
|
||||||
|
|
||||||
|
**Before submitting:**
|
||||||
|
- Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)
|
||||||
|
- Ensure your preset has a valid `preset.yml` manifest
|
||||||
|
- Create a GitHub release with a version tag (e.g., v1.0.0)
|
||||||
|
- Test installation from the release archive: `specify preset add --from <download-url>`
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: preset-id
|
||||||
|
attributes:
|
||||||
|
label: Preset ID
|
||||||
|
description: Unique preset identifier (lowercase with hyphens only)
|
||||||
|
placeholder: "e.g., healthcare-compliance"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: preset-name
|
||||||
|
attributes:
|
||||||
|
label: Preset Name
|
||||||
|
description: Human-readable preset name
|
||||||
|
placeholder: "e.g., Healthcare Compliance"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: Semantic version number
|
||||||
|
placeholder: "e.g., 1.0.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Brief description of what your preset does (under 200 characters)
|
||||||
|
placeholder: Enforces HIPAA-compliant spec workflows with audit templates and compliance checklists
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: author
|
||||||
|
attributes:
|
||||||
|
label: Author
|
||||||
|
description: Your name or organization
|
||||||
|
placeholder: "e.g., John Doe or Acme Corp"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: repository
|
||||||
|
attributes:
|
||||||
|
label: Repository URL
|
||||||
|
description: GitHub repository URL for your preset
|
||||||
|
placeholder: "https://github.com/your-org/spec-kit-your-preset"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: download-url
|
||||||
|
attributes:
|
||||||
|
label: Download URL
|
||||||
|
description: URL to the GitHub release archive for your preset (e.g., https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip)
|
||||||
|
placeholder: "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: license
|
||||||
|
attributes:
|
||||||
|
label: License
|
||||||
|
description: Open source license type
|
||||||
|
placeholder: "e.g., MIT, Apache-2.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: speckit-version
|
||||||
|
attributes:
|
||||||
|
label: Required Spec Kit Version
|
||||||
|
description: Minimum Spec Kit version required
|
||||||
|
placeholder: "e.g., >=0.3.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: templates-provided
|
||||||
|
attributes:
|
||||||
|
label: Templates Provided
|
||||||
|
description: List the template overrides your preset provides
|
||||||
|
placeholder: |
|
||||||
|
- spec-template.md — adds compliance section
|
||||||
|
- plan-template.md — includes audit checkpoints
|
||||||
|
- checklist-template.md — HIPAA compliance checklist
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: commands-provided
|
||||||
|
attributes:
|
||||||
|
label: Commands Provided (optional)
|
||||||
|
description: List any command overrides your preset provides
|
||||||
|
placeholder: |
|
||||||
|
- speckit.specify.md — customized for compliance workflows
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: tags
|
||||||
|
attributes:
|
||||||
|
label: Tags
|
||||||
|
description: 2-5 relevant tags (lowercase, separated by commas)
|
||||||
|
placeholder: "compliance, healthcare, hipaa, audit"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: features
|
||||||
|
attributes:
|
||||||
|
label: Key Features
|
||||||
|
description: List the main features and capabilities of your preset
|
||||||
|
placeholder: |
|
||||||
|
- HIPAA-compliant spec templates
|
||||||
|
- Audit trail checklists
|
||||||
|
- Compliance review workflow
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: testing
|
||||||
|
attributes:
|
||||||
|
label: Testing Checklist
|
||||||
|
description: Confirm that your preset has been tested
|
||||||
|
options:
|
||||||
|
- label: Preset installs successfully via `specify preset add`
|
||||||
|
required: true
|
||||||
|
- label: Template resolution works correctly after installation
|
||||||
|
required: true
|
||||||
|
- label: Documentation is complete and accurate
|
||||||
|
required: true
|
||||||
|
- label: Tested on at least one real project
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: requirements
|
||||||
|
attributes:
|
||||||
|
label: Submission Requirements
|
||||||
|
description: Verify your preset meets all requirements
|
||||||
|
options:
|
||||||
|
- label: Valid `preset.yml` manifest included
|
||||||
|
required: true
|
||||||
|
- label: README.md with description and usage instructions
|
||||||
|
required: true
|
||||||
|
- label: LICENSE file included
|
||||||
|
required: true
|
||||||
|
- label: GitHub release created with version tag
|
||||||
|
required: true
|
||||||
|
- label: Preset ID follows naming conventions (lowercase-with-hyphens)
|
||||||
|
required: true
|
||||||
8
.github/workflows/release-trigger.yml
vendored
8
.github/workflows/release-trigger.yml
vendored
@@ -86,8 +86,10 @@ jobs:
|
|||||||
if [ -f "CHANGELOG.md" ]; then
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
DATE=$(date +%Y-%m-%d)
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
# Get the previous tag to compare commits
|
# Get the previous tag by sorting all version tags numerically
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
# (git describe --tags only finds tags reachable from HEAD,
|
||||||
|
# which misses tags on unmerged release branches)
|
||||||
|
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | head -n 1)
|
||||||
|
|
||||||
echo "Generating changelog from commits..."
|
echo "Generating changelog from commits..."
|
||||||
if [[ -n "$PREVIOUS_TAG" ]]; then
|
if [[ -n "$PREVIOUS_TAG" ]]; then
|
||||||
@@ -104,7 +106,7 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "## [${{ steps.version.outputs.version }}] - $DATE"
|
echo "## [${{ steps.version.outputs.version }}] - $DATE"
|
||||||
echo ""
|
echo ""
|
||||||
echo "### Changed"
|
echo "### Changes"
|
||||||
echo ""
|
echo ""
|
||||||
echo "$COMMITS"
|
echo "$COMMITS"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ gh release create "$VERSION" \
|
|||||||
.genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \
|
.genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \
|
.genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-windsurf-ps-"$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-sh-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-codex-ps-"$VERSION".zip \
|
.genreleases/spec-kit-template-codex-ps-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \
|
.genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
.PARAMETER Agents
|
.PARAMETER Agents
|
||||||
Comma or space separated subset of agents to build (default: all)
|
Comma or space separated subset of agents to build (default: all)
|
||||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, iflow, generic
|
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, junie, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, iflow, generic
|
||||||
|
|
||||||
.PARAMETER Scripts
|
.PARAMETER Scripts
|
||||||
Comma or space separated subset of script types to build (default: both)
|
Comma or space separated subset of script types to build (default: both)
|
||||||
@@ -201,20 +201,26 @@ agent: $basename
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format.
|
# Create skills in <skills_dir>\<name>\SKILL.md format.
|
||||||
# Kimi CLI discovers skills as directories containing a SKILL.md file,
|
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||||
# invoked with /skill:<name> (e.g. /skill:speckit.specify).
|
# current dotted-name exception (e.g. speckit.plan).
|
||||||
function New-KimiSkills {
|
#
|
||||||
|
# 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(
|
param(
|
||||||
[string]$SkillsDir,
|
[string]$SkillsDir,
|
||||||
[string]$ScriptVariant
|
[string]$ScriptVariant,
|
||||||
|
[string]$AgentName,
|
||||||
|
[string]$Separator = '-'
|
||||||
)
|
)
|
||||||
|
|
||||||
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
|
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
foreach ($template in $templates) {
|
foreach ($template in $templates) {
|
||||||
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
|
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
|
||||||
$skillName = "speckit.$name"
|
$skillName = "speckit${Separator}$name"
|
||||||
$skillDir = Join-Path $SkillsDir $skillName
|
$skillDir = Join-Path $SkillsDir $skillName
|
||||||
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
|
||||||
|
|
||||||
@@ -267,7 +273,7 @@ function New-KimiSkills {
|
|||||||
|
|
||||||
$body = $outputLines -join "`n"
|
$body = $outputLines -join "`n"
|
||||||
$body = $body -replace '\{ARGS\}', '$ARGUMENTS'
|
$body = $body -replace '\{ARGS\}', '$ARGUMENTS'
|
||||||
$body = $body -replace '__AGENT__', 'kimi'
|
$body = $body -replace '__AGENT__', $AgentName
|
||||||
$body = Rewrite-Paths -Content $body
|
$body = Rewrite-Paths -Content $body
|
||||||
|
|
||||||
# Strip existing frontmatter, keep only body
|
# Strip existing frontmatter, keep only body
|
||||||
@@ -283,7 +289,7 @@ function New-KimiSkills {
|
|||||||
if ($inBody) { $templateBody += "$line`n" }
|
if ($inBody) { $templateBody += "$line`n" }
|
||||||
}
|
}
|
||||||
|
|
||||||
$skillContent = "---`nname: `"$skillName`"`ndescription: `"$description`"`n---`n`n$templateBody"
|
$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
|
Set-Content -Path (Join-Path $skillDir "SKILL.md") -Value $skillContent -NoNewline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,9 +401,14 @@ function Build-Variant {
|
|||||||
$cmdDir = Join-Path $baseDir ".windsurf/workflows"
|
$cmdDir = Join-Path $baseDir ".windsurf/workflows"
|
||||||
Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
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' {
|
'codex' {
|
||||||
$cmdDir = Join-Path $baseDir ".codex/prompts"
|
$skillsDir = Join-Path $baseDir ".agents/skills"
|
||||||
Generate-Commands -Agent 'codex' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||||
|
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-'
|
||||||
}
|
}
|
||||||
'kilocode' {
|
'kilocode' {
|
||||||
$cmdDir = Join-Path $baseDir ".kilocode/workflows"
|
$cmdDir = Join-Path $baseDir ".kilocode/workflows"
|
||||||
@@ -452,7 +463,7 @@ function Build-Variant {
|
|||||||
'kimi' {
|
'kimi' {
|
||||||
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
||||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||||
New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script
|
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'
|
||||||
}
|
}
|
||||||
'trae' {
|
'trae' {
|
||||||
$rulesDir = Join-Path $baseDir ".trae/rules"
|
$rulesDir = Join-Path $baseDir ".trae/rules"
|
||||||
@@ -483,7 +494,7 @@ function Build-Variant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Define all agents and scripts
|
# Define all agents and scripts
|
||||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'iflow', 'generic')
|
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'junie', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'iflow', 'generic')
|
||||||
$AllScripts = @('sh', 'ps')
|
$AllScripts = @('sh', 'ps')
|
||||||
|
|
||||||
function Normalize-List {
|
function Normalize-List {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
|||||||
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
||||||
# Version argument should include leading 'v'.
|
# Version argument should include leading 'v'.
|
||||||
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
||||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all)
|
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all)
|
||||||
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
||||||
# Examples:
|
# Examples:
|
||||||
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
||||||
@@ -26,9 +26,27 @@ fi
|
|||||||
echo "Building release packages for $NEW_VERSION"
|
echo "Building release packages for $NEW_VERSION"
|
||||||
|
|
||||||
# Create and use .genreleases directory for all build artifacts
|
# Create and use .genreleases directory for all build artifacts
|
||||||
GENRELEASES_DIR=".genreleases"
|
# 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"
|
mkdir -p "$GENRELEASES_DIR"
|
||||||
rm -rf "$GENRELEASES_DIR"/* || true
|
rm -rf "${GENRELEASES_DIR%/}/"* || true
|
||||||
|
|
||||||
rewrite_paths() {
|
rewrite_paths() {
|
||||||
sed -E \
|
sed -E \
|
||||||
@@ -121,18 +139,24 @@ EOF
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format.
|
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
||||||
# Kimi CLI discovers skills as directories containing a SKILL.md file,
|
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||||
# invoked with /skill:<name> (e.g. /skill:speckit.specify).
|
# current dotted-name exception (e.g. speckit.plan).
|
||||||
create_kimi_skills() {
|
#
|
||||||
|
# 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 skills_dir="$1"
|
||||||
local script_variant="$2"
|
local script_variant="$2"
|
||||||
|
local agent_name="$3"
|
||||||
|
local separator="${4:-"-"}"
|
||||||
|
|
||||||
for template in templates/commands/*.md; do
|
for template in templates/commands/*.md; do
|
||||||
[[ -f "$template" ]] || continue
|
[[ -f "$template" ]] || continue
|
||||||
local name
|
local name
|
||||||
name=$(basename "$template" .md)
|
name=$(basename "$template" .md)
|
||||||
local skill_name="speckit.${name}"
|
local skill_name="speckit${separator}${name}"
|
||||||
local skill_dir="${skills_dir}/${skill_name}"
|
local skill_dir="${skills_dir}/${skill_name}"
|
||||||
mkdir -p "$skill_dir"
|
mkdir -p "$skill_dir"
|
||||||
|
|
||||||
@@ -175,9 +199,9 @@ create_kimi_skills() {
|
|||||||
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
||||||
{ print }
|
{ print }
|
||||||
')
|
')
|
||||||
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed 's/__AGENT__/kimi/g' | rewrite_paths)
|
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths)
|
||||||
|
|
||||||
# Strip existing frontmatter and prepend Kimi frontmatter
|
# Strip existing frontmatter and prepend skills frontmatter.
|
||||||
local template_body
|
local template_body
|
||||||
template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found')
|
template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found')
|
||||||
|
|
||||||
@@ -185,6 +209,10 @@ create_kimi_skills() {
|
|||||||
printf -- '---\n'
|
printf -- '---\n'
|
||||||
printf 'name: "%s"\n' "$skill_name"
|
printf 'name: "%s"\n' "$skill_name"
|
||||||
printf 'description: "%s"\n' "$description"
|
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 -- '---\n\n'
|
||||||
printf '%s\n' "$template_body"
|
printf '%s\n' "$template_body"
|
||||||
} > "$skill_dir/SKILL.md"
|
} > "$skill_dir/SKILL.md"
|
||||||
@@ -218,7 +246,7 @@ build_variant() {
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" -exec cp --parents {} "$SPEC_DIR"/ \; ; echo "Copied templates -> .specify/templates"; }
|
[[ -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
|
case $agent in
|
||||||
claude)
|
claude)
|
||||||
@@ -248,9 +276,12 @@ build_variant() {
|
|||||||
windsurf)
|
windsurf)
|
||||||
mkdir -p "$base_dir/.windsurf/workflows"
|
mkdir -p "$base_dir/.windsurf/workflows"
|
||||||
generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;;
|
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)
|
codex)
|
||||||
mkdir -p "$base_dir/.codex/prompts"
|
mkdir -p "$base_dir/.agents/skills"
|
||||||
generate_commands codex md "\$ARGUMENTS" "$base_dir/.codex/prompts" "$script" ;;
|
create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;;
|
||||||
kilocode)
|
kilocode)
|
||||||
mkdir -p "$base_dir/.kilocode/workflows"
|
mkdir -p "$base_dir/.kilocode/workflows"
|
||||||
generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;;
|
generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;;
|
||||||
@@ -290,7 +321,7 @@ build_variant() {
|
|||||||
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
||||||
kimi)
|
kimi)
|
||||||
mkdir -p "$base_dir/.kimi/skills"
|
mkdir -p "$base_dir/.kimi/skills"
|
||||||
create_kimi_skills "$base_dir/.kimi/skills" "$script" ;;
|
create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
|
||||||
trae)
|
trae)
|
||||||
mkdir -p "$base_dir/.trae/rules"
|
mkdir -p "$base_dir/.trae/rules"
|
||||||
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
||||||
@@ -309,37 +340,38 @@ build_variant() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Determine agent list
|
# Determine agent list
|
||||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic)
|
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic)
|
||||||
ALL_SCRIPTS=(sh ps)
|
ALL_SCRIPTS=(sh ps)
|
||||||
|
|
||||||
norm_list() {
|
|
||||||
tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?"\n":"") $i);out=1}}}END{printf("\n")}'
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_subset() {
|
validate_subset() {
|
||||||
local type=$1; shift; local -n allowed=$1; shift; local items=("$@")
|
local type=$1; shift
|
||||||
|
local allowed_str="$1"; shift
|
||||||
local invalid=0
|
local invalid=0
|
||||||
for it in "${items[@]}"; do
|
for it in "$@"; do
|
||||||
local found=0
|
local found=0
|
||||||
for a in "${allowed[@]}"; do [[ $it == "$a" ]] && { found=1; break; }; done
|
for a in $allowed_str; do
|
||||||
|
if [[ "$it" == "$a" ]]; then found=1; break; fi
|
||||||
|
done
|
||||||
if [[ $found -eq 0 ]]; then
|
if [[ $found -eq 0 ]]; then
|
||||||
echo "Error: unknown $type '$it' (allowed: ${allowed[*]})" >&2
|
echo "Error: unknown $type '$it' (allowed: $allowed_str)" >&2
|
||||||
invalid=1
|
invalid=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
return $invalid
|
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
|
if [[ -n ${AGENTS:-} ]]; then
|
||||||
mapfile -t AGENT_LIST < <(printf '%s' "$AGENTS" | norm_list)
|
read -ra AGENT_LIST <<< "$(printf '%s' "$AGENTS" | read_list)"
|
||||||
validate_subset agent ALL_AGENTS "${AGENT_LIST[@]}" || exit 1
|
validate_subset agent "${ALL_AGENTS[*]}" "${AGENT_LIST[@]}" || exit 1
|
||||||
else
|
else
|
||||||
AGENT_LIST=("${ALL_AGENTS[@]}")
|
AGENT_LIST=("${ALL_AGENTS[@]}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n ${SCRIPTS:-} ]]; then
|
if [[ -n ${SCRIPTS:-} ]]; then
|
||||||
mapfile -t SCRIPT_LIST < <(printf '%s' "$SCRIPTS" | norm_list)
|
read -ra SCRIPT_LIST <<< "$(printf '%s' "$SCRIPTS" | read_list)"
|
||||||
validate_subset script ALL_SCRIPTS "${SCRIPT_LIST[@]}" || exit 1
|
validate_subset script "${ALL_SCRIPTS[*]}" "${SCRIPT_LIST[@]}" || exit 1
|
||||||
else
|
else
|
||||||
SCRIPT_LIST=("${ALL_SCRIPTS[@]}")
|
SCRIPT_LIST=("${ALL_SCRIPTS[@]}")
|
||||||
fi
|
fi
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -39,4 +39,4 @@ jobs:
|
|||||||
any-of-labels: ''
|
any-of-labels: ''
|
||||||
|
|
||||||
# Operations per run (helps avoid rate limits)
|
# Operations per run (helps avoid rate limits)
|
||||||
operations-per-run: 100
|
operations-per-run: 250
|
||||||
|
|||||||
11
AGENTS.md
11
AGENTS.md
@@ -33,8 +33,9 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
|||||||
| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI |
|
| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI |
|
||||||
| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI |
|
| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI |
|
||||||
| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI |
|
| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI |
|
||||||
| **Codex CLI** | `.codex/prompts/` | Markdown | `codex` | Codex CLI |
|
| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (skills) |
|
||||||
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
|
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
|
||||||
|
| **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains |
|
||||||
| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
|
| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
|
||||||
| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI |
|
| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI |
|
||||||
| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE |
|
| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE |
|
||||||
@@ -318,6 +319,7 @@ Require a command-line tool to be installed:
|
|||||||
- **Cursor**: `cursor-agent` CLI
|
- **Cursor**: `cursor-agent` CLI
|
||||||
- **Qwen Code**: `qwen` CLI
|
- **Qwen Code**: `qwen` CLI
|
||||||
- **opencode**: `opencode` CLI
|
- **opencode**: `opencode` CLI
|
||||||
|
- **Junie**: `junie` CLI
|
||||||
- **Kiro CLI**: `kiro-cli` CLI
|
- **Kiro CLI**: `kiro-cli` CLI
|
||||||
- **CodeBuddy CLI**: `codebuddy` CLI
|
- **CodeBuddy CLI**: `codebuddy` CLI
|
||||||
- **Qoder CLI**: `qodercli` CLI
|
- **Qoder CLI**: `qodercli` CLI
|
||||||
@@ -339,7 +341,7 @@ Work within integrated development environments:
|
|||||||
|
|
||||||
### Markdown Format
|
### Markdown Format
|
||||||
|
|
||||||
Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi
|
Used by: Claude, Cursor, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi
|
||||||
|
|
||||||
**Standard format:**
|
**Standard format:**
|
||||||
|
|
||||||
@@ -377,8 +379,9 @@ Command content with {SCRIPT} and {{args}} placeholders.
|
|||||||
## Directory Conventions
|
## Directory Conventions
|
||||||
|
|
||||||
- **CLI agents**: Usually `.<agent-name>/commands/`
|
- **CLI agents**: Usually `.<agent-name>/commands/`
|
||||||
- **Common prompt-based exceptions**:
|
- **Skills-based exceptions**:
|
||||||
- Codex: `.codex/prompts/`
|
- Codex: `.agents/skills/` (skills, invoked as `$speckit-<command>`)
|
||||||
|
- **Prompt-based exceptions**:
|
||||||
- Kiro CLI: `.kiro/prompts/`
|
- Kiro CLI: `.kiro/prompts/`
|
||||||
- Pi: `.pi/prompts/`
|
- Pi: `.pi/prompts/`
|
||||||
- **IDE agents**: Follow IDE-specific patterns:
|
- **IDE agents**: Follow IDE-specific patterns:
|
||||||
|
|||||||
174
CHANGELOG.md
174
CHANGELOG.md
@@ -1,17 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
## [0.4.0] - 2026-03-23
|
||||||
|
|
||||||
Recent changes to the Specify CLI and templates are documented here.
|
### Changes
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
- fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936)
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
- fix(codex): native skills fallback refresh + legacy prompt suppression (#1930)
|
||||||
|
- feat(cli): embed core pack in wheel for offline/air-gapped deployment (#1803)
|
||||||
|
- ci: increase stale workflow operations-per-run to 250 (#1922)
|
||||||
|
- docs: update publishing guide with Category and Effect columns (#1913)
|
||||||
|
- fix: Align native skills frontmatter with install_ai_skills (#1920)
|
||||||
|
- feat: add timestamp-based branch naming option for `specify init` (#1911)
|
||||||
|
- docs: add Extension Comparison Guide for community extensions (#1897)
|
||||||
|
- docs: update SUPPORT.md, fix issue templates, add preset submission template (#1910)
|
||||||
|
- Add support for Junie (#1831)
|
||||||
|
- feat: migrate Codex/agy init to native skills workflow (#1906)
|
||||||
|
- chore: bump version to 0.3.2 (#1909)
|
||||||
|
|
||||||
## [0.3.2] - 2026-03-19
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Add conduct extension to community catalog (#1908)
|
|
||||||
- feat(extensions): add verify-tasks extension to community catalog (#1871)
|
- feat(extensions): add verify-tasks extension to community catalog (#1871)
|
||||||
- feat(presets): add enable/disable toggle and update semantics (#1891)
|
- feat(presets): add enable/disable toggle and update semantics (#1891)
|
||||||
- feat: add iFlow CLI support (#1875)
|
- feat: add iFlow CLI support (#1875)
|
||||||
@@ -26,78 +31,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- chore: update DocGuard extension to v0.9.10 (#1890)
|
- chore: update DocGuard extension to v0.9.10 (#1890)
|
||||||
- Feature/spec kit add pi coding agent pullrequest (#1853)
|
- Feature/spec kit add pi coding agent pullrequest (#1853)
|
||||||
- feat: register spec-kit-learn extension (#1883)
|
- feat: register spec-kit-learn extension (#1883)
|
||||||
- chore: bump version to 0.3.1 (#1880)
|
|
||||||
- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878)
|
|
||||||
- fix(ai-skills): exclude non-speckit copilot agent markdown from skill… (#1867)
|
|
||||||
- feat: add Trae IDE support as a new agent (#1817)
|
|
||||||
- feat(cli): polite deep merge for settings.json and support JSONC (#1874)
|
|
||||||
- feat(extensions,presets): add priority-based resolution ordering (#1855)
|
|
||||||
- fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876)
|
|
||||||
- fix(scripts): harden bash scripts — escape, compat, and error handling (#1869)
|
|
||||||
- Add cognitive-squad to community extension catalog (#1870)
|
|
||||||
- docs: add Go / React brownfield walkthrough to community walkthroughs (#1868)
|
|
||||||
- chore: update DocGuard extension to v0.9.8 (#1859)
|
|
||||||
- Feature: add specify status command (#1837)
|
|
||||||
- fix(extensions): show extension ID in list output (#1843)
|
|
||||||
- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)
|
|
||||||
- feat: Add DocGuard CDD enforcement extension to community catalog (#1838)
|
|
||||||
- chore: bump version to 0.3.0 (#1839)
|
|
||||||
- feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
|
|
||||||
- fix: match 'Last updated' timestamp with or without bold markers (#1836)
|
|
||||||
- Add specify doctor command for project health diagnostics (#1828)
|
|
||||||
- fix: harden bash scripts against shell injection and improve robustness (#1809)
|
|
||||||
- fix: clean up command templates (specify, analyze) (#1810)
|
|
||||||
- fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730)
|
|
||||||
- fix(cli): deprecate explicit command support for agy (#1798) (#1808)
|
|
||||||
- Add /selftest.extension core extension to test other extensions (#1758)
|
|
||||||
- feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776)
|
|
||||||
- Add Java brownfield walkthrough to community walkthroughs (#1820)
|
|
||||||
- chore: bump version to 0.2.1 (#1813)
|
|
||||||
- Added February 2026 newsletter (#1812)
|
|
||||||
- feat: add Kimi Code CLI agent support (#1790)
|
|
||||||
- docs: fix broken links in quickstart guide (#1759) (#1797)
|
|
||||||
- docs: add catalog cli help documentation (#1793) (#1794)
|
|
||||||
- fix: use quiet checkout to avoid exception on git checkout (#1792)
|
|
||||||
- feat(extensions): support .extensionignore to exclude files during install (#1781)
|
|
||||||
- feat: add Codex support for extension command registration (#1767)
|
|
||||||
- chore: bump version to 0.2.0 (#1786)
|
|
||||||
- fix: sync agent list comments with actual supported agents (#1785)
|
|
||||||
- feat(extensions): support multiple active catalogs simultaneously (#1720)
|
|
||||||
- Pavel/add tabnine cli support (#1503)
|
|
||||||
- Add Understanding extension to community catalog (#1778)
|
|
||||||
- Add ralph extension to community catalog (#1780)
|
|
||||||
- Update README with project initialization instructions (#1772)
|
|
||||||
- feat: add review extension to community catalog (#1775)
|
|
||||||
- Add fleet extension to community catalog (#1771)
|
|
||||||
- Integration of Mistral vibe support into speckit (#1725)
|
|
||||||
- fix: Remove duplicate options in specify.md (#1765)
|
|
||||||
- fix: use global branch numbering instead of per-short-name detection (#1757)
|
|
||||||
- Add Community Walkthroughs section to README (#1766)
|
|
||||||
- feat(extensions): add Jira Integration to community catalog (#1764)
|
|
||||||
- Add Azure DevOps Integration extension to community catalog (#1734)
|
|
||||||
- Fix docs: update Antigravity link and add initialization example (#1748)
|
|
||||||
- fix: wire after_tasks and after_implement hook events into command templates (#1702)
|
|
||||||
- make c ignores consistent with c++ (#1747)
|
|
||||||
- chore: bump version to 0.1.13 (#1746)
|
|
||||||
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
|
|
||||||
- feat: add verify extension to community catalog (#1726)
|
|
||||||
- Add Retrospective Extension to community catalog README table (#1741)
|
|
||||||
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
|
|
||||||
- fix: correct Copilot extension command registration (#1724)
|
|
||||||
- fix(implement): remove Makefile from C ignore patterns (#1558)
|
|
||||||
- Add sync extension to community catalog (#1728)
|
|
||||||
- fix(checklist): clarify file handling behavior for append vs create (#1556)
|
|
||||||
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
|
|
||||||
- chore: bump version to 0.1.12 (#1737)
|
|
||||||
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
|
|
||||||
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
|
||||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-03-19
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- chore: bump version to 0.3.2
|
||||||
|
- Add conduct extension to community catalog (#1908)
|
||||||
|
|
||||||
## [0.3.1] - 2026-03-17
|
## [0.3.1] - 2026-03-17
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- chore: bump version to 0.3.1
|
||||||
- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878)
|
- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878)
|
||||||
- fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867)
|
- fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867)
|
||||||
- feat: add Trae IDE support as a new agent (#1817)
|
- feat: add Trae IDE support as a new agent (#1817)
|
||||||
@@ -113,52 +59,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)
|
- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)
|
||||||
- feat: Add DocGuard CDD enforcement extension to community catalog (#1838)
|
- feat: Add DocGuard CDD enforcement extension to community catalog (#1838)
|
||||||
|
|
||||||
|
|
||||||
## [0.3.0] - 2026-03-13
|
## [0.3.0] - 2026-03-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- No changes have been documented for this release yet.
|
- chore: bump version to 0.3.0
|
||||||
|
- feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
|
||||||
<!-- Entries for 0.2.x and earlier releases are documented in their respective sections below. -->
|
- fix: match 'Last updated' timestamp with or without bold markers (#1836)
|
||||||
- make c ignores consistent with c++ (#1747)
|
- Add specify doctor command for project health diagnostics (#1828)
|
||||||
- chore: bump version to 0.1.13 (#1746)
|
- fix: harden bash scripts against shell injection and improve robustness (#1809)
|
||||||
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
|
- fix: clean up command templates (specify, analyze) (#1810)
|
||||||
- feat: add verify extension to community catalog (#1726)
|
- fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730)
|
||||||
- Add Retrospective Extension to community catalog README table (#1741)
|
- fix(cli): deprecate explicit command support for agy (#1798) (#1808)
|
||||||
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
|
- Add /selftest.extension core extension to test other extensions (#1758)
|
||||||
- fix: correct Copilot extension command registration (#1724)
|
- feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776)
|
||||||
- fix(implement): remove Makefile from C ignore patterns (#1558)
|
- Add Java brownfield walkthrough to community walkthroughs (#1820)
|
||||||
- Add sync extension to community catalog (#1728)
|
|
||||||
- fix(checklist): clarify file handling behavior for append vs create (#1556)
|
|
||||||
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
|
|
||||||
- chore: bump version to 0.1.12 (#1737)
|
|
||||||
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
|
|
||||||
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
|
||||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
|
||||||
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks
|
|
||||||
- feat(presets): Pluggable preset system with preset catalog and template resolver
|
|
||||||
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
|
|
||||||
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
|
|
||||||
- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info`
|
|
||||||
- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management
|
|
||||||
- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system
|
|
||||||
- `--preset` option for `specify init` to install presets during initialization
|
|
||||||
- Priority-based preset resolution: presets with lower priority number win (`--priority` flag)
|
|
||||||
- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts
|
|
||||||
- Template resolution priority stack: overrides → presets → extensions → core
|
|
||||||
- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`)
|
|
||||||
- Preset scaffold directory (`presets/scaffold/`)
|
|
||||||
- Scripts updated to use template resolution instead of hardcoded paths
|
|
||||||
- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init
|
|
||||||
- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations
|
|
||||||
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
|
|
||||||
|
|
||||||
## [0.2.1] - 2026-03-11
|
## [0.2.1] - 2026-03-11
|
||||||
|
|
||||||
@@ -385,28 +300,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Add pytest and Python linting (ruff) to CI (#1637)
|
- Add pytest and Python linting (ruff) to CI (#1637)
|
||||||
- feat: add pull request template for better contribution guidelines (#1634)
|
- feat: add pull request template for better contribution guidelines (#1634)
|
||||||
|
|
||||||
## [0.0.99] - 2026-02-19
|
|
||||||
|
|
||||||
- Feat/ai skills (#1632)
|
|
||||||
|
|
||||||
## [0.0.98] - 2026-02-19
|
|
||||||
|
|
||||||
- chore(deps): bump actions/stale from 9 to 10 (#1623)
|
|
||||||
- feat: add dependabot configuration for pip and GitHub Actions updates (#1622)
|
|
||||||
|
|
||||||
## [0.0.97] - 2026-02-18
|
|
||||||
|
|
||||||
- Remove Maintainers section from README.md (#1618)
|
|
||||||
|
|
||||||
## [0.0.96] - 2026-02-17
|
|
||||||
|
|
||||||
- fix: typo in plan-template.md (#1446)
|
|
||||||
|
|
||||||
## [0.0.95] - 2026-02-12
|
|
||||||
|
|
||||||
- Feat: add a new agent: Google Anti Gravity (#1220)
|
|
||||||
|
|
||||||
## [0.0.94] - 2026-02-11
|
|
||||||
|
|
||||||
- Add stale workflow for 180-day inactive issues and PRs (#1594)
|
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -49,9 +49,13 @@ Choose your preferred installation method:
|
|||||||
|
|
||||||
#### Option 1: Persistent Installation (Recommended)
|
#### Option 1: Persistent Installation (Recommended)
|
||||||
|
|
||||||
Install once and use everywhere:
|
Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||||
|
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||||
|
|
||||||
|
# Or install latest from main (may include unreleased changes)
|
||||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
|
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -73,7 +77,7 @@ specify check
|
|||||||
To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade:
|
To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Option 2: One-time Usage
|
#### Option 2: One-time Usage
|
||||||
@@ -81,13 +85,13 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki
|
|||||||
Run directly without installing:
|
Run directly without installing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create new project
|
# Create new project (pinned to a stable release — replace vX.Y.Z with the latest tag)
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||||
|
|
||||||
# Or initialize in existing project
|
# Or initialize in existing project
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init . --ai claude
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai claude
|
||||||
# or
|
# or
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai claude
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai claude
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits of persistent installation:**
|
**Benefits of persistent installation:**
|
||||||
@@ -97,9 +101,13 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c
|
|||||||
- Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall`
|
- Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall`
|
||||||
- Cleaner shell configuration
|
- Cleaner shell configuration
|
||||||
|
|
||||||
|
#### Option 3: Enterprise / Air-Gapped Installation
|
||||||
|
|
||||||
|
If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) guide for step-by-step instructions on using `pip download` to create portable, OS-specific wheel bundles on a connected machine.
|
||||||
|
|
||||||
### 2. Establish project principles
|
### 2. Establish project principles
|
||||||
|
|
||||||
Launch your AI assistant in the project directory. The `/speckit.*` commands are available in the assistant.
|
Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||||
|
|
||||||
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
||||||
|
|
||||||
@@ -173,7 +181,7 @@ See Spec-Driven Development in action across different scenarios with these comm
|
|||||||
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
|
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
|
||||||
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
|
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
|
||||||
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
|
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
|
||||||
| [Codex CLI](https://github.com/openai/codex) | ✅ | |
|
| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-<command>`. |
|
||||||
| [Cursor](https://cursor.sh/) | ✅ | |
|
| [Cursor](https://cursor.sh/) | ✅ | |
|
||||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
|
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
|
||||||
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
|
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
|
||||||
@@ -190,6 +198,7 @@ See Spec-Driven Development in action across different scenarios with these comm
|
|||||||
| [Kimi Code](https://code.kimi.com/) | ✅ | |
|
| [Kimi Code](https://code.kimi.com/) | ✅ | |
|
||||||
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | |
|
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | |
|
||||||
| [Windsurf](https://windsurf.com/) | ✅ | |
|
| [Windsurf](https://windsurf.com/) | ✅ | |
|
||||||
|
| [Junie](https://junie.jetbrains.com/) | ✅ | |
|
||||||
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
|
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
|
||||||
| [Trae](https://www.trae.ai/) | ✅ | |
|
| [Trae](https://www.trae.ai/) | ✅ | |
|
||||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||||
@@ -201,16 +210,16 @@ The `specify` command supports the following options:
|
|||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `init` | Initialize a new Specify project from the latest template |
|
| `init` | Initialize a new Specify project from the latest template |
|
||||||
| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, etc.) |
|
| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, etc.) |
|
||||||
|
|
||||||
### `specify init` Arguments & Options
|
### `specify init` Arguments & Options
|
||||||
|
|
||||||
| Argument/Option | Type | Description |
|
| Argument/Option | Type | Description |
|
||||||
| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
|
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
|
||||||
| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, or `generic` (requires `--ai-commands-dir`) |
|
| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, or `generic` (requires `--ai-commands-dir`) |
|
||||||
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
|
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
|
||||||
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
|
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||||
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
|
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
|
||||||
@@ -221,6 +230,7 @@ The `specify` command supports the following options:
|
|||||||
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
||||||
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
|
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
|
||||||
|
| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts |
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
@@ -258,6 +268,9 @@ specify init my-project --ai bob
|
|||||||
# Initialize with Pi Coding Agent support
|
# Initialize with Pi Coding Agent support
|
||||||
specify init my-project --ai pi
|
specify init my-project --ai pi
|
||||||
|
|
||||||
|
# Initialize with Codex CLI support
|
||||||
|
specify init my-project --ai codex --ai-skills
|
||||||
|
|
||||||
# Initialize with Antigravity support
|
# Initialize with Antigravity support
|
||||||
specify init my-project --ai agy --ai-skills
|
specify init my-project --ai agy --ai-skills
|
||||||
|
|
||||||
@@ -292,13 +305,18 @@ specify init my-project --ai claude --ai-skills
|
|||||||
# Initialize in current directory with agent skills
|
# Initialize in current directory with agent skills
|
||||||
specify init --here --ai gemini --ai-skills
|
specify init --here --ai gemini --ai-skills
|
||||||
|
|
||||||
|
# Use timestamp-based branch numbering (useful for distributed teams)
|
||||||
|
specify init my-project --ai claude --branch-numbering timestamp
|
||||||
|
|
||||||
# Check system requirements
|
# Check system requirements
|
||||||
specify check
|
specify check
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Slash Commands
|
### Available Slash Commands
|
||||||
|
|
||||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development:
|
After running `specify init`, your AI coding agent will have access to these slash commands for structured development.
|
||||||
|
|
||||||
|
For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`.
|
||||||
|
|
||||||
#### Core Commands
|
#### Core Commands
|
||||||
|
|
||||||
@@ -484,11 +502,11 @@ specify init <project_name> --ai copilot
|
|||||||
|
|
||||||
# Or in current directory:
|
# Or in current directory:
|
||||||
specify init . --ai claude
|
specify init . --ai claude
|
||||||
specify init . --ai codex
|
specify init . --ai codex --ai-skills
|
||||||
|
|
||||||
# or use --here flag
|
# or use --here flag
|
||||||
specify init --here --ai claude
|
specify init --here --ai claude
|
||||||
specify init --here --ai codex
|
specify init --here --ai codex --ai-skills
|
||||||
|
|
||||||
# Force merge into a non-empty current directory
|
# Force merge into a non-empty current directory
|
||||||
specify init . --force --ai claude
|
specify init . --force --ai claude
|
||||||
|
|||||||
13
SUPPORT.md
13
SUPPORT.md
@@ -1,18 +1,17 @@
|
|||||||
# Support
|
# Support
|
||||||
|
|
||||||
## How to file issues and get help
|
## How to get help
|
||||||
|
|
||||||
This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue.
|
Please search existing [issues](https://github.com/github/spec-kit/issues) and [discussions](https://github.com/github/spec-kit/discussions) before creating new ones to avoid duplicates.
|
||||||
|
|
||||||
For help or questions about using this project, please:
|
|
||||||
|
|
||||||
- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports, feature requests, or questions about the Spec-Driven Development methodology
|
|
||||||
- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process
|
|
||||||
- Review the [README](./README.md) for getting started instructions and troubleshooting tips
|
- Review the [README](./README.md) for getting started instructions and troubleshooting tips
|
||||||
|
- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process
|
||||||
|
- Ask in [GitHub Discussions](https://github.com/github/spec-kit/discussions) for questions about using Spec Kit or the Spec-Driven Development methodology
|
||||||
|
- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports and feature requests
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Spec Kit** is under active development and maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support, feature requests, and community questions in a timely manner.
|
**Spec Kit** is under active development and maintained by GitHub staff and the community. We will do our best to respond to support, feature requests, and community questions as time permits.
|
||||||
|
|
||||||
## GitHub Support Policy
|
## GitHub Support Policy
|
||||||
|
|
||||||
|
|||||||
@@ -12,18 +12,22 @@
|
|||||||
|
|
||||||
### Initialize a New Project
|
### Initialize a New Project
|
||||||
|
|
||||||
The easiest way to get started is to initialize a new project:
|
The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||||
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||||
|
|
||||||
|
# Or install latest from main (may include unreleased changes)
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
||||||
```
|
```
|
||||||
|
|
||||||
Or initialize in the current directory:
|
Or initialize in the current directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init .
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init .
|
||||||
# or use the --here flag
|
# or use the --here flag
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init --here
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here
|
||||||
```
|
```
|
||||||
|
|
||||||
### Specify AI Agent
|
### Specify AI Agent
|
||||||
@@ -31,11 +35,11 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here
|
|||||||
You can proactively specify your AI agent during initialization:
|
You can proactively specify your AI agent during initialization:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai claude
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai claude
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai gemini
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai gemini
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai copilot
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai copilot
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai codebuddy
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai codebuddy
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai pi
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai pi
|
||||||
```
|
```
|
||||||
|
|
||||||
### Specify Script Type (Shell vs PowerShell)
|
### Specify Script Type (Shell vs PowerShell)
|
||||||
@@ -51,8 +55,8 @@ Auto behavior:
|
|||||||
Force a specific script type:
|
Force a specific script type:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --script sh
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --script sh
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --script ps
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --script ps
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ignore Agent Tools Check
|
### Ignore Agent Tools Check
|
||||||
@@ -60,7 +64,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <project_name
|
|||||||
If you prefer to get the templates without checking for the right tools:
|
If you prefer to get the templates without checking for the right tools:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai claude --ignore-agent-tools
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai claude --ignore-agent-tools
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
@@ -75,6 +79,52 @@ The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Enterprise / Air-Gapped Installation
|
||||||
|
|
||||||
|
If your environment blocks access to PyPI (you see 403 errors when running `uv tool install` or `pip install`), you can create a portable wheel bundle on a connected machine and transfer it to the air-gapped target.
|
||||||
|
|
||||||
|
**Step 1: Build the wheel on a connected machine (same OS and Python version as the target)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/github/spec-kit.git
|
||||||
|
cd spec-kit
|
||||||
|
|
||||||
|
# Build the wheel
|
||||||
|
pip install build
|
||||||
|
python -m build --wheel --outdir dist/
|
||||||
|
|
||||||
|
# Download the wheel and all its runtime dependencies
|
||||||
|
pip download -d dist/ dist/specify_cli-*.whl
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important:** `pip download` resolves platform-specific wheels (e.g., PyYAML includes native extensions). You must run this step on a machine with the **same OS and Python version** as the air-gapped target. If you need to support multiple platforms, repeat this step on each target OS (Linux, macOS, Windows) and Python version.
|
||||||
|
|
||||||
|
**Step 2: Transfer the `dist/` directory to the air-gapped machine**
|
||||||
|
|
||||||
|
Copy the entire `dist/` directory (which contains the `specify-cli` wheel and all dependency wheels) to the target machine via USB, network share, or other approved transfer method.
|
||||||
|
|
||||||
|
**Step 3: Install on the air-gapped machine**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --no-index --find-links=./dist specify-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Initialize a project (no network required)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize a project — no GitHub access needed
|
||||||
|
specify init my-project --ai claude --offline
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub.
|
||||||
|
|
||||||
|
> **Deprecation notice:** Starting with v0.6.0, `specify init` will use bundled assets by default and the `--offline` flag will be removed. The GitHub download path will be retired because bundled assets eliminate the need for network access, avoid proxy/firewall issues, and guarantee that templates always match the installed CLI version. No action will be needed — `specify init` will simply work without network access out of the box.
|
||||||
|
|
||||||
|
> **Note:** Python 3.11+ is required.
|
||||||
|
|
||||||
|
> **Windows note:** Offline scaffolding requires PowerShell 7+ (`pwsh`), not Windows PowerShell 5.x (`powershell.exe`). Install from https://aka.ms/powershell.
|
||||||
|
|
||||||
### Git Credential Manager on Linux
|
### Git Credential Manager on Linux
|
||||||
|
|
||||||
If you're having issues with Git authentication on Linux, you can install Git Credential Manager:
|
If you're having issues with Git authentication on Linux, you can install Git Credential Manager:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
| What to Upgrade | Command | When to Use |
|
| What to Upgrade | Command | When to Use |
|
||||||
|----------------|---------|-------------|
|
|----------------|---------|-------------|
|
||||||
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git` | Get latest CLI features without touching project files |
|
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
|
||||||
| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |
|
| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||||
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||||
|
|
||||||
@@ -20,16 +20,18 @@ The CLI tool (`specify`) is separate from your project files. Upgrade it to get
|
|||||||
|
|
||||||
### If you installed with `uv tool install`
|
### If you installed with `uv tool install`
|
||||||
|
|
||||||
|
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
### If you use one-shot `uvx` commands
|
### If you use one-shot `uvx` commands
|
||||||
|
|
||||||
No upgrade needed—`uvx` always fetches the latest version. Just run your commands as normal:
|
Specify the desired release tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai copilot
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify the upgrade
|
### Verify the upgrade
|
||||||
|
|||||||
@@ -209,9 +209,22 @@ Edit `extensions/catalog.community.json` and add your extension:
|
|||||||
Add your extension to the Available Extensions table in `extensions/README.md`:
|
Add your extension to the Available Extensions table in `extensions/README.md`:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
| Your Extension Name | Brief description of what it does | [repo-name](https://github.com/your-org/spec-kit-your-extension) |
|
| Your Extension Name | Brief description of what it does | `<category>` | <effect> | [repo-name](https://github.com/your-org/spec-kit-your-extension) |
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**(Table) Category** — pick the one that best fits your extension:
|
||||||
|
|
||||||
|
- `docs` — reads, validates, or generates spec artifacts
|
||||||
|
- `code` — reviews, validates, or modifies source code
|
||||||
|
- `process` — orchestrates workflow across phases
|
||||||
|
- `integration` — syncs with external platforms
|
||||||
|
- `visibility` — reports on project health or progress
|
||||||
|
|
||||||
|
**Effect** — choose one:
|
||||||
|
|
||||||
|
- Read-only — produces reports without modifying files
|
||||||
|
- Read+Write — modifies files, creates artifacts, or updates specs
|
||||||
|
|
||||||
Insert your extension in alphabetical order in the table.
|
Insert your extension in alphabetical order in the table.
|
||||||
|
|
||||||
### 4. Submit Pull Request
|
### 4. Submit Pull Request
|
||||||
|
|||||||
@@ -70,30 +70,34 @@ specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/ta
|
|||||||
|
|
||||||
The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json):
|
The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json):
|
||||||
|
|
||||||
| Extension | Purpose | URL |
|
**Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress
|
||||||
|-----------|---------|-----|
|
|
||||||
| Archive Extension | Archive merged features into main project memory. | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
**Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
|
||||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
| Extension | Purpose | Category | Effect | URL |
|
||||||
| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
|
|-----------|---------|----------|--------|-----|
|
||||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
|
||||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||||
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | [understanding](https://github.com/Testimonial/understanding) |
|
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||||
|
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) |
|
||||||
|
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||||
|
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||||
|
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||||
|
|
||||||
|
|
||||||
## Adding Your Extension
|
## Adding Your Extension
|
||||||
|
|||||||
71
extensions/git/README.md
Normal file
71
extensions/git/README.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Git Branching Workflow Extension
|
||||||
|
|
||||||
|
Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection for Spec Kit.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This extension provides Git branching operations as an optional, self-contained module. It manages:
|
||||||
|
|
||||||
|
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
||||||
|
- **Branch validation** to ensure branches follow naming conventions
|
||||||
|
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
|
||||||
|
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
|
||||||
|
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
| Event | Command | Optional | Description |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
|
||||||
|
| `after_implement` | `speckit.git.validate` | Yes | Validate branch naming after implementation |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Branch numbering strategy: "sequential" or "timestamp"
|
||||||
|
branch_numbering: sequential
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install from the bundled extension directory
|
||||||
|
specify extension add extensions/git --dev
|
||||||
|
|
||||||
|
# Or it auto-installs during specify init (migration period)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Disable the git extension (spec creation continues without branching)
|
||||||
|
specify extension disable git
|
||||||
|
|
||||||
|
# Re-enable it
|
||||||
|
specify extension enable git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
When Git is not installed or the directory is not a Git repository:
|
||||||
|
- Spec directories are still created under `specs/`
|
||||||
|
- Branch creation is skipped with a warning
|
||||||
|
- Branch validation is skipped with a warning
|
||||||
|
- Remote detection returns empty results
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
The extension bundles cross-platform scripts:
|
||||||
|
|
||||||
|
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
||||||
|
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
||||||
|
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
||||||
|
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
||||||
66
extensions/git/commands/speckit.git.feature.md
Normal file
66
extensions/git/commands/speckit.git.feature.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
description: "Create a feature branch with sequential or timestamp numbering"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Feature Branch
|
||||||
|
|
||||||
|
Create a new feature branch for the given specification.
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, warn the user and skip branch creation (spec directory will still be created)
|
||||||
|
|
||||||
|
## Branch Numbering Mode
|
||||||
|
|
||||||
|
Determine the branch numbering strategy by checking configuration in this order:
|
||||||
|
|
||||||
|
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||||
|
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||||
|
3. Default to `sequential` if neither exists
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Generate a concise short name (2-4 words) for the branch:
|
||||||
|
- Analyze the feature description and extract the most meaningful keywords
|
||||||
|
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||||
|
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||||
|
|
||||||
|
Run the appropriate script based on your platform:
|
||||||
|
|
||||||
|
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||||
|
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||||
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||||
|
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||||
|
|
||||||
|
**IMPORTANT**:
|
||||||
|
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||||
|
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||||
|
- You must only ever run this script once per feature
|
||||||
|
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
||||||
|
|
||||||
|
If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to:
|
||||||
|
- **Bash**: `scripts/bash/create-new-feature.sh`
|
||||||
|
- **PowerShell**: `scripts/powershell/create-new-feature.ps1`
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed or the current directory is not a Git repository:
|
||||||
|
- The script will still create the spec directory under `specs/`
|
||||||
|
- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||||
|
- The workflow continues normally without branch creation
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
The script outputs JSON with:
|
||||||
|
- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||||
|
- `SPEC_FILE`: Path to the created spec file
|
||||||
|
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||||
45
extensions/git/commands/speckit.git.remote.md
Normal file
45
extensions/git/commands/speckit.git.remote.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
description: "Detect Git remote URL for GitHub integration"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Detect Git Remote URL
|
||||||
|
|
||||||
|
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, output a warning and return empty:
|
||||||
|
```
|
||||||
|
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Run the following command to get the remote URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Parse the remote URL and determine:
|
||||||
|
|
||||||
|
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||||
|
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||||
|
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||||
|
|
||||||
|
Supported URL formats:
|
||||||
|
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||||
|
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||||
|
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||||
|
- Return an empty result
|
||||||
|
- Do NOT error — other workflows should continue without Git remote information
|
||||||
49
extensions/git/commands/speckit.git.validate.md
Normal file
49
extensions/git/commands/speckit.git.validate.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
description: "Validate current branch follows feature branch naming conventions"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validate Feature Branch
|
||||||
|
|
||||||
|
Validate that the current Git branch follows the expected feature branch naming conventions.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||||
|
- If Git is not available, output a warning and skip validation:
|
||||||
|
```
|
||||||
|
[specify] Warning: Git repository not detected; skipped branch validation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
Get the current branch name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
The branch name must match one of these patterns:
|
||||||
|
|
||||||
|
1. **Sequential**: `^[0-9]{3}-` (e.g., `001-feature-name`, `042-fix-bug`)
|
||||||
|
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
If on a feature branch (matches either pattern):
|
||||||
|
- Output: `✓ On feature branch: <branch-name>`
|
||||||
|
- Check if the corresponding spec directory exists under `specs/`:
|
||||||
|
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the `###` portion
|
||||||
|
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||||
|
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||||
|
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||||
|
|
||||||
|
If NOT on a feature branch:
|
||||||
|
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||||
|
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
If Git is not installed or the directory is not a Git repository:
|
||||||
|
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
||||||
|
- If set, validate that value against the naming patterns
|
||||||
|
- If not set, skip validation with a warning
|
||||||
5
extensions/git/config-template.yml
Normal file
5
extensions/git/config-template.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Git Branching Workflow Extension Configuration
|
||||||
|
# Copy this file to .specify/extensions/git/git-config.yml to customize
|
||||||
|
|
||||||
|
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||||
|
branch_numbering: sequential
|
||||||
47
extensions/git/extension.yml
Normal file
47
extensions/git/extension.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
schema_version: "1.0"
|
||||||
|
|
||||||
|
extension:
|
||||||
|
id: git
|
||||||
|
name: "Git Branching Workflow"
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
||||||
|
author: spec-kit-core
|
||||||
|
repository: https://github.com/github/spec-kit
|
||||||
|
license: MIT
|
||||||
|
|
||||||
|
requires:
|
||||||
|
speckit_version: ">=0.2.0"
|
||||||
|
tools:
|
||||||
|
- name: git
|
||||||
|
required: false
|
||||||
|
|
||||||
|
provides:
|
||||||
|
commands:
|
||||||
|
- name: speckit.git.feature
|
||||||
|
file: commands/speckit.git.feature.md
|
||||||
|
description: "Create a feature branch with sequential or timestamp numbering"
|
||||||
|
- name: speckit.git.validate
|
||||||
|
file: commands/speckit.git.validate.md
|
||||||
|
description: "Validate current branch follows feature branch naming conventions"
|
||||||
|
- name: speckit.git.remote
|
||||||
|
file: commands/speckit.git.remote.md
|
||||||
|
description: "Detect Git remote URL for GitHub integration"
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
before_specify:
|
||||||
|
command: speckit.git.feature
|
||||||
|
optional: false
|
||||||
|
description: "Create feature branch before specification"
|
||||||
|
after_implement:
|
||||||
|
command: speckit.git.validate
|
||||||
|
optional: true
|
||||||
|
prompt: "Verify feature branch naming?"
|
||||||
|
description: "Validate branch naming after implementation"
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- "git"
|
||||||
|
- "branching"
|
||||||
|
- "workflow"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
branch_numbering: sequential
|
||||||
394
extensions/git/scripts/bash/create-new-feature.sh
Normal file
394
extensions/git/scripts/bash/create-new-feature.sh
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
JSON_MODE=false
|
||||||
|
SHORT_NAME=""
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
USE_TIMESTAMP=false
|
||||||
|
ARGS=()
|
||||||
|
i=1
|
||||||
|
while [ $i -le $# ]; do
|
||||||
|
arg="${!i}"
|
||||||
|
case "$arg" in
|
||||||
|
--json)
|
||||||
|
JSON_MODE=true
|
||||||
|
;;
|
||||||
|
--short-name)
|
||||||
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
|
echo 'Error: --short-name requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
next_arg="${!i}"
|
||||||
|
# Check if the next argument is another option (starts with --)
|
||||||
|
if [[ "$next_arg" == --* ]]; then
|
||||||
|
echo 'Error: --short-name requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SHORT_NAME="$next_arg"
|
||||||
|
;;
|
||||||
|
--number)
|
||||||
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
|
echo 'Error: --number requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
next_arg="${!i}"
|
||||||
|
if [[ "$next_arg" == --* ]]; then
|
||||||
|
echo 'Error: --number requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BRANCH_NUMBER="$next_arg"
|
||||||
|
;;
|
||||||
|
--timestamp)
|
||||||
|
USE_TIMESTAMP=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --json Output in JSON format"
|
||||||
|
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
|
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||||
|
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
|
echo " --help, -h Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||||
|
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||||
|
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ARGS+=("$arg")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||||
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
|
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||||
|
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
||||||
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
|
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to find the repository root by searching for existing project markers
|
||||||
|
find_repo_root() {
|
||||||
|
local dir="$1"
|
||||||
|
while [ "$dir" != "/" ]; do
|
||||||
|
if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then
|
||||||
|
echo "$dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from specs directory
|
||||||
|
get_highest_from_specs() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
local highest=0
|
||||||
|
|
||||||
|
if [ -d "$specs_dir" ]; then
|
||||||
|
for dir in "$specs_dir"/*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
dirname=$(basename "$dir")
|
||||||
|
# Only match sequential prefixes (###-*), skip timestamp dirs
|
||||||
|
if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
|
||||||
|
number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from git branches
|
||||||
|
get_highest_from_branches() {
|
||||||
|
local highest=0
|
||||||
|
|
||||||
|
# Get all branches (local and remote)
|
||||||
|
branches=$(git branch -a 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$branches" ]; then
|
||||||
|
while IFS= read -r branch; do
|
||||||
|
# Clean branch name: remove leading markers and remote prefixes
|
||||||
|
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
||||||
|
|
||||||
|
# Extract feature number if branch matches pattern ###-*
|
||||||
|
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
|
||||||
|
number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$branches"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check existing branches (local and remote) and return next available number
|
||||||
|
check_existing_branches() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
|
||||||
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||||
|
git fetch --all --prune >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Get highest number from ALL branches (not just matching short name)
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
|
||||||
|
# Get highest number from ALL specs (not just matching short name)
|
||||||
|
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||||
|
|
||||||
|
# Take the maximum of both
|
||||||
|
local max_num=$highest_branch
|
||||||
|
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||||
|
max_num=$highest_spec
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return next number
|
||||||
|
echo $((max_num + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to clean and format a branch name
|
||||||
|
clean_branch_name() {
|
||||||
|
local name="$1"
|
||||||
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve repository root. Prefer git information when available, but fall back
|
||||||
|
# to searching for repository markers so the workflow still functions in repositories that
|
||||||
|
# were initialised with --no-git.
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Source common.sh using the following priority:
|
||||||
|
# 1. common.sh next to this script (source checkout layout)
|
||||||
|
# 2. .specify/scripts/bash/common.sh under the project root (installed project)
|
||||||
|
# 3. scripts/bash/common.sh under the project root (source checkout fallback)
|
||||||
|
# 4. git-common.sh next to this script (minimal fallback)
|
||||||
|
_common_loaded=false
|
||||||
|
|
||||||
|
if [ -f "$SCRIPT_DIR/common.sh" ]; then
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
_common_loaded=true
|
||||||
|
else
|
||||||
|
# When running from an extension install (.specify/extensions/git/scripts/bash/),
|
||||||
|
# resolve to .specify/ (4 levels up), then to the project root (5 levels up).
|
||||||
|
_dot_specify="$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)"
|
||||||
|
_project_root="$(cd "$SCRIPT_DIR/../../../../.." 2>/dev/null && pwd)"
|
||||||
|
|
||||||
|
if [ -n "$_dot_specify" ] && [ -f "$_dot_specify/scripts/bash/common.sh" ]; then
|
||||||
|
source "$_dot_specify/scripts/bash/common.sh"
|
||||||
|
_common_loaded=true
|
||||||
|
elif [ -n "$_project_root" ] && [ -f "$_project_root/scripts/bash/common.sh" ]; then
|
||||||
|
source "$_project_root/scripts/bash/common.sh"
|
||||||
|
_common_loaded=true
|
||||||
|
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
|
||||||
|
source "$SCRIPT_DIR/git-common.sh"
|
||||||
|
_common_loaded=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_common_loaded" != "true" ]; then
|
||||||
|
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If only git-common.sh was loaded, verify that the required helpers
|
||||||
|
# (resolve_template, json_escape) are available. These are provided by the
|
||||||
|
# core common.sh; git-common.sh only supplies has_git / check_feature_branch.
|
||||||
|
if ! type resolve_template >/dev/null 2>&1 || ! type json_escape >/dev/null 2>&1; then
|
||||||
|
echo "Error: resolve_template/json_escape not defined. The core common.sh is required but could not be located." >&2
|
||||||
|
echo "Tried: $SCRIPT_DIR/common.sh, .specify/scripts/bash/common.sh, scripts/bash/common.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
HAS_GIT=true
|
||||||
|
else
|
||||||
|
REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")"
|
||||||
|
if [ -z "$REPO_ROOT" ]; then
|
||||||
|
echo "Error: Could not determine repository root. Please run this script from within the repository." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
HAS_GIT=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
SPECS_DIR="$REPO_ROOT/specs"
|
||||||
|
mkdir -p "$SPECS_DIR"
|
||||||
|
|
||||||
|
# Function to generate branch name with stop word filtering and length filtering
|
||||||
|
generate_branch_name() {
|
||||||
|
local description="$1"
|
||||||
|
|
||||||
|
# Common stop words to filter out
|
||||||
|
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||||
|
|
||||||
|
# Convert to lowercase and split into words
|
||||||
|
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||||
|
|
||||||
|
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||||
|
local meaningful_words=()
|
||||||
|
for word in $clean_name; do
|
||||||
|
# Skip empty words
|
||||||
|
[ -z "$word" ] && continue
|
||||||
|
|
||||||
|
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
||||||
|
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||||
|
if [ ${#word} -ge 3 ]; then
|
||||||
|
meaningful_words+=("$word")
|
||||||
|
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
||||||
|
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||||
|
meaningful_words+=("$word")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# If we have meaningful words, use first 3-4 of them
|
||||||
|
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||||
|
local max_words=3
|
||||||
|
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||||
|
|
||||||
|
local result=""
|
||||||
|
local count=0
|
||||||
|
for word in "${meaningful_words[@]}"; do
|
||||||
|
if [ $count -ge $max_words ]; then break; fi
|
||||||
|
if [ -n "$result" ]; then result="$result-"; fi
|
||||||
|
result="$result$word"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
echo "$result"
|
||||||
|
else
|
||||||
|
# Fallback to original logic if no meaningful words found
|
||||||
|
local cleaned=$(clean_branch_name "$description")
|
||||||
|
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate branch name
|
||||||
|
if [ -n "$SHORT_NAME" ]; then
|
||||||
|
# Use provided short name, just clean it up
|
||||||
|
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||||
|
else
|
||||||
|
# Generate from description with smart filtering
|
||||||
|
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warn if --number and --timestamp are both specified
|
||||||
|
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||||
|
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine branch prefix
|
||||||
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
else
|
||||||
|
# Determine branch number
|
||||||
|
if [ -z "$BRANCH_NUMBER" ]; then
|
||||||
|
if [ "$HAS_GIT" = true ]; then
|
||||||
|
# Check existing branches on remotes
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||||
|
else
|
||||||
|
# Fall back to local directory check
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||||
|
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
|
# Validate and truncate if necessary
|
||||||
|
MAX_BRANCH_LENGTH=244
|
||||||
|
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||||
|
# Calculate how much we need to trim from suffix
|
||||||
|
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||||
|
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||||
|
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||||
|
|
||||||
|
# Truncate suffix at word boundary if possible
|
||||||
|
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||||
|
# Remove trailing hyphen if truncation created one
|
||||||
|
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||||
|
|
||||||
|
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||||
|
|
||||||
|
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||||
|
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||||
|
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HAS_GIT" = true ]; then
|
||||||
|
if ! git checkout -q -b "$BRANCH_NAME" >/dev/null 2>&1; then
|
||||||
|
# Check if branch already exists
|
||||||
|
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||||
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||||
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||||
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||||
|
cp "$TEMPLATE" "$SPEC_FILE"
|
||||||
|
else
|
||||||
|
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||||
|
touch "$SPEC_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Inform the user how to persist the feature variable in their own shell
|
||||||
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
|
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
jq -cn \
|
||||||
|
--arg branch_name "$BRANCH_NAME" \
|
||||||
|
--arg spec_file "$SPEC_FILE" \
|
||||||
|
--arg feature_num "$FEATURE_NUM" \
|
||||||
|
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
|
||||||
|
else
|
||||||
|
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||||
|
echo "SPEC_FILE: $SPEC_FILE"
|
||||||
|
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||||
|
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||||
|
fi
|
||||||
30
extensions/git/scripts/bash/git-common.sh
Normal file
30
extensions/git/scripts/bash/git-common.sh
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Git-specific common functions for the git extension.
|
||||||
|
# Extracted from scripts/bash/common.sh — contains only git-specific
|
||||||
|
# branch validation and detection logic.
|
||||||
|
|
||||||
|
# Check if we have git available
|
||||||
|
has_git() {
|
||||||
|
git rev-parse --show-toplevel >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate that a branch name matches the expected feature branch pattern.
|
||||||
|
# Accepts sequential (###-*) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
||||||
|
check_feature_branch() {
|
||||||
|
local branch="$1"
|
||||||
|
local has_git_repo="$2"
|
||||||
|
|
||||||
|
# For non-git repos, we can't enforce branch naming but still provide output
|
||||||
|
if [[ "$has_git_repo" != "true" ]]; then
|
||||||
|
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||||
|
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||||||
|
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
368
extensions/git/scripts/powershell/create-new-feature.ps1
Normal file
368
extensions/git/scripts/powershell/create-new-feature.ps1
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Create a new feature
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$Json,
|
||||||
|
[string]$ShortName,
|
||||||
|
[Parameter()]
|
||||||
|
[int]$Number = 0,
|
||||||
|
[switch]$Timestamp,
|
||||||
|
[switch]$Help,
|
||||||
|
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||||
|
[string[]]$FeatureDescription
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Show help if requested
|
||||||
|
if ($Help) {
|
||||||
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Options:"
|
||||||
|
Write-Host " -Json Output in JSON format"
|
||||||
|
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
|
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||||
|
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
|
Write-Host " -Help Show this help message"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Examples:"
|
||||||
|
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
||||||
|
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
||||||
|
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if feature description provided
|
||||||
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||||
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
||||||
|
|
||||||
|
# Validate description is not empty after trimming (e.g., user passed only whitespace)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
||||||
|
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve repository root. Prefer git information when available, but fall back
|
||||||
|
# to searching for repository markers so the workflow still functions in repositories that
|
||||||
|
# were initialized with --no-git.
|
||||||
|
function Find-RepositoryRoot {
|
||||||
|
param(
|
||||||
|
[string]$StartDir,
|
||||||
|
[string[]]$Markers = @('.git', '.specify')
|
||||||
|
)
|
||||||
|
$current = Resolve-Path $StartDir
|
||||||
|
while ($true) {
|
||||||
|
foreach ($marker in $Markers) {
|
||||||
|
if (Test-Path (Join-Path $current $marker)) {
|
||||||
|
return $current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$parent = Split-Path $current -Parent
|
||||||
|
if ($parent -eq $current) {
|
||||||
|
# Reached filesystem root without finding markers
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$current = $parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HighestNumberFromSpecs {
|
||||||
|
param([string]$SpecsDir)
|
||||||
|
|
||||||
|
$highest = 0
|
||||||
|
if (Test-Path $SpecsDir) {
|
||||||
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||||
|
if ($_.Name -match '^(\d{3})-') {
|
||||||
|
$num = [int]$matches[1]
|
||||||
|
if ($num -gt $highest) { $highest = $num }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $highest
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HighestNumberFromBranches {
|
||||||
|
param()
|
||||||
|
|
||||||
|
$highest = 0
|
||||||
|
try {
|
||||||
|
$branches = git branch -a 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
foreach ($branch in $branches) {
|
||||||
|
# Clean branch name: remove leading markers and remote prefixes
|
||||||
|
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||||
|
|
||||||
|
# Extract feature number if branch matches pattern ###-*
|
||||||
|
if ($cleanBranch -match '^(\d{3})-') {
|
||||||
|
$num = [int]$matches[1]
|
||||||
|
if ($num -gt $highest) { $highest = $num }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# If git command fails, return 0
|
||||||
|
Write-Verbose "Could not check Git branches: $_"
|
||||||
|
}
|
||||||
|
return $highest
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NextBranchNumber {
|
||||||
|
param(
|
||||||
|
[string]$SpecsDir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||||
|
try {
|
||||||
|
git fetch --all --prune 2>$null | Out-Null
|
||||||
|
} catch {
|
||||||
|
# Ignore fetch errors
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get highest number from ALL branches (not just matching short name)
|
||||||
|
$highestBranch = Get-HighestNumberFromBranches
|
||||||
|
|
||||||
|
# Get highest number from ALL specs (not just matching short name)
|
||||||
|
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||||
|
|
||||||
|
# Take the maximum of both
|
||||||
|
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||||
|
|
||||||
|
# Return next number
|
||||||
|
return $maxNum + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanBranchName {
|
||||||
|
param([string]$Name)
|
||||||
|
|
||||||
|
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||||
|
}
|
||||||
|
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
|
||||||
|
if (-not $fallbackRoot) {
|
||||||
|
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load common functions (includes Resolve-Template).
|
||||||
|
# Search locations in priority order:
|
||||||
|
# 1. common.ps1 next to this script (source checkout layout)
|
||||||
|
# 2. .specify/scripts/powershell/common.ps1 under the project root (installed project)
|
||||||
|
# 3. scripts/powershell/common.ps1 under the project root (source checkout fallback)
|
||||||
|
# 4. git-common.ps1 next to this script (minimal fallback)
|
||||||
|
$commonLoaded = $false
|
||||||
|
|
||||||
|
if (Test-Path "$PSScriptRoot/common.ps1") {
|
||||||
|
. "$PSScriptRoot/common.ps1"
|
||||||
|
$commonLoaded = $true
|
||||||
|
} else {
|
||||||
|
$coreCommonCandidates = @()
|
||||||
|
|
||||||
|
if ($fallbackRoot) {
|
||||||
|
$coreCommonCandidates += (Join-Path $fallbackRoot ".specify/scripts/powershell/common.ps1")
|
||||||
|
$coreCommonCandidates += (Join-Path $fallbackRoot "scripts/powershell/common.ps1")
|
||||||
|
}
|
||||||
|
|
||||||
|
$coreCommonCandidates += "$PSScriptRoot/git-common.ps1"
|
||||||
|
|
||||||
|
foreach ($candidate in $coreCommonCandidates) {
|
||||||
|
if ($candidate -and (Test-Path $candidate)) {
|
||||||
|
. $candidate
|
||||||
|
$commonLoaded = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $commonLoaded) {
|
||||||
|
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
||||||
|
}
|
||||||
|
|
||||||
|
# If only git-common.ps1 was loaded, verify that Resolve-Template is available.
|
||||||
|
# Resolve-Template is provided by the core common.ps1; git-common.ps1 only
|
||||||
|
# supplies Test-HasGit / Test-FeatureBranch.
|
||||||
|
if (-not (Get-Command Resolve-Template -ErrorAction SilentlyContinue)) {
|
||||||
|
throw ("Resolve-Template not defined. The core common.ps1 is required but could not be located. " +
|
||||||
|
"Tried: $PSScriptRoot/common.ps1, .specify/scripts/powershell/common.ps1, scripts/powershell/common.ps1")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$hasGit = $true
|
||||||
|
} else {
|
||||||
|
throw "Git not available"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$repoRoot = $fallbackRoot
|
||||||
|
$hasGit = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Location $repoRoot
|
||||||
|
|
||||||
|
$specsDir = Join-Path $repoRoot 'specs'
|
||||||
|
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
|
||||||
|
|
||||||
|
# Function to generate branch name with stop word filtering and length filtering
|
||||||
|
function Get-BranchName {
|
||||||
|
param([string]$Description)
|
||||||
|
|
||||||
|
# Common stop words to filter out
|
||||||
|
$stopWords = @(
|
||||||
|
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
||||||
|
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
||||||
|
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
||||||
|
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
||||||
|
'want', 'need', 'add', 'get', 'set'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to lowercase and extract words (alphanumeric only)
|
||||||
|
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
||||||
|
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
||||||
|
|
||||||
|
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||||
|
$meaningfulWords = @()
|
||||||
|
foreach ($word in $words) {
|
||||||
|
# Skip stop words
|
||||||
|
if ($stopWords -contains $word) { continue }
|
||||||
|
|
||||||
|
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
|
||||||
|
if ($word.Length -ge 3) {
|
||||||
|
$meaningfulWords += $word
|
||||||
|
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
||||||
|
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||||
|
$meaningfulWords += $word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If we have meaningful words, use first 3-4 of them
|
||||||
|
if ($meaningfulWords.Count -gt 0) {
|
||||||
|
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
||||||
|
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
||||||
|
return $result
|
||||||
|
} else {
|
||||||
|
# Fallback to original logic if no meaningful words found
|
||||||
|
$result = ConvertTo-CleanBranchName -Name $Description
|
||||||
|
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
||||||
|
return [string]::Join('-', $fallbackWords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate branch name
|
||||||
|
if ($ShortName) {
|
||||||
|
# Use provided short name, just clean it up
|
||||||
|
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||||
|
} else {
|
||||||
|
# Generate from description with smart filtering
|
||||||
|
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Warn if -Number and -Timestamp are both specified
|
||||||
|
if ($Timestamp -and $Number -ne 0) {
|
||||||
|
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||||
|
$Number = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine branch prefix
|
||||||
|
if ($Timestamp) {
|
||||||
|
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
|
} else {
|
||||||
|
# Determine branch number
|
||||||
|
if ($Number -eq 0) {
|
||||||
|
if ($hasGit) {
|
||||||
|
# Check existing branches on remotes
|
||||||
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||||
|
} else {
|
||||||
|
# Fall back to local directory check
|
||||||
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$featureNum = ('{0:000}' -f $Number)
|
||||||
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
|
# Validate and truncate if necessary
|
||||||
|
$maxBranchLength = 244
|
||||||
|
if ($branchName.Length -gt $maxBranchLength) {
|
||||||
|
# Calculate how much we need to trim from suffix
|
||||||
|
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||||
|
$prefixLength = $featureNum.Length + 1
|
||||||
|
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||||
|
|
||||||
|
# Truncate suffix
|
||||||
|
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||||
|
# Remove trailing hyphen if truncation created one
|
||||||
|
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||||
|
|
||||||
|
$originalBranchName = $branchName
|
||||||
|
$branchName = "$featureNum-$truncatedSuffix"
|
||||||
|
|
||||||
|
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||||
|
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||||
|
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasGit) {
|
||||||
|
$branchCreated = $false
|
||||||
|
try {
|
||||||
|
git checkout -q -b $branchName 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$branchCreated = $true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# Exception during git command
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $branchCreated) {
|
||||||
|
# Check if branch already exists
|
||||||
|
$existingBranch = git branch --list $branchName 2>$null
|
||||||
|
if ($existingBranch) {
|
||||||
|
if ($Timestamp) {
|
||||||
|
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||||
|
}
|
||||||
|
|
||||||
|
$featureDir = Join-Path $specsDir $branchName
|
||||||
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||||
|
|
||||||
|
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||||
|
$specFile = Join-Path $featureDir 'spec.md'
|
||||||
|
if ($template -and (Test-Path $template)) {
|
||||||
|
Copy-Item $template $specFile -Force
|
||||||
|
} else {
|
||||||
|
New-Item -ItemType File -Path $specFile | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||||
|
$env:SPECIFY_FEATURE = $branchName
|
||||||
|
|
||||||
|
if ($Json) {
|
||||||
|
$obj = [PSCustomObject]@{
|
||||||
|
BRANCH_NAME = $branchName
|
||||||
|
SPEC_FILE = $specFile
|
||||||
|
FEATURE_NUM = $featureNum
|
||||||
|
HAS_GIT = $hasGit
|
||||||
|
}
|
||||||
|
$obj | ConvertTo-Json -Compress
|
||||||
|
} else {
|
||||||
|
Write-Output "BRANCH_NAME: $branchName"
|
||||||
|
Write-Output "SPEC_FILE: $specFile"
|
||||||
|
Write-Output "FEATURE_NUM: $featureNum"
|
||||||
|
Write-Output "HAS_GIT: $hasGit"
|
||||||
|
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||||
|
}
|
||||||
|
|
||||||
33
extensions/git/scripts/powershell/git-common.ps1
Normal file
33
extensions/git/scripts/powershell/git-common.ps1
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Git-specific common functions for the git extension.
|
||||||
|
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
|
||||||
|
# branch validation and detection logic.
|
||||||
|
|
||||||
|
function Test-HasGit {
|
||||||
|
try {
|
||||||
|
git rev-parse --show-toplevel 2>$null | Out-Null
|
||||||
|
return ($LASTEXITCODE -eq 0)
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-FeatureBranch {
|
||||||
|
param(
|
||||||
|
[string]$Branch,
|
||||||
|
[bool]$HasGit = $true
|
||||||
|
)
|
||||||
|
|
||||||
|
# For non-git repos, we can't enforce branch naming but still provide output
|
||||||
|
if (-not $HasGit) {
|
||||||
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||||
|
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
|
||||||
|
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.3.2"
|
version = "0.4.0"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -27,6 +27,24 @@ build-backend = "hatchling.build"
|
|||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/specify_cli"]
|
packages = ["src/specify_cli"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
|
# Bundle core assets so `specify init` works without network access (air-gapped / enterprise)
|
||||||
|
# Page templates (exclude commands/ — bundled separately below to avoid duplication)
|
||||||
|
"templates/agent-file-template.md" = "specify_cli/core_pack/templates/agent-file-template.md"
|
||||||
|
"templates/checklist-template.md" = "specify_cli/core_pack/templates/checklist-template.md"
|
||||||
|
"templates/constitution-template.md" = "specify_cli/core_pack/templates/constitution-template.md"
|
||||||
|
"templates/plan-template.md" = "specify_cli/core_pack/templates/plan-template.md"
|
||||||
|
"templates/spec-template.md" = "specify_cli/core_pack/templates/spec-template.md"
|
||||||
|
"templates/tasks-template.md" = "specify_cli/core_pack/templates/tasks-template.md"
|
||||||
|
"templates/vscode-settings.json" = "specify_cli/core_pack/templates/vscode-settings.json"
|
||||||
|
# Command templates
|
||||||
|
"templates/commands" = "specify_cli/core_pack/commands"
|
||||||
|
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
||||||
|
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||||
|
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
||||||
|
".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 = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
|
|||||||
@@ -33,19 +33,30 @@ get_current_branch() {
|
|||||||
if [[ -d "$specs_dir" ]]; then
|
if [[ -d "$specs_dir" ]]; then
|
||||||
local latest_feature=""
|
local latest_feature=""
|
||||||
local highest=0
|
local highest=0
|
||||||
|
local latest_timestamp=""
|
||||||
|
|
||||||
for dir in "$specs_dir"/*; do
|
for dir in "$specs_dir"/*; do
|
||||||
if [[ -d "$dir" ]]; then
|
if [[ -d "$dir" ]]; then
|
||||||
local dirname=$(basename "$dir")
|
local dirname=$(basename "$dir")
|
||||||
if [[ "$dirname" =~ ^([0-9]{3})- ]]; then
|
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||||
|
# Timestamp-based branch: compare lexicographically
|
||||||
|
local ts="${BASH_REMATCH[1]}"
|
||||||
|
if [[ "$ts" > "$latest_timestamp" ]]; then
|
||||||
|
latest_timestamp="$ts"
|
||||||
|
latest_feature=$dirname
|
||||||
|
fi
|
||||||
|
elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then
|
||||||
local number=${BASH_REMATCH[1]}
|
local number=${BASH_REMATCH[1]}
|
||||||
number=$((10#$number))
|
number=$((10#$number))
|
||||||
if [[ "$number" -gt "$highest" ]]; then
|
if [[ "$number" -gt "$highest" ]]; then
|
||||||
highest=$number
|
highest=$number
|
||||||
|
# Only update if no timestamp branch found yet
|
||||||
|
if [[ -z "$latest_timestamp" ]]; then
|
||||||
latest_feature=$dirname
|
latest_feature=$dirname
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -n "$latest_feature" ]]; then
|
if [[ -n "$latest_feature" ]]; then
|
||||||
@@ -72,9 +83,9 @@ check_feature_branch() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then
|
if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||||||
echo "Feature branches should be named like: 001-feature-name" >&2
|
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -90,15 +101,18 @@ find_feature_dir_by_prefix() {
|
|||||||
local branch_name="$2"
|
local branch_name="$2"
|
||||||
local specs_dir="$repo_root/specs"
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
# Extract numeric prefix from branch (e.g., "004" from "004-whatever")
|
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||||
if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then
|
local prefix=""
|
||||||
# If branch doesn't have numeric prefix, fall back to exact match
|
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||||
echo "$specs_dir/$branch_name"
|
echo "$specs_dir/$branch_name"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local prefix="${BASH_REMATCH[1]}"
|
|
||||||
|
|
||||||
# Search for directories in specs/ that start with this prefix
|
# Search for directories in specs/ that start with this prefix
|
||||||
local matches=()
|
local matches=()
|
||||||
if [[ -d "$specs_dir" ]]; then
|
if [[ -d "$specs_dir" ]]; then
|
||||||
@@ -119,7 +133,7 @@ find_feature_dir_by_prefix() {
|
|||||||
else
|
else
|
||||||
# Multiple matches - this shouldn't happen with proper naming convention
|
# Multiple matches - this shouldn't happen with proper naming convention
|
||||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||||
echo "Please ensure only one spec directory exists per numeric prefix." >&2
|
echo "Please ensure only one spec directory exists per prefix." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ set -e
|
|||||||
JSON_MODE=false
|
JSON_MODE=false
|
||||||
SHORT_NAME=""
|
SHORT_NAME=""
|
||||||
BRANCH_NUMBER=""
|
BRANCH_NUMBER=""
|
||||||
|
USE_TIMESTAMP=false
|
||||||
ARGS=()
|
ARGS=()
|
||||||
i=1
|
i=1
|
||||||
while [ $i -le $# ]; do
|
while [ $i -le $# ]; do
|
||||||
@@ -40,18 +41,23 @@ while [ $i -le $# ]; do
|
|||||||
fi
|
fi
|
||||||
BRANCH_NUMBER="$next_arg"
|
BRANCH_NUMBER="$next_arg"
|
||||||
;;
|
;;
|
||||||
|
--timestamp)
|
||||||
|
USE_TIMESTAMP=true
|
||||||
|
;;
|
||||||
--help|-h)
|
--help|-h)
|
||||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
|
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " --json Output in JSON format"
|
echo " --json Output in JSON format"
|
||||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||||
|
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
echo " --help, -h Show this help message"
|
echo " --help, -h Show this help message"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||||
|
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -63,7 +69,7 @@ done
|
|||||||
|
|
||||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>" >&2
|
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -96,11 +102,14 @@ get_highest_from_specs() {
|
|||||||
for dir in "$specs_dir"/*; do
|
for dir in "$specs_dir"/*; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
dirname=$(basename "$dir")
|
dirname=$(basename "$dir")
|
||||||
number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0")
|
# Only match sequential prefixes (###-*), skip timestamp dirs
|
||||||
|
if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
|
||||||
|
number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
|
||||||
number=$((10#$number))
|
number=$((10#$number))
|
||||||
if [ "$number" -gt "$highest" ]; then
|
if [ "$number" -gt "$highest" ]; then
|
||||||
highest=$number
|
highest=$number
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -242,8 +251,19 @@ else
|
|||||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Determine branch number
|
# Warn if --number and --timestamp are both specified
|
||||||
if [ -z "$BRANCH_NUMBER" ]; then
|
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||||
|
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine branch prefix
|
||||||
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
else
|
||||||
|
# Determine branch number
|
||||||
|
if [ -z "$BRANCH_NUMBER" ]; then
|
||||||
if [ "$HAS_GIT" = true ]; then
|
if [ "$HAS_GIT" = true ]; then
|
||||||
# Check existing branches on remotes
|
# Check existing branches on remotes
|
||||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||||
@@ -252,19 +272,21 @@ if [ -z "$BRANCH_NUMBER" ]; then
|
|||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
fi
|
||||||
|
|
||||||
# GitHub enforces a 244-byte limit on branch names
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
# Validate and truncate if necessary
|
# Validate and truncate if necessary
|
||||||
MAX_BRANCH_LENGTH=244
|
MAX_BRANCH_LENGTH=244
|
||||||
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||||
# Calculate how much we need to trim from suffix
|
# Calculate how much we need to trim from suffix
|
||||||
# Account for: feature number (3) + hyphen (1) = 4 chars
|
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))
|
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||||
|
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||||
|
|
||||||
# Truncate suffix at word boundary if possible
|
# Truncate suffix at word boundary if possible
|
||||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||||
@@ -283,7 +305,11 @@ if [ "$HAS_GIT" = true ]; then
|
|||||||
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
||||||
# Check if branch already exists
|
# Check if branch already exists
|
||||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||||
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||||
|
else
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
||||||
|
|||||||
@@ -30,12 +30,12 @@
|
|||||||
#
|
#
|
||||||
# 5. Multi-Agent Support
|
# 5. Multi-Agent Support
|
||||||
# - Handles agent-specific file paths and naming conventions
|
# - Handles agent-specific file paths and naming conventions
|
||||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic
|
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic
|
||||||
# - Can update single agents or all existing agent files
|
# - Can update single agents or all existing agent files
|
||||||
# - Creates default Claude file if no agent files exist
|
# - Creates default Claude file if no agent files exist
|
||||||
#
|
#
|
||||||
# Usage: ./update-agent-context.sh [agent_type]
|
# Usage: ./update-agent-context.sh [agent_type]
|
||||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic
|
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic
|
||||||
# Leave empty to update all existing agent files
|
# Leave empty to update all existing agent files
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -68,6 +68,7 @@ CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc"
|
|||||||
QWEN_FILE="$REPO_ROOT/QWEN.md"
|
QWEN_FILE="$REPO_ROOT/QWEN.md"
|
||||||
AGENTS_FILE="$REPO_ROOT/AGENTS.md"
|
AGENTS_FILE="$REPO_ROOT/AGENTS.md"
|
||||||
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
|
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
|
||||||
|
JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md"
|
||||||
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
|
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
|
||||||
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
||||||
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
||||||
@@ -638,6 +639,9 @@ update_specific_agent() {
|
|||||||
windsurf)
|
windsurf)
|
||||||
update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1
|
update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1
|
||||||
;;
|
;;
|
||||||
|
junie)
|
||||||
|
update_agent_file "$JUNIE_FILE" "Junie" || return 1
|
||||||
|
;;
|
||||||
kilocode)
|
kilocode)
|
||||||
update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1
|
update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1
|
||||||
;;
|
;;
|
||||||
@@ -691,7 +695,7 @@ update_specific_agent() {
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "Unknown agent type '$agent_type'"
|
log_error "Unknown agent type '$agent_type'"
|
||||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic"
|
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -740,6 +744,7 @@ update_all_existing_agents() {
|
|||||||
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
|
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
|
||||||
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
|
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
|
||||||
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
|
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
|
||||||
|
_update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false
|
||||||
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
|
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
|
||||||
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
|
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
|
||||||
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
|
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
|
||||||
@@ -778,7 +783,7 @@ print_summary() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]"
|
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]"
|
||||||
}
|
}
|
||||||
|
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
|
|||||||
@@ -38,16 +38,27 @@ function Get-CurrentBranch {
|
|||||||
if (Test-Path $specsDir) {
|
if (Test-Path $specsDir) {
|
||||||
$latestFeature = ""
|
$latestFeature = ""
|
||||||
$highest = 0
|
$highest = 0
|
||||||
|
$latestTimestamp = ""
|
||||||
|
|
||||||
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
||||||
if ($_.Name -match '^(\d{3})-') {
|
if ($_.Name -match '^(\d{8}-\d{6})-') {
|
||||||
|
# Timestamp-based branch: compare lexicographically
|
||||||
|
$ts = $matches[1]
|
||||||
|
if ($ts -gt $latestTimestamp) {
|
||||||
|
$latestTimestamp = $ts
|
||||||
|
$latestFeature = $_.Name
|
||||||
|
}
|
||||||
|
} elseif ($_.Name -match '^(\d{3})-') {
|
||||||
$num = [int]$matches[1]
|
$num = [int]$matches[1]
|
||||||
if ($num -gt $highest) {
|
if ($num -gt $highest) {
|
||||||
$highest = $num
|
$highest = $num
|
||||||
|
# Only update if no timestamp branch found yet
|
||||||
|
if (-not $latestTimestamp) {
|
||||||
$latestFeature = $_.Name
|
$latestFeature = $_.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($latestFeature) {
|
if ($latestFeature) {
|
||||||
return $latestFeature
|
return $latestFeature
|
||||||
@@ -79,9 +90,9 @@ function Test-FeatureBranch {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Branch -notmatch '^[0-9]{3}-') {
|
if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||||
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
|
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
|
||||||
Write-Output "Feature branches should be named like: 001-feature-name"
|
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
return $true
|
return $true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ param(
|
|||||||
[string]$ShortName,
|
[string]$ShortName,
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[int]$Number = 0,
|
[int]$Number = 0,
|
||||||
|
[switch]$Timestamp,
|
||||||
[switch]$Help,
|
[switch]$Help,
|
||||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||||
[string[]]$FeatureDescription
|
[string[]]$FeatureDescription
|
||||||
@@ -14,23 +15,25 @@ $ErrorActionPreference = 'Stop'
|
|||||||
|
|
||||||
# Show help if requested
|
# Show help if requested
|
||||||
if ($Help) {
|
if ($Help) {
|
||||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>"
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Options:"
|
Write-Host "Options:"
|
||||||
Write-Host " -Json Output in JSON format"
|
Write-Host " -Json Output in JSON format"
|
||||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||||
|
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
Write-Host " -Help Show this help message"
|
Write-Host " -Help Show this help message"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Examples:"
|
Write-Host "Examples:"
|
||||||
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
||||||
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
||||||
|
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if feature description provided
|
# Check if feature description provided
|
||||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] <feature description>"
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ function Get-HighestNumberFromSpecs {
|
|||||||
$highest = 0
|
$highest = 0
|
||||||
if (Test-Path $SpecsDir) {
|
if (Test-Path $SpecsDir) {
|
||||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||||
if ($_.Name -match '^(\d+)') {
|
if ($_.Name -match '^(\d{3})-') {
|
||||||
$num = [int]$matches[1]
|
$num = [int]$matches[1]
|
||||||
if ($num -gt $highest) { $highest = $num }
|
if ($num -gt $highest) { $highest = $num }
|
||||||
}
|
}
|
||||||
@@ -93,7 +96,7 @@ function Get-HighestNumberFromBranches {
|
|||||||
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||||
|
|
||||||
# Extract feature number if branch matches pattern ###-*
|
# Extract feature number if branch matches pattern ###-*
|
||||||
if ($cleanBranch -match '^(\d+)-') {
|
if ($cleanBranch -match '^(\d{3})-') {
|
||||||
$num = [int]$matches[1]
|
$num = [int]$matches[1]
|
||||||
if ($num -gt $highest) { $highest = $num }
|
if ($num -gt $highest) { $highest = $num }
|
||||||
}
|
}
|
||||||
@@ -216,8 +219,19 @@ if ($ShortName) {
|
|||||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||||
}
|
}
|
||||||
|
|
||||||
# Determine branch number
|
# Warn if -Number and -Timestamp are both specified
|
||||||
if ($Number -eq 0) {
|
if ($Timestamp -and $Number -ne 0) {
|
||||||
|
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||||
|
$Number = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine branch prefix
|
||||||
|
if ($Timestamp) {
|
||||||
|
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
|
} else {
|
||||||
|
# Determine branch number
|
||||||
|
if ($Number -eq 0) {
|
||||||
if ($hasGit) {
|
if ($hasGit) {
|
||||||
# Check existing branches on remotes
|
# Check existing branches on remotes
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||||
@@ -225,18 +239,20 @@ if ($Number -eq 0) {
|
|||||||
# Fall back to local directory check
|
# Fall back to local directory check
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$featureNum = ('{0:000}' -f $Number)
|
$featureNum = ('{0:000}' -f $Number)
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
|
}
|
||||||
|
|
||||||
# GitHub enforces a 244-byte limit on branch names
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
# Validate and truncate if necessary
|
# Validate and truncate if necessary
|
||||||
$maxBranchLength = 244
|
$maxBranchLength = 244
|
||||||
if ($branchName.Length -gt $maxBranchLength) {
|
if ($branchName.Length -gt $maxBranchLength) {
|
||||||
# Calculate how much we need to trim from suffix
|
# Calculate how much we need to trim from suffix
|
||||||
# Account for: feature number (3) + hyphen (1) = 4 chars
|
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||||
$maxSuffixLength = $maxBranchLength - 4
|
$prefixLength = $featureNum.Length + 1
|
||||||
|
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||||
|
|
||||||
# Truncate suffix
|
# Truncate suffix
|
||||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||||
@@ -266,7 +282,11 @@ if ($hasGit) {
|
|||||||
# Check if branch already exists
|
# Check if branch already exists
|
||||||
$existingBranch = git branch --list $branchName 2>$null
|
$existingBranch = git branch --list $branchName 2>$null
|
||||||
if ($existingBranch) {
|
if ($existingBranch) {
|
||||||
|
if ($Timestamp) {
|
||||||
|
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||||
|
} else {
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||||
|
}
|
||||||
exit 1
|
exit 1
|
||||||
} else {
|
} else {
|
||||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
|
|||||||
2. Plan Data Extraction
|
2. Plan Data Extraction
|
||||||
3. Agent File Management (create from template or update existing)
|
3. Agent File Management (create from template or update existing)
|
||||||
4. Content Generation (technology stack, recent changes, timestamp)
|
4. Content Generation (technology stack, recent changes, timestamp)
|
||||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, generic)
|
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, generic)
|
||||||
|
|
||||||
.PARAMETER AgentType
|
.PARAMETER AgentType
|
||||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||||
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
|
|||||||
#>
|
#>
|
||||||
param(
|
param(
|
||||||
[Parameter(Position=0)]
|
[Parameter(Position=0)]
|
||||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','iflow','generic')]
|
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','iflow','generic')]
|
||||||
[string]$AgentType
|
[string]$AgentType
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ $CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc'
|
|||||||
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md'
|
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md'
|
||||||
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||||
$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md'
|
$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md'
|
||||||
|
$JUNIE_FILE = Join-Path $REPO_ROOT '.junie/AGENTS.md'
|
||||||
$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md'
|
$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md'
|
||||||
$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md'
|
$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md'
|
||||||
$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md'
|
$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md'
|
||||||
@@ -397,6 +398,7 @@ function Update-SpecificAgent {
|
|||||||
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
|
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
|
||||||
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
|
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
|
||||||
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
|
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
|
||||||
|
'junie' { Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie' }
|
||||||
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
|
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
|
||||||
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
||||||
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
||||||
@@ -414,7 +416,7 @@ function Update-SpecificAgent {
|
|||||||
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
|
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
|
||||||
'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' }
|
'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' }
|
||||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic'; return $false }
|
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic'; return $false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +430,7 @@ function Update-AllExistingAgents {
|
|||||||
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
|
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
|
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
|
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
|
||||||
|
if (Test-Path $JUNIE_FILE) { if (-not (Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
|
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
|
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
|
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
|
||||||
@@ -456,7 +459,7 @@ function Print-Summary {
|
|||||||
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
|
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
|
||||||
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
|
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]'
|
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]'
|
||||||
}
|
}
|
||||||
|
|
||||||
function Main {
|
function Main {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import sys
|
|||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
import shlex
|
|
||||||
import json
|
import json
|
||||||
import json5
|
import json5
|
||||||
import stat
|
import stat
|
||||||
@@ -172,8 +171,8 @@ AGENT_CONFIG = {
|
|||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"name": "Codex CLI",
|
"name": "Codex CLI",
|
||||||
"folder": ".codex/",
|
"folder": ".agents/",
|
||||||
"commands_subdir": "prompts", # Special: uses prompts/ not commands/
|
"commands_subdir": "skills", # Codex now uses project skills directly
|
||||||
"install_url": "https://github.com/openai/codex",
|
"install_url": "https://github.com/openai/codex",
|
||||||
"requires_cli": True,
|
"requires_cli": True,
|
||||||
},
|
},
|
||||||
@@ -184,6 +183,13 @@ AGENT_CONFIG = {
|
|||||||
"install_url": None, # IDE-based
|
"install_url": None, # IDE-based
|
||||||
"requires_cli": False,
|
"requires_cli": False,
|
||||||
},
|
},
|
||||||
|
"junie": {
|
||||||
|
"name": "Junie",
|
||||||
|
"folder": ".junie/",
|
||||||
|
"commands_subdir": "commands",
|
||||||
|
"install_url": "https://junie.jetbrains.com/",
|
||||||
|
"requires_cli": True,
|
||||||
|
},
|
||||||
"kilocode": {
|
"kilocode": {
|
||||||
"name": "Kilo Code",
|
"name": "Kilo Code",
|
||||||
"folder": ".kilocode/",
|
"folder": ".kilocode/",
|
||||||
@@ -309,6 +315,9 @@ AI_ASSISTANT_ALIASES = {
|
|||||||
"kiro": "kiro-cli",
|
"kiro": "kiro-cli",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Agents that use TOML command format (others use Markdown)
|
||||||
|
_TOML_AGENTS = frozenset({"gemini", "tabnine"})
|
||||||
|
|
||||||
def _build_ai_assistant_help() -> str:
|
def _build_ai_assistant_help() -> str:
|
||||||
"""Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config."""
|
"""Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config."""
|
||||||
|
|
||||||
@@ -939,9 +948,26 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
|||||||
}
|
}
|
||||||
return zip_path, metadata
|
return zip_path, metadata
|
||||||
|
|
||||||
def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path:
|
def download_and_extract_template(
|
||||||
|
project_path: Path,
|
||||||
|
ai_assistant: str,
|
||||||
|
script_type: str,
|
||||||
|
is_current_dir: bool = False,
|
||||||
|
*,
|
||||||
|
skip_legacy_codex_prompts: bool = False,
|
||||||
|
verbose: bool = True,
|
||||||
|
tracker: StepTracker | None = None,
|
||||||
|
client: httpx.Client = None,
|
||||||
|
debug: bool = False,
|
||||||
|
github_token: str = None,
|
||||||
|
) -> Path:
|
||||||
"""Download the latest release and extract it to create a new project.
|
"""Download the latest release and extract it to create a new project.
|
||||||
Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup)
|
Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
``skip_legacy_codex_prompts`` suppresses the legacy top-level
|
||||||
|
``.codex`` directory from older template archives in Codex skills mode.
|
||||||
|
The name is kept for backward compatibility with existing callers.
|
||||||
"""
|
"""
|
||||||
current_dir = Path.cwd()
|
current_dir = Path.cwd()
|
||||||
|
|
||||||
@@ -981,6 +1007,19 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
project_path.mkdir(parents=True)
|
project_path.mkdir(parents=True)
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
def _validate_zip_members_within(root: Path) -> None:
|
||||||
|
"""Validate all ZIP members stay within ``root`` (Zip Slip guard)."""
|
||||||
|
root_resolved = root.resolve()
|
||||||
|
for member in zip_ref.namelist():
|
||||||
|
member_path = (root / member).resolve()
|
||||||
|
try:
|
||||||
|
member_path.relative_to(root_resolved)
|
||||||
|
except ValueError:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unsafe path in ZIP archive: {member} "
|
||||||
|
"(potential path traversal)"
|
||||||
|
)
|
||||||
|
|
||||||
zip_contents = zip_ref.namelist()
|
zip_contents = zip_ref.namelist()
|
||||||
if tracker:
|
if tracker:
|
||||||
tracker.start("zip-list")
|
tracker.start("zip-list")
|
||||||
@@ -991,6 +1030,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
if is_current_dir:
|
if is_current_dir:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
temp_path = Path(temp_dir)
|
temp_path = Path(temp_dir)
|
||||||
|
_validate_zip_members_within(temp_path)
|
||||||
zip_ref.extractall(temp_path)
|
zip_ref.extractall(temp_path)
|
||||||
|
|
||||||
extracted_items = list(temp_path.iterdir())
|
extracted_items = list(temp_path.iterdir())
|
||||||
@@ -1010,6 +1050,11 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
console.print("[cyan]Found nested directory structure[/cyan]")
|
console.print("[cyan]Found nested directory structure[/cyan]")
|
||||||
|
|
||||||
for item in source_dir.iterdir():
|
for item in source_dir.iterdir():
|
||||||
|
# In Codex skills mode, do not materialize the legacy
|
||||||
|
# top-level .codex directory from older prompt-based
|
||||||
|
# template archives.
|
||||||
|
if skip_legacy_codex_prompts and ai_assistant == "codex" and item.name == ".codex":
|
||||||
|
continue
|
||||||
dest_path = project_path / item.name
|
dest_path = project_path / item.name
|
||||||
if item.is_dir():
|
if item.is_dir():
|
||||||
if dest_path.exists():
|
if dest_path.exists():
|
||||||
@@ -1034,6 +1079,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
if verbose and not tracker:
|
if verbose and not tracker:
|
||||||
console.print("[cyan]Template files merged into current directory[/cyan]")
|
console.print("[cyan]Template files merged into current directory[/cyan]")
|
||||||
else:
|
else:
|
||||||
|
_validate_zip_members_within(project_path)
|
||||||
zip_ref.extractall(project_path)
|
zip_ref.extractall(project_path)
|
||||||
|
|
||||||
extracted_items = list(project_path.iterdir())
|
extracted_items = list(project_path.iterdir())
|
||||||
@@ -1060,6 +1106,13 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
elif verbose:
|
elif verbose:
|
||||||
console.print("[cyan]Flattened nested directory structure[/cyan]")
|
console.print("[cyan]Flattened nested directory structure[/cyan]")
|
||||||
|
|
||||||
|
# For fresh-directory Codex skills init, suppress legacy
|
||||||
|
# top-level .codex layout extracted from older archives.
|
||||||
|
if skip_legacy_codex_prompts and ai_assistant == "codex":
|
||||||
|
legacy_codex_dir = project_path / ".codex"
|
||||||
|
if legacy_codex_dir.is_dir():
|
||||||
|
shutil.rmtree(legacy_codex_dir, ignore_errors=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if tracker:
|
if tracker:
|
||||||
tracker.error("extract", str(e))
|
tracker.error("extract", str(e))
|
||||||
@@ -1089,6 +1142,305 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
return project_path
|
return project_path
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_core_pack() -> Path | None:
|
||||||
|
"""Return the filesystem path to the bundled core_pack directory, or None.
|
||||||
|
|
||||||
|
Only present in wheel installs: hatchling's force-include copies
|
||||||
|
templates/, scripts/ etc. into specify_cli/core_pack/ at build time.
|
||||||
|
|
||||||
|
Source-checkout and editable installs do NOT have this directory.
|
||||||
|
Callers that need to work in both environments must check the repo-root
|
||||||
|
trees (templates/, scripts/) as a fallback when this returns None.
|
||||||
|
"""
|
||||||
|
# Wheel install: core_pack is a sibling directory of this file
|
||||||
|
candidate = Path(__file__).parent / "core_pack"
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_bundled_git_extension() -> Path | None:
|
||||||
|
"""Return the path to the bundled git extension, or None.
|
||||||
|
|
||||||
|
Checks the wheel's core_pack first, then falls back to the
|
||||||
|
source-checkout ``extensions/git/`` directory.
|
||||||
|
"""
|
||||||
|
core = _locate_core_pack()
|
||||||
|
if core is not None:
|
||||||
|
candidate = core / "extensions" / "git"
|
||||||
|
if (candidate / "extension.yml").is_file():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# Source-checkout / editable install: look relative to repo root
|
||||||
|
repo_root = Path(__file__).parent.parent.parent
|
||||||
|
candidate = repo_root / "extensions" / "git"
|
||||||
|
if (candidate / "extension.yml").is_file():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _install_bundled_git_extension(project_path: Path) -> bool:
|
||||||
|
"""Auto-install the bundled git extension during ``specify init``.
|
||||||
|
|
||||||
|
This is a migration-period mechanism (pre-1.0.0) that auto-enables
|
||||||
|
the git extension so that existing branching workflows continue to work.
|
||||||
|
Before 1.0.0, this auto-install will be removed and the extension will
|
||||||
|
become opt-in.
|
||||||
|
|
||||||
|
Returns True if the extension was installed or already present,
|
||||||
|
False otherwise.
|
||||||
|
"""
|
||||||
|
ext_source = _locate_bundled_git_extension()
|
||||||
|
if ext_source is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .extensions import ExtensionManager
|
||||||
|
manager = ExtensionManager(project_path)
|
||||||
|
|
||||||
|
# Skip if already installed (e.g. via preset), but only if the
|
||||||
|
# on-disk extension manifest still exists. This guards against
|
||||||
|
# stale/corrupted registry entries.
|
||||||
|
if manager.registry.is_installed("git"):
|
||||||
|
ext_manifest = project_path / ".specify" / "extensions" / "git" / "extension.yml"
|
||||||
|
if ext_manifest.is_file():
|
||||||
|
return True
|
||||||
|
# Registry is stale — remove entry so reinstall can proceed
|
||||||
|
manager.registry.remove("git")
|
||||||
|
|
||||||
|
speckit_ver = get_speckit_version()
|
||||||
|
manager.install_from_directory(ext_source, speckit_ver)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
# Non-fatal: branching still works via core scripts during migration,
|
||||||
|
# but log a warning so users can tell the auto-install did not happen.
|
||||||
|
console.print(
|
||||||
|
"[dim yellow]Warning: failed to auto-install bundled git extension; "
|
||||||
|
"branching via the git extension may be unavailable. "
|
||||||
|
f"Details: {exc}[/dim yellow]"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_release_script() -> tuple[Path, str]:
|
||||||
|
"""Return (script_path, shell_cmd) for the platform-appropriate release script.
|
||||||
|
|
||||||
|
Checks the bundled core_pack first, then falls back to the source checkout.
|
||||||
|
Returns the bash script on Unix and the PowerShell script on Windows.
|
||||||
|
Raises FileNotFoundError if neither can be found.
|
||||||
|
"""
|
||||||
|
if os.name == "nt":
|
||||||
|
name = "create-release-packages.ps1"
|
||||||
|
shell = shutil.which("pwsh")
|
||||||
|
if not shell:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"'pwsh' (PowerShell 7+) not found on PATH. "
|
||||||
|
"The bundled release script requires PowerShell 7+ (pwsh), "
|
||||||
|
"not Windows PowerShell 5.x (powershell.exe). "
|
||||||
|
"Install from https://aka.ms/powershell to use offline scaffolding."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
name = "create-release-packages.sh"
|
||||||
|
shell = "bash"
|
||||||
|
|
||||||
|
# Wheel install: core_pack/release_scripts/
|
||||||
|
candidate = Path(__file__).parent / "core_pack" / "release_scripts" / name
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate, shell
|
||||||
|
|
||||||
|
# Source-checkout fallback
|
||||||
|
repo_root = Path(__file__).parent.parent.parent
|
||||||
|
candidate = repo_root / ".github" / "workflows" / "scripts" / name
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate, shell
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout")
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold_from_core_pack(
|
||||||
|
project_path: Path,
|
||||||
|
ai_assistant: str,
|
||||||
|
script_type: str,
|
||||||
|
is_current_dir: bool = False,
|
||||||
|
*,
|
||||||
|
tracker: StepTracker | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Scaffold a project from bundled core_pack assets — no network access required.
|
||||||
|
|
||||||
|
Invokes the bundled create-release-packages script (bash on Unix, PowerShell
|
||||||
|
on Windows) to generate the full project scaffold for a single agent. This
|
||||||
|
guarantees byte-for-byte parity between ``specify init`` and the GitHub
|
||||||
|
release ZIPs because both use the exact same script.
|
||||||
|
|
||||||
|
Returns True on success. Returns False if offline scaffolding failed for
|
||||||
|
any reason, including missing or unreadable assets, missing required tools
|
||||||
|
(bash, pwsh, zip), release-script failure or timeout, or unexpected runtime
|
||||||
|
exceptions. When ``--offline`` is active the caller should treat False as
|
||||||
|
a hard error rather than falling back to a network download.
|
||||||
|
"""
|
||||||
|
# --- Locate asset sources ---
|
||||||
|
core = _locate_core_pack()
|
||||||
|
|
||||||
|
# Command templates
|
||||||
|
if core and (core / "commands").is_dir():
|
||||||
|
commands_dir = core / "commands"
|
||||||
|
else:
|
||||||
|
repo_root = Path(__file__).parent.parent.parent
|
||||||
|
commands_dir = repo_root / "templates" / "commands"
|
||||||
|
if not commands_dir.is_dir():
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", "command templates not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Scripts directory (parent of bash/ and powershell/)
|
||||||
|
if core and (core / "scripts").is_dir():
|
||||||
|
scripts_dir = core / "scripts"
|
||||||
|
else:
|
||||||
|
repo_root = Path(__file__).parent.parent.parent
|
||||||
|
scripts_dir = repo_root / "scripts"
|
||||||
|
if not scripts_dir.is_dir():
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", "scripts directory not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Page templates (spec-template.md, plan-template.md, vscode-settings.json, etc.)
|
||||||
|
if core and (core / "templates").is_dir():
|
||||||
|
templates_dir = core / "templates"
|
||||||
|
else:
|
||||||
|
repo_root = Path(__file__).parent.parent.parent
|
||||||
|
templates_dir = repo_root / "templates"
|
||||||
|
if not templates_dir.is_dir():
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", "page templates not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Release script
|
||||||
|
try:
|
||||||
|
release_script, shell_cmd = _locate_release_script()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", str(exc))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Preflight: verify required external tools are available
|
||||||
|
if os.name != "nt":
|
||||||
|
if not shutil.which("bash"):
|
||||||
|
msg = "'bash' not found on PATH. Required for offline scaffolding."
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", msg)
|
||||||
|
return False
|
||||||
|
if not shutil.which("zip"):
|
||||||
|
msg = "'zip' not found on PATH. Required for offline scaffolding. Install with: apt install zip / brew install zip"
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", msg)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if tracker:
|
||||||
|
tracker.start("scaffold", "applying bundled assets")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not is_current_dir:
|
||||||
|
project_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmp = Path(tmpdir)
|
||||||
|
|
||||||
|
# Set up a repo-like directory layout in the temp dir so the
|
||||||
|
# release script finds templates/commands/, scripts/, etc.
|
||||||
|
tmpl_cmds = tmp / "templates" / "commands"
|
||||||
|
tmpl_cmds.mkdir(parents=True)
|
||||||
|
for f in commands_dir.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
shutil.copy2(f, tmpl_cmds / f.name)
|
||||||
|
|
||||||
|
# Page templates (needed for vscode-settings.json etc.)
|
||||||
|
if templates_dir.is_dir():
|
||||||
|
tmpl_root = tmp / "templates"
|
||||||
|
for f in templates_dir.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
shutil.copy2(f, tmpl_root / f.name)
|
||||||
|
|
||||||
|
# Scripts (bash/ and powershell/)
|
||||||
|
for subdir in ("bash", "powershell"):
|
||||||
|
src = scripts_dir / subdir
|
||||||
|
if src.is_dir():
|
||||||
|
dst = tmp / "scripts" / subdir
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
for f in src.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
shutil.copy2(f, dst / f.name)
|
||||||
|
|
||||||
|
# Run the release script for this single agent + script type
|
||||||
|
env = os.environ.copy()
|
||||||
|
# Pin GENRELEASES_DIR inside the temp dir so a user-exported
|
||||||
|
# value cannot redirect output or cause rm -rf outside the sandbox.
|
||||||
|
env["GENRELEASES_DIR"] = str(tmp / ".genreleases")
|
||||||
|
if os.name == "nt":
|
||||||
|
cmd = [
|
||||||
|
shell_cmd, "-File", str(release_script),
|
||||||
|
"-Version", "v0.0.0",
|
||||||
|
"-Agents", ai_assistant,
|
||||||
|
"-Scripts", script_type,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
cmd = [shell_cmd, str(release_script), "v0.0.0"]
|
||||||
|
env["AGENTS"] = ai_assistant
|
||||||
|
env["SCRIPTS"] = script_type
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, cwd=str(tmp), env=env,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
msg = "release script timed out after 120 seconds"
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", msg)
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Error:[/red] {msg}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
msg = result.stderr.strip() or result.stdout.strip() or "unknown error"
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", f"release script failed: {msg}")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Release script failed:[/red] {msg}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Copy the generated files to the project directory
|
||||||
|
build_dir = tmp / ".genreleases" / f"sdd-{ai_assistant}-package-{script_type}"
|
||||||
|
if not build_dir.is_dir():
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", "release script produced no output")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for item in build_dir.rglob("*"):
|
||||||
|
if item.is_file():
|
||||||
|
rel = item.relative_to(build_dir)
|
||||||
|
dest = project_path / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# When scaffolding into an existing directory (--here),
|
||||||
|
# use the same merge semantics as the GitHub-download path.
|
||||||
|
if is_current_dir and dest.name == "settings.json" and dest.parent.name == ".vscode":
|
||||||
|
handle_vscode_settings(item, dest, rel, verbose=False, tracker=tracker)
|
||||||
|
else:
|
||||||
|
shutil.copy2(item, dest)
|
||||||
|
|
||||||
|
if tracker:
|
||||||
|
tracker.complete("scaffold", "bundled assets applied")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if tracker:
|
||||||
|
tracker.error("scaffold", str(e))
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Error scaffolding from bundled assets:[/red] {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
|
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
|
||||||
"""Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows)."""
|
"""Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows)."""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
@@ -1211,6 +1563,18 @@ AGENT_SKILLS_DIR_OVERRIDES = {
|
|||||||
# Default skills directory for agents not in AGENT_CONFIG
|
# Default skills directory for agents not in AGENT_CONFIG
|
||||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
DEFAULT_SKILLS_DIR = ".agents/skills"
|
||||||
|
|
||||||
|
# Agents whose downloaded template already contains skills in the final layout.
|
||||||
|
#
|
||||||
|
# Technical debt note:
|
||||||
|
# - Spec-kit currently has multiple SKILL.md generators:
|
||||||
|
# 1) release packaging scripts that build the template zip (native skills),
|
||||||
|
# 2) `install_ai_skills()` which converts extracted command templates to skills,
|
||||||
|
# 3) extension/preset overrides via `agents.CommandRegistrar.render_skill_command()`.
|
||||||
|
# - Keep the skills frontmatter schema aligned across all generators
|
||||||
|
# (at minimum: name/description/compatibility/metadata.{author,source}).
|
||||||
|
# - When adding fields here, update the release scripts and override writers too.
|
||||||
|
NATIVE_SKILLS_AGENTS = {"codex", "kimi"}
|
||||||
|
|
||||||
# Enhanced descriptions for each spec-kit command skill
|
# Enhanced descriptions for each spec-kit command skill
|
||||||
SKILL_DESCRIPTIONS = {
|
SKILL_DESCRIPTIONS = {
|
||||||
"specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.",
|
"specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.",
|
||||||
@@ -1243,18 +1607,27 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
|||||||
return project_path / DEFAULT_SKILLS_DIR
|
return project_path / DEFAULT_SKILLS_DIR
|
||||||
|
|
||||||
|
|
||||||
def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool:
|
def install_ai_skills(
|
||||||
|
project_path: Path,
|
||||||
|
selected_ai: str,
|
||||||
|
tracker: StepTracker | None = None,
|
||||||
|
*,
|
||||||
|
overwrite_existing: bool = False,
|
||||||
|
) -> bool:
|
||||||
"""Install Prompt.MD files from templates/commands/ as agent skills.
|
"""Install Prompt.MD files from templates/commands/ as agent skills.
|
||||||
|
|
||||||
Skills are written to the agent-specific skills directory following the
|
Skills are written to the agent-specific skills directory following the
|
||||||
`agentskills.io <https://agentskills.io/specification>`_ specification.
|
`agentskills.io <https://agentskills.io/specification>`_ specification.
|
||||||
Installation is additive — existing files are never removed and prompt
|
Installation is additive by default — existing files are never removed and
|
||||||
command files in the agent's commands directory are left untouched.
|
prompt command files in the agent's commands directory are left untouched.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_path: Target project directory.
|
project_path: Target project directory.
|
||||||
selected_ai: AI assistant key from ``AGENT_CONFIG``.
|
selected_ai: AI assistant key from ``AGENT_CONFIG``.
|
||||||
tracker: Optional progress tracker.
|
tracker: Optional progress tracker.
|
||||||
|
overwrite_existing: When True, overwrite any existing ``SKILL.md`` file
|
||||||
|
in the target skills directory (including user-authored content).
|
||||||
|
Defaults to False.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``True`` if at least one skill was installed or all skills were
|
``True`` if at least one skill was installed or all skills were
|
||||||
@@ -1339,8 +1712,6 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
command_name = command_name[len("speckit."):]
|
command_name = command_name[len("speckit."):]
|
||||||
if command_name.endswith(".agent"):
|
if command_name.endswith(".agent"):
|
||||||
command_name = command_name[:-len(".agent")]
|
command_name = command_name[:-len(".agent")]
|
||||||
# Kimi CLI discovers skills by directory name and invokes them as
|
|
||||||
# /skill:<name> — use dot separator to match packaging convention.
|
|
||||||
if selected_ai == "kimi":
|
if selected_ai == "kimi":
|
||||||
skill_name = f"speckit.{command_name}"
|
skill_name = f"speckit.{command_name}"
|
||||||
else:
|
else:
|
||||||
@@ -1386,7 +1757,8 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
|
|
||||||
skill_file = skill_dir / "SKILL.md"
|
skill_file = skill_dir / "SKILL.md"
|
||||||
if skill_file.exists():
|
if skill_file.exists():
|
||||||
# Do not overwrite user-customized skills on re-runs
|
if not overwrite_existing:
|
||||||
|
# Default behavior: do not overwrite user-customized skills on re-runs
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
continue
|
continue
|
||||||
skill_file.write_text(skill_content, encoding="utf-8")
|
skill_file.write_text(skill_content, encoding="utf-8")
|
||||||
@@ -1416,20 +1788,45 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
return installed_count > 0 or skipped_count > 0
|
return installed_count > 0 or skipped_count > 0
|
||||||
|
|
||||||
|
|
||||||
def _handle_agy_deprecation(console: Console) -> None:
|
def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
|
||||||
"""
|
"""Return True when a native-skills agent has spec-kit bundled skills."""
|
||||||
Print the deprecation error for the Antigravity (agy) agent and exit.
|
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||||
|
if not skills_dir.is_dir():
|
||||||
|
return False
|
||||||
|
|
||||||
Context for future AI reviewers (e.g., GitHub Copilot):
|
pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
|
||||||
- Prior to Antigravity v1.20.5, users could rely on explicit agent command definitions generated by this tool.
|
return any(skills_dir.glob(pattern))
|
||||||
- v1.20.5 removed this "command support" in favor of "Agent Skills".
|
|
||||||
- `specify_cli` handles this by throwing a fail-fast deprecation error if `--ai agy` is
|
|
||||||
invoked without the accompanying `--ai-skills` flag. Users are instructed to add
|
AGENT_SKILLS_MIGRATIONS = {
|
||||||
the skills flag to generate agent skills templates instead.
|
"agy": {
|
||||||
"""
|
"error": "Explicit command support was deprecated in Antigravity version 1.20.5.",
|
||||||
console.print("\n[red]Error:[/red] Explicit command support was deprecated in Antigravity version 1.20.5.")
|
"usage": "specify init <project> --ai agy --ai-skills",
|
||||||
|
"interactive_note": (
|
||||||
|
"'agy' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
|
||||||
|
"automatically for compatibility (explicit .agent/commands usage is deprecated)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"error": (
|
||||||
|
"Custom prompt-based spec-kit initialization is deprecated for Codex CLI; "
|
||||||
|
"use agent skills instead."
|
||||||
|
),
|
||||||
|
"usage": "specify init <project> --ai codex --ai-skills",
|
||||||
|
"interactive_note": (
|
||||||
|
"'codex' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
|
||||||
|
"automatically for compatibility (.agents/skills is the recommended Codex layout)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_agent_skills_migration(console: Console, agent_key: str) -> None:
|
||||||
|
"""Print a fail-fast migration error for agents that now require skills."""
|
||||||
|
migration = AGENT_SKILLS_MIGRATIONS[agent_key]
|
||||||
|
console.print(f"\n[red]Error:[/red] {migration['error']}")
|
||||||
console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.")
|
console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.")
|
||||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai agy --ai-skills")
|
console.print(f"[yellow]Usage:[/yellow] {migration['usage']}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -1446,18 +1843,30 @@ def init(
|
|||||||
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
||||||
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
||||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||||
|
offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."),
|
||||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||||
|
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize a new Specify project from the latest template.
|
Initialize a new Specify project.
|
||||||
|
|
||||||
|
By default, project files are downloaded from the latest GitHub release.
|
||||||
|
Use --offline to scaffold from assets bundled inside the specify-cli
|
||||||
|
package instead (no internet access required, ideal for air-gapped or
|
||||||
|
enterprise environments).
|
||||||
|
|
||||||
|
NOTE: Starting with v0.6.0, bundled assets will be used by default and
|
||||||
|
the --offline flag will be removed. The GitHub download path will be
|
||||||
|
retired because bundled assets eliminate the need for network access,
|
||||||
|
avoid proxy/firewall issues, and guarantee that templates always match
|
||||||
|
the installed CLI version.
|
||||||
|
|
||||||
This command will:
|
This command will:
|
||||||
1. Check that required tools are installed (git is optional)
|
1. Check that required tools are installed (git is optional)
|
||||||
2. Let you choose your AI assistant
|
2. Let you choose your AI assistant
|
||||||
3. Download the appropriate template from GitHub
|
3. Download template from GitHub (or use bundled assets with --offline)
|
||||||
4. Extract the template to a new project directory or current directory
|
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||||
5. Initialize a fresh git repository (if not --no-git and no existing repo)
|
5. Optionally set up AI assistant commands
|
||||||
6. Optionally set up AI assistant commands
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
specify init my-project
|
specify init my-project
|
||||||
@@ -1467,7 +1876,7 @@ def init(
|
|||||||
specify init . --ai claude # Initialize in current directory
|
specify init . --ai claude # Initialize in current directory
|
||||||
specify init . # Initialize in current directory (interactive AI selection)
|
specify init . # Initialize in current directory (interactive AI selection)
|
||||||
specify init --here --ai claude # Alternative syntax for current directory
|
specify init --here --ai claude # Alternative syntax for current directory
|
||||||
specify init --here --ai codex
|
specify init --here --ai codex --ai-skills
|
||||||
specify init --here --ai codebuddy
|
specify init --here --ai codebuddy
|
||||||
specify init --here --ai vibe # Initialize with Mistral Vibe support
|
specify init --here --ai vibe # Initialize with Mistral Vibe support
|
||||||
specify init --here
|
specify init --here
|
||||||
@@ -1475,6 +1884,7 @@ def init(
|
|||||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||||
specify init --here --ai gemini --ai-skills
|
specify init --here --ai gemini --ai-skills
|
||||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||||
|
specify init my-project --offline # Use bundled assets (no network access)
|
||||||
specify init my-project --ai claude --preset healthcare-compliance # With preset
|
specify init my-project --ai claude --preset healthcare-compliance # With preset
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1514,6 +1924,11 @@ def init(
|
|||||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||||
|
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
||||||
|
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if here:
|
if here:
|
||||||
project_name = Path.cwd().name
|
project_name = Path.cwd().name
|
||||||
project_path = Path.cwd()
|
project_path = Path.cwd()
|
||||||
@@ -1557,24 +1972,16 @@ def init(
|
|||||||
"copilot"
|
"copilot"
|
||||||
)
|
)
|
||||||
|
|
||||||
# [DEPRECATION NOTICE: Antigravity (agy)]
|
# Agents that have moved from explicit commands/prompts to agent skills.
|
||||||
# As of Antigravity v1.20.5, traditional CLI "command" support was fully removed
|
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
|
||||||
# in favor of "Agent Skills" (SKILL.md files under <agent_folder>/skills/<skill_name>/).
|
# If selected interactively (no --ai provided), automatically enable
|
||||||
# Because 'specify_cli' historically populated .agent/commands/, we now must explicitly
|
|
||||||
# enforce the `--ai-skills` flag for `agy` to ensure valid template generation.
|
|
||||||
if selected_ai == "agy" and not ai_skills:
|
|
||||||
# If agy was selected interactively (no --ai provided), automatically enable
|
|
||||||
# ai_skills so the agent remains usable without requiring an extra flag.
|
# ai_skills so the agent remains usable without requiring an extra flag.
|
||||||
# Preserve deprecation behavior only for explicit '--ai agy' without skills.
|
# Preserve fail-fast behavior only for explicit '--ai <agent>' without skills.
|
||||||
if ai_assistant:
|
if ai_assistant:
|
||||||
_handle_agy_deprecation(console)
|
_handle_agent_skills_migration(console, selected_ai)
|
||||||
else:
|
else:
|
||||||
ai_skills = True
|
ai_skills = True
|
||||||
console.print(
|
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
|
||||||
"\n[yellow]Note:[/yellow] 'agy' was selected interactively; "
|
|
||||||
"enabling [cyan]--ai-skills[/cyan] automatically for compatibility "
|
|
||||||
"(explicit .agent/commands usage is deprecated)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate --ai-commands-dir usage
|
# Validate --ai-commands-dir usage
|
||||||
if selected_ai == "generic":
|
if selected_ai == "generic":
|
||||||
@@ -1650,12 +2057,37 @@ def init(
|
|||||||
tracker.complete("ai-select", f"{selected_ai}")
|
tracker.complete("ai-select", f"{selected_ai}")
|
||||||
tracker.add("script-select", "Select script type")
|
tracker.add("script-select", "Select script type")
|
||||||
tracker.complete("script-select", selected_script)
|
tracker.complete("script-select", selected_script)
|
||||||
|
|
||||||
|
# Determine whether to use bundled assets or download from GitHub (default).
|
||||||
|
# --offline opts in to bundled assets; without it, always use GitHub.
|
||||||
|
# When --offline is set, scaffold_from_core_pack() will try the wheel's
|
||||||
|
# core_pack/ first, then fall back to source-checkout paths. If neither
|
||||||
|
# location has the required assets it returns False and we error out.
|
||||||
|
_core = _locate_core_pack()
|
||||||
|
|
||||||
|
use_github = not offline
|
||||||
|
|
||||||
|
if use_github and _core is not None:
|
||||||
|
console.print(
|
||||||
|
"[yellow]Note:[/yellow] Bundled assets are available in this install. "
|
||||||
|
"Use [bold]--offline[/bold] to skip the GitHub download — faster, "
|
||||||
|
"no network required, and guaranteed version match.\n"
|
||||||
|
"This will become the default in v0.6.0."
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_github:
|
||||||
for key, label in [
|
for key, label in [
|
||||||
("fetch", "Fetch latest release"),
|
("fetch", "Fetch latest release"),
|
||||||
("download", "Download template"),
|
("download", "Download template"),
|
||||||
("extract", "Extract template"),
|
("extract", "Extract template"),
|
||||||
("zip-list", "Archive contents"),
|
("zip-list", "Archive contents"),
|
||||||
("extracted-summary", "Extraction summary"),
|
("extracted-summary", "Extraction summary"),
|
||||||
|
]:
|
||||||
|
tracker.add(key, label)
|
||||||
|
else:
|
||||||
|
tracker.add("scaffold", "Apply bundled assets")
|
||||||
|
|
||||||
|
for key, label in [
|
||||||
("chmod", "Ensure scripts executable"),
|
("chmod", "Ensure scripts executable"),
|
||||||
("constitution", "Constitution setup"),
|
("constitution", "Constitution setup"),
|
||||||
]:
|
]:
|
||||||
@@ -1677,10 +2109,39 @@ def init(
|
|||||||
try:
|
try:
|
||||||
verify = not skip_tls
|
verify = not skip_tls
|
||||||
local_ssl_context = ssl_context if verify else False
|
local_ssl_context = ssl_context if verify else False
|
||||||
local_client = httpx.Client(verify=local_ssl_context)
|
|
||||||
|
|
||||||
download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token)
|
|
||||||
|
|
||||||
|
if use_github:
|
||||||
|
with httpx.Client(verify=local_ssl_context) as local_client:
|
||||||
|
download_and_extract_template(
|
||||||
|
project_path,
|
||||||
|
selected_ai,
|
||||||
|
selected_script,
|
||||||
|
here,
|
||||||
|
skip_legacy_codex_prompts=(selected_ai == "codex" and ai_skills),
|
||||||
|
verbose=False,
|
||||||
|
tracker=tracker,
|
||||||
|
client=local_client,
|
||||||
|
debug=debug,
|
||||||
|
github_token=github_token,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker)
|
||||||
|
if not scaffold_ok:
|
||||||
|
# --offline explicitly requested: never attempt a network download
|
||||||
|
console.print(
|
||||||
|
"\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n"
|
||||||
|
"Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n"
|
||||||
|
"Remove --offline to attempt a GitHub download instead."
|
||||||
|
)
|
||||||
|
# Surface the specific failure reason from the tracker
|
||||||
|
for step in tracker.steps:
|
||||||
|
if step["key"] == "scaffold" and step["detail"]:
|
||||||
|
console.print(f"[red]Detail:[/red] {step['detail']}")
|
||||||
|
break
|
||||||
|
# Clean up partial project directory (same as the GitHub-download failure path)
|
||||||
|
if not here and project_path.exists():
|
||||||
|
shutil.rmtree(project_path)
|
||||||
|
raise typer.Exit(1)
|
||||||
# For generic agent, rename placeholder directory to user-specified path
|
# For generic agent, rename placeholder directory to user-specified path
|
||||||
if selected_ai == "generic" and ai_commands_dir:
|
if selected_ai == "generic" and ai_commands_dir:
|
||||||
placeholder_dir = project_path / ".speckit" / "commands"
|
placeholder_dir = project_path / ".speckit" / "commands"
|
||||||
@@ -1698,6 +2159,33 @@ def init(
|
|||||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||||
|
|
||||||
if ai_skills:
|
if ai_skills:
|
||||||
|
if selected_ai in NATIVE_SKILLS_AGENTS:
|
||||||
|
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||||
|
bundled_found = _has_bundled_skills(project_path, selected_ai)
|
||||||
|
if bundled_found:
|
||||||
|
if tracker:
|
||||||
|
tracker.start("ai-skills")
|
||||||
|
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
|
||||||
|
else:
|
||||||
|
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
|
||||||
|
else:
|
||||||
|
# Compatibility fallback: convert command templates to skills
|
||||||
|
# when an older template archive does not include native skills.
|
||||||
|
# This keeps `specify init --here --ai codex --ai-skills` usable
|
||||||
|
# in repos that already contain unrelated skills under .agents/skills.
|
||||||
|
fallback_ok = install_ai_skills(
|
||||||
|
project_path,
|
||||||
|
selected_ai,
|
||||||
|
tracker=tracker,
|
||||||
|
overwrite_existing=True,
|
||||||
|
)
|
||||||
|
if not fallback_ok:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, "
|
||||||
|
"but none were found and fallback conversion failed. "
|
||||||
|
"Re-run with an up-to-date template."
|
||||||
|
)
|
||||||
|
else:
|
||||||
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
|
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
|
||||||
|
|
||||||
# When --ai-skills is used on a NEW project and skills were
|
# When --ai-skills is used on a NEW project and skills were
|
||||||
@@ -1744,12 +2232,19 @@ def init(
|
|||||||
"ai": selected_ai,
|
"ai": selected_ai,
|
||||||
"ai_skills": ai_skills,
|
"ai_skills": ai_skills,
|
||||||
"ai_commands_dir": ai_commands_dir,
|
"ai_commands_dir": ai_commands_dir,
|
||||||
|
"branch_numbering": branch_numbering or "sequential",
|
||||||
"here": here,
|
"here": here,
|
||||||
"preset": preset,
|
"preset": preset,
|
||||||
|
"offline": offline,
|
||||||
"script": selected_script,
|
"script": selected_script,
|
||||||
"speckit_version": get_speckit_version(),
|
"speckit_version": get_speckit_version(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Auto-install the bundled git extension (migration period, pre-1.0.0).
|
||||||
|
# This preserves backward compatibility for existing branching workflows.
|
||||||
|
# Before 1.0.0, this will be removed and git becomes opt-in.
|
||||||
|
_install_bundled_git_extension(project_path)
|
||||||
|
|
||||||
# Install preset if specified
|
# Install preset if specified
|
||||||
if preset:
|
if preset:
|
||||||
try:
|
try:
|
||||||
@@ -1781,7 +2276,13 @@ def init(
|
|||||||
except Exception as preset_err:
|
except Exception as preset_err:
|
||||||
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
|
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
|
||||||
|
|
||||||
|
# Scaffold path has no zip archive to clean up
|
||||||
|
if not use_github:
|
||||||
|
tracker.skip("cleanup", "not needed (no download)")
|
||||||
|
|
||||||
tracker.complete("final", "project ready")
|
tracker.complete("final", "project ready")
|
||||||
|
except (typer.Exit, SystemExit):
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tracker.error("final", str(e))
|
tracker.error("final", str(e))
|
||||||
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
|
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
|
||||||
@@ -1843,38 +2344,48 @@ def init(
|
|||||||
steps_lines.append("1. You're already in the project directory!")
|
steps_lines.append("1. You're already in the project directory!")
|
||||||
step_num = 2
|
step_num = 2
|
||||||
|
|
||||||
# Add Codex-specific setup step if needed
|
if selected_ai == "codex" and ai_skills:
|
||||||
if selected_ai == "codex":
|
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||||
codex_path = project_path / ".codex"
|
|
||||||
quoted_path = shlex.quote(str(codex_path))
|
|
||||||
if os.name == "nt": # Windows
|
|
||||||
cmd = f"setx CODEX_HOME {quoted_path}"
|
|
||||||
else: # Unix-like systems
|
|
||||||
cmd = f"export CODEX_HOME={quoted_path}"
|
|
||||||
|
|
||||||
steps_lines.append(f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]")
|
|
||||||
step_num += 1
|
step_num += 1
|
||||||
|
|
||||||
steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:")
|
codex_skill_mode = selected_ai == "codex" and ai_skills
|
||||||
|
kimi_skill_mode = selected_ai == "kimi"
|
||||||
|
native_skill_mode = codex_skill_mode or kimi_skill_mode
|
||||||
|
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||||
|
|
||||||
steps_lines.append(" 2.1 [cyan]/speckit.constitution[/] - Establish project principles")
|
def _display_cmd(name: str) -> str:
|
||||||
steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification")
|
if codex_skill_mode:
|
||||||
steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan")
|
return f"$speckit-{name}"
|
||||||
steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks")
|
if kimi_skill_mode:
|
||||||
steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation")
|
return f"/skill:speckit.{name}"
|
||||||
|
return f"/speckit.{name}"
|
||||||
|
|
||||||
|
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
|
||||||
|
|
||||||
|
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
|
||||||
|
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
|
||||||
|
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
|
||||||
|
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
|
||||||
|
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
|
||||||
|
|
||||||
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
|
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
|
||||||
console.print()
|
console.print()
|
||||||
console.print(steps_panel)
|
console.print(steps_panel)
|
||||||
|
|
||||||
|
enhancement_intro = (
|
||||||
|
"Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||||
|
if native_skill_mode
|
||||||
|
else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||||
|
)
|
||||||
enhancement_lines = [
|
enhancement_lines = [
|
||||||
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
|
enhancement_intro,
|
||||||
"",
|
"",
|
||||||
"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
|
f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
|
||||||
"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
|
||||||
"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
|
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
|
||||||
]
|
]
|
||||||
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
|
enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
|
||||||
|
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2))
|
||||||
console.print()
|
console.print()
|
||||||
console.print(enhancements_panel)
|
console.print(enhancements_panel)
|
||||||
|
|
||||||
@@ -2600,7 +3111,7 @@ def preset_catalog_add(
|
|||||||
# Load existing config
|
# Load existing config
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
try:
|
try:
|
||||||
config = yaml.safe_load(config_path.read_text()) or {}
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
|
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -2628,7 +3139,7 @@ def preset_catalog_add(
|
|||||||
})
|
})
|
||||||
|
|
||||||
config["catalogs"] = catalogs
|
config["catalogs"] = catalogs
|
||||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||||
|
|
||||||
install_label = "install allowed" if install_allowed else "discovery only"
|
install_label = "install allowed" if install_allowed else "discovery only"
|
||||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||||
@@ -2656,7 +3167,7 @@ def preset_catalog_remove(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = yaml.safe_load(config_path.read_text()) or {}
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
console.print("[red]Error:[/red] Failed to read preset catalog config.")
|
console.print("[red]Error:[/red] Failed to read preset catalog config.")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -2673,7 +3184,7 @@ def preset_catalog_remove(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
config["catalogs"] = catalogs
|
config["catalogs"] = catalogs
|
||||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||||
if not catalogs:
|
if not catalogs:
|
||||||
@@ -2942,7 +3453,7 @@ def catalog_add(
|
|||||||
# Load existing config
|
# Load existing config
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
try:
|
try:
|
||||||
config = yaml.safe_load(config_path.read_text()) or {}
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
|
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -2970,7 +3481,7 @@ def catalog_add(
|
|||||||
})
|
})
|
||||||
|
|
||||||
config["catalogs"] = catalogs
|
config["catalogs"] = catalogs
|
||||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||||
|
|
||||||
install_label = "install allowed" if install_allowed else "discovery only"
|
install_label = "install allowed" if install_allowed else "discovery only"
|
||||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||||
@@ -2998,7 +3509,7 @@ def catalog_remove(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = yaml.safe_load(config_path.read_text()) or {}
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
console.print("[red]Error:[/red] Failed to read catalog config.")
|
console.print("[red]Error:[/red] Failed to read catalog config.")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -3015,7 +3526,7 @@ def catalog_remove(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
config["catalogs"] = catalogs
|
config["catalogs"] = catalogs
|
||||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||||
if not catalogs:
|
if not catalogs:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ command files into agent-specific directories in the correct format.
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
import platform
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
@@ -59,13 +60,19 @@ class CommandRegistrar:
|
|||||||
"extension": ".md"
|
"extension": ".md"
|
||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"dir": ".codex/prompts",
|
"dir": ".agents/skills",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": "/SKILL.md",
|
||||||
|
},
|
||||||
|
"windsurf": {
|
||||||
|
"dir": ".windsurf/workflows",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"args": "$ARGUMENTS",
|
"args": "$ARGUMENTS",
|
||||||
"extension": ".md"
|
"extension": ".md"
|
||||||
},
|
},
|
||||||
"windsurf": {
|
"junie": {
|
||||||
"dir": ".windsurf/workflows",
|
"dir": ".junie/commands",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"args": "$ARGUMENTS",
|
"args": "$ARGUMENTS",
|
||||||
"extension": ".md"
|
"extension": ".md"
|
||||||
@@ -140,7 +147,7 @@ class CommandRegistrar:
|
|||||||
"dir": ".kimi/skills",
|
"dir": ".kimi/skills",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"args": "$ARGUMENTS",
|
"args": "$ARGUMENTS",
|
||||||
"extension": "/SKILL.md"
|
"extension": "/SKILL.md",
|
||||||
},
|
},
|
||||||
"trae": {
|
"trae": {
|
||||||
"dir": ".trae/rules",
|
"dir": ".trae/rules",
|
||||||
@@ -182,6 +189,9 @@ class CommandRegistrar:
|
|||||||
except yaml.YAMLError:
|
except yaml.YAMLError:
|
||||||
frontmatter = {}
|
frontmatter = {}
|
||||||
|
|
||||||
|
if not isinstance(frontmatter, dict):
|
||||||
|
frontmatter = {}
|
||||||
|
|
||||||
return frontmatter, body
|
return frontmatter, body
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -197,7 +207,7 @@ class CommandRegistrar:
|
|||||||
if not fm:
|
if not fm:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
|
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||||
return f"---\n{yaml_str}---\n"
|
return f"---\n{yaml_str}---\n"
|
||||||
|
|
||||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||||
@@ -209,11 +219,14 @@ class CommandRegistrar:
|
|||||||
Returns:
|
Returns:
|
||||||
Modified frontmatter with adjusted paths
|
Modified frontmatter with adjusted paths
|
||||||
"""
|
"""
|
||||||
if "scripts" in frontmatter:
|
for script_key in ("scripts", "agent_scripts"):
|
||||||
for key in frontmatter["scripts"]:
|
scripts = frontmatter.get(script_key)
|
||||||
script_path = frontmatter["scripts"][key]
|
if not isinstance(scripts, dict):
|
||||||
if script_path.startswith("../../scripts/"):
|
continue
|
||||||
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
|
||||||
|
for key, script_path in scripts.items():
|
||||||
|
if isinstance(script_path, str) and script_path.startswith("../../scripts/"):
|
||||||
|
scripts[key] = f".specify/scripts/{script_path[14:]}"
|
||||||
return frontmatter
|
return frontmatter
|
||||||
|
|
||||||
def render_markdown_command(
|
def render_markdown_command(
|
||||||
@@ -270,6 +283,101 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
return "\n".join(toml_lines)
|
return "\n".join(toml_lines)
|
||||||
|
|
||||||
|
def render_skill_command(
|
||||||
|
self,
|
||||||
|
agent_name: str,
|
||||||
|
skill_name: str,
|
||||||
|
frontmatter: dict,
|
||||||
|
body: str,
|
||||||
|
source_id: str,
|
||||||
|
source_file: str,
|
||||||
|
project_root: Path,
|
||||||
|
) -> str:
|
||||||
|
"""Render a command override as a SKILL.md file.
|
||||||
|
|
||||||
|
SKILL-target agents should receive the same skills-oriented
|
||||||
|
frontmatter shape used elsewhere in the project instead of the
|
||||||
|
original command frontmatter.
|
||||||
|
|
||||||
|
Technical debt note:
|
||||||
|
Spec-kit currently has multiple SKILL.md generators (template packaging,
|
||||||
|
init-time conversion, and extension/preset overrides). Keep the skill
|
||||||
|
frontmatter keys aligned (name/description/compatibility/metadata, with
|
||||||
|
metadata.author and metadata.source subkeys) to avoid drift across agents.
|
||||||
|
"""
|
||||||
|
if not isinstance(frontmatter, dict):
|
||||||
|
frontmatter = {}
|
||||||
|
|
||||||
|
if agent_name == "codex":
|
||||||
|
body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)
|
||||||
|
|
||||||
|
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
|
||||||
|
skill_frontmatter = {
|
||||||
|
"name": skill_name,
|
||||||
|
"description": description,
|
||||||
|
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||||
|
"metadata": {
|
||||||
|
"author": "github-spec-kit",
|
||||||
|
"source": f"{source_id}:{source_file}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return self.render_frontmatter(skill_frontmatter) + "\n" + body
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str:
|
||||||
|
"""Resolve script placeholders for Codex skill overrides.
|
||||||
|
|
||||||
|
This intentionally scopes the fix to Codex, which is the newly
|
||||||
|
migrated runtime path in this PR. Existing Kimi behavior is left
|
||||||
|
unchanged for now.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from . import load_init_options
|
||||||
|
except ImportError:
|
||||||
|
return body
|
||||||
|
|
||||||
|
if not isinstance(frontmatter, dict):
|
||||||
|
frontmatter = {}
|
||||||
|
|
||||||
|
scripts = frontmatter.get("scripts", {}) or {}
|
||||||
|
agent_scripts = frontmatter.get("agent_scripts", {}) or {}
|
||||||
|
if not isinstance(scripts, dict):
|
||||||
|
scripts = {}
|
||||||
|
if not isinstance(agent_scripts, dict):
|
||||||
|
agent_scripts = {}
|
||||||
|
|
||||||
|
script_variant = load_init_options(project_root).get("script")
|
||||||
|
if script_variant not in {"sh", "ps"}:
|
||||||
|
fallback_order = []
|
||||||
|
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
|
||||||
|
secondary_variant = "sh" if default_variant == "ps" else "ps"
|
||||||
|
|
||||||
|
if default_variant in scripts or default_variant in agent_scripts:
|
||||||
|
fallback_order.append(default_variant)
|
||||||
|
if secondary_variant in scripts or secondary_variant in agent_scripts:
|
||||||
|
fallback_order.append(secondary_variant)
|
||||||
|
|
||||||
|
for key in scripts:
|
||||||
|
if key not in fallback_order:
|
||||||
|
fallback_order.append(key)
|
||||||
|
for key in agent_scripts:
|
||||||
|
if key not in fallback_order:
|
||||||
|
fallback_order.append(key)
|
||||||
|
|
||||||
|
script_variant = fallback_order[0] if fallback_order else None
|
||||||
|
|
||||||
|
script_command = scripts.get(script_variant) if script_variant else None
|
||||||
|
if script_command:
|
||||||
|
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||||
|
body = body.replace("{SCRIPT}", script_command)
|
||||||
|
|
||||||
|
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
|
||||||
|
if agent_script_command:
|
||||||
|
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||||
|
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
|
||||||
|
|
||||||
|
return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex")
|
||||||
|
|
||||||
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||||
"""Convert argument placeholder format.
|
"""Convert argument placeholder format.
|
||||||
|
|
||||||
@@ -283,6 +391,18 @@ class CommandRegistrar:
|
|||||||
"""
|
"""
|
||||||
return content.replace(from_placeholder, to_placeholder)
|
return content.replace(from_placeholder, to_placeholder)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:
|
||||||
|
"""Compute the on-disk command or skill name for an agent."""
|
||||||
|
if agent_config["extension"] != "/SKILL.md":
|
||||||
|
return cmd_name
|
||||||
|
|
||||||
|
short_name = cmd_name
|
||||||
|
if short_name.startswith("speckit."):
|
||||||
|
short_name = short_name[len("speckit."):]
|
||||||
|
|
||||||
|
return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}"
|
||||||
|
|
||||||
def register_commands(
|
def register_commands(
|
||||||
self,
|
self,
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
@@ -334,14 +454,20 @@ class CommandRegistrar:
|
|||||||
body, "$ARGUMENTS", agent_config["args"]
|
body, "$ARGUMENTS", agent_config["args"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if agent_config["format"] == "markdown":
|
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||||
|
|
||||||
|
if agent_config["extension"] == "/SKILL.md":
|
||||||
|
output = self.render_skill_command(
|
||||||
|
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
|
||||||
|
)
|
||||||
|
elif agent_config["format"] == "markdown":
|
||||||
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||||
elif agent_config["format"] == "toml":
|
elif agent_config["format"] == "toml":
|
||||||
output = self.render_toml_command(frontmatter, body, source_id)
|
output = self.render_toml_command(frontmatter, body, source_id)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||||
|
|
||||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
dest_file.write_text(output, encoding="utf-8")
|
dest_file.write_text(output, encoding="utf-8")
|
||||||
|
|
||||||
@@ -351,9 +477,15 @@ class CommandRegistrar:
|
|||||||
registered.append(cmd_name)
|
registered.append(cmd_name)
|
||||||
|
|
||||||
for alias in cmd_info.get("aliases", []):
|
for alias in cmd_info.get("aliases", []):
|
||||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
|
||||||
|
alias_output = output
|
||||||
|
if agent_config["extension"] == "/SKILL.md":
|
||||||
|
alias_output = self.render_skill_command(
|
||||||
|
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
|
||||||
|
)
|
||||||
|
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
|
||||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
alias_file.write_text(output, encoding="utf-8")
|
alias_file.write_text(alias_output, encoding="utf-8")
|
||||||
if agent_name == "copilot":
|
if agent_name == "copilot":
|
||||||
self.write_copilot_prompt(project_root, alias)
|
self.write_copilot_prompt(project_root, alias)
|
||||||
registered.append(alias)
|
registered.append(alias)
|
||||||
@@ -396,7 +528,7 @@ class CommandRegistrar:
|
|||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
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"].split("/")[0]
|
agent_dir = project_root / agent_config["dir"]
|
||||||
|
|
||||||
if agent_dir.exists():
|
if agent_dir.exists():
|
||||||
try:
|
try:
|
||||||
@@ -430,7 +562,8 @@ class CommandRegistrar:
|
|||||||
commands_dir = project_root / agent_config["dir"]
|
commands_dir = project_root / agent_config["dir"]
|
||||||
|
|
||||||
for cmd_name in cmd_names:
|
for cmd_name in cmd_names:
|
||||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||||
|
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||||
if cmd_file.exists():
|
if cmd_file.exists():
|
||||||
cmd_file.unlink()
|
cmd_file.unlink()
|
||||||
|
|
||||||
|
|||||||
@@ -975,8 +975,8 @@ class ExtensionCatalog:
|
|||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
data = yaml.safe_load(config_path.read_text()) or {}
|
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
except (yaml.YAMLError, OSError) as e:
|
except (yaml.YAMLError, OSError, UnicodeError) as e:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Failed to read catalog config {config_path}: {e}"
|
f"Failed to read catalog config {config_path}: {e}"
|
||||||
)
|
)
|
||||||
@@ -1467,8 +1467,8 @@ class ConfigManager:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return yaml.safe_load(file_path.read_text()) or {}
|
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
|
||||||
except (yaml.YAMLError, OSError):
|
except (yaml.YAMLError, OSError, UnicodeError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _get_extension_defaults(self) -> Dict[str, Any]:
|
def _get_extension_defaults(self) -> Dict[str, Any]:
|
||||||
@@ -1659,8 +1659,8 @@ class HookExecutor:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return yaml.safe_load(self.config_file.read_text()) or {}
|
return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {}
|
||||||
except (yaml.YAMLError, OSError):
|
except (yaml.YAMLError, OSError, UnicodeError):
|
||||||
return {
|
return {
|
||||||
"installed": [],
|
"installed": [],
|
||||||
"settings": {"auto_execute_hooks": True},
|
"settings": {"auto_execute_hooks": True},
|
||||||
@@ -1675,7 +1675,8 @@ class HookExecutor:
|
|||||||
"""
|
"""
|
||||||
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.config_file.write_text(
|
self.config_file.write_text(
|
||||||
yaml.dump(config, default_flow_style=False, sort_keys=False)
|
yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
def register_hooks(self, manifest: ExtensionManifest):
|
def register_hooks(self, manifest: ExtensionManifest):
|
||||||
|
|||||||
@@ -646,8 +646,6 @@ class PresetManager:
|
|||||||
short_name = cmd_name
|
short_name = cmd_name
|
||||||
if short_name.startswith("speckit."):
|
if short_name.startswith("speckit."):
|
||||||
short_name = short_name[len("speckit."):]
|
short_name = short_name[len("speckit."):]
|
||||||
# Kimi CLI discovers skills by directory name and invokes them as
|
|
||||||
# /skill:<name> — use dot separator to match packaging convention.
|
|
||||||
if selected_ai == "kimi":
|
if selected_ai == "kimi":
|
||||||
skill_name = f"speckit.{short_name}"
|
skill_name = f"speckit.{short_name}"
|
||||||
else:
|
else:
|
||||||
@@ -1064,8 +1062,8 @@ class PresetCatalog:
|
|||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
data = yaml.safe_load(config_path.read_text()) or {}
|
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
except (yaml.YAMLError, OSError) as e:
|
except (yaml.YAMLError, OSError, UnicodeError) as e:
|
||||||
raise PresetValidationError(
|
raise PresetValidationError(
|
||||||
f"Failed to read catalog config {config_path}: {e}"
|
f"Failed to read catalog config {config_path}: {e}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,10 +73,44 @@ Given that feature description, do this:
|
|||||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||||
|
|
||||||
2. **Create the feature branch** by running the script with `--short-name` (and `--json`), and do NOT pass `--number` (the script auto-detects the next globally available number across all branches and spec directories):
|
2. **Create the feature branch** (unless already handled by a `before_specify` hook — see Pre-Execution Checks above). If a mandatory `before_specify` hook for `speckit.git.feature` already executed and created the branch, **skip this step entirely** and use the branch/spec information from the hook result. Otherwise:
|
||||||
|
|
||||||
|
**Git extension check**: Before running the branch creation script, check if the git extension is enabled:
|
||||||
|
- Check if `.specify/extensions/.registry` exists (a single JSON file tracking all extensions)
|
||||||
|
- If it exists, read the JSON and look for an `extensions.git` entry
|
||||||
|
- **Only skip branching** when `extensions.git.enabled` is **present and explicitly `false`**. In all other cases — including when the registry file is missing, when `extensions.git` has no entry, or when `"enabled"` is `true` or absent — proceed with normal branch creation (extension script if present, else core `{SCRIPT}` fallback)
|
||||||
|
- If the git extension is **disabled** (explicitly `"enabled": false`), **skip branch creation entirely** — do **not** run the branch creation script. Instead:
|
||||||
|
- Derive a spec directory name from the short name, e.g. `specs/<short-name>/`
|
||||||
|
- Explicitly set the following variables so later steps can use them:
|
||||||
|
- `FEATURE_DIR="specs/<short-name>"`
|
||||||
|
- `SPEC_FILE="$FEATURE_DIR/spec.md"`
|
||||||
|
- Ensure the directory and spec file exist:
|
||||||
|
- Bash:
|
||||||
|
- `mkdir -p "$FEATURE_DIR"`
|
||||||
|
- `touch "$SPEC_FILE"`
|
||||||
|
- PowerShell:
|
||||||
|
- `New-Item -ItemType Directory -Path $FEATURE_DIR -Force | Out-Null`
|
||||||
|
- `New-Item -ItemType File -Path $SPEC_FILE -Force | Out-Null`
|
||||||
|
- Then proceed directly to step 3 using `FEATURE_DIR` and `SPEC_FILE`
|
||||||
|
- If the registry file does not exist, proceed with branch creation using the default behavior (backward compatibility)
|
||||||
|
|
||||||
|
Run the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
|
||||||
|
|
||||||
|
**Branch numbering mode**: Before running the script, determine the branch numbering strategy:
|
||||||
|
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value (extension config takes precedence)
|
||||||
|
2. If not found, check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||||
|
3. Default to `sequential` if neither exists
|
||||||
|
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
|
||||||
|
- If `"sequential"` or absent, do not add any extra flag (default behavior)
|
||||||
|
|
||||||
|
**Script resolution**: Use the extension's bundled scripts when available, falling back to core scripts:
|
||||||
|
- **Bash**: If `.specify/extensions/git/scripts/bash/create-new-feature.sh` exists, use it; otherwise, fall back to `{SCRIPT}`
|
||||||
|
- **PowerShell**: If `.specify/extensions/git/scripts/powershell/create-new-feature.ps1` exists, use it; otherwise, fall back to `{SCRIPT}`
|
||||||
|
|
||||||
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
|
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
|
||||||
|
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
|
||||||
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
||||||
|
- PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
|
||||||
|
|
||||||
**IMPORTANT**:
|
**IMPORTANT**:
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||||
|
|||||||
@@ -29,11 +29,17 @@ class TestAgentConfigConsistency:
|
|||||||
assert "q" not in cfg
|
assert "q" not in cfg
|
||||||
|
|
||||||
def test_extension_registrar_includes_codex(self):
|
def test_extension_registrar_includes_codex(self):
|
||||||
"""Extension command registrar should include codex targeting .codex/prompts."""
|
"""Extension command registrar should include codex targeting .agents/skills."""
|
||||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||||
|
|
||||||
assert "codex" in cfg
|
assert "codex" in cfg
|
||||||
assert cfg["codex"]["dir"] == ".codex/prompts"
|
assert cfg["codex"]["dir"] == ".agents/skills"
|
||||||
|
assert cfg["codex"]["extension"] == "/SKILL.md"
|
||||||
|
|
||||||
|
def test_runtime_codex_uses_native_skills(self):
|
||||||
|
"""Codex runtime config should point at .agents/skills."""
|
||||||
|
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
||||||
|
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
|
||||||
|
|
||||||
def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):
|
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."""
|
"""Bash and PowerShell release scripts should agree on agent key set for Kiro."""
|
||||||
@@ -71,6 +77,16 @@ class TestAgentConfigConsistency:
|
|||||||
assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None
|
assert re.search(r"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
|
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
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ Tests cover:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import zipfile
|
||||||
import pytest
|
import pytest
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
import yaml
|
import yaml
|
||||||
|
import typer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -471,8 +473,7 @@ class TestInstallAiSkills:
|
|||||||
skills_dir = _get_skills_dir(proj, agent_key)
|
skills_dir = _get_skills_dir(proj, agent_key)
|
||||||
assert skills_dir.exists()
|
assert skills_dir.exists()
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||||
# Kimi uses dot-separator (speckit.specify) to match /skill:speckit.* invocation;
|
# Kimi uses dotted skill names; other agents use hyphen-separated names.
|
||||||
# all other agents use hyphen-separator (speckit-specify).
|
|
||||||
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
||||||
assert expected_skill_name in skill_dirs
|
assert expected_skill_name in skill_dirs
|
||||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||||
@@ -694,6 +695,175 @@ class TestNewProjectCommandSkip:
|
|||||||
prompts_dir = target / ".kiro" / "prompts"
|
prompts_dir = target / ".kiro" / "prompts"
|
||||||
assert not prompts_dir.exists()
|
assert not prompts_dir.exists()
|
||||||
|
|
||||||
|
def test_codex_native_skills_preserved_without_conversion(self, tmp_path):
|
||||||
|
"""Codex should keep bundled .agents/skills and skip install_ai_skills conversion."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
target = tmp_path / "new-codex-proj"
|
||||||
|
|
||||||
|
def fake_download(project_path, *args, **kwargs):
|
||||||
|
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||||
|
|
||||||
|
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||||
|
patch("specify_cli.ensure_executable_scripts"), \
|
||||||
|
patch("specify_cli.ensure_constitution_from_template"), \
|
||||||
|
patch("specify_cli.install_ai_skills") as mock_skills, \
|
||||||
|
patch("specify_cli.is_git_repo", return_value=False), \
|
||||||
|
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_skills.assert_not_called()
|
||||||
|
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path):
|
||||||
|
"""Codex should attempt fallback conversion when bundled skills are missing."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
target = tmp_path / "missing-codex-skills"
|
||||||
|
|
||||||
|
with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
|
||||||
|
patch("specify_cli.ensure_executable_scripts"), \
|
||||||
|
patch("specify_cli.ensure_constitution_from_template"), \
|
||||||
|
patch("specify_cli.install_ai_skills", return_value=False) as mock_skills, \
|
||||||
|
patch("specify_cli.is_git_repo", return_value=False), \
|
||||||
|
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
mock_skills.assert_called_once()
|
||||||
|
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
|
||||||
|
assert "Expected bundled agent skills" in result.output
|
||||||
|
assert "fallback conversion failed" in result.output
|
||||||
|
|
||||||
|
def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):
|
||||||
|
"""Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
target = tmp_path / "foreign-codex-skills"
|
||||||
|
|
||||||
|
def fake_download(project_path, *args, **kwargs):
|
||||||
|
skill_dir = project_path / ".agents" / "skills" / "other-tool"
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n")
|
||||||
|
|
||||||
|
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||||
|
patch("specify_cli.ensure_executable_scripts"), \
|
||||||
|
patch("specify_cli.ensure_constitution_from_template"), \
|
||||||
|
patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
|
||||||
|
patch("specify_cli.is_git_repo", return_value=False), \
|
||||||
|
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_skills.assert_called_once()
|
||||||
|
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
|
||||||
|
|
||||||
|
def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch):
|
||||||
|
"""Codex --here skills init should not delete a pre-existing .codex directory."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
target = tmp_path / "codex-preserve-here"
|
||||||
|
target.mkdir()
|
||||||
|
existing_prompts = target / ".codex" / "prompts"
|
||||||
|
existing_prompts.mkdir(parents=True)
|
||||||
|
(existing_prompts / "custom.md").write_text("custom")
|
||||||
|
monkeypatch.chdir(target)
|
||||||
|
|
||||||
|
with patch("specify_cli.download_and_extract_template", return_value=target), \
|
||||||
|
patch("specify_cli.ensure_executable_scripts"), \
|
||||||
|
patch("specify_cli.ensure_constitution_from_template"), \
|
||||||
|
patch("specify_cli.install_ai_skills", return_value=True), \
|
||||||
|
patch("specify_cli.is_git_repo", return_value=True), \
|
||||||
|
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["init", "--here", "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||||
|
input="y\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert (target / ".codex").exists()
|
||||||
|
assert (existing_prompts / "custom.md").exists()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
def test_commands_preserved_when_skills_fail(self, tmp_path):
|
def test_commands_preserved_when_skills_fail(self, tmp_path):
|
||||||
"""If skills fail, commands should NOT be removed (safety net)."""
|
"""If skills fail, commands should NOT be removed (safety net)."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -784,6 +954,21 @@ class TestSkipIfExists:
|
|||||||
# All 4 templates should produce skills (specify, plan, tasks, empty_fm)
|
# All 4 templates should produce skills (specify, plan, tasks, empty_fm)
|
||||||
assert len(skill_dirs) == 4
|
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 =====
|
# ===== SKILL_DESCRIPTIONS Coverage Tests =====
|
||||||
|
|
||||||
@@ -837,6 +1022,17 @@ class TestCliValidation:
|
|||||||
assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
|
assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
|
||||||
assert "--ai-skills" in result.output
|
assert "--ai-skills" in result.output
|
||||||
|
|
||||||
|
def test_codex_without_ai_skills_fails(self):
|
||||||
|
"""--ai codex without --ai-skills should fail with exit code 1."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" in result.output
|
||||||
|
assert "--ai-skills" in result.output
|
||||||
|
|
||||||
def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):
|
def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):
|
||||||
"""Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
|
"""Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -879,6 +1075,72 @@ class TestCliValidation:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Explicit command support was deprecated" not in result.output
|
assert "Explicit command support was deprecated" not in result.output
|
||||||
|
|
||||||
|
def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch):
|
||||||
|
"""Interactive selector returning codex without --ai-skills should automatically enable --ai-skills."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
def _fake_select_with_arrows(*args, **kwargs):
|
||||||
|
options = kwargs.get("options")
|
||||||
|
if options is None and len(args) >= 1:
|
||||||
|
options = args[0]
|
||||||
|
|
||||||
|
if isinstance(options, dict) and "codex" in options:
|
||||||
|
return "codex"
|
||||||
|
if isinstance(options, (list, tuple)) and "codex" in options:
|
||||||
|
return "codex"
|
||||||
|
|
||||||
|
if isinstance(options, dict) and options:
|
||||||
|
return next(iter(options.keys()))
|
||||||
|
if isinstance(options, (list, tuple)) and options:
|
||||||
|
return options[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
|
||||||
|
|
||||||
|
def _fake_download(*args, **kwargs):
|
||||||
|
project_path = Path(args[0])
|
||||||
|
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
|
||||||
|
assert ".agents/skills" in result.output
|
||||||
|
assert "$speckit-constitution" in result.output
|
||||||
|
assert "/speckit.constitution" not in result.output
|
||||||
|
assert "Optional skills that you can use for your specs" in result.output
|
||||||
|
|
||||||
|
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
|
||||||
|
"""Kimi next-steps guidance should display /skill:speckit.* usage."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
def _fake_download(*args, **kwargs):
|
||||||
|
project_path = Path(args[0])
|
||||||
|
skill_dir = project_path / ".kimi" / "skills" / "speckit.specify"
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "/skill:speckit.constitution" in result.output
|
||||||
|
assert "/speckit.constitution" not in result.output
|
||||||
|
assert "Optional skills that you can use for your specs" in result.output
|
||||||
|
|
||||||
def test_ai_skills_flag_appears_in_help(self):
|
def test_ai_skills_flag_appears_in_help(self):
|
||||||
"""--ai-skills should appear in init --help output."""
|
"""--ai-skills should appear in init --help output."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -898,10 +1160,12 @@ class TestCliValidation:
|
|||||||
target = tmp_path / "kiro-alias-proj"
|
target = tmp_path / "kiro-alias-proj"
|
||||||
|
|
||||||
with patch("specify_cli.download_and_extract_template") as mock_download, \
|
with patch("specify_cli.download_and_extract_template") as mock_download, \
|
||||||
|
patch("specify_cli.scaffold_from_core_pack", create=True) as mock_scaffold, \
|
||||||
patch("specify_cli.ensure_executable_scripts"), \
|
patch("specify_cli.ensure_executable_scripts"), \
|
||||||
patch("specify_cli.ensure_constitution_from_template"), \
|
patch("specify_cli.ensure_constitution_from_template"), \
|
||||||
patch("specify_cli.is_git_repo", return_value=False), \
|
patch("specify_cli.is_git_repo", return_value=False), \
|
||||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
||||||
|
mock_scaffold.return_value = True
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
[
|
[
|
||||||
@@ -917,9 +1181,14 @@ class TestCliValidation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_download.called
|
# Without --offline, the download path should be taken.
|
||||||
# download_and_extract_template(project_path, ai_assistant, script_type, ...)
|
assert mock_download.called, (
|
||||||
|
"Expected download_and_extract_template to be called (default non-offline path)"
|
||||||
|
)
|
||||||
assert mock_download.call_args.args[1] == "kiro-cli"
|
assert mock_download.call_args.args[1] == "kiro-cli"
|
||||||
|
assert not mock_scaffold.called, (
|
||||||
|
"scaffold_from_core_pack should not be called without --offline"
|
||||||
|
)
|
||||||
|
|
||||||
def test_q_removed_from_agent_config(self):
|
def test_q_removed_from_agent_config(self):
|
||||||
"""Amazon Q legacy key should not remain in AGENT_CONFIG."""
|
"""Amazon Q legacy key should not remain in AGENT_CONFIG."""
|
||||||
|
|||||||
170
tests/test_branch_numbering.py
Normal file
170
tests/test_branch_numbering.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for branch numbering options (sequential vs timestamp).
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Persisting branch_numbering in init-options.json
|
||||||
|
- Default value when branch_numbering is None
|
||||||
|
- Validation of branch_numbering values
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from specify_cli import save_init_options
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveBranchNumbering:
|
||||||
|
"""Tests for save_init_options with branch_numbering."""
|
||||||
|
|
||||||
|
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
|
||||||
|
opts = {"branch_numbering": "timestamp", "ai": "claude"}
|
||||||
|
save_init_options(tmp_path, opts)
|
||||||
|
|
||||||
|
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||||
|
assert saved["branch_numbering"] == "timestamp"
|
||||||
|
|
||||||
|
def test_save_branch_numbering_sequential(self, tmp_path: Path):
|
||||||
|
opts = {"branch_numbering": "sequential", "ai": "claude"}
|
||||||
|
save_init_options(tmp_path, opts)
|
||||||
|
|
||||||
|
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||||
|
assert saved["branch_numbering"] == "sequential"
|
||||||
|
|
||||||
|
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path, monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
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"
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
||||||
|
assert saved["branch_numbering"] == "sequential"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBranchNumberingValidation:
|
||||||
|
"""Tests for branch_numbering CLI validation via CliRunner."""
|
||||||
|
|
||||||
|
def test_invalid_branch_numbering_rejected(self, tmp_path: Path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Invalid --branch-numbering" in result.output
|
||||||
|
|
||||||
|
def test_valid_branch_numbering_sequential(self, tmp_path: Path, monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
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()
|
||||||
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|
||||||
|
def test_valid_branch_numbering_timestamp(self, tmp_path: Path, monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
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()
|
||||||
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitExtensionAutoInstall:
|
||||||
|
"""Tests for bundled git extension auto-install during specify init."""
|
||||||
|
|
||||||
|
def test_git_extension_installed_during_init(self, tmp_path: Path, monkeypatch):
|
||||||
|
"""Verify that `specify init` auto-installs the bundled git extension."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
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"
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
# Extension files should exist
|
||||||
|
ext_dir = project_dir / ".specify" / "extensions" / "git"
|
||||||
|
assert ext_dir.is_dir(), "git extension directory not created"
|
||||||
|
assert (ext_dir / "extension.yml").is_file(), "extension.yml not installed"
|
||||||
|
|
||||||
|
# Registry should contain the git extension
|
||||||
|
registry_file = project_dir / ".specify" / "extensions" / ".registry"
|
||||||
|
assert registry_file.is_file(), "extension registry not created"
|
||||||
|
registry = json.loads(registry_file.read_text())
|
||||||
|
assert "git" in registry.get("extensions", {}), "git not in registry"
|
||||||
|
assert registry["extensions"]["git"]["enabled"] is True
|
||||||
|
|
||||||
|
def test_git_extension_noop_when_already_installed(self, tmp_path: Path):
|
||||||
|
"""_install_bundled_git_extension should no-op if git is already installed."""
|
||||||
|
from specify_cli import _install_bundled_git_extension
|
||||||
|
from specify_cli.extensions import ExtensionManager
|
||||||
|
|
||||||
|
project_dir = tmp_path / "proj"
|
||||||
|
(project_dir / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
# First install
|
||||||
|
result1 = _install_bundled_git_extension(project_dir)
|
||||||
|
assert result1 is True
|
||||||
|
|
||||||
|
# Second install should also succeed (no-op)
|
||||||
|
result2 = _install_bundled_git_extension(project_dir)
|
||||||
|
assert result2 is True
|
||||||
|
|
||||||
|
# Only one entry in registry
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
assert manager.registry.is_installed("git")
|
||||||
|
|
||||||
|
def test_git_extension_reinstalls_when_directory_missing(self, tmp_path: Path):
|
||||||
|
"""_install_bundled_git_extension should reinstall if registry says installed but directory is gone."""
|
||||||
|
import shutil
|
||||||
|
from specify_cli import _install_bundled_git_extension
|
||||||
|
from specify_cli.extensions import ExtensionManager
|
||||||
|
|
||||||
|
project_dir = tmp_path / "proj"
|
||||||
|
(project_dir / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
# First install
|
||||||
|
result1 = _install_bundled_git_extension(project_dir)
|
||||||
|
assert result1 is True
|
||||||
|
|
||||||
|
ext_dir = project_dir / ".specify" / "extensions" / "git"
|
||||||
|
assert ext_dir.is_dir()
|
||||||
|
|
||||||
|
# Simulate stale registry: delete extension directory but keep registry
|
||||||
|
shutil.rmtree(ext_dir)
|
||||||
|
assert not ext_dir.exists()
|
||||||
|
|
||||||
|
# Registry still says installed
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
assert manager.registry.is_installed("git")
|
||||||
|
|
||||||
|
# Re-install should detect missing directory and reinstall
|
||||||
|
result2 = _install_bundled_git_extension(project_dir)
|
||||||
|
assert result2 is True
|
||||||
|
assert ext_dir.is_dir(), "extension directory should be reinstalled"
|
||||||
|
assert (ext_dir / "extension.yml").is_file(), "extension.yml should be reinstalled"
|
||||||
613
tests/test_core_pack_scaffold.py
Normal file
613
tests/test_core_pack_scaffold.py
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
|
)
|
||||||
@@ -665,9 +665,10 @@ class TestCommandRegistrar:
|
|||||||
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
||||||
|
|
||||||
def test_codex_agent_config_present(self):
|
def test_codex_agent_config_present(self):
|
||||||
"""Codex should be mapped to .codex/prompts."""
|
"""Codex should be mapped to .agents/skills."""
|
||||||
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
||||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts"
|
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".agents/skills"
|
||||||
|
assert CommandRegistrar.AGENT_CONFIGS["codex"]["extension"] == "/SKILL.md"
|
||||||
|
|
||||||
def test_pi_agent_config_present(self):
|
def test_pi_agent_config_present(self):
|
||||||
"""Pi should be mapped to .pi/prompts."""
|
"""Pi should be mapped to .pi/prompts."""
|
||||||
@@ -717,6 +718,21 @@ $ARGUMENTS
|
|||||||
assert frontmatter == {}
|
assert frontmatter == {}
|
||||||
assert body == content
|
assert body == content
|
||||||
|
|
||||||
|
def test_parse_frontmatter_non_mapping_returns_empty_dict(self):
|
||||||
|
"""Non-mapping YAML frontmatter should not crash downstream renderers."""
|
||||||
|
content = """---
|
||||||
|
- item1
|
||||||
|
- item2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Command body
|
||||||
|
"""
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
frontmatter, body = registrar.parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert frontmatter == {}
|
||||||
|
assert "Command body" in body
|
||||||
|
|
||||||
def test_render_frontmatter(self):
|
def test_render_frontmatter(self):
|
||||||
"""Test rendering frontmatter to YAML."""
|
"""Test rendering frontmatter to YAML."""
|
||||||
frontmatter = {
|
frontmatter = {
|
||||||
@@ -731,6 +747,18 @@ $ARGUMENTS
|
|||||||
assert output.endswith("---\n")
|
assert output.endswith("---\n")
|
||||||
assert "description: Test command" in output
|
assert "description: Test command" in output
|
||||||
|
|
||||||
|
def test_render_frontmatter_unicode(self):
|
||||||
|
"""Test rendering frontmatter preserves non-ASCII characters."""
|
||||||
|
frontmatter = {
|
||||||
|
"description": "Prüfe Konformität der Implementierung"
|
||||||
|
}
|
||||||
|
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
output = registrar.render_frontmatter(frontmatter)
|
||||||
|
|
||||||
|
assert "Prüfe Konformität" in output
|
||||||
|
assert "\\u" not in output
|
||||||
|
|
||||||
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
|
||||||
@@ -808,6 +836,299 @@ $ARGUMENTS
|
|||||||
assert (claude_dir / "speckit.alias.cmd.md").exists()
|
assert (claude_dir / "speckit.alias.cmd.md").exists()
|
||||||
assert (claude_dir / "speckit.shortcut.md").exists()
|
assert (claude_dir / "speckit.shortcut.md").exists()
|
||||||
|
|
||||||
|
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
|
||||||
|
"""Codex skill cleanup should use the same mapped names as registration."""
|
||||||
|
skills_dir = project_dir / ".agents" / "skills"
|
||||||
|
(skills_dir / "speckit-specify").mkdir(parents=True)
|
||||||
|
(skills_dir / "speckit-specify" / "SKILL.md").write_text("body")
|
||||||
|
(skills_dir / "speckit-shortcut").mkdir(parents=True)
|
||||||
|
(skills_dir / "speckit-shortcut" / "SKILL.md").write_text("body")
|
||||||
|
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.unregister_commands(
|
||||||
|
{"codex": ["speckit.specify", "speckit.shortcut"]},
|
||||||
|
project_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||||
|
assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):
|
||||||
|
"""A Codex project under .agents/skills should not implicitly activate Amp."""
|
||||||
|
skills_dir = project_dir / ".agents" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
||||||
|
|
||||||
|
assert "codex" in registered
|
||||||
|
assert "amp" not in registered
|
||||||
|
assert not (project_dir / ".agents" / "commands").exists()
|
||||||
|
|
||||||
|
def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir):
|
||||||
|
"""Codex SKILL.md output should use skills-oriented frontmatter."""
|
||||||
|
skills_dir = project_dir / ".agents" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-test.hello" / "SKILL.md"
|
||||||
|
assert skill_file.exists()
|
||||||
|
|
||||||
|
content = skill_file.read_text()
|
||||||
|
assert "name: speckit-test.hello" in content
|
||||||
|
assert "description: Test hello command" in content
|
||||||
|
assert "compatibility:" in content
|
||||||
|
assert "metadata:" in content
|
||||||
|
assert "source: test-ext:commands/hello.md" in content
|
||||||
|
assert "<!-- Extension:" not in content
|
||||||
|
|
||||||
|
def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||||
|
"""Codex SKILL.md overrides should resolve script placeholders."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "ext-scripted"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "ext-scripted",
|
||||||
|
"name": "Scripted Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.test.plan",
|
||||||
|
"file": "commands/plan.md",
|
||||||
|
"description": "Scripted command",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
(ext_dir / "commands" / "plan.md").write_text(
|
||||||
|
"""---
|
||||||
|
description: "Scripted command"
|
||||||
|
scripts:
|
||||||
|
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||||
|
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||||
|
agent_scripts:
|
||||||
|
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||||
|
ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__
|
||||||
|
---
|
||||||
|
|
||||||
|
Run {SCRIPT}
|
||||||
|
Then {AGENT_SCRIPT}
|
||||||
|
Agent __AGENT__
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
init_options.write_text('{"ai":"codex","ai_skills":true,"script":"sh"}')
|
||||||
|
|
||||||
|
skills_dir = project_dir / ".agents" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-test.plan" / "SKILL.md"
|
||||||
|
assert skill_file.exists()
|
||||||
|
|
||||||
|
content = skill_file.read_text()
|
||||||
|
assert "{SCRIPT}" not in content
|
||||||
|
assert "{AGENT_SCRIPT}" not in content
|
||||||
|
assert "__AGENT__" not in content
|
||||||
|
assert "{ARGS}" not in content
|
||||||
|
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||||
|
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||||
|
|
||||||
|
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
|
||||||
|
"""Codex alias skills should render their own matching `name:` frontmatter."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "ext-alias-skill"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "ext-alias-skill",
|
||||||
|
"name": "Alias Skill Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.alias.cmd",
|
||||||
|
"file": "commands/cmd.md",
|
||||||
|
"aliases": ["speckit.shortcut"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Alias skill\n---\n\nBody\n")
|
||||||
|
|
||||||
|
skills_dir = project_dir / ".agents" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
|
primary = skills_dir / "speckit-alias.cmd" / "SKILL.md"
|
||||||
|
alias = skills_dir / "speckit-shortcut" / "SKILL.md"
|
||||||
|
|
||||||
|
assert primary.exists()
|
||||||
|
assert alias.exists()
|
||||||
|
assert "name: speckit-alias.cmd" in primary.read_text()
|
||||||
|
assert "name: speckit-shortcut" in alias.read_text()
|
||||||
|
|
||||||
|
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
||||||
|
self, project_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""Codex placeholder substitution should still work without init-options.json."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "ext-script-fallback"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "ext-script-fallback",
|
||||||
|
"name": "Script fallback",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.fallback.plan",
|
||||||
|
"file": "commands/plan.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
(ext_dir / "commands" / "plan.md").write_text(
|
||||||
|
"""---
|
||||||
|
description: "Fallback scripted command"
|
||||||
|
scripts:
|
||||||
|
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||||
|
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||||
|
agent_scripts:
|
||||||
|
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||||
|
---
|
||||||
|
|
||||||
|
Run {SCRIPT}
|
||||||
|
Then {AGENT_SCRIPT}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Intentionally do NOT create .specify/init-options.json
|
||||||
|
skills_dir = project_dir / ".agents" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md"
|
||||||
|
assert skill_file.exists()
|
||||||
|
|
||||||
|
content = skill_file.read_text()
|
||||||
|
assert "{SCRIPT}" not in content
|
||||||
|
assert "{AGENT_SCRIPT}" not in content
|
||||||
|
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||||
|
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||||
|
|
||||||
|
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
|
||||||
|
self, project_dir, temp_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Without init metadata, Windows fallback should prefer ps scripts over sh."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.agents.platform.system", lambda: "Windows")
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "ext-script-windows-fallback"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "ext-script-windows-fallback",
|
||||||
|
"name": "Script fallback windows",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.windows.plan",
|
||||||
|
"file": "commands/plan.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
(ext_dir / "commands" / "plan.md").write_text(
|
||||||
|
"""---
|
||||||
|
description: "Windows fallback scripted command"
|
||||||
|
scripts:
|
||||||
|
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||||
|
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||||
|
agent_scripts:
|
||||||
|
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||||
|
ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__
|
||||||
|
---
|
||||||
|
|
||||||
|
Run {SCRIPT}
|
||||||
|
Then {AGENT_SCRIPT}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
skills_dir = project_dir / ".agents" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-windows.plan" / "SKILL.md"
|
||||||
|
assert skill_file.exists()
|
||||||
|
|
||||||
|
content = skill_file.read_text()
|
||||||
|
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
|
||||||
|
assert ".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex" in content
|
||||||
|
assert ".specify/scripts/bash/setup-plan.sh" not in content
|
||||||
|
|
||||||
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
||||||
"""Test registering commands for Copilot agent with .agent.md extension."""
|
"""Test registering commands for Copilot agent with .agent.md extension."""
|
||||||
# Create .github/agents directory (Copilot project)
|
# Create .github/agents directory (Copilot project)
|
||||||
|
|||||||
252
tests/test_timestamp_branches.py
Normal file
252
tests/test_timestamp_branches.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Pytest tests for timestamp-based branch naming in create-new-feature.sh and common.sh.
|
||||||
|
|
||||||
|
Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
||||||
|
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def git_repo(tmp_path: Path) -> Path:
|
||||||
|
"""Create a temp git repo with scripts and .specify dir."""
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "commit", "--allow-empty", "-m", "init", "-q"],
|
||||||
|
cwd=tmp_path,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
scripts_dir = tmp_path / "scripts" / "bash"
|
||||||
|
scripts_dir.mkdir(parents=True)
|
||||||
|
shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh")
|
||||||
|
shutil.copy(COMMON_SH, scripts_dir / "common.sh")
|
||||||
|
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def no_git_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Create a temp directory without git, but with scripts."""
|
||||||
|
scripts_dir = tmp_path / "scripts" / "bash"
|
||||||
|
scripts_dir.mkdir(parents=True)
|
||||||
|
shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh")
|
||||||
|
shutil.copy(COMMON_SH, scripts_dir / "common.sh")
|
||||||
|
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def run_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||||
|
"""Run create-new-feature.sh with given args."""
|
||||||
|
cmd = ["bash", "scripts/bash/create-new-feature.sh", *args]
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def source_and_call(func_call: str, env: dict | None = None) -> subprocess.CompletedProcess:
|
||||||
|
"""Source common.sh and call a function."""
|
||||||
|
cmd = f'source "{COMMON_SH}" && {func_call}'
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", "-c", cmd],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, **(env or {})},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Timestamp Branch Tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimestampBranch:
|
||||||
|
def test_timestamp_creates_branch(self, git_repo: Path):
|
||||||
|
"""Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix."""
|
||||||
|
result = run_script(git_repo, "--timestamp", "--short-name", "user-auth", "Add user auth")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
branch = None
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if line.startswith("BRANCH_NAME:"):
|
||||||
|
branch = line.split(":", 1)[1].strip()
|
||||||
|
assert branch is not None
|
||||||
|
assert re.match(r"^\d{8}-\d{6}-user-auth$", branch), f"unexpected branch: {branch}"
|
||||||
|
|
||||||
|
def test_number_and_timestamp_warns(self, git_repo: Path):
|
||||||
|
"""Test 3: --number + --timestamp warns and uses timestamp."""
|
||||||
|
result = run_script(git_repo, "--timestamp", "--number", "42", "--short-name", "feat", "Feature")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert "Warning" in result.stderr and "--number" in result.stderr
|
||||||
|
|
||||||
|
def test_json_output_keys(self, git_repo: Path):
|
||||||
|
"""Test 4: JSON output contains expected keys."""
|
||||||
|
import json
|
||||||
|
result = run_script(git_repo, "--json", "--timestamp", "--short-name", "api", "API feature")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
for key in ("BRANCH_NAME", "SPEC_FILE", "FEATURE_NUM"):
|
||||||
|
assert key in data, f"missing {key} in JSON: {data}"
|
||||||
|
assert re.match(r"^\d{8}-\d{6}$", data["FEATURE_NUM"])
|
||||||
|
|
||||||
|
def test_long_name_truncation(self, git_repo: Path):
|
||||||
|
"""Test 5: Long branch name is truncated to <= 244 chars."""
|
||||||
|
long_name = "a-" * 150 + "end"
|
||||||
|
result = run_script(git_repo, "--timestamp", "--short-name", long_name, "Long feature")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
branch = None
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if line.startswith("BRANCH_NAME:"):
|
||||||
|
branch = line.split(":", 1)[1].strip()
|
||||||
|
assert branch is not None
|
||||||
|
assert len(branch) <= 244
|
||||||
|
assert re.match(r"^\d{8}-\d{6}-", branch)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sequential Branch Tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSequentialBranch:
|
||||||
|
def test_sequential_default_with_existing_specs(self, git_repo: Path):
|
||||||
|
"""Test 2: Sequential default with existing specs."""
|
||||||
|
(git_repo / "specs" / "001-first-feat").mkdir(parents=True)
|
||||||
|
(git_repo / "specs" / "002-second-feat").mkdir(parents=True)
|
||||||
|
result = run_script(git_repo, "--short-name", "new-feat", "New feature")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
branch = None
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if line.startswith("BRANCH_NAME:"):
|
||||||
|
branch = line.split(":", 1)[1].strip()
|
||||||
|
assert branch is not None
|
||||||
|
assert re.match(r"^\d{3}-new-feat$", branch), f"unexpected branch: {branch}"
|
||||||
|
|
||||||
|
def test_sequential_ignores_timestamp_dirs(self, git_repo: Path):
|
||||||
|
"""Sequential numbering skips timestamp dirs when computing next number."""
|
||||||
|
(git_repo / "specs" / "002-first-feat").mkdir(parents=True)
|
||||||
|
(git_repo / "specs" / "20260319-143022-ts-feat").mkdir(parents=True)
|
||||||
|
result = run_script(git_repo, "--short-name", "next-feat", "Next feature")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
branch = None
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if line.startswith("BRANCH_NAME:"):
|
||||||
|
branch = line.split(":", 1)[1].strip()
|
||||||
|
assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckFeatureBranch:
|
||||||
|
def test_accepts_timestamp_branch(self):
|
||||||
|
"""Test 6: check_feature_branch accepts timestamp branch."""
|
||||||
|
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_accepts_sequential_branch(self):
|
||||||
|
"""Test 7: check_feature_branch accepts sequential branch."""
|
||||||
|
result = source_and_call('check_feature_branch "004-feat" "true"')
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_rejects_main(self):
|
||||||
|
"""Test 8: check_feature_branch rejects main."""
|
||||||
|
result = source_and_call('check_feature_branch "main" "true"')
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
def test_rejects_partial_timestamp(self):
|
||||||
|
"""Test 9: check_feature_branch rejects 7-digit date."""
|
||||||
|
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── find_feature_dir_by_prefix Tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindFeatureDirByPrefix:
|
||||||
|
def test_timestamp_branch(self, tmp_path: Path):
|
||||||
|
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
|
||||||
|
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True)
|
||||||
|
result = source_and_call(
|
||||||
|
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
|
||||||
|
|
||||||
|
def test_cross_branch_prefix(self, tmp_path: Path):
|
||||||
|
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
|
||||||
|
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
|
||||||
|
result = source_and_call(
|
||||||
|
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
|
||||||
|
|
||||||
|
|
||||||
|
# ── get_current_branch Tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCurrentBranch:
|
||||||
|
def test_env_var(self):
|
||||||
|
"""Test 12: get_current_branch returns SPECIFY_FEATURE env var."""
|
||||||
|
result = source_and_call("get_current_branch", env={"SPECIFY_FEATURE": "my-custom-branch"})
|
||||||
|
assert result.stdout.strip() == "my-custom-branch"
|
||||||
|
|
||||||
|
|
||||||
|
# ── No-git Tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoGitTimestamp:
|
||||||
|
def test_no_git_timestamp(self, no_git_dir: Path):
|
||||||
|
"""Test 13: No-git repo + timestamp creates spec dir with warning."""
|
||||||
|
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
|
||||||
|
assert len(spec_dirs) > 0, "spec dir not created"
|
||||||
|
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestE2EFlow:
|
||||||
|
def test_e2e_timestamp(self, git_repo: Path):
|
||||||
|
"""Test 14: E2E timestamp flow — branch, dir, validation."""
|
||||||
|
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=git_repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}"
|
||||||
|
assert (git_repo / "specs" / branch).is_dir()
|
||||||
|
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||||
|
assert val.returncode == 0
|
||||||
|
|
||||||
|
def test_e2e_sequential(self, git_repo: Path):
|
||||||
|
"""Test 15: E2E sequential flow (regression guard)."""
|
||||||
|
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=git_repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
assert re.match(r"^\d{3}-seq-feat$", branch), f"branch: {branch}"
|
||||||
|
assert (git_repo / "specs" / branch).is_dir()
|
||||||
|
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||||
|
assert val.returncode == 0
|
||||||
Reference in New Issue
Block a user