Merge branch 'main' into feature/add-forgecode-agent-support

This commit is contained in:
ericnoam
2026-04-02 20:52:57 +02:00
53 changed files with 3248 additions and 3655 deletions

View File

@@ -27,35 +27,63 @@ jobs:
- name: Check if release already exists
id: check_release
run: |
chmod +x .github/workflows/scripts/check-release-exists.sh
.github/workflows/scripts/check-release-exists.sh ${{ steps.version.outputs.tag }}
VERSION="${{ steps.version.outputs.tag }}"
if gh release view "$VERSION" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Release $VERSION already exists, skipping..."
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Release $VERSION does not exist, proceeding..."
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release package variants
if: steps.check_release.outputs.exists == 'false'
run: |
chmod +x .github/workflows/scripts/create-release-packages.sh
.github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }}
- name: Generate release notes
if: steps.check_release.outputs.exists == 'false'
id: release_notes
run: |
chmod +x .github/workflows/scripts/generate-release-notes.sh
# Get the previous tag for changelog generation
PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${{ steps.version.outputs.tag }}^ 2>/dev/null || echo "")
# Default to v0.0.0 if no previous tag is found (e.g., first release)
VERSION="${{ steps.version.outputs.tag }}"
VERSION_NO_V=${VERSION#v}
# Find previous tag
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${VERSION}$" | head -n 1)
if [ -z "$PREVIOUS_TAG" ]; then
PREVIOUS_TAG="v0.0.0"
PREVIOUS_TAG=""
fi
.github/workflows/scripts/generate-release-notes.sh ${{ steps.version.outputs.tag }} "$PREVIOUS_TAG"
# Get commits since previous tag
if [ -z "$PREVIOUS_TAG" ]; then
COMMIT_COUNT=$(git rev-list --count HEAD)
if [ "$COMMIT_COUNT" -gt 20 ]; then
COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges HEAD~20..HEAD)
else
COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges)
fi
else
COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges "$PREVIOUS_TAG"..HEAD)
fi
cat > release_notes.md << NOTES_EOF
## Install
\`\`\`bash
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@${VERSION}
specify init my-project
\`\`\`
NOTES_EOF
echo "## What's Changed" >> release_notes.md
echo "" >> release_notes.md
echo "$COMMITS" >> release_notes.md
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
run: |
chmod +x .github/workflows/scripts/create-github-release.sh
.github/workflows/scripts/create-github-release.sh ${{ steps.version.outputs.tag }}
VERSION="${{ steps.version.outputs.tag }}"
VERSION_NO_V=${VERSION#v}
gh release create "$VERSION" \
--title "Spec Kit - $VERSION_NO_V" \
--notes-file release_notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# check-release-exists.sh
# Check if a GitHub release already exists for the given version
# Usage: check-release-exists.sh <version>
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION="$1"
if gh release view "$VERSION" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Release $VERSION already exists, skipping..."
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Release $VERSION does not exist, proceeding..."
fi

View File

@@ -1,72 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# create-github-release.sh
# Create a GitHub release with all template zip files
# Usage: create-github-release.sh <version>
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION="$1"
# Remove 'v' prefix from version for release title
VERSION_NO_V=${VERSION#v}
gh release create "$VERSION" \
.genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \
.genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \
.genreleases/spec-kit-template-claude-sh-"$VERSION".zip \
.genreleases/spec-kit-template-claude-ps-"$VERSION".zip \
.genreleases/spec-kit-template-gemini-sh-"$VERSION".zip \
.genreleases/spec-kit-template-gemini-ps-"$VERSION".zip \
.genreleases/spec-kit-template-cursor-agent-sh-"$VERSION".zip \
.genreleases/spec-kit-template-cursor-agent-ps-"$VERSION".zip \
.genreleases/spec-kit-template-opencode-sh-"$VERSION".zip \
.genreleases/spec-kit-template-opencode-ps-"$VERSION".zip \
.genreleases/spec-kit-template-qwen-sh-"$VERSION".zip \
.genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \
.genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \
.genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \
.genreleases/spec-kit-template-junie-sh-"$VERSION".zip \
.genreleases/spec-kit-template-junie-ps-"$VERSION".zip \
.genreleases/spec-kit-template-codex-sh-"$VERSION".zip \
.genreleases/spec-kit-template-codex-ps-"$VERSION".zip \
.genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \
.genreleases/spec-kit-template-kilocode-ps-"$VERSION".zip \
.genreleases/spec-kit-template-auggie-sh-"$VERSION".zip \
.genreleases/spec-kit-template-auggie-ps-"$VERSION".zip \
.genreleases/spec-kit-template-roo-sh-"$VERSION".zip \
.genreleases/spec-kit-template-roo-ps-"$VERSION".zip \
.genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \
.genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \
.genreleases/spec-kit-template-qodercli-sh-"$VERSION".zip \
.genreleases/spec-kit-template-qodercli-ps-"$VERSION".zip \
.genreleases/spec-kit-template-amp-sh-"$VERSION".zip \
.genreleases/spec-kit-template-amp-ps-"$VERSION".zip \
.genreleases/spec-kit-template-shai-sh-"$VERSION".zip \
.genreleases/spec-kit-template-shai-ps-"$VERSION".zip \
.genreleases/spec-kit-template-tabnine-sh-"$VERSION".zip \
.genreleases/spec-kit-template-tabnine-ps-"$VERSION".zip \
.genreleases/spec-kit-template-kiro-cli-sh-"$VERSION".zip \
.genreleases/spec-kit-template-kiro-cli-ps-"$VERSION".zip \
.genreleases/spec-kit-template-agy-sh-"$VERSION".zip \
.genreleases/spec-kit-template-agy-ps-"$VERSION".zip \
.genreleases/spec-kit-template-bob-sh-"$VERSION".zip \
.genreleases/spec-kit-template-bob-ps-"$VERSION".zip \
.genreleases/spec-kit-template-vibe-sh-"$VERSION".zip \
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
.genreleases/spec-kit-template-trae-sh-"$VERSION".zip \
.genreleases/spec-kit-template-trae-ps-"$VERSION".zip \
.genreleases/spec-kit-template-pi-sh-"$VERSION".zip \
.genreleases/spec-kit-template-pi-ps-"$VERSION".zip \
.genreleases/spec-kit-template-iflow-sh-"$VERSION".zip \
.genreleases/spec-kit-template-iflow-ps-"$VERSION".zip \
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
--title "Spec Kit Templates - $VERSION_NO_V" \
--notes-file release_notes.md

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# generate-release-notes.sh
# Generate release notes from git history
# Usage: generate-release-notes.sh <new_version> <last_tag>
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <new_version> <last_tag>" >&2
exit 1
fi
NEW_VERSION="$1"
LAST_TAG="$2"
# Get commits since last tag
if [ "$LAST_TAG" = "v0.0.0" ]; then
# Check how many commits we have and use that as the limit
COMMIT_COUNT=$(git rev-list --count HEAD)
if [ "$COMMIT_COUNT" -gt 10 ]; then
COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~10..HEAD)
else
COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~$COMMIT_COUNT..HEAD 2>/dev/null || git log --oneline --pretty=format:"- %s")
fi
else
COMMITS=$(git log --oneline --pretty=format:"- %s" $LAST_TAG..HEAD)
fi
# Create release notes
cat > release_notes.md << EOF
This is the latest set of releases that you can use with your agent of choice. We recommend using the Specify CLI to scaffold your projects, however you can download these independently and manage them yourself.
## Changelog
$COMMITS
EOF
echo "Generated release notes:"
cat release_notes.md

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# get-next-version.sh
# Calculate the next version based on the latest git tag and output GitHub Actions variables
# Usage: get-next-version.sh
# Get the latest tag, or use v0.0.0 if no tags exist
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
# Extract version number and increment
VERSION=$(echo $LATEST_TAG | sed 's/v//')
IFS='.' read -ra VERSION_PARTS <<< "$VERSION"
MAJOR=${VERSION_PARTS[0]:-0}
MINOR=${VERSION_PARTS[1]:-0}
PATCH=${VERSION_PARTS[2]:-0}
# Increment patch version
PATCH=$((PATCH + 1))
NEW_VERSION="v$MAJOR.$MINOR.$PATCH"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "New version will be: $NEW_VERSION"

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# simulate-release.sh
# Simulate the release process locally without pushing to GitHub
# Usage: simulate-release.sh [version]
# If version is omitted, auto-increments patch version
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${BLUE}🧪 Simulating Release Process Locally${NC}"
echo "======================================"
echo ""
# Step 1: Determine version
if [[ -n "${1:-}" ]]; then
VERSION="${1#v}"
TAG="v$VERSION"
echo -e "${GREEN}📝 Using manual version: $VERSION${NC}"
else
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo -e "${BLUE}Latest tag: $LATEST_TAG${NC}"
VERSION=$(echo $LATEST_TAG | sed 's/v//')
IFS='.' read -ra VERSION_PARTS <<< "$VERSION"
MAJOR=${VERSION_PARTS[0]:-0}
MINOR=${VERSION_PARTS[1]:-0}
PATCH=${VERSION_PARTS[2]:-0}
PATCH=$((PATCH + 1))
VERSION="$MAJOR.$MINOR.$PATCH"
TAG="v$VERSION"
echo -e "${GREEN}📝 Auto-incremented to: $VERSION${NC}"
fi
echo ""
# Step 2: Check if tag exists
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo -e "${RED}❌ Error: Tag $TAG already exists!${NC}"
echo " Please use a different version or delete the tag first."
exit 1
fi
echo -e "${GREEN}✓ Tag $TAG is available${NC}"
# Step 3: Backup current state
echo ""
echo -e "${YELLOW}💾 Creating backup of current state...${NC}"
BACKUP_DIR=$(mktemp -d)
cp pyproject.toml "$BACKUP_DIR/pyproject.toml.bak"
cp CHANGELOG.md "$BACKUP_DIR/CHANGELOG.md.bak"
echo -e "${GREEN}✓ Backup created at: $BACKUP_DIR${NC}"
# Step 4: Update pyproject.toml
echo ""
echo -e "${YELLOW}📝 Updating pyproject.toml...${NC}"
sed -i.tmp "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm -f pyproject.toml.tmp
echo -e "${GREEN}✓ Updated pyproject.toml to version $VERSION${NC}"
# Step 5: Update CHANGELOG.md
echo ""
echo -e "${YELLOW}📝 Updating CHANGELOG.md...${NC}"
DATE=$(date +%Y-%m-%d)
# Get the previous tag to compare commits
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [[ -n "$PREVIOUS_TAG" ]]; then
echo " Generating changelog from commits since $PREVIOUS_TAG"
# Get commits since last tag, format as bullet points
COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release")
else
echo " No previous tag found - this is the first release"
COMMITS="- Initial release"
fi
# Create temp file with new entry
{
head -n 8 CHANGELOG.md
echo ""
echo "## [$VERSION] - $DATE"
echo ""
echo "### Changed"
echo ""
echo "$COMMITS"
echo ""
tail -n +9 CHANGELOG.md
} > CHANGELOG.md.tmp
mv CHANGELOG.md.tmp CHANGELOG.md
echo -e "${GREEN}✓ Updated CHANGELOG.md with commits since $PREVIOUS_TAG${NC}"
# Step 6: Show what would be committed
echo ""
echo -e "${YELLOW}📋 Changes that would be committed:${NC}"
git diff pyproject.toml CHANGELOG.md
# Step 7: Create temporary tag (no push)
echo ""
echo -e "${YELLOW}🏷️ Creating temporary local tag...${NC}"
git tag -a "$TAG" -m "Simulated release $TAG" 2>/dev/null || true
echo -e "${GREEN}✓ Tag $TAG created locally${NC}"
# Step 8: Simulate release artifact creation
echo ""
echo -e "${YELLOW}📦 Simulating release package creation...${NC}"
echo " (High-level simulation only; packaging script is not executed)"
echo ""
# Check if script exists and is executable
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -x "$SCRIPT_DIR/create-release-packages.sh" ]]; then
echo -e "${BLUE}In a real release, the following command would be run to create packages:${NC}"
echo " $SCRIPT_DIR/create-release-packages.sh \"$TAG\""
echo ""
echo "This simulation does not enumerate individual package files to avoid"
echo "drifting from the actual behavior of create-release-packages.sh."
else
echo -e "${RED}⚠️ create-release-packages.sh not found or not executable${NC}"
fi
# Step 9: Simulate release notes generation
echo ""
echo -e "${YELLOW}📄 Simulating release notes generation...${NC}"
echo ""
PREVIOUS_TAG=$(git describe --tags --abbrev=0 $TAG^ 2>/dev/null || echo "")
if [[ -n "$PREVIOUS_TAG" ]]; then
echo -e "${BLUE}Changes since $PREVIOUS_TAG:${NC}"
git log --oneline "$PREVIOUS_TAG".."$TAG" | head -n 10
echo ""
else
echo -e "${BLUE}No previous tag found - this would be the first release${NC}"
fi
# Step 10: Summary
echo ""
echo -e "${GREEN}🎉 Simulation Complete!${NC}"
echo "======================================"
echo ""
echo -e "${BLUE}Summary:${NC}"
echo " Version: $VERSION"
echo " Tag: $TAG"
echo " Backup: $BACKUP_DIR"
echo ""
echo -e "${YELLOW}⚠️ SIMULATION ONLY - NO CHANGES PUSHED${NC}"
echo ""
echo -e "${BLUE}Next steps:${NC}"
echo " 1. Review the changes above"
echo " 2. To keep changes: git add pyproject.toml CHANGELOG.md && git commit"
echo " 3. To discard changes: git checkout pyproject.toml CHANGELOG.md && git tag -d $TAG"
echo " 4. To restore from backup: cp $BACKUP_DIR/* ."
echo ""
echo -e "${BLUE}To run the actual release:${NC}"
echo " Go to: https://github.com/github/spec-kit/actions/workflows/release-trigger.yml"
echo " Click 'Run workflow' and enter version: $VERSION"
echo ""

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# update-version.sh
# Update version in pyproject.toml (for release artifacts only)
# Usage: update-version.sh <version>
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION="$1"
# Remove 'v' prefix for Python versioning
PYTHON_VERSION=${VERSION#v}
if [ -f "pyproject.toml" ]; then
sed -i "s/version = \".*\"/version = \"$PYTHON_VERSION\"/" pyproject.toml
echo "Updated pyproject.toml version to $PYTHON_VERSION (for release artifacts only)"
else
echo "Warning: pyproject.toml not found, skipping version update"
fi

View File

@@ -2,6 +2,25 @@
<!-- insert new changelog below this comment -->
## [0.4.5] - 2026-04-02
### Changed
- Stage 6: Complete migration — remove legacy scaffold path (#1924) (#2063)
- Install Claude Code as native skills and align preset/integration flows (#2051)
- Add repoindex 0402 (#2062)
- Stage 5: Skills, Generic & Option-Driven Integrations (#1924) (#2052)
- feat(scripts): add --dry-run flag to create-new-feature (#1998)
- fix: support feature branch numbers with 4+ digits (#2040)
- Add community content disclaimers (#2058)
- docs: add community extensions website link to README and extensions docs (#2014)
- docs: remove dead Cognitive Squad and Understanding extension links and from extensions/catalog.community.json (#2057)
- Add fix-findings extension to community catalog (#2039)
- Stage 4: TOML integrations — gemini and tabnine migrated to plugin architecture (#2050)
- feat: add 5 lifecycle extensions to community catalog (#2049)
- Stage 3: Standard markdown integrations — 19 agents migrated to plugin architecture (#2038)
- chore: release 0.4.4, begin 0.4.5.dev0 development (#2048)
## [0.4.4] - 2026-04-01
### Changed

View File

@@ -211,6 +211,7 @@ The following community-contributed extensions are available in [`catalog.commun
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
@@ -280,7 +281,7 @@ Community projects that extend, visualize, or build on Spec Kit:
| [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) |
| [Amp](https://ampcode.com/) | ✅ | |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | Installs skills in `.claude/skills`; invoke spec-kit as `/speckit-constitution`, `/speckit-plan`, etc. |
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
| [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/) | ✅ | |
@@ -404,8 +405,8 @@ specify init my-project --ai claude --debug
# Use GitHub token for API requests (helpful for corporate environments)
specify init my-project --ai claude --github-token ghp_your_token_here
# Install agent skills with the project
specify init my-project --ai claude --ai-skills
# Claude Code installs skills with the project by default
specify init my-project --ai claude
# Initialize in current directory with agent skills
specify init --here --ai gemini --ai-skills
@@ -419,7 +420,11 @@ specify check
### Available Slash Commands
After running `specify init`, your AI coding agent will have access to these slash commands for structured development.
After running `specify init`, your AI coding agent will have access to these structured development commands.
Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`.
Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`.
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`.

View File

@@ -941,6 +941,43 @@
"created_at": "2026-03-14T00:00:00Z",
"updated_at": "2026-03-14T00:00:00Z"
},
"repoindex":{
"name": "Repository Index",
"id": "repoindex",
"description": "Generate index of your repo for overview, architecuture and module",
"author": "Yiyu Liu",
"version": "1.0.0",
"download_url": "https://github.com/liuyiyu/spec-kit-repoindex/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/liuyiyu/spec-kit-repoindex",
"homepage": "https://github.com/liuyiyu/spec-kit-repoindex",
"documentation": "https://github.com/liuyiyu/spec-kit-repoindex/tree/main/docs",
"changelog": "https://github.com/liuyiyu/spec-kit-repoindex/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "no need",
"version": ">=1.0.0",
"required": false
}
]
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"utility",
"brownfield",
"analysis"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-23T13:30:00Z",
"updated_at": "2026-03-23T13:30:00Z"
},
"retro": {
"name": "Retro Extension",
"id": "retro",
@@ -1331,5 +1368,6 @@
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
}
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.4.5.dev0"
version = "0.4.6.dev0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [
@@ -41,8 +41,6 @@ packages = ["src/specify_cli"]
"templates/commands" = "specify_cli/core_pack/commands"
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh"
".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1"
[project.optional-dependencies]
test = [

View File

@@ -78,7 +78,7 @@ get_current_branch() {
latest_timestamp="$ts"
latest_feature=$dirname
fi
elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
local number=${BASH_REMATCH[1]}
number=$((10#$number))
if [[ "$number" -gt "$highest" ]]; then
@@ -124,9 +124,15 @@ check_feature_branch() {
return 0
fi
if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$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
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
@@ -146,7 +152,7 @@ find_feature_dir_by_prefix() {
local prefix=""
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
prefix="${BASH_REMATCH[1]}"
elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then
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

View File

@@ -3,6 +3,7 @@
set -e
JSON_MODE=false
DRY_RUN=false
ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
@@ -15,6 +16,9 @@ while [ $i -le $# ]; do
--json)
JSON_MODE=true
;;
--dry-run)
DRY_RUN=true
;;
--allow-existing-branch)
ALLOW_EXISTING=true
;;
@@ -49,10 +53,11 @@ while [ $i -le $# ]; do
USE_TIMESTAMP=true
;;
--help|-h)
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
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)"
@@ -74,7 +79,7 @@ done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
@@ -110,39 +115,59 @@ get_highest_from_specs() {
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
_extract_highest_number() {
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 sequential feature number (>=3 digits), skip timestamp branches.
if echo "$clean_branch" | grep -Eq '^[0-9]{3,}-' && ! echo "$clean_branch" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$clean_branch" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
done <<< "$branches"
fi
fi
done
echo "$highest"
}
# Function to check existing branches (local and remote) and return next available number
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
done
echo "$highest"
}
# Function to check existing branches (local and remote) and return next available number.
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
# 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)
if [ "$skip_fetch" = true ]; then
# Side-effect-free: query remotes via ls-remote
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
# Get highest number from ALL specs (not just matching short name)
local highest_spec=$(get_highest_from_specs "$specs_dir")
@@ -179,7 +204,9 @@ fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
mkdir -p "$SPECS_DIR"
if [ "$DRY_RUN" != true ]; then
mkdir -p "$SPECS_DIR"
fi
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
@@ -251,7 +278,14 @@ if [ "$USE_TIMESTAMP" = true ]; then
else
# Determine branch number
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$HAS_GIT" = true ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
# Dry-run without git: local spec dirs only
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
@@ -288,62 +322,79 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
if [ "$HAS_GIT" = true ]; then
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
# Check if branch already exists
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
# Switch to the existing branch instead of failing
if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
# Check if branch already exists
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
# Switch to the existing branch instead of failing
if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
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: 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
mkdir -p "$FEATURE_DIR"
if [ ! -f "$SPEC_FILE" ]; then
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
exit 1
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
fi
fi
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
# Inform the user how to persist the feature variable in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
mkdir -p "$FEATURE_DIR"
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ ! -f "$SPEC_FILE" ]; then
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
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
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}'
if [ "$DRY_RUN" = true ]; 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,DRY_RUN:true}'
else
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}'
fi
else
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
if [ "$DRY_RUN" = true ]; then
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$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
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"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
fi
fi

View File

@@ -83,8 +83,8 @@ function Get-CurrentBranch {
$latestTimestamp = $ts
$latestFeature = $_.Name
}
} elseif ($_.Name -match '^(\d{3})-') {
$num = [int]$matches[1]
} elseif ($_.Name -match '^(\d{3,})-') {
$num = [long]$matches[1]
if ($num -gt $highest) {
$highest = $num
# Only update if no timestamp branch found yet
@@ -139,9 +139,13 @@ function Test-FeatureBranch {
return $true
}
if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') {
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -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"
Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name"
return $false
}
return $true

View File

@@ -4,6 +4,7 @@
param(
[switch]$Json,
[switch]$AllowExistingBranch,
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[long]$Number = 0,
@@ -16,10 +17,11 @@ $ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
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)"
@@ -35,7 +37,7 @@ if ($Help) {
# Check if feature description provided
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
@@ -49,7 +51,7 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) {
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
[long]$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
@@ -65,48 +67,87 @@ function Get-HighestNumberFromSpecs {
return $highest
}
function Get-HighestNumberFromBranches {
param()
# Extract the highest sequential feature number from a list of branch/ref names.
# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
function Get-HighestNumberFromNames {
param([string[]]$Names)
[long]$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 sequential feature number (>=3 digits), skip timestamp branches.
if ($cleanBranch -match '^(\d{3,})-' -and $cleanBranch -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
foreach ($name in $Names) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
} catch {
# If git command fails, return 0
Write-Verbose "Could not check Git branches: $_"
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0 -and $branches) {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
}
return 0
}
function Get-HighestNumberFromRemoteRefs {
[long]$highest = 0
try {
$remotes = git remote 2>$null
if ($remotes) {
foreach ($remote in $remotes) {
$env:GIT_TERMINAL_PROMPT = '0'
$refs = git ls-remote --heads $remote 2>$null
$env:GIT_TERMINAL_PROMPT = $null
if ($LASTEXITCODE -eq 0 -and $refs) {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
}
} catch {
Write-Verbose "Could not query remote refs: $_"
}
return $highest
}
# Return next available branch number. When SkipFetch is true, queries remotes
# via ls-remote (read-only) instead of fetching.
function Get-NextBranchNumber {
param(
[string]$SpecsDir
[string]$SpecsDir,
[switch]$SkipFetch
)
# 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
if ($SkipFetch) {
# Side-effect-free: query remotes via ls-remote
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
# 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
}
$highestBranch = Get-HighestNumberFromBranches
}
# 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
@@ -119,7 +160,7 @@ function Get-NextBranchNumber {
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
@@ -134,12 +175,14 @@ $hasGit = Test-HasGit
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
if (-not $DryRun) {
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',
@@ -148,17 +191,17 @@ function Get-BranchName {
'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
@@ -167,7 +210,7 @@ function Get-BranchName {
$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 }
@@ -203,7 +246,13 @@ if ($Timestamp) {
} else {
# Determine branch number
if ($Number -eq 0) {
if ($hasGit) {
if ($DryRun -and $hasGit) {
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
# Dry-run without git: local spec dirs only
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
@@ -224,86 +273,94 @@ if ($branchName.Length -gt $maxBranchLength) {
# 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
}
$featureDir = Join-Path $specsDir $branchName
$specFile = Join-Path $featureDir 'spec.md'
if (-not $branchCreated) {
# Check if branch already exists
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
# Switch to the existing branch instead of failing
git checkout -q $branchName 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
if (-not $DryRun) {
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 ($AllowExistingBranch) {
# Switch to the existing branch instead of failing
git checkout -q $branchName 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
exit 1
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
exit 1
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
exit 1
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
exit 1
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
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"
}
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
if (-not (Test-Path -PathType Leaf $specFile)) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
if ($template -and (Test-Path $template)) {
Copy-Item $template $specFile -Force
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
exit 1
New-Item -ItemType File -Path $specFile -Force | Out-Null
}
}
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
# Set the SPECIFY_FEATURE environment variable for the current session
$env:SPECIFY_FEATURE = $branchName
}
$featureDir = Join-Path $specsDir $branchName
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
$specFile = Join-Path $featureDir 'spec.md'
if (-not (Test-Path -PathType Leaf $specFile)) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
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]@{
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
$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"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,18 @@ from copy import deepcopy
import yaml
def _build_agent_configs() -> dict[str, Any]:
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
from specify_cli.integrations import INTEGRATION_REGISTRY
configs: dict[str, dict[str, Any]] = {}
for key, integration in INTEGRATION_REGISTRY.items():
if key == "generic":
continue
if integration.registrar_config:
configs[key] = dict(integration.registrar_config)
return configs
class CommandRegistrar:
"""Handles registration of commands with AI agents.
@@ -23,161 +35,26 @@ class CommandRegistrar:
and companion files (e.g. Copilot .prompt.md).
"""
# Agent configurations with directory, format, and argument placeholder
AGENT_CONFIGS = {
"claude": {
"dir": ".claude/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"gemini": {
"dir": ".gemini/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml"
},
"copilot": {
"dir": ".github/agents",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".agent.md"
},
"cursor-agent": {
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"qwen": {
"dir": ".qwen/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"opencode": {
"dir": ".opencode/command",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"codex": {
"dir": ".agents/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
},
"windsurf": {
"dir": ".windsurf/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"junie": {
"dir": ".junie/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kilocode": {
"dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"auggie": {
"dir": ".augment/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"roo": {
"dir": ".roo/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"codebuddy": {
"dir": ".codebuddy/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"qodercli": {
"dir": ".qoder/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kiro-cli": {
"dir": ".kiro/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"pi": {
"dir": ".pi/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"amp": {
"dir": ".agents/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"shai": {
"dir": ".shai/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"tabnine": {
"dir": ".tabnine/agent/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml"
},
"bob": {
"dir": ".bob/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kimi": {
"dir": ".kimi/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
},
"trae": {
"dir": ".trae/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"iflow": {
"dir": ".iflow/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"forge": {
"dir": ".forge/commands",
"format": "markdown",
"args": "{{parameters}}",
"extension": ".md",
"strip_frontmatter_keys": ["handoffs"],
"inject_name": True,
},
"vibe": {
"dir": ".vibe/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
}
}
# Derived from INTEGRATION_REGISTRY — single source of truth.
# Populated lazily via _ensure_configs() on first use.
AGENT_CONFIGS: dict[str, dict[str, Any]] = {}
_configs_loaded: bool = False
def __init__(self) -> None:
self._ensure_configs()
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
cls._ensure_configs()
@classmethod
def _ensure_configs(cls) -> None:
if not cls._configs_loaded:
try:
cls.AGENT_CONFIGS = _build_agent_configs()
cls._configs_loaded = True
except ImportError:
pass # Circular import during module init; retry on next access
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
@@ -372,16 +249,35 @@ class CommandRegistrar:
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
skill_frontmatter = self.build_skill_frontmatter(
agent_name,
skill_name,
description,
f"{source_id}:{source_file}",
)
return self.render_frontmatter(skill_frontmatter) + "\n" + body
@staticmethod
def build_skill_frontmatter(
agent_name: str,
skill_name: str,
description: str,
source: str,
) -> dict:
"""Build consistent SKILL.md frontmatter across all skill generators."""
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}",
"source": source,
},
}
return self.render_frontmatter(skill_frontmatter) + "\n" + body
if agent_name == "claude":
# Claude skills should only run when explicitly invoked.
skill_frontmatter["disable-model-invocation"] = True
return skill_frontmatter
@staticmethod
def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str:
@@ -489,6 +385,7 @@ class CommandRegistrar:
Raises:
ValueError: If agent is not supported
"""
self._ensure_configs()
if agent_name not in self.AGENT_CONFIGS:
raise ValueError(f"Unsupported agent: {agent_name}")
@@ -545,12 +442,12 @@ class CommandRegistrar:
for alias in cmd_info.get("aliases", []):
alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
# For agents with inject_name, render with alias-specific frontmatter
if agent_config.get("inject_name"):
alias_frontmatter = deepcopy(frontmatter)
alias_frontmatter["name"] = alias
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root
@@ -568,7 +465,7 @@ class CommandRegistrar:
alias_output = self.render_skill_command(
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
)
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(alias_output, encoding="utf-8")
@@ -613,6 +510,7 @@ class CommandRegistrar:
"""
results = {}
self._ensure_configs()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
agent_dir = project_root / agent_config["dir"]
@@ -640,6 +538,7 @@ class CommandRegistrar:
registered_commands: Dict mapping agent names to command name lists
project_root: Path to project root
"""
self._ensure_configs()
for agent_name, cmd_names in registered_commands.items():
if agent_name not in self.AGENT_CONFIGS:
continue
@@ -657,3 +556,12 @@ class CommandRegistrar:
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
if prompt_file.exists():
prompt_file.unlink()
# Populate AGENT_CONFIGS after class definition.
# Catches ImportError from circular imports during module loading;
# _configs_loaded stays False so the next explicit access retries.
try:
CommandRegistrar._ensure_configs()
except ImportError:
pass

View File

@@ -801,15 +801,12 @@ class ExtensionManager:
original_desc = frontmatter.get("description", "")
description = original_desc or f"Extension command: {cmd_name}"
frontmatter_data = {
"name": skill_name,
"description": description,
"compatibility": "Requires spec-kit project structure with .specify/ directory",
"metadata": {
"author": "github-spec-kit",
"source": f"extension:{manifest.id}",
},
}
frontmatter_data = registrar.build_skill_frontmatter(
selected_ai,
skill_name,
description,
f"extension:{manifest.id}",
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
# Derive a human-friendly title from the command name
@@ -2138,11 +2135,14 @@ class HookExecutor:
init_options = self._load_init_options()
selected_ai = init_options.get("ai")
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
kimi_skill_mode = selected_ai == "kimi"
skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
return f"${skill_name}"
if claude_skill_mode and skill_name:
return f"/{skill_name}"
if kimi_skill_mode and skill_name:
return f"/skill:{skill_name}"

View File

@@ -46,18 +46,22 @@ def _register_builtins() -> None:
users install and invoke.
"""
# -- Imports (alphabetical) -------------------------------------------
from .agy import AgyIntegration
from .amp import AmpIntegration
from .auggie import AuggieIntegration
from .bob import BobIntegration
from .claude import ClaudeIntegration
from .codex import CodexIntegration
from .codebuddy import CodebuddyIntegration
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
from .iflow import IflowIntegration
from .junie import JunieIntegration
from .kilocode import KilocodeIntegration
from .kimi import KimiIntegration
from .kiro_cli import KiroCliIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
@@ -71,18 +75,22 @@ def _register_builtins() -> None:
from .windsurf import WindsurfIntegration
# -- Registration (alphabetical) --------------------------------------
_register(AgyIntegration())
_register(AmpIntegration())
_register(AuggieIntegration())
_register(BobIntegration())
_register(ClaudeIntegration())
_register(CodexIntegration())
_register(CodebuddyIntegration())
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())
_register(KimiIntegration())
_register(KiroCliIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())

View File

@@ -0,0 +1,41 @@
"""Antigravity (agy) integration — skills-based agent.
Antigravity uses ``.agent/skills/speckit-<name>/SKILL.md`` layout.
Explicit command support was deprecated in version 1.20.5;
``--skills`` defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class AgyIntegration(SkillsIntegration):
"""Integration for Antigravity IDE."""
key = "agy"
config = {
"name": "Antigravity",
"folder": ".agent/",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".agent/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Antigravity since v1.20.5)",
),
]

View File

@@ -0,0 +1,17 @@
# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy

View File

@@ -7,6 +7,8 @@ Provides:
integrations (the common case — subclass, set three class attrs, done).
- ``TomlIntegration`` — concrete base for TOML-format integrations
(Gemini, Tabnine — subclass, set three class attrs, done).
- ``SkillsIntegration`` — concrete base for integrations that install
commands as agent skills (``speckit-<name>/SKILL.md`` layout).
"""
from __future__ import annotations
@@ -200,10 +202,14 @@ class IntegrationBase(ABC):
) -> Path:
"""Write *content* to *dest*, hash it, and record in *manifest*.
Creates parent directories as needed. Returns *dest*.
Creates parent directories as needed. Writes bytes directly to
avoid platform newline translation (CRLF on Windows). Any
``\r\n`` sequences in *content* are normalised to ``\n`` before
writing. Returns *dest*.
"""
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding="utf-8")
normalized = content.replace("\r\n", "\n")
dest.write_bytes(normalized.encode("utf-8"))
rel = dest.resolve().relative_to(project_root.resolve())
manifest.record_existing(rel)
return dest
@@ -633,3 +639,155 @@ class TomlIntegration(IntegrationBase):
created.extend(self.install_scripts(project_root, manifest))
return created
# ---------------------------------------------------------------------------
# SkillsIntegration — skills-format agents (Codex, Kimi, Agy)
# ---------------------------------------------------------------------------
class SkillsIntegration(IntegrationBase):
"""Concrete base for integrations that install commands as agent skills.
Skills use the ``speckit-<name>/SKILL.md`` directory layout following
the `agentskills.io <https://agentskills.io/specification>`_ spec.
Subclasses set ``key``, ``config``, ``registrar_config`` (and
optionally ``context_file``) like any integration. They may also
override ``options()`` to declare additional CLI flags (e.g.
``--skills``, ``--migrate-legacy``).
``setup()`` processes each shared command template into a
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
"""
def skills_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the skills output directory.
Derived from ``config["folder"]`` and the configured
``commands_subdir`` (defaults to ``"skills"``).
Raises ``ValueError`` when ``config`` or ``folder`` is missing.
"""
if not self.config:
raise ValueError(
f"{type(self).__name__}.config is not set."
)
folder = self.config.get("folder")
if not folder:
raise ValueError(
f"{type(self).__name__}.config is missing required 'folder' entry."
)
subdir = self.config.get("commands_subdir", "skills")
return project_root / folder / subdir
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install command templates as agent skills.
Creates ``speckit-<name>/SKILL.md`` for each shared command
template. Each SKILL.md has normalised frontmatter containing
``name``, ``description``, ``compatibility``, and ``metadata``.
"""
import yaml
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
skills_dir = self.skills_dest(project_root).resolve()
try:
skills_dir.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Skills destination {skills_dir} escapes "
f"project root {project_root_resolved}"
) from exc
script_type = opts.get("script_type", "sh")
arg_placeholder = (
self.registrar_config.get("args", "$ARGUMENTS")
if self.registrar_config
else "$ARGUMENTS"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
# Derive the skill name from the template stem
command_name = src_file.stem # e.g. "plan"
skill_name = f"speckit-{command_name.replace('.', '-')}"
# Parse frontmatter for description
frontmatter: dict[str, Any] = {}
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
try:
fm = yaml.safe_load(parts[1])
if isinstance(fm, dict):
frontmatter = fm
except yaml.YAMLError:
pass
# Process body through the standard template pipeline
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder
)
# Strip the processed frontmatter — we rebuild it for skills.
# Preserve leading whitespace in the body to match release ZIP
# output byte-for-byte (the template body starts with \n after
# the closing ---).
if processed_body.startswith("---"):
parts = processed_body.split("---", 2)
if len(parts) >= 3:
processed_body = parts[2]
# Select description — use the original template description
# to stay byte-for-byte identical with release ZIP output.
description = frontmatter.get("description", "")
if not description:
description = f"Spec Kit: {command_name} workflow"
# Build SKILL.md with manually formatted frontmatter to match
# the release packaging script output exactly (double-quoted
# values, no yaml.safe_dump quoting differences).
def _quote(v: str) -> str:
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
skill_content = (
f"---\n"
f"name: {_quote(skill_name)}\n"
f"description: {_quote(description)}\n"
f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"metadata:\n"
f" author: {_quote('github-spec-kit')}\n"
f" source: {_quote('templates/commands/' + src_file.name)}\n"
f"---\n"
f"{processed_body}"
)
# Write speckit-<name>/SKILL.md
skill_dir = skills_dir / skill_name
skill_file = skill_dir / "SKILL.md"
dst = self.write_file_and_record(
skill_content, skill_file, project_root, manifest
)
created.append(dst)
created.extend(self.install_scripts(project_root, manifest))
return created

View File

@@ -1,21 +1,109 @@
"""Claude Code integration."""
from ..base import MarkdownIntegration
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from ..base import SkillsIntegration
from ..manifest import IntegrationManifest
class ClaudeIntegration(MarkdownIntegration):
class ClaudeIntegration(SkillsIntegration):
"""Integration for Claude Code skills."""
key = "claude"
config = {
"name": "Claude Code",
"folder": ".claude/",
"commands_subdir": "commands",
"commands_subdir": "skills",
"install_url": "https://docs.anthropic.com/en/docs/claude-code/setup",
"requires_cli": True,
}
registrar_config = {
"dir": ".claude/commands",
"dir": ".claude/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"extension": "/SKILL.md",
}
context_file = "CLAUDE.md"
def command_filename(self, template_name: str) -> str:
"""Claude skills live at .claude/skills/<name>/SKILL.md."""
skill_name = f"speckit-{template_name.replace('.', '-')}"
return f"{skill_name}/SKILL.md"
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
"""Render a processed command template as a Claude skill."""
skill_name = f"speckit-{template_name.replace('.', '-')}"
description = frontmatter.get(
"description",
f"Spec-kit workflow command: {template_name}",
)
skill_frontmatter = self._build_skill_fm(
skill_name, description, f"templates/commands/{template_name}.md"
)
frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip()
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
from specify_cli.agents import CommandRegistrar
return CommandRegistrar.build_skill_frontmatter(
self.key, name, description, source
)
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Claude skills into .claude/skills."""
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
dest = self.skills_dest(project_root).resolve()
try:
dest.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Integration destination {dest} escapes "
f"project root {project_root_resolved}"
) from exc
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
from specify_cli.agents import CommandRegistrar
registrar = CommandRegistrar()
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
frontmatter, body = registrar.parse_frontmatter(processed)
if not isinstance(frontmatter, dict):
frontmatter = {}
rendered = self._render_skill(src_file.stem, frontmatter, body)
dst_file = self.write_file_and_record(
rendered,
dest / self.command_filename(src_file.stem),
project_root,
manifest,
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created

View File

@@ -0,0 +1,40 @@
"""Codex CLI integration — skills-based agent.
Codex uses the ``.agents/skills/speckit-<name>/SKILL.md`` layout.
Commands are deprecated; ``--skills`` defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class CodexIntegration(SkillsIntegration):
"""Integration for OpenAI Codex CLI."""
key = "codex"
config = {
"name": "Codex CLI",
"folder": ".agents/",
"commands_subdir": "skills",
"install_url": "https://github.com/openai/codex",
"requires_cli": True,
}
registrar_config = {
"dir": ".agents/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Codex)",
),
]

View File

@@ -0,0 +1,17 @@
# update-context.ps1 — Codex CLI integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# update-context.sh — Codex CLI integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex

View File

@@ -0,0 +1,133 @@
"""Generic integration — bring your own agent.
Requires ``--commands-dir`` to specify the output directory for command
files. No longer special-cased in the core CLI — just another
integration with its own required option.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, MarkdownIntegration
from ..manifest import IntegrationManifest
class GenericIntegration(MarkdownIntegration):
"""Integration for user-specified (generic) agents."""
key = "generic"
config = {
"name": "Generic (bring your own agent)",
"folder": None, # Set dynamically from --commands-dir
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": "", # Set dynamically from --commands-dir
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = None
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--commands-dir",
required=True,
help="Directory for command files (e.g. .myagent/commands/)",
),
]
@staticmethod
def _resolve_commands_dir(
parsed_options: dict[str, Any] | None,
opts: dict[str, Any],
) -> str:
"""Extract ``--commands-dir`` from parsed options or raw_options.
Returns the directory string or raises ``ValueError``.
"""
parsed_options = parsed_options or {}
commands_dir = parsed_options.get("commands_dir")
if commands_dir:
return commands_dir
# Fall back to raw_options (--integration-options="--commands-dir ...")
raw = opts.get("raw_options")
if raw:
import shlex
tokens = shlex.split(raw)
for i, token in enumerate(tokens):
if token == "--commands-dir" and i + 1 < len(tokens):
return tokens[i + 1]
if token.startswith("--commands-dir="):
return token.split("=", 1)[1]
raise ValueError(
"--commands-dir is required for the generic integration"
)
def commands_dest(self, project_root: Path) -> Path:
"""Not supported for GenericIntegration — use setup() directly.
GenericIntegration is stateless; the output directory comes from
``parsed_options`` or ``raw_options`` at call time, not from
instance state.
"""
raise ValueError(
"GenericIntegration.commands_dest() cannot be called directly; "
"the output directory is resolved from parsed_options in setup()"
)
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install commands to the user-provided commands directory."""
commands_dir = self._resolve_commands_dir(parsed_options, opts)
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
dest = (project_root / commands_dir).resolve()
try:
dest.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Integration destination {dest} escapes "
f"project root {project_root_resolved}"
) from exc
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = "$ARGUMENTS"
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
processed, dest / dst_name, project_root, manifest
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created

View File

@@ -0,0 +1,17 @@
# update-context.ps1 — Generic integration: create/update context file
#
# Thin wrapper that delegates to the shared update-agent-context script.
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# update-context.sh — Generic integration: create/update context file
#
# Thin wrapper that delegates to the shared update-agent-context script.
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic

View File

@@ -0,0 +1,124 @@
"""Kimi Code integration — skills-based agent (Moonshot AI).
Kimi uses the ``.kimi/skills/speckit-<name>/SKILL.md`` layout with
``/skill:speckit-<name>`` invocation syntax.
Includes legacy migration logic for projects initialised before Kimi
moved from dotted skill directories (``speckit.xxx``) to hyphenated
(``speckit-xxx``).
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
class KimiIntegration(SkillsIntegration):
"""Integration for Kimi Code CLI (Moonshot AI)."""
key = "kimi"
config = {
"name": "Kimi Code",
"folder": ".kimi/",
"commands_subdir": "skills",
"install_url": "https://code.kimi.com/",
"requires_cli": True,
}
registrar_config = {
"dir": ".kimi/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "KIMI.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Kimi)",
),
IntegrationOption(
"--migrate-legacy",
is_flag=True,
default=False,
help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)",
),
]
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install skills with optional legacy dotted-name migration."""
parsed_options = parsed_options or {}
# Run base setup first so hyphenated targets (speckit-*) exist,
# then migrate/clean legacy dotted dirs without risking user content loss.
created = super().setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
if parsed_options.get("migrate_legacy", False):
skills_dir = self.skills_dest(project_root)
if skills_dir.is_dir():
_migrate_legacy_kimi_dotted_skills(skills_dir)
return created
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
"""Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
Returns ``(migrated_count, removed_count)``.
"""
if not skills_dir.is_dir():
return (0, 0)
migrated_count = 0
removed_count = 0
for legacy_dir in sorted(skills_dir.glob("speckit.*")):
if not legacy_dir.is_dir():
continue
if not (legacy_dir / "SKILL.md").exists():
continue
suffix = legacy_dir.name[len("speckit."):]
if not suffix:
continue
target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
if not target_dir.exists():
shutil.move(str(legacy_dir), str(target_dir))
migrated_count += 1
continue
# Target exists — only remove legacy if SKILL.md is identical
target_skill = target_dir / "SKILL.md"
legacy_skill = legacy_dir / "SKILL.md"
if target_skill.is_file():
try:
if target_skill.read_bytes() == legacy_skill.read_bytes():
has_extra = any(
child.name != "SKILL.md" for child in legacy_dir.iterdir()
)
if not has_extra:
shutil.rmtree(legacy_dir)
removed_count += 1
except OSError:
pass
return (migrated_count, removed_count)

View File

@@ -0,0 +1,17 @@
# update-context.ps1 — Kimi Code integration: create/update KIMI.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# update-context.sh — Kimi Code integration: create/update KIMI.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi

View File

@@ -714,7 +714,14 @@ class PresetManager:
selected_ai = init_opts.get("ai")
if not isinstance(selected_ai, str):
return []
ai_skills_enabled = bool(init_opts.get("ai_skills"))
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
# Native skill agents (e.g. codex/kimi/agy) materialize brand-new
# preset skills in _register_commands() because their detected agent
# directory is already the skills directory. This flag is only for
# command-backed agents that also mirror commands into skills.
create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md"
written: List[str] = []
@@ -741,6 +748,10 @@ class PresetManager:
target_skill_names.append(skill_name)
if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir():
target_skill_names.append(legacy_skill_name)
if not target_skill_names and create_missing_skills:
missing_skill_dir = skills_dir / skill_name
if not missing_skill_dir.exists():
target_skill_names.append(skill_name)
if not target_skill_names:
continue
@@ -760,15 +771,16 @@ class PresetManager:
)
for target_skill_name in target_skill_names:
frontmatter_data = {
"name": target_skill_name,
"description": enhanced_desc,
"compatibility": "Requires spec-kit project structure with .specify/ directory",
"metadata": {
"author": "github-spec-kit",
"source": f"preset:{manifest.id}",
},
}
skill_subdir = skills_dir / target_skill_name
if skill_subdir.exists() and not skill_subdir.is_dir():
continue
skill_subdir.mkdir(parents=True, exist_ok=True)
frontmatter_data = registrar.build_skill_frontmatter(
selected_ai,
target_skill_name,
enhanced_desc,
f"preset:{manifest.id}",
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_content = (
f"---\n"
@@ -778,7 +790,7 @@ class PresetManager:
f"{body}\n"
)
skill_file = skills_dir / target_skill_name / "SKILL.md"
skill_file = skill_subdir / "SKILL.md"
skill_file.write_text(skill_content, encoding="utf-8")
written.append(target_skill_name)
@@ -850,15 +862,12 @@ class PresetManager:
original_desc or f"Spec-kit workflow command: {short_name}",
)
frontmatter_data = {
"name": skill_name,
"description": enhanced_desc,
"compatibility": "Requires spec-kit project structure with .specify/ directory",
"metadata": {
"author": "github-spec-kit",
"source": f"templates/commands/{short_name}.md",
},
}
frontmatter_data = registrar.build_skill_frontmatter(
selected_ai if isinstance(selected_ai, str) else "",
skill_name,
enhanced_desc,
f"templates/commands/{short_name}.md",
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_title = self._skill_title_from_command(short_name)
skill_content = (
@@ -883,15 +892,12 @@ class PresetManager:
command_name = extension_restore["command_name"]
title_name = self._skill_title_from_command(command_name)
frontmatter_data = {
"name": skill_name,
"description": frontmatter.get("description", f"Extension command: {command_name}"),
"compatibility": "Requires spec-kit project structure with .specify/ directory",
"metadata": {
"author": "github-spec-kit",
"source": extension_restore["source"],
},
}
frontmatter_data = registrar.build_skill_frontmatter(
selected_ai if isinstance(selected_ai, str) else "",
skill_name,
frontmatter.get("description", f"Extension command: {command_name}"),
extension_restore["source"],
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_content = (
f"---\n"
@@ -1040,14 +1046,15 @@ class PresetManager:
if registered_skills:
self._unregister_skills(registered_skills, pack_dir)
try:
from . import NATIVE_SKILLS_AGENTS
from .agents import CommandRegistrar
except ImportError:
NATIVE_SKILLS_AGENTS = set()
registered_commands = {
agent_name: cmd_names
for agent_name, cmd_names in registered_commands.items()
if agent_name not in NATIVE_SKILLS_AGENTS
}
CommandRegistrar = None
if CommandRegistrar is not None:
registered_commands = {
agent_name: cmd_names
for agent_name, cmd_names in registered_commands.items()
if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md"
}
# Unregister non-skill command files from AI agents.
if registered_commands:

10
tests/conftest.py Normal file
View File

@@ -0,0 +1,10 @@
"""Shared test helpers for the Spec Kit test suite."""
import re
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
def strip_ansi(text: str) -> str:
"""Remove ANSI escape codes from Rich-formatted CLI output."""
return _ANSI_ESCAPE_RE.sub("", text)

View File

@@ -3,26 +3,24 @@
import json
import os
import pytest
class TestInitIntegrationFlag:
def test_integration_and_ai_mutually_exclusive(self):
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", "test-project", "--ai", "claude", "--integration", "copilot",
"init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot",
])
assert result.exit_code != 0
assert "mutually exclusive" in result.output
def test_unknown_integration_rejected(self):
def test_unknown_integration_rejected(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", "test-project", "--integration", "nonexistent",
"init", str(tmp_path / "test-project"), "--integration", "nonexistent",
])
assert result.exit_code != 0
assert "Unknown integration" in result.output
@@ -75,9 +73,38 @@ class TestInitIntegrationFlag:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "--integration copilot" in result.output
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-here-existing"
project.mkdir()
commands_dir = project / ".claude" / "skills"
commands_dir.mkdir(parents=True)
skill_dir = commands_dir / "speckit-specify"
skill_dir.mkdir(parents=True)
command_file = skill_dir / "SKILL.md"
command_file.write_text("# preexisting command\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert command_file.exists()
# init replaces skills (not additive); verify the file has valid skill content
assert command_file.exists()
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_shared_infra_skips_existing_files(self, tmp_path):
"""Pre-existing shared files are not overwritten by _install_shared_infra."""
from typer.testing import CliRunner

View File

@@ -0,0 +1,27 @@
"""Tests for AgyIntegration (Antigravity)."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestAgyIntegration(SkillsIntegrationTests):
KEY = "agy"
FOLDER = ".agent/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agent/skills"
CONTEXT_FILE = "AGENTS.md"
class TestAgyAutoPromote:
"""--ai agy auto-promotes to integration path."""
def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path):
"""--ai agy should work the same as --integration agy."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
assert (target / ".agent" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -176,7 +176,9 @@ class MarkdownIntegrationTests:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
assert f"--integration {self.KEY}" in result.output
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner

View File

@@ -0,0 +1,404 @@
"""Reusable test mixin for standard SkillsIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``SkillsIntegrationTests``.
Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely,
adapted for the ``speckit-<name>/SKILL.md`` skills layout.
"""
import os
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import SkillsIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class SkillsIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".agents/"
COMMANDS_SUBDIR: str — e.g. "skills"
REGISTRAR_DIR: str — e.g. ".agents/skills"
CONTEXT_FILE: str — e.g. "AGENTS.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_skills_integration(self):
assert isinstance(get_integration(self.KEY), SkillsIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == "/SKILL.md"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
assert f.exists()
assert f.name == "SKILL.md"
assert f.parent.name.startswith("speckit-")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.skills_dest(tmp_path)
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0, "No skill files were created"
for f in skill_files:
# Each SKILL.md is in speckit-<name>/ under the skills directory
assert f.resolve().parent.parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_skill_directory_structure(self, tmp_path):
"""Each command produces speckit-<name>/SKILL.md."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
expected_commands = {
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
}
# Derive command names from the skill directory names
actual_commands = set()
for f in skill_files:
skill_dir_name = f.parent.name # e.g. "speckit-plan"
assert skill_dir_name.startswith("speckit-")
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
assert actual_commands == expected_commands
def test_skill_frontmatter_structure(self, tmp_path):
"""SKILL.md must have name, description, compatibility, metadata."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert content.startswith("---\n"), f"{f} missing frontmatter"
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "name" in fm, f"{f} frontmatter missing 'name'"
assert "description" in fm, f"{f} frontmatter missing 'description'"
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
assert fm["metadata"]["author"] == "github-spec-kit"
assert "source" in fm["metadata"]
def test_skill_uses_template_descriptions(self, tmp_path):
"""SKILL.md should use the original template description for ZIP parity."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
# Description must be a non-empty string (from the template)
assert isinstance(fm["description"], str)
assert len(fm["description"]) > 0, f"{f} has empty description"
def test_templates_are_processed(self, tmp_path):
"""Skill body must have placeholders replaced, not raw templates."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content after the frontmatter."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
# Body is everything after the second ---
parts = content.split("---", 2)
body = parts[2].strip() if len(parts) >= 3 else ""
assert len(body) > 0, f"{f} has empty body"
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
def test_pre_existing_skills_not_removed(self, tmp_path):
"""Pre-existing non-speckit skills should be left untouched."""
i = get_integration(self.KEY)
skills_dir = i.skills_dest(tmp_path)
foreign_dir = skills_dir / "other-tool"
foreign_dir.mkdir(parents=True)
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
# -- Scripts ----------------------------------------------------------
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
def test_sh_script_is_executable(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
assert os.access(sh, os.X_OK)
# -- CLI auto-promote -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
skills_dir = i.skills_dest(project)
assert skills_dir.is_dir(), f"--ai {self.KEY} did not create skills directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
skills_dir = i.skills_dest(project)
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
# -- IntegrationOption ------------------------------------------------
def test_options_include_skills_flag(self):
i = get_integration(self.KEY)
opts = i.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 1
assert skills_opts[0].is_flag is True
# -- Complete file inventory ------------------------------------------
_SKILL_COMMANDS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the full expected file list for a given script variant."""
i = get_integration(self.KEY)
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
files = []
# Skill files
for cmd in self._SKILL_COMMANDS:
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
# Integration metadata
files += [
".specify/init-options.json",
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
f".specify/integrations/{self.KEY}/scripts/update-context.ps1",
f".specify/integrations/{self.KEY}/scripts/update-context.sh",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
]
# Script variant
if script_variant == "sh":
files += [
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/update-agent-context.sh",
]
else:
files += [
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/update-agent-context.ps1",
]
# Templates
files += [
".specify/templates/agent-file-template.md",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
]
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY,
"--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY,
"--script", "ps", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)

View File

@@ -226,7 +226,9 @@ class TomlIntegrationTests:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
assert f"--integration {self.KEY}" in result.output
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner

View File

@@ -1,11 +1,281 @@
"""Tests for ClaudeIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
import json
import os
from unittest.mock import patch
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import IntegrationBase
from specify_cli.integrations.manifest import IntegrationManifest
class TestClaudeIntegration(MarkdownIntegrationTests):
KEY = "claude"
FOLDER = ".claude/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".claude/commands"
CONTEXT_FILE = "CLAUDE.md"
class TestClaudeIntegration:
def test_registered(self):
assert "claude" in INTEGRATION_REGISTRY
assert get_integration("claude") is not None
def test_is_base_integration(self):
assert isinstance(get_integration("claude"), IntegrationBase)
def test_config_uses_skills(self):
integration = get_integration("claude")
assert integration.config["folder"] == ".claude/"
assert integration.config["commands_subdir"] == "skills"
def test_registrar_config_uses_skill_layout(self):
integration = get_integration("claude")
assert integration.registrar_config["dir"] == ".claude/skills"
assert integration.registrar_config["format"] == "markdown"
assert integration.registrar_config["args"] == "$ARGUMENTS"
assert integration.registrar_config["extension"] == "/SKILL.md"
def test_context_file(self):
integration = get_integration("claude")
assert integration.context_file == "CLAUDE.md"
def test_setup_creates_skill_files(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
skill_files = [path for path in created if path.name == "SKILL.md"]
assert skill_files
skills_dir = tmp_path / ".claude" / "skills"
assert skills_dir.is_dir()
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
assert plan_skill.exists()
content = plan_skill.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert "__AGENT__" not in content
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed["name"] == "speckit-plan"
assert parsed["disable-model-invocation"] is True
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
def test_setup_installs_update_context_scripts(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts"
assert scripts_dir.is_dir()
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created}
assert ".specify/integrations/claude/scripts/update-context.sh" in tracked
assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked
def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-promote"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--ai",
"claude",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert not (project / ".claude" / "commands").exists()
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["ai"] == "claude"
assert init_options["ai_skills"] is True
assert init_options["integration"] == "claude"
def test_integration_flag_creates_skill_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-integration"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"claude",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
def test_interactive_claude_selection_uses_integration_path(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-interactive"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
with patch("specify_cli.select_with_arrows", return_value="claude"):
result = runner.invoke(
app,
[
"init",
"--here",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".specify" / "integration.json").exists()
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert skill_file.exists()
assert "disable-model-invocation: true" in skill_file.read_text(encoding="utf-8")
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["ai"] == "claude"
assert init_options["ai_skills"] is True
assert init_options["integration"] == "claude"
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
"""Claude init should succeed even without install_ai_skills."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "fail-proj"
result = runner.invoke(
app,
["init", str(target), "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
)
assert result.exit_code == 0
assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
def test_claude_hooks_render_skill_invocation(self, tmp_path):
from specify_cli.extensions import HookExecutor
project = tmp_path / "claude-hooks"
project.mkdir()
init_options = project / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True}))
hook_executor = HookExecutor(project)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "Executing: `/speckit-plan`" in message
assert "EXECUTE_COMMAND: speckit.plan" in message
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
from specify_cli import save_init_options
from specify_cli.presets import PresetManager
project = tmp_path / "claude-preset-skill"
project.mkdir()
save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"})
skills_dir = project / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
preset_dir = tmp_path / "claude-skill-command"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.research.md").write_text(
"---\n"
"description: Research workflow\n"
"---\n\n"
"preset:claude-skill-command\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "claude-skill-command",
"name": "Claude Skill Command",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.research",
"file": "commands/speckit.research.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-research" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "preset:claude-skill-command" in content
assert "name: speckit-research" in content
assert "disable-model-invocation: true" in content
metadata = manager.registry.get("claude-skill-command")
assert "speckit-research" in metadata.get("registered_skills", [])

View File

@@ -0,0 +1,27 @@
"""Tests for CodexIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestCodexIntegration(SkillsIntegrationTests):
KEY = "codex"
FOLDER = ".agents/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
CONTEXT_FILE = "AGENTS.md"
class TestCodexAutoPromote:
"""--ai codex auto-promotes to integration path."""
def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path):
"""--ai codex should work the same as --integration codex."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--ai", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -0,0 +1,311 @@
"""Tests for GenericIntegration."""
import os
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.base import MarkdownIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class TestGenericIntegration:
"""Tests for GenericIntegration — requires --commands-dir option."""
# -- Registration -----------------------------------------------------
def test_registered(self):
from specify_cli.integrations import INTEGRATION_REGISTRY
assert "generic" in INTEGRATION_REGISTRY
def test_is_markdown_integration(self):
assert isinstance(get_integration("generic"), MarkdownIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder_is_none(self):
i = get_integration("generic")
assert i.config["folder"] is None
def test_config_requires_cli_false(self):
i = get_integration("generic")
assert i.config["requires_cli"] is False
def test_context_file_is_none(self):
i = get_integration("generic")
assert i.context_file is None
# -- Options ----------------------------------------------------------
def test_options_include_commands_dir(self):
i = get_integration("generic")
opts = i.options()
assert len(opts) == 1
assert opts[0].name == "--commands-dir"
assert opts[0].required is True
assert opts[0].is_flag is False
# -- Setup / teardown -------------------------------------------------
def test_setup_requires_commands_dir(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
with pytest.raises(ValueError, match="--commands-dir is required"):
i.setup(tmp_path, m, parsed_options={})
def test_setup_requires_nonempty_commands_dir(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
with pytest.raises(ValueError, match="--commands-dir is required"):
i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".myagent/commands"},
)
expected_dir = tmp_path / ".myagent" / "commands"
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_setup_creates_md_files(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_files:
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")
def test_templates_are_processed(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.install(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.install(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
m.save()
modified = created[0]
modified.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified.exists()
assert modified in skipped
def test_different_commands_dirs(self, tmp_path):
"""Generic should work with various user-specified paths."""
for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]:
project = tmp_path / path.replace("/", "-")
project.mkdir()
i = get_integration("generic")
m = IntegrationManifest("generic", project)
created = i.setup(
project, m,
parsed_options={"commands_dir": path},
)
expected = project / path
assert expected.is_dir(), f"Dir {expected} not created for {path}"
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
# -- Scripts ----------------------------------------------------------
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts"
assert scripts_dir.is_dir(), "Scripts directory not created for generic"
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
def test_sh_script_is_executable(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh"
assert os.access(sh, os.X_OK)
# -- CLI --------------------------------------------------------------
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
"""--integration generic without --ai-commands-dir should fail."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "test-generic"), "--integration", "generic",
"--script", "sh", "--no-git",
])
# Generic requires --commands-dir / --ai-commands-dir
# The integration path validates via setup()
assert result.exit_code != 0
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-generic-sh"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--ai-commands-dir", ".myagent/commands",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
expected = sorted([
".myagent/commands/speckit.analyze.md",
".myagent/commands/speckit.checklist.md",
".myagent/commands/speckit.clarify.md",
".myagent/commands/speckit.constitution.md",
".myagent/commands/speckit.implement.md",
".myagent/commands/speckit.plan.md",
".myagent/commands/speckit.specify.md",
".myagent/commands/speckit.tasks.md",
".myagent/commands/speckit.taskstoissues.md",
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/generic/scripts/update-context.ps1",
".specify/integrations/generic/scripts/update-context.sh",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/update-agent-context.sh",
".specify/templates/agent-file-template.md",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-generic-ps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--ai-commands-dir", ".myagent/commands",
"--script", "ps", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
expected = sorted([
".myagent/commands/speckit.analyze.md",
".myagent/commands/speckit.checklist.md",
".myagent/commands/speckit.clarify.md",
".myagent/commands/speckit.constitution.md",
".myagent/commands/speckit.implement.md",
".myagent/commands/speckit.plan.md",
".myagent/commands/speckit.specify.md",
".myagent/commands/speckit.tasks.md",
".myagent/commands/speckit.taskstoissues.md",
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/generic/scripts/update-context.ps1",
".specify/integrations/generic/scripts/update-context.sh",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/update-agent-context.ps1",
".specify/templates/agent-file-template.md",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)

View File

@@ -0,0 +1,149 @@
"""Tests for KimiIntegration — skills integration with legacy migration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
class TestKimiIntegration(SkillsIntegrationTests):
KEY = "kimi"
FOLDER = ".kimi/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".kimi/skills"
CONTEXT_FILE = "KIMI.md"
class TestKimiOptions:
"""Kimi declares --skills and --migrate-legacy options."""
def test_migrate_legacy_option(self):
i = get_integration("kimi")
opts = i.options()
migrate_opts = [o for o in opts if o.name == "--migrate-legacy"]
assert len(migrate_opts) == 1
assert migrate_opts[0].is_flag is True
assert migrate_opts[0].default is False
class TestKimiLegacyMigration:
"""Test Kimi dotted → hyphenated skill directory migration."""
def test_migrate_dotted_to_hyphenated(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Plan Skill\n")
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 1
assert removed == 0
assert not legacy.exists()
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
def test_skip_when_target_exists_different_content(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Old\n")
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text("# New (different)\n")
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 0
assert legacy.exists()
assert target.exists()
def test_remove_when_target_exists_same_content(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
content = "# Identical\n"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text(content)
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text(content)
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 1
assert not legacy.exists()
assert target.exists()
def test_preserve_legacy_with_extra_files(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
content = "# Same\n"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text(content)
(legacy / "extra.md").write_text("user file")
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text(content)
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 0
assert legacy.exists()
def test_nonexistent_dir_returns_zeros(self, tmp_path):
migrated, removed = _migrate_legacy_kimi_dotted_skills(
tmp_path / ".kimi" / "skills"
)
assert migrated == 0
assert removed == 0
def test_setup_with_migrate_legacy_option(self, tmp_path):
"""KimiIntegration.setup() with --migrate-legacy migrates dotted dirs."""
i = get_integration("kimi")
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.oldcmd"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Legacy\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert not legacy.exists()
assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
# New skills from templates should also exist
assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
class TestKimiNextSteps:
"""CLI output tests for kimi next-steps display."""
def test_next_steps_show_skill_invocation(self, tmp_path):
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
import os
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "kimi-next-steps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "kimi", "--no-git",
"--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
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

View File

@@ -1,5 +1,7 @@
"""Tests for KiroCliIntegration."""
import os
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -9,3 +11,29 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".kiro/prompts"
CONTEXT_FILE = "AGENTS.md"
class TestKiroAlias:
"""--ai kiro alias normalizes to kiro-cli and auto-promotes."""
def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):
"""--ai kiro should normalize to canonical kiro-cli and auto-promote."""
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "kiro-alias-proj"
target.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(target)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "kiro",
"--ignore-agent-tools", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()

View File

@@ -11,13 +11,17 @@ from specify_cli.integrations.base import MarkdownIntegration
from .conftest import StubIntegration
# Every integration key that must be registered (Stage 2 + Stage 3).
# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5).
ALL_INTEGRATION_KEYS = [
"copilot",
# Stage 3 — standard markdown integrations
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
"roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
# Stage 4 — TOML integrations
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations
"codex", "kimi", "agy", "generic",
]
@@ -61,9 +65,16 @@ class TestRegistryCompleteness:
class TestRegistrarKeyAlignment:
"""Every integration key must have a matching AGENT_CONFIGS entry."""
"""Every integration key must have a matching AGENT_CONFIGS entry.
@pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS)
``generic`` is excluded because it has no fixed directory — its
output path comes from ``--commands-dir`` at runtime.
"""
@pytest.mark.parametrize(
"key",
[k for k in ALL_INTEGRATION_KEYS if k != "generic"],
)
def test_integration_key_in_registrar(self, key):
from specify_cli.agents import CommandRegistrar
assert key in CommandRegistrar.AGENT_CONFIGS, (

View File

@@ -1,4 +1,4 @@
"""Consistency checks for agent configuration across runtime and packaging scripts."""
"""Consistency checks for agent configuration across runtime surfaces."""
import re
from pathlib import Path
@@ -41,52 +41,6 @@ class TestAgentConfigConsistency:
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):
"""Bash and PowerShell release scripts should agree on agent key set for Kiro."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
assert sh_match is not None
sh_agents = sh_match.group(1).split()
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
assert ps_match is not None
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
assert "kiro-cli" in sh_agents
assert "kiro-cli" in ps_agents
assert "shai" in sh_agents
assert "shai" in ps_agents
assert "agy" in sh_agents
assert "agy" in ps_agents
assert "q" not in sh_agents
assert "q" not in ps_agents
def test_release_ps_switch_has_shai_and_agy_generation(self):
"""PowerShell release builder must generate files for shai and agy agents."""
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
assert re.search(r"'shai'\s*\{.*?\.shai/commands", ps_text, re.S) is not None
assert re.search(r"'agy'\s*\{.*?\.agent/commands", ps_text, re.S) is not None
def test_release_sh_switch_has_shai_and_agy_generation(self):
"""Bash release builder must generate files for shai and agy agents."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None
assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None
def test_release_scripts_generate_codex_skills(self):
"""Release scripts should generate Codex skills in .agents/skills."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
assert ".agents/skills" in sh_text
assert ".agents/skills" in ps_text
assert re.search(r"codex\)\s*\n.*?create_skills.*?\.agents/skills.*?\"-\"", sh_text, re.S) is not None
assert re.search(r"'codex'\s*\{.*?\.agents/skills.*?New-Skills.*?-Separator '-'", ps_text, re.S) is not None
def test_init_ai_help_includes_roo_and_kiro_alias(self):
"""CLI help text for --ai should stay in sync with agent config and alias guidance."""
assert "roo" in AI_ASSISTANT_HELP
@@ -102,22 +56,6 @@ class TestAgentConfigConsistency:
assert "sha256sum -c -" in post_create_text
assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text
def test_release_output_targets_kiro_prompt_dir(self):
"""Packaging and release scripts should no longer emit amazonq artifacts."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert ".kiro/prompts" in sh_text
assert ".kiro/prompts" in ps_text
assert ".amazonq/prompts" not in sh_text
assert ".amazonq/prompts" not in ps_text
assert "spec-kit-template-kiro-cli-sh-" in gh_release_text
assert "spec-kit-template-kiro-cli-ps-" in gh_release_text
assert "spec-kit-template-q-sh-" not in gh_release_text
assert "spec-kit-template-q-ps-" not in gh_release_text
def test_agent_context_scripts_use_kiro_cli(self):
"""Agent context scripts should advertise kiro-cli and not legacy q agent key."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
@@ -149,38 +87,6 @@ class TestAgentConfigConsistency:
assert cfg["args"] == "{{args}}"
assert cfg["extension"] == ".toml"
def test_release_agent_lists_include_tabnine(self):
"""Bash and PowerShell release scripts should include tabnine in agent lists."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
assert sh_match is not None
sh_agents = sh_match.group(1).split()
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
assert ps_match is not None
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
assert "tabnine" in sh_agents
assert "tabnine" in ps_agents
def test_release_scripts_generate_tabnine_toml_commands(self):
"""Release scripts should generate TOML commands for tabnine in .tabnine/agent/commands."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
assert ".tabnine/agent/commands" in sh_text
assert ".tabnine/agent/commands" in ps_text
assert re.search(r"'tabnine'\s*\{.*?\.tabnine/agent/commands", ps_text, re.S) is not None
def test_github_release_includes_tabnine_packages(self):
"""GitHub release script should include tabnine template packages."""
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert "spec-kit-template-tabnine-sh-" in gh_release_text
assert "spec-kit-template-tabnine-ps-" in gh_release_text
def test_agent_context_scripts_include_tabnine(self):
"""Agent context scripts should support tabnine agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
@@ -213,22 +119,6 @@ class TestAgentConfigConsistency:
assert kimi_cfg["dir"] == ".kimi/skills"
assert kimi_cfg["extension"] == "/SKILL.md"
def test_kimi_in_release_agent_lists(self):
"""Bash and PowerShell release scripts should include kimi in agent lists."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
assert sh_match is not None
sh_agents = sh_match.group(1).split()
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
assert ps_match is not None
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
assert "kimi" in sh_agents
assert "kimi" in ps_agents
def test_kimi_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'kimi' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
@@ -239,13 +129,6 @@ class TestAgentConfigConsistency:
assert "kimi" in validate_set_values
def test_kimi_in_github_release_output(self):
"""GitHub release script should include kimi template packages."""
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert "spec-kit-template-kimi-sh-" in gh_release_text
assert "spec-kit-template-kimi-ps-" in gh_release_text
def test_ai_help_includes_kimi(self):
"""CLI help text for --ai should include kimi."""
assert "kimi" in AI_ASSISTANT_HELP
@@ -270,38 +153,6 @@ class TestAgentConfigConsistency:
assert trae_cfg["args"] == "$ARGUMENTS"
assert trae_cfg["extension"] == ".md"
def test_trae_in_release_agent_lists(self):
"""Bash and PowerShell release scripts should include trae in agent lists."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
assert sh_match is not None
sh_agents = sh_match.group(1).split()
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
assert ps_match is not None
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
assert "trae" in sh_agents
assert "trae" in ps_agents
def test_trae_in_release_scripts_generate_commands(self):
"""Release scripts should generate markdown commands for trae in .trae/rules."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
assert ".trae/rules" in sh_text
assert ".trae/rules" in ps_text
assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None
def test_trae_in_github_release_output(self):
"""GitHub release script should include trae template packages."""
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert "spec-kit-template-trae-sh-" in gh_release_text
assert "spec-kit-template-trae-ps-" in gh_release_text
def test_trae_in_agent_context_scripts(self):
"""Agent context scripts should support trae agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
@@ -347,32 +198,6 @@ class TestAgentConfigConsistency:
assert pi_cfg["args"] == "$ARGUMENTS"
assert pi_cfg["extension"] == ".md"
def test_pi_in_release_agent_lists(self):
"""Bash and PowerShell release scripts should include pi in agent lists."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
assert sh_match is not None
sh_agents = sh_match.group(1).split()
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
assert ps_match is not None
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
assert "pi" in sh_agents
assert "pi" in ps_agents
def test_release_scripts_generate_pi_prompt_templates(self):
"""Release scripts should generate Markdown prompt templates for pi in .pi/prompts."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
assert ".pi/prompts" in sh_text
assert ".pi/prompts" in ps_text
assert re.search(r"pi\)\s*\n.*?\.pi/prompts", sh_text, re.S) is not None
assert re.search(r"'pi'\s*\{.*?\.pi/prompts", ps_text, re.S) is not None
def test_pi_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'pi' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
@@ -383,13 +208,6 @@ class TestAgentConfigConsistency:
assert "pi" in validate_set_values
def test_pi_in_github_release_output(self):
"""GitHub release script should include pi template packages."""
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert "spec-kit-template-pi-sh-" in gh_release_text
assert "spec-kit-template-pi-ps-" in gh_release_text
def test_agent_context_scripts_include_pi(self):
"""Agent context scripts should support pi agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
@@ -422,38 +240,6 @@ class TestAgentConfigConsistency:
assert cfg["iflow"]["format"] == "markdown"
assert cfg["iflow"]["args"] == "$ARGUMENTS"
def test_iflow_in_release_agent_lists(self):
"""Bash and PowerShell release scripts should include iflow in agent lists."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
assert sh_match is not None
sh_agents = sh_match.group(1).split()
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
assert ps_match is not None
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
assert "iflow" in sh_agents
assert "iflow" in ps_agents
def test_iflow_in_release_scripts_build_variant(self):
"""Release scripts should generate Markdown commands for iflow in .iflow/commands."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
assert ".iflow/commands" in sh_text
assert ".iflow/commands" in ps_text
assert re.search(r"'iflow'\s*\{.*?\.iflow/commands", ps_text, re.S) is not None
def test_iflow_in_github_release_output(self):
"""GitHub release script should include iflow template packages."""
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert "spec-kit-template-iflow-sh-" in gh_release_text
assert "spec-kit-template-iflow-ps-" in gh_release_text
def test_iflow_in_agent_context_scripts(self):
"""Agent context scripts should support iflow agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")

File diff suppressed because it is too large Load Diff

View File

@@ -30,18 +30,13 @@ class TestSaveBranchNumbering:
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):
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
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"])
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
@@ -56,34 +51,24 @@ class TestBranchNumberingValidation:
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar"])
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
assert result.exit_code == 1
assert "Invalid --branch-numbering" in result.output
def test_valid_branch_numbering_sequential(self, tmp_path: Path, monkeypatch):
def test_valid_branch_numbering_sequential(self, tmp_path: Path):
from typer.testing import CliRunner
from 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"])
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")
def test_valid_branch_numbering_timestamp(self, tmp_path: Path, monkeypatch):
def test_valid_branch_numbering_timestamp(self, tmp_path: Path):
from typer.testing import CliRunner
from 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"])
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")

View File

@@ -41,14 +41,14 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
"""Create and return the expected skills directory for the given agent."""
# Match the logic in _get_skills_dir() from specify_cli
from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR
from specify_cli import AGENT_CONFIG
agent_config = AGENT_CONFIG.get(ai, {})
agent_folder = agent_config.get("folder", "")
if agent_folder:
skills_dir = project_root / agent_folder.rstrip("/") / "skills"
else:
skills_dir = project_root / DEFAULT_SKILLS_DIR
skills_dir = project_root / ".agents" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
return skills_dir
@@ -269,6 +269,7 @@ class TestExtensionSkillRegistration:
assert isinstance(parsed, dict)
assert parsed["name"] == "speckit-test-ext-hello"
assert "description" in parsed
assert parsed["disable-model-invocation"] is True
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
"""No skills should be created when ai_skills is false."""

View File

@@ -16,6 +16,7 @@ import shutil
from pathlib import Path
from datetime import datetime, timezone
from tests.conftest import strip_ansi
from specify_cli.extensions import (
CatalogEntry,
CORE_COMMAND_NAMES,
@@ -1016,7 +1017,7 @@ $ARGUMENTS
def test_register_commands_for_claude(self, extension_dir, project_dir):
"""Test registering commands for Claude agent."""
# Create .claude directory
claude_dir = project_dir / ".claude" / "commands"
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
ExtensionManager(project_dir) # Initialize manager (side effects only)
@@ -1033,13 +1034,12 @@ $ARGUMENTS
assert "speckit.test-ext.hello" in registered
# Check command file was created
cmd_file = claude_dir / "speckit.test-ext.hello.md"
cmd_file = claude_dir / "speckit-test-ext-hello" / "SKILL.md"
assert cmd_file.exists()
content = cmd_file.read_text()
assert "description: Test hello command" in content
assert "<!-- Extension: test-ext -->" in content
assert "<!-- Config: .specify/extensions/test-ext/ -->" in content
assert "test-ext" in content
def test_command_with_aliases(self, project_dir, temp_dir):
"""Test registering a command with aliases."""
@@ -1077,7 +1077,7 @@ $ARGUMENTS
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest")
claude_dir = project_dir / ".claude" / "commands"
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
@@ -1087,8 +1087,8 @@ $ARGUMENTS
assert len(registered) == 2
assert "speckit.ext-alias.cmd" in registered
assert "speckit.ext-alias.shortcut" in registered
assert (claude_dir / "speckit.ext-alias.cmd.md").exists()
assert (claude_dir / "speckit.ext-alias.shortcut.md").exists()
assert (claude_dir / "speckit-ext-alias-cmd" / "SKILL.md").exists()
assert (claude_dir / "speckit-ext-alias-shortcut" / "SKILL.md").exists()
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
"""Codex skill cleanup should use the same mapped names as registration."""
@@ -1465,7 +1465,7 @@ Then {AGENT_SCRIPT}
content = cmd_file.read_text()
assert "description: Test hello command" in content
assert "<!-- Extension: test-ext -->" in content
assert "test-ext" in content
def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
"""Test that companion .prompt.md files are created in .github/prompts/."""
@@ -1540,7 +1540,7 @@ Then {AGENT_SCRIPT}
def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):
"""Test that non-copilot agents do NOT create .prompt.md files."""
claude_dir = project_dir / ".claude" / "commands"
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
@@ -1591,7 +1591,7 @@ class TestIntegration:
def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
"""Test complete installation and removal workflow."""
# Create Claude directory
(project_dir / ".claude" / "commands").mkdir(parents=True)
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
@@ -1609,7 +1609,7 @@ class TestIntegration:
assert installed[0]["id"] == "test-ext"
# Verify command registered
cmd_file = project_dir / ".claude" / "commands" / "speckit.test-ext.hello.md"
cmd_file = project_dir / ".claude" / "skills" / "speckit-test-ext-hello" / "SKILL.md"
assert cmd_file.exists()
# Verify registry has registered commands (now a dict keyed by agent)
@@ -3007,7 +3007,7 @@ class TestExtensionUpdateCLI:
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "commands").mkdir(parents=True)
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True)
@@ -3056,7 +3056,7 @@ class TestExtensionUpdateCLI:
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "commands").mkdir(parents=True)
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
@@ -3067,14 +3067,16 @@ class TestExtensionUpdateCLI:
registered_commands = backup_registry_entry.get("registered_commands", {})
command_files = []
registrar = CommandRegistrar()
from specify_cli.agents import CommandRegistrar as AgentRegistrar
agent_registrar = AgentRegistrar()
for agent_name, cmd_names in registered_commands.items():
if agent_name not in registrar.AGENT_CONFIGS:
if agent_name not in agent_registrar.AGENT_CONFIGS:
continue
agent_cfg = registrar.AGENT_CONFIGS[agent_name]
agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name]
commands_dir = project_dir / agent_cfg["dir"]
for cmd_name in cmd_names:
cmd_path = commands_dir / f"{cmd_name}{agent_cfg['extension']}"
output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg)
cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}"
command_files.append(cmd_path)
assert command_files, "Expected at least one registered command file"
@@ -3126,11 +3128,12 @@ class TestExtensionListCLI:
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
# Verify the extension ID is shown in the output
assert "test-ext" in result.output
assert "test-ext" in plain
# Verify name and version are also shown
assert "Test Extension" in result.output
assert "1.0.0" in result.output
assert "Test Extension" in plain
assert "1.0.0" in plain
class TestExtensionPriority:
@@ -3360,7 +3363,8 @@ class TestExtensionPriorityCLI:
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
assert "Priority: 7" in result.output
plain = strip_ansi(result.output)
assert "Priority: 7" in plain
def test_set_priority_changes_priority(self, extension_dir, project_dir):
"""Test set-priority command changes extension priority."""
@@ -3381,7 +3385,8 @@ class TestExtensionPriorityCLI:
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
assert "priority changed: 10 → 5" in result.output
plain = strip_ansi(result.output)
assert "priority changed: 10 → 5" in plain
# Reload registry to see updated value
manager2 = ExtensionManager(project_dir)
@@ -3403,7 +3408,8 @@ class TestExtensionPriorityCLI:
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
assert "already has priority 5" in result.output
plain = strip_ansi(result.output)
assert "already has priority 5" in plain
def test_set_priority_invalid_value(self, extension_dir, project_dir):
"""Test set-priority rejects invalid priority values."""

View File

@@ -20,6 +20,7 @@ from datetime import datetime, timezone
import yaml
from tests.conftest import strip_ansi
from specify_cli.presets import (
PresetManifest,
PresetRegistry,
@@ -1771,19 +1772,20 @@ class TestSelfTestPreset:
assert "preset:self-test" in content
def test_self_test_registers_commands_for_claude(self, project_dir):
"""Test that installing self-test registers commands in .claude/commands/."""
# Create Claude agent directory to simulate Claude being set up
claude_dir = project_dir / ".claude" / "commands"
"""Test that installing self-test registers skills in .claude/skills/."""
# Create Claude skills directory to simulate Claude being set up
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
# Check the command was registered
cmd_file = claude_dir / "speckit.specify.md"
assert cmd_file.exists(), "Command not registered in .claude/commands/"
# Check the skill was registered
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
assert cmd_file.exists(), "Skill not registered in .claude/skills/"
content = cmd_file.read_text()
assert "preset:self-test" in content
assert "self-test" in content
assert "source:" in content # skill frontmatter includes metadata.source
def test_self_test_registers_commands_for_gemini(self, project_dir):
"""Test that installing self-test registers commands in .gemini/commands/ as TOML."""
@@ -1803,13 +1805,13 @@ class TestSelfTestPreset:
def test_self_test_unregisters_commands_on_remove(self, project_dir):
"""Test that removing self-test cleans up registered commands."""
claude_dir = project_dir / ".claude" / "commands"
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
cmd_file = claude_dir / "speckit.specify.md"
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
assert cmd_file.exists()
manager.remove("self-test")
@@ -1825,7 +1827,7 @@ class TestSelfTestPreset:
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
"""Test that extension command overrides are skipped if the extension isn't installed."""
claude_dir = project_dir / ".claude" / "commands"
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
preset_dir = temp_dir / "ext-override-preset"
@@ -1868,7 +1870,7 @@ class TestSelfTestPreset:
def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir):
"""Test that extension command overrides ARE registered when the extension is installed."""
claude_dir = project_dir / ".claude" / "commands"
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True)
@@ -1904,8 +1906,8 @@ class TestSelfTestPreset:
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
cmd_file = claude_dir / "speckit.fakeext.cmd.md"
assert cmd_file.exists(), "Command not registered despite extension being present"
cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md"
assert cmd_file.exists(), "Skill not registered despite extension being present"
# ===== Init Options and Skills Tests =====
@@ -1963,7 +1965,7 @@ class TestPresetSkills:
self._create_skill(skills_dir, "speckit-specify")
# Also create the claude commands dir so commands get registered
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
# Install self-test preset (has a command override for speckit.specify)
manager = PresetManager(project_dir)
@@ -1974,6 +1976,7 @@ class TestPresetSkills:
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:self-test" in content, "Skill should reference preset source"
assert "disable-model-invocation: true" in content
# Verify it was recorded in registry
metadata = manager.registry.get("self-test")
@@ -1981,12 +1984,10 @@ class TestPresetSkills:
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
"""When --ai-skills was NOT used, preset install should not touch skills."""
self._write_init_options(project_dir, ai="claude", ai_skills=False)
skills_dir = project_dir / ".claude" / "skills"
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
skills_dir = project_dir / ".qwen" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
@@ -2017,18 +2018,16 @@ class TestPresetSkills:
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
"""When no init-options.json exists, preset install should not touch skills."""
skills_dir = project_dir / ".claude" / "skills"
skills_dir = project_dir / ".qwen" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
content = skill_file.read_text()
assert "untouched" in content
file_content = skill_file.read_text()
assert "untouched" in file_content
def test_skill_restored_on_preset_remove(self, project_dir, temp_dir):
"""When a preset is removed, skills should be restored from core templates."""
@@ -2036,7 +2035,7 @@ class TestPresetSkills:
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
# Set up core command template in the project so restoration works
core_cmds = project_dir / ".specify" / "templates" / "commands"
@@ -2059,13 +2058,14 @@ class TestPresetSkills:
content = skill_file.read_text()
assert "preset:self-test" not in content, "Preset content should be gone"
assert "templates/commands/specify.md" in content, "Should reference core template"
assert "disable-model-invocation: true" in content
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="old")
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
@@ -2091,13 +2091,11 @@ class TestPresetSkills:
def test_skill_not_overridden_when_skill_path_is_file(self, project_dir):
"""Preset install should skip non-directory skill targets."""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._write_init_options(project_dir, ai="qwen")
skills_dir = project_dir / ".qwen" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
(skills_dir / "speckit-specify").write_text("not-a-directory")
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
@@ -2111,8 +2109,6 @@ class TestPresetSkills:
self._write_init_options(project_dir, ai="claude")
# Don't create skills dir — simulate --ai-skills never created them
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
@@ -2349,6 +2345,55 @@ class TestPresetSkills:
metadata = manager.registry.get("self-test")
assert "speckit-specify" in metadata.get("registered_skills", [])
def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir):
"""Kimi native skills should still receive brand-new preset commands."""
self._write_init_options(project_dir, ai="kimi", ai_skills=False)
skills_dir = project_dir / ".kimi" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
preset_dir = temp_dir / "kimi-new-skill"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.research.md").write_text(
"---\n"
"description: Kimi research workflow\n"
"---\n\n"
"preset:kimi-new-skill\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "kimi-new-skill",
"name": "Kimi New Skill",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.research",
"file": "commands/speckit.research.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-research" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:kimi-new-skill" in content
assert "name: speckit-research" in content
metadata = manager.registry.get("kimi-new-skill")
assert "speckit-research" in metadata.get("registered_skills", [])
def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir):
"""Kimi preset skill overrides should resolve placeholders and rewrite project paths."""
self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh")
@@ -2401,22 +2446,78 @@ class TestPresetSkills:
assert ".specify/memory/constitution.md" in content
assert "for kimi" in content
def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir):
"""Agy preset removal should restore native skills instead of deleting them."""
self._write_init_options(project_dir, ai="agy", ai_skills=True)
skills_dir = project_dir / ".agent" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="before override")
core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md"
core_command.write_text(
"---\n"
"description: Restored core specify workflow\n"
"---\n\n"
"restored core body\n"
)
preset_dir = temp_dir / "agy-override"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.specify.md").write_text(
"---\n"
"description: Agy override\n"
"---\n\n"
"preset agy body\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "agy-override",
"name": "Agy Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert "preset agy body" in skill_file.read_text()
assert manager.remove("agy-override") is True
assert skill_file.exists()
restored = skill_file.read_text()
assert "restored core body" in restored
assert "name: speckit-specify" in restored
def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir):
"""Non-dict init-options payloads should not crash preset install/remove flows."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text("[]")
skills_dir = project_dir / ".claude" / "skills"
skills_dir = project_dir / ".qwen" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(self_test_dir, "0.1.5")
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "untouched" in content
skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "untouched" in skill_content
class TestPresetSetPriority:
@@ -2441,7 +2542,8 @@ class TestPresetSetPriority:
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
assert "priority changed: 10 → 5" in result.output
plain = strip_ansi(result.output)
assert "priority changed: 10 → 5" in plain
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
@@ -2463,7 +2565,8 @@ class TestPresetSetPriority:
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
assert "already has priority 5" in result.output
plain = strip_ansi(result.output)
assert "already has priority 5" in plain
def test_set_priority_invalid_value(self, project_dir, pack_dir):
"""Test set-priority rejects invalid priority values."""

View File

@@ -186,11 +186,26 @@ class TestCheckFeatureBranch:
result = source_and_call('check_feature_branch "main" "true"')
assert result.returncode != 0
def test_accepts_four_digit_sequential_branch(self):
"""check_feature_branch accepts 4+ digit sequential branch."""
result = source_and_call('check_feature_branch "1234-feat" "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
def test_rejects_timestamp_without_slug(self):
"""check_feature_branch rejects timestamp-like branch missing trailing slug."""
result = source_and_call('check_feature_branch "20260319-143022" "true"')
assert result.returncode != 0
def test_rejects_7digit_timestamp_without_slug(self):
"""check_feature_branch rejects 7-digit date + 6-digit time without slug."""
result = source_and_call('check_feature_branch "2026031-143022" "true"')
assert result.returncode != 0
# ── find_feature_dir_by_prefix Tests ─────────────────────────────────────────
@@ -214,6 +229,15 @@ class TestFindFeatureDirByPrefix:
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
def test_four_digit_sequential_prefix(self, tmp_path: Path):
"""find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
(tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
# ── get_current_branch Tests ─────────────────────────────────────────────────
@@ -412,3 +436,341 @@ class TestAllowExistingBranchPowerShell:
assert "-AllowExistingBranch" in contents
# Ensure the flag is referenced in script logic, not just declared
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
# ── Dry-Run Tests ────────────────────────────────────────────────────────────
class TestDryRun:
def test_dry_run_sequential_outputs_name(self, git_repo: Path):
"""T009: Dry-run computes correct branch name 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, "--dry-run", "--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 == "003-new-feat", f"expected 003-new-feat, got: {branch}"
def test_dry_run_no_branch_created(self, git_repo: Path):
"""T010: Dry-run does not create a git branch."""
result = run_script(
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
)
assert result.returncode == 0, result.stderr
branches = subprocess.run(
["git", "branch", "--list", "*no-branch*"],
cwd=git_repo,
capture_output=True,
text=True,
)
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
def test_dry_run_no_spec_dir_created(self, git_repo: Path):
"""T011: Dry-run does not create any directories (including root specs/)."""
specs_root = git_repo / "specs"
if specs_root.exists():
shutil.rmtree(specs_root)
assert not specs_root.exists(), "specs/ should not exist before dry-run"
result = run_script(
git_repo, "--dry-run", "--short-name", "no-dir", "No dir feature"
)
assert result.returncode == 0, result.stderr
assert not specs_root.exists(), "specs/ should not be created during dry-run"
def test_dry_run_empty_repo(self, git_repo: Path):
"""T012: Dry-run returns 001 prefix when no existing specs or branches."""
result = run_script(
git_repo, "--dry-run", "--short-name", "first", "First 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 == "001-first", f"expected 001-first, got: {branch}"
def test_dry_run_with_short_name(self, git_repo: Path):
"""T013: Dry-run with --short-name produces expected name."""
(git_repo / "specs" / "001-existing").mkdir(parents=True)
(git_repo / "specs" / "002-existing").mkdir(parents=True)
(git_repo / "specs" / "003-existing").mkdir(parents=True)
result = run_script(
git_repo, "--dry-run", "--short-name", "user-auth", "Add user authentication"
)
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 == "004-user-auth", f"expected 004-user-auth, got: {branch}"
def test_dry_run_then_real_run_match(self, git_repo: Path):
"""T014: Dry-run name matches subsequent real creation."""
(git_repo / "specs" / "001-existing").mkdir(parents=True)
# Dry-run first
dry_result = run_script(
git_repo, "--dry-run", "--short-name", "match-test", "Match test"
)
assert dry_result.returncode == 0, dry_result.stderr
dry_branch = None
for line in dry_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
dry_branch = line.split(":", 1)[1].strip()
# Real run
real_result = run_script(
git_repo, "--short-name", "match-test", "Match test"
)
assert real_result.returncode == 0, real_result.stderr
real_branch = None
for line in real_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
real_branch = line.split(":", 1)[1].strip()
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
def test_dry_run_accounts_for_remote_branches(self, git_repo: Path):
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering."""
(git_repo / "specs" / "001-existing").mkdir(parents=True)
# Set up a bare remote and push (use subdirs of git_repo for isolation)
remote_dir = git_repo / "test-remote.git"
subprocess.run(
["git", "init", "--bare", str(remote_dir)],
check=True, capture_output=True,
)
subprocess.run(
["git", "remote", "add", "origin", str(remote_dir)],
check=True, cwd=git_repo, capture_output=True,
)
subprocess.run(
["git", "push", "-u", "origin", "HEAD"],
check=True, cwd=git_repo, capture_output=True,
)
# Clone into a second copy, create a higher-numbered branch, push it
second_clone = git_repo / "test-second-clone"
subprocess.run(
["git", "clone", str(remote_dir), str(second_clone)],
check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=second_clone, check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=second_clone, check=True, capture_output=True,
)
# Create branch 005 on the remote (higher than local 001)
subprocess.run(
["git", "checkout", "-b", "005-remote-only"],
cwd=second_clone, check=True, capture_output=True,
)
subprocess.run(
["git", "push", "origin", "005-remote-only"],
cwd=second_clone, check=True, capture_output=True,
)
# Primary repo: dry-run should see 005 via ls-remote and return 006
dry_result = run_script(
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
)
assert dry_result.returncode == 0, dry_result.stderr
dry_branch = None
for line in dry_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
dry_branch = line.split(":", 1)[1].strip()
assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}"
def test_dry_run_json_includes_field(self, git_repo: Path):
"""T015: JSON output includes DRY_RUN field when --dry-run is active."""
import json
result = run_script(
git_repo, "--dry-run", "--json", "--short-name", "json-test", "JSON test"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}"
assert data["DRY_RUN"] is True
def test_dry_run_json_absent_without_flag(self, git_repo: Path):
"""T016: Normal JSON output does NOT include DRY_RUN field."""
import json
result = run_script(
git_repo, "--json", "--short-name", "no-dry", "No dry run"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
def test_dry_run_with_timestamp(self, git_repo: Path):
"""T017: Dry-run works with --timestamp flag."""
result = run_script(
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp 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, "no BRANCH_NAME in output"
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
# Verify no side effects
branches = subprocess.run(
["git", "branch", "--list", f"*ts-feat*"],
cwd=git_repo,
capture_output=True,
text=True,
)
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
assert branches.stdout.strip() == ""
def test_dry_run_with_number(self, git_repo: Path):
"""T018: Dry-run works with --number flag."""
result = run_script(
git_repo, "--dry-run", "--number", "42", "--short-name", "num-feat", "Number 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 == "042-num-feat", f"expected 042-num-feat, got: {branch}"
def test_dry_run_no_git(self, no_git_dir: Path):
"""T019: Dry-run works in non-git directory."""
(no_git_dir / "specs" / "001-existing").mkdir(parents=True)
result = run_script(
no_git_dir, "--dry-run", "--short-name", "no-git-dry", "No git dry run"
)
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 == "002-no-git-dry", f"expected 002-no-git-dry, got: {branch}"
# Verify no spec dir created
spec_dirs = [
d.name
for d in (no_git_dir / "specs").iterdir()
if d.is_dir() and "no-git-dry" in d.name
]
assert len(spec_dirs) == 0
# ── PowerShell Dry-Run Tests ─────────────────────────────────────────────────
def _has_pwsh() -> bool:
"""Check if pwsh is available."""
try:
subprocess.run(["pwsh", "--version"], capture_output=True, check=True)
return True
except (FileNotFoundError, subprocess.CalledProcessError):
return False
def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
"""Run create-new-feature.ps1 from the temp repo's scripts directory."""
script = cwd / "scripts" / "powershell" / "create-new-feature.ps1"
cmd = ["pwsh", "-NoProfile", "-File", str(script), *args]
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
@pytest.fixture
def ps_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with PowerShell 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,
)
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
shutil.copy(common_ps, ps_dir / "common.ps1")
(tmp_path / ".specify" / "templates").mkdir(parents=True)
return tmp_path
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
class TestPowerShellDryRun:
def test_ps_dry_run_outputs_name(self, ps_git_repo: Path):
"""PowerShell -DryRun computes correct branch name."""
(ps_git_repo / "specs" / "001-first").mkdir(parents=True)
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "ps-feat", "PS 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 == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create a git branch."""
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
)
assert result.returncode == 0, result.stderr
branches = subprocess.run(
["git", "branch", "--list", "*no-ps-branch*"],
cwd=ps_git_repo,
capture_output=True,
text=True,
)
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create specs/ directory."""
specs_root = ps_git_repo / "specs"
if specs_root.exists():
shutil.rmtree(specs_root)
assert not specs_root.exists()
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-dir", "No dir"
)
assert result.returncode == 0, result.stderr
assert not specs_root.exists(), "specs/ should not be created during dry-run"
def test_ps_dry_run_json_includes_field(self, ps_git_repo: Path):
"""PowerShell -DryRun JSON output includes DRY_RUN field."""
import json
result = run_ps_script(
ps_git_repo, "-DryRun", "-Json", "-ShortName", "ps-json", "JSON test"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}"
assert data["DRY_RUN"] is True
def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path):
"""PowerShell normal JSON output does NOT include DRY_RUN field."""
import json
result = run_ps_script(
ps_git_repo, "-Json", "-ShortName", "ps-no-dry", "No dry run"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"