Compare commits

...

14 Commits

Author SHA1 Message Date
Manfred Riem
fc3b98ea09 fix: rename Qoder AGENT_CONFIG key from 'qoder' to 'qodercli' to match actual CLI executable (#1651)
* fix: rename Qoder CLI to QoderCLI across scripts and documentation

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .github/workflows/scripts/create-release-packages.ps1

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 13:30:16 -06:00
Manfred Riem
6150f1e317 Add generic agent support with customizable command directories (#1639)
- Add --ai generic option for unsupported AI agents (bring your own agent)
- Require --ai-commands-dir to specify where agent reads commands from
- Generate Markdown commands with $ARGUMENTS format (compatible with most agents)
- Rebuild CHANGELOG from GitHub releases (last 10 releases)
- Align version to 0.1.3

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-20 11:05:38 -06:00
Manfred Riem
6fca5d83b2 fix: pin click>=8.1 to prevent Python 3.14/Homebrew env isolation crash (#1648)
* fix: pin click>=8.1 to prevent Python 3.14/Homebrew env isolation failures

Fixes #1631. When uv installs specify-cli on macOS with Homebrew Python
3.14, the virtual environment can fail to fully isolate from the system
site-packages, causing Homebrew's click to be loaded instead of the one
uv installed. If that system click is older than 8.1, it lacks the `ctx`
keyword argument in `ParamType.get_metavar()`, which typer 0.24.0 requires,
resulting in:

  TypeError: ParamType.get_metavar() got an unexpected keyword argument 'ctx'

Adding an explicit `click>=8.1` dependency gives uv a hard constraint so
the correct version is always resolved and installed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update CHANGELOG.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 10:22:16 -06:00
Manfred Riem
465acd9024 fix: include 'src/**' path in release workflow triggers (#1646)
Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
2026-02-20 09:46:29 -06:00
dependabot[bot]
04fc3fd1ba chore(deps): bump github/codeql-action from 3 to 4 (#1635)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 16:31:23 -06:00
Manfred Riem
24d76b5d92 Add pytest and Python linting (ruff) to CI (#1637)
* feat: add GitHub Actions workflow for testing and linting Python code

* fix: resolve ruff lint errors in specify_cli

- Remove extraneous f-string prefixes (F541)
- Split multi-statement lines (E701, E702)
- Remove unused variable assignments (F841)
- Remove ruff format check from CI workflow (format-only PR to follow)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: strip ANSI codes in ai-skills help text test

The Rich/Typer CLI injects ANSI escape codes into option names in
--help output, causing plain string matching to fail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-19 16:21:58 -06:00
Manfred Riem
0f7d04b12b feat: add pull request template for better contribution guidelines (#1634) 2026-02-19 15:49:34 -06:00
Manfred Riem
9402ebd00a Feat/ai skills (#1632)
* implement ai-skills command line switch

* fix: address review comments, remove breaking change for existing projects, add tests

* fix: review comments

* fix: review comments

* fix: review comments

* fix: review comments

* fix: review comments, add test cases for all the agents

* fix: review comments

* fix: review comments

* chore: trigger CI

* chore: trigger CodeQL

* ci: add CodeQL workflow for code scanning

* ci: add actions language to CodeQL workflow, disable default setup

---------

Co-authored-by: dhilipkumars <s.dhilipkumar@gmail.com>
2026-02-19 13:24:41 -06:00
dependabot[bot]
d410d188fc chore(deps): bump actions/stale from 9 to 10 (#1623)
Bumps [actions/stale](https://github.com/actions/stale) from 9 to 10.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v9...v10)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '10'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 07:41:50 -06:00
Manfred Riem
686c91f94e feat: add dependabot configuration for pip and GitHub Actions updates (#1622) 2026-02-18 17:42:41 -06:00
Manfred Riem
22036732d8 Remove Maintainers section from README.md (#1618) 2026-02-18 16:11:16 -06:00
Laurent Thiebault
c78f8423f6 fix: typo in plan-template.md (#1446) 2026-02-17 07:22:43 -06:00
Hanzhi Yang
76cca34293 Feat: add a new agent: Google Anti Gravity (#1220)
* Add support for Antigravity (agy) agent

* fix a few things after gemini code update

* Fix missed merge conflicts

* As PR states it is IDE integration setting requires_cli to 'False'

---------

Co-authored-by: Manfred Riem <manfred.riem@microsoft.com>
2026-02-12 12:31:48 -06:00
Copilot
9a1e3037b0 Add stale workflow for 180-day inactive issues and PRs (#1594)
* Initial plan

* Add stale issues and PRs workflow

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* Add 7-day grace period before closing stale items

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* Update stale timing: mark at 150 days, close at 180 days

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-02-11 16:03:17 -06:00
20 changed files with 1218 additions and 337 deletions

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,22 @@
## Description
<!-- What does this PR do? Why is it needed? -->
## Testing
<!-- How did you test your changes? -->
- [ ] Tested locally with `uv run specify --help`
- [ ] Ran existing tests with `uv sync && uv run pytest`
- [ ] Tested with a sample project (if applicable)
## AI Disclosure
<!-- Per our Contributing guidelines, AI assistance must be disclosed. -->
<!-- See: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md#ai-contributions-in-spec-kit -->
- [ ] I **did not** use AI assistance for this contribution
- [ ] I **did** use AI assistance (describe below)
<!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): -->

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

32
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
strategy:
fail-fast: false
matrix:
language: [ 'actions', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"

View File

@@ -6,6 +6,7 @@ on:
paths: paths:
- 'memory/**' - 'memory/**'
- 'scripts/**' - 'scripts/**'
- 'src/**'
- 'templates/**' - 'templates/**'
- '.github/workflows/**' - '.github/workflows/**'
workflow_dispatch: workflow_dispatch:
@@ -57,4 +58,12 @@ jobs:
run: | run: |
chmod +x .github/workflows/scripts/update-version.sh chmod +x .github/workflows/scripts/update-version.sh
.github/workflows/scripts/update-version.sh ${{ steps.get_tag.outputs.new_version }} .github/workflows/scripts/update-version.sh ${{ steps.get_tag.outputs.new_version }}
- name: Commit version bump to main
if: steps.check_release.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add pyproject.toml
git diff --cached --quiet || git commit -m "chore: bump version to ${{ steps.get_tag.outputs.new_version }} [skip ci]"
git push

View File

@@ -40,15 +40,19 @@ gh release create "$VERSION" \
.genreleases/spec-kit-template-roo-ps-"$VERSION".zip \ .genreleases/spec-kit-template-roo-ps-"$VERSION".zip \
.genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \ .genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \
.genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \ .genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \
.genreleases/spec-kit-template-qoder-sh-"$VERSION".zip \ .genreleases/spec-kit-template-qodercli-sh-"$VERSION".zip \
.genreleases/spec-kit-template-qoder-ps-"$VERSION".zip \ .genreleases/spec-kit-template-qodercli-ps-"$VERSION".zip \
.genreleases/spec-kit-template-amp-sh-"$VERSION".zip \ .genreleases/spec-kit-template-amp-sh-"$VERSION".zip \
.genreleases/spec-kit-template-amp-ps-"$VERSION".zip \ .genreleases/spec-kit-template-amp-ps-"$VERSION".zip \
.genreleases/spec-kit-template-shai-sh-"$VERSION".zip \ .genreleases/spec-kit-template-shai-sh-"$VERSION".zip \
.genreleases/spec-kit-template-shai-ps-"$VERSION".zip \ .genreleases/spec-kit-template-shai-ps-"$VERSION".zip \
.genreleases/spec-kit-template-q-sh-"$VERSION".zip \ .genreleases/spec-kit-template-q-sh-"$VERSION".zip \
.genreleases/spec-kit-template-q-ps-"$VERSION".zip \ .genreleases/spec-kit-template-q-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-sh-"$VERSION".zip \
.genreleases/spec-kit-template-bob-ps-"$VERSION".zip \ .genreleases/spec-kit-template-bob-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" \ --title "Spec Kit Templates - $VERSION_NO_V" \
--notes-file release_notes.md --notes-file release_notes.md

View File

@@ -14,7 +14,7 @@
.PARAMETER Agents .PARAMETER Agents
Comma or space separated subset of agents to build (default: all) Comma or space separated subset of agents to build (default: all)
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qoder Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qodercli, shai, agy, generic
.PARAMETER Scripts .PARAMETER Scripts
Comma or space separated subset of script types to build (default: both) Comma or space separated subset of script types to build (default: both)
@@ -343,9 +343,13 @@ function Build-Variant {
$cmdDir = Join-Path $baseDir ".bob/commands" $cmdDir = Join-Path $baseDir ".bob/commands"
Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
} }
'qoder' { 'qodercli' {
$cmdDir = Join-Path $baseDir ".qoder/commands" $cmdDir = Join-Path $baseDir ".qoder/commands"
Generate-Commands -Agent 'qoder' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script Generate-Commands -Agent 'qodercli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
}
'generic' {
$cmdDir = Join-Path $baseDir ".speckit/commands"
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
} }
} }
@@ -356,7 +360,7 @@ function Build-Variant {
} }
# Define all agents and scripts # Define all agents and scripts
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qoder') $AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qodercli', 'shai', 'agy', 'generic')
$AllScripts = @('sh', 'ps') $AllScripts = @('sh', 'ps')
function Normalize-List { function Normalize-List {

View File

@@ -6,7 +6,7 @@ set -euo pipefail
# Usage: .github/workflows/scripts/create-release-packages.sh <version> # Usage: .github/workflows/scripts/create-release-packages.sh <version>
# Version argument should include leading 'v'. # Version argument should include leading 'v'.
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. # Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex amp shai bob (default: all) # AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex amp shai bob generic (default: all)
# SCRIPTS : space or comma separated subset of: sh ps (default: both) # SCRIPTS : space or comma separated subset of: sh ps (default: both)
# Examples: # Examples:
# AGENTS=claude SCRIPTS=sh $0 v0.2.0 # AGENTS=claude SCRIPTS=sh $0 v0.2.0
@@ -203,9 +203,9 @@ build_variant() {
codebuddy) codebuddy)
mkdir -p "$base_dir/.codebuddy/commands" mkdir -p "$base_dir/.codebuddy/commands"
generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;; generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;;
qoder) qodercli)
mkdir -p "$base_dir/.qoder/commands" mkdir -p "$base_dir/.qoder/commands"
generate_commands qoder md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;; generate_commands qodercli md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;;
amp) amp)
mkdir -p "$base_dir/.agents/commands" mkdir -p "$base_dir/.agents/commands"
generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;; generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;;
@@ -215,16 +215,22 @@ build_variant() {
q) q)
mkdir -p "$base_dir/.amazonq/prompts" mkdir -p "$base_dir/.amazonq/prompts"
generate_commands q md "\$ARGUMENTS" "$base_dir/.amazonq/prompts" "$script" ;; generate_commands q md "\$ARGUMENTS" "$base_dir/.amazonq/prompts" "$script" ;;
agy)
mkdir -p "$base_dir/.agent/workflows"
generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/workflows" "$script" ;;
bob) bob)
mkdir -p "$base_dir/.bob/commands" mkdir -p "$base_dir/.bob/commands"
generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;; generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;;
generic)
mkdir -p "$base_dir/.speckit/commands"
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
esac esac
( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . ) ( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . )
echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip"
} }
# Determine agent list # Determine agent list
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q bob qoder) ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q agy bob qodercli generic)
ALL_SCRIPTS=(sh ps) ALL_SCRIPTS=(sh ps)
norm_list() { norm_list() {

42
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight UTC
workflow_dispatch: # Allow manual triggering
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
# Days of inactivity before an issue or PR becomes stale
days-before-stale: 150
# Days of inactivity before a stale issue or PR is closed (after being marked stale)
days-before-close: 30
# Stale issue settings
stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.'
stale-issue-label: 'stale'
# Stale PR settings
stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.'
stale-pr-label: 'stale'
# Exempt issues and PRs with these labels from being marked as stale
exempt-issue-labels: 'pinned,security'
exempt-pr-labels: 'pinned,security'
# Only issues or PRs with all of these labels are checked
# Leave empty to check all issues and PRs
any-of-labels: ''
# Operations per run (helps avoid rate limits)
operations-per-run: 100

50
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Test & Lint Python
permissions:
contents: read
on:
push:
branches: ["main"]
pull_request:
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Run ruff check
run: uvx ruff check src/
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --extra test
- name: Run tests
run: uv run pytest

3
.gitignore vendored
View File

@@ -44,10 +44,9 @@ env/
.genreleases/ .genreleases/
*.zip *.zip
sdd-*/ sdd-*/
docs/dev docs/dev
# Extension system # Extension system
.specify/extensions/.cache/ .specify/extensions/.cache/
.specify/extensions/.backup/ .specify/extensions/.backup/
.specify/extensions/*/local-config.yml .specify/extensions/*/local-config.yml

View File

@@ -43,11 +43,12 @@ Specify supports multiple AI agents by generating agent-specific command files a
| **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI | | **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI |
| **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE | | **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE |
| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | | **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI |
| **Qoder CLI** | `.qoder/commands/` | Markdown | `qoder` | Qoder CLI | | **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI |
| **Amazon Q Developer CLI** | `.amazonq/prompts/` | Markdown | `q` | Amazon Q Developer CLI | | **Amazon Q Developer CLI** | `.amazonq/prompts/` | Markdown | `q` | Amazon Q Developer CLI |
| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | | **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI |
| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | | **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI |
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
### Step-by-Step Integration Guide ### Step-by-Step Integration Guide
@@ -313,7 +314,7 @@ Require a command-line tool to be installed:
- **opencode**: `opencode` CLI - **opencode**: `opencode` CLI
- **Amazon Q Developer CLI**: `q` CLI - **Amazon Q Developer CLI**: `q` CLI
- **CodeBuddy CLI**: `codebuddy` CLI - **CodeBuddy CLI**: `codebuddy` CLI
- **Qoder CLI**: `qoder` CLI - **Qoder CLI**: `qodercli` CLI
- **Amp**: `amp` CLI - **Amp**: `amp` CLI
- **SHAI**: `shai` CLI - **SHAI**: `shai` CLI

View File

@@ -7,291 +7,60 @@ All notable changes to the Specify CLI and templates are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-01-28 ## [0.1.4] - Unreleased
### Added
- **Extension System**: Introduced modular extension architecture for Spec Kit
- Extensions are self-contained packages that add commands and functionality without bloating core
- Extension manifest schema (`extension.yml`) with validation
- Extension registry (`.specify/extensions/.registry`) for tracking installed extensions
- Extension manager module (`src/specify_cli/extensions.py`) for installation/removal
- New CLI commands:
- `specify extension list` - List installed extensions
- `specify extension add` - Install extension from local directory or URL
- `specify extension remove` - Uninstall extension
- `specify extension search` - Search extension catalog
- `specify extension info` - Show detailed extension information
- Semantic versioning compatibility checks
- Support for extension configuration files
- Command registration system for AI agents (Claude support initially)
- Added dependencies: `pyyaml>=6.0`, `packaging>=23.0`
- **Extension Catalog**: Extension discovery and distribution system
- Central catalog (`extensions/catalog.json`) for published extensions
- Extension catalog manager (`ExtensionCatalog` class) with:
- Catalog fetching from GitHub
- 1-hour local caching for performance
- Search by query, tag, author, or verification status
- Extension info retrieval
- Catalog cache stored in `.specify/extensions/.cache/`
- Search and info commands with rich console output
- Added 9 catalog-specific unit tests (100% pass rate)
- **Jira Extension**: First official extension for Jira integration
- Extension ID: `jira`
- Version: 1.0.0
- Commands:
- `/speckit.jira.specstoissues` - Create Jira hierarchy from spec and tasks
- `/speckit.jira.discover-fields` - Discover Jira custom fields
- `/speckit.jira.sync-status` - Sync task completion status
- Comprehensive documentation (README, usage guide, examples)
- MIT licensed
- **Hook System**: Extension lifecycle hooks for automation
- `HookExecutor` class for managing extension hooks
- Hooks registered in `.specify/extensions.yml`
- Hook registration during extension installation
- Hook unregistration during extension removal
- Support for optional and mandatory hooks
- Hook execution messages for AI agent integration
- Condition support for conditional hook execution (placeholder)
- **Extension Management**: Advanced extension management commands
- `specify extension update` - Check and update extensions to latest version
- `specify extension enable` - Enable a disabled extension
- `specify extension disable` - Disable extension without removing it
- Version comparison with catalog
- Update notifications
- Preserve configuration during updates
- **Multi-Agent Support**: Extensions now work with all supported AI agents (Phase 6)
- Automatic detection and registration for all agents in project
- Support for 16+ AI agents (Claude, Gemini, Copilot, Cursor, Qwen, and more)
- Agent-specific command formats (Markdown and TOML)
- Automatic argument placeholder conversion ($ARGUMENTS → {{args}})
- Commands registered for all detected agents during installation
- Multi-agent command unregistration on extension removal
- `CommandRegistrar.register_commands_for_agent()` method
- `CommandRegistrar.register_commands_for_all_agents()` method
- **Configuration Layers**: Full configuration cascade system (Phase 6)
- **Layer 1**: Defaults from extension manifest (`extension.yml`)
- **Layer 2**: Project config (`.specify/extensions/{ext-id}/{ext-id}-config.yml`)
- **Layer 3**: Local config (`.specify/extensions/{ext-id}/local-config.yml`, gitignored)
- **Layer 4**: Environment variables (`SPECKIT_{EXT_ID}_{KEY}` pattern)
- Recursive config merging with proper precedence
- `ConfigManager` class for programmatic config access
- `get_config()`, `get_value()`, `has_value()` methods
- Support for nested configuration paths with dot-notation
- **Hook Condition Evaluation**: Smart hook execution based on runtime conditions (Phase 6)
- Config conditions: `config.key.path is set`, `config.key == 'value'`, `config.key != 'value'`
- Environment conditions: `env.VAR is set`, `env.VAR == 'value'`, `env.VAR != 'value'`
- Automatic filtering of hooks based on condition evaluation
- Safe fallback behavior on evaluation errors
- Case-insensitive pattern matching
- **Hook Integration**: Agent-level hook checking and execution (Phase 6)
- `check_hooks_for_event()` method for AI agents to query hooks after core commands
- Condition-aware hook filtering before execution
- `enable_hooks()` and `disable_hooks()` methods per extension
- Formatted hook messages for agent display
- `execute_hook()` method for hook execution information
- **Documentation Suite**: Comprehensive documentation for users and developers
- **EXTENSION-USER-GUIDE.md**: Complete user guide with installation, usage, configuration, and troubleshooting
- **EXTENSION-API-REFERENCE.md**: Technical API reference with manifest schema, Python API, and CLI commands
- **EXTENSION-PUBLISHING-GUIDE.md**: Publishing guide for extension authors
- **RFC-EXTENSION-SYSTEM.md**: Extension architecture design document
- **Extension Template**: Starter template in `extensions/template/` for creating new extensions
- Fully commented `extension.yml` manifest template
- Example command file with detailed explanations
- Configuration template with all options
- Complete project structure (README, LICENSE, CHANGELOG, .gitignore)
- EXAMPLE-README.md showing final documentation format
- **Unit Tests**: Comprehensive test suite with 39 tests covering all extension system components
- Test coverage: 83% of extension module code
- Test dependencies: `pytest>=7.0`, `pytest-cov>=4.0`
- Configured pytest in `pyproject.toml`
### Changed
- Version bumped to 0.1.0 (minor release for new feature)
## [0.0.22] - 2025-11-07
- Support for VS Code/Copilot agents, and moving away from prompts to proper agents with hand-offs.
- Move to use `AGENTS.md` for Copilot workloads, since it's already supported out-of-the-box.
- Adds support for the version command. ([#486](https://github.com/github/spec-kit/issues/486))
- Fixes potential bug with the `create-new-feature.ps1` script that ignores existing feature branches when determining next feature number ([#975](https://github.com/github/spec-kit/issues/975))
- Add graceful fallback and logging for GitHub API rate-limiting during template fetch ([#970](https://github.com/github/spec-kit/issues/970))
## [0.0.21] - 2025-10-21
- Fixes [#975](https://github.com/github/spec-kit/issues/975) (thank you [@fgalarraga](https://github.com/fgalarraga)).
- Adds support for Amp CLI.
- Adds support for VS Code hand-offs and moves prompts to be full-fledged chat modes.
- Adds support for `version` command (addresses [#811](https://github.com/github/spec-kit/issues/811) and [#486](https://github.com/github/spec-kit/issues/486), thank you [@mcasalaina](https://github.com/mcasalaina) and [@dentity007](https://github.com/dentity007)).
- Adds support for rendering the rate limit errors from the CLI when encountered ([#970](https://github.com/github/spec-kit/issues/970), thank you [@psmman](https://github.com/psmman)).
## [0.0.20] - 2025-10-14
### Added
- **Intelligent Branch Naming**: `create-new-feature` scripts now support `--short-name` parameter for custom branch names
- When `--short-name` provided: Uses the custom name directly (cleaned and formatted)
- When omitted: Automatically generates meaningful names using stop word filtering and length-based filtering
- Filters out common stop words (I, want, to, the, for, etc.)
- Removes words shorter than 3 characters (unless they're uppercase acronyms)
- Takes 3-4 most meaningful words from the description
- **Enforces GitHub's 244-byte branch name limit** with automatic truncation and warnings
- Examples:
- "I want to create user authentication" → `001-create-user-authentication`
- "Implement OAuth2 integration for API" → `001-implement-oauth2-integration-api`
- "Fix payment processing bug" → `001-fix-payment-processing`
- Very long descriptions are automatically truncated at word boundaries to stay within limits
- Designed for AI agents to provide semantic short names while maintaining standalone usability
### Changed
- Enhanced help documentation for `create-new-feature.sh` and `create-new-feature.ps1` scripts with examples
- Branch names now validated against GitHub's 244-byte limit with automatic truncation if needed
## [0.0.19] - 2025-10-10
### Added
- Support for CodeBuddy (thank you to [@lispking](https://github.com/lispking) for the contribution).
- You can now see Git-sourced errors in the Specify CLI.
### Changed
- Fixed the path to the constitution in `plan.md` (thank you to [@lyzno1](https://github.com/lyzno1) for spotting).
- Fixed backslash escapes in generated TOML files for Gemini (thank you to [@hsin19](https://github.com/hsin19) for the contribution).
- Implementation command now ensures that the correct ignore files are added (thank you to [@sigent-amazon](https://github.com/sigent-amazon) for the contribution).
## [0.0.18] - 2025-10-06
### Added
- Support for using `.` as a shorthand for current directory in `specify init .` command, equivalent to `--here` flag but more intuitive for users.
- Use the `/speckit.` command prefix to easily discover Spec Kit-related commands.
- Refactor the prompts and templates to simplify their capabilities and how they are tracked. No more polluting things with tests when they are not needed.
- Ensure that tasks are created per user story (simplifies testing and validation).
- Add support for Visual Studio Code prompt shortcuts and automatic script execution.
### Changed
- All command files now prefixed with `speckit.` (e.g., `speckit.specify.md`, `speckit.plan.md`) for better discoverability and differentiation in IDE/CLI command palettes and file explorers
## [0.0.17] - 2025-09-22
### Added
- New `/clarify` command template to surface up to 5 targeted clarification questions for an existing spec and persist answers into a Clarifications section in the spec.
- New `/analyze` command template providing a non-destructive cross-artifact discrepancy and alignment report (spec, clarifications, plan, tasks, constitution) inserted after `/tasks` and before `/implement`.
- Note: Constitution rules are explicitly treated as non-negotiable; any conflict is a CRITICAL finding requiring artifact remediation, not weakening of principles.
## [0.0.16] - 2025-09-22
### Added
- `--force` flag for `init` command to bypass confirmation when using `--here` in a non-empty directory and proceed with merging/overwriting files.
## [0.0.15] - 2025-09-21
### Added
- Support for Roo Code.
## [0.0.14] - 2025-09-21
### Changed
- Error messages are now shown consistently.
## [0.0.13] - 2025-09-21
### Added
- Support for Kilo Code. Thank you [@shahrukhkhan489](https://github.com/shahrukhkhan489) with [#394](https://github.com/github/spec-kit/pull/394).
- Support for Auggie CLI. Thank you [@hungthai1401](https://github.com/hungthai1401) with [#137](https://github.com/github/spec-kit/pull/137).
- Agent folder security notice displayed after project provisioning completion, warning users that some agents may store credentials or auth tokens in their agent folders and recommending adding relevant folders to `.gitignore` to prevent accidental credential leakage.
### Changed
- Warning displayed to ensure that folks are aware that they might need to add their agent folder to `.gitignore`.
- Cleaned up the `check` command output.
## [0.0.12] - 2025-09-21
### Changed
- Added additional context for OpenAI Codex users - they need to set an additional environment variable, as described in [#417](https://github.com/github/spec-kit/issues/417).
## [0.0.11] - 2025-09-20
### Added
- Codex CLI support (thank you [@honjo-hiroaki-gtt](https://github.com/honjo-hiroaki-gtt) for the contribution in [#14](https://github.com/github/spec-kit/pull/14))
- Codex-aware context update tooling (Bash and PowerShell) so feature plans refresh `AGENTS.md` alongside existing assistants without manual edits.
## [0.0.10] - 2025-09-20
### Fixed ### Fixed
- Addressed [#378](https://github.com/github/spec-kit/issues/378) where a GitHub token may be attached to the request when it was empty. - **Qoder CLI detection**: Renamed `AGENT_CONFIG` key from `"qoder"` to `"qodercli"` to match the actual executable name, fixing `specify check` and `specify init --ai` detection failures
## [0.0.9] - 2025-09-19 ## [0.1.3] - Unreleased
### Changed
- Improved agent selector UI with cyan highlighting for agent keys and gray parentheses for full names
## [0.0.8] - 2025-09-19
### Added ### Added
- Windsurf IDE support as additional AI assistant option (thank you [@raedkit](https://github.com/raedkit) for the work in [#151](https://github.com/github/spec-kit/pull/151)) - **Generic Agent Support**: Added `--ai generic` option for unsupported AI agents ("bring your own agent")
- GitHub token support for API requests to handle corporate environments and rate limiting (contributed by [@zryfish](https://github.com/@zryfish) in [#243](https://github.com/github/spec-kit/pull/243)) - Requires `--ai-commands-dir <path>` to specify where the agent reads commands from
- Generates Markdown commands with `$ARGUMENTS` format (compatible with most agents)
- Example: `specify init my-project --ai generic --ai-commands-dir .myagent/commands/`
- Enables users to start with Spec Kit immediately while their agent awaits formal support
### Changed ## [0.0.102] - 2026-02-20
- Updated README with Windsurf examples and GitHub token usage - fix: include 'src/**' path in release workflow triggers (#1646)
- Enhanced release workflow to include Windsurf templates
## [0.0.7] - 2025-09-18 ## [0.0.101] - 2026-02-19
### Changed - chore(deps): bump github/codeql-action from 3 to 4 (#1635)
- Updated command instructions in the CLI. ## [0.0.100] - 2026-02-19
- Cleaned up the code to not render agent-specific information when it's generic.
## [0.0.6] - 2025-09-17 - Add pytest and Python linting (ruff) to CI (#1637)
- feat: add pull request template for better contribution guidelines (#1634)
### Added ## [0.0.99] - 2026-02-19
- opencode support as additional AI assistant option - Feat/ai skills (#1632)
## [0.0.5] - 2025-09-17 ## [0.0.98] - 2026-02-19
### Added - chore(deps): bump actions/stale from 9 to 10 (#1623)
- feat: add dependabot configuration for pip and GitHub Actions updates (#1622)
- Qwen Code support as additional AI assistant option ## [0.0.97] - 2026-02-18
## [0.0.4] - 2025-09-14 - Remove Maintainers section from README.md (#1618)
### Added ## [0.0.96] - 2026-02-17
- SOCKS proxy support for corporate environments via `httpx[socks]` dependency - fix: typo in plan-template.md (#1446)
### Fixed ## [0.0.95] - 2026-02-12
N/A - Feat: add a new agent: Google Anti Gravity (#1220)
### Changed ## [0.0.94] - 2026-02-11
N/A - Add stale workflow for 180-day inactive issues and PRs (#1594)
## [0.0.93] - 2026-02-10
- Add modular extension system (#1551)

View File

@@ -31,7 +31,6 @@
- [📖 Learn More](#-learn-more) - [📖 Learn More](#-learn-more)
- [📋 Detailed Process](#-detailed-process) - [📋 Detailed Process](#-detailed-process)
- [🔍 Troubleshooting](#-troubleshooting) - [🔍 Troubleshooting](#-troubleshooting)
- [👥 Maintainers](#-maintainers)
- [💬 Support](#-support) - [💬 Support](#-support)
- [🙏 Acknowledgements](#-acknowledgements) - [🙏 Acknowledgements](#-acknowledgements)
- [📄 License](#-license) - [📄 License](#-license)
@@ -162,6 +161,8 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
| [Roo Code](https://roocode.com/) | ✅ | | | [Roo Code](https://roocode.com/) | ✅ | |
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | | [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | |
| [Windsurf](https://windsurf.com/) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | |
| [Antigravity (agy)](https://agy.ai/) | ✅ | |
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
## 🔧 Specify CLI Reference ## 🔧 Specify CLI Reference
@@ -172,14 +173,15 @@ The `specify` command supports the following options:
| Command | Description | | Command | Description |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `init` | Initialize a new Specify project from the latest template | | `init` | Initialize a new Specify project from the latest template |
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `shai`, `qoder`) | | `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `shai`, `qodercli`) |
### `specify init` Arguments & Options ### `specify init` Arguments & Options
| Argument/Option | Type | Description | | Argument/Option | Type | Description |
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | | `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `bob`, or `qoder` | | `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `agy`, `bob`, `qodercli`, or `generic` (requires `--ai-commands-dir`) |
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
| `--no-git` | Flag | Skip git repository initialization | | `--no-git` | Flag | Skip git repository initialization |
@@ -188,6 +190,7 @@ The `specify` command supports the following options:
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
| `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--debug` | Flag | Enable detailed debug output for troubleshooting |
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
### Examples ### Examples
@@ -202,7 +205,7 @@ specify init my-project --ai claude
specify init my-project --ai cursor-agent specify init my-project --ai cursor-agent
# Initialize with Qoder support # Initialize with Qoder support
specify init my-project --ai qoder specify init my-project --ai qodercli
# Initialize with Windsurf support # Initialize with Windsurf support
specify init my-project --ai windsurf specify init my-project --ai windsurf
@@ -216,6 +219,9 @@ specify init my-project --ai shai
# Initialize with IBM Bob support # Initialize with IBM Bob support
specify init my-project --ai bob specify init my-project --ai bob
# Initialize with an unsupported agent (generic / bring your own agent)
specify init my-project --ai generic --ai-commands-dir .myagent/commands/
# Initialize with PowerShell scripts (Windows/cross-platform) # Initialize with PowerShell scripts (Windows/cross-platform)
specify init my-project --ai copilot --script ps specify init my-project --ai copilot --script ps
@@ -238,6 +244,12 @@ specify init my-project --ai claude --debug
# Use GitHub token for API requests (helpful for corporate environments) # Use GitHub token for API requests (helpful for corporate environments)
specify init my-project --ai claude --github-token ghp_your_token_here 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
# Initialize in current directory with agent skills
specify init --here --ai gemini --ai-skills
# Check system requirements # Check system requirements
specify check specify check
``` ```
@@ -636,11 +648,6 @@ echo "Cleaning up..."
rm gcm-linux_amd64.2.6.1.deb rm gcm-linux_amd64.2.6.1.deb
``` ```
## 👥 Maintainers
- Den Delimarsky ([@localden](https://github.com/localden))
- John Lam ([@jflam](https://github.com/jflam))
## 💬 Support ## 💬 Support
For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development. For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.

View File

@@ -1,10 +1,11 @@
[project] [project]
name = "specify-cli" name = "specify-cli"
version = "0.1.0" version = "0.1.4"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"typer", "typer",
"click>=8.1",
"rich", "rich",
"httpx[socks]", "httpx[socks]",
"platformdirs", "platformdirs",

View File

@@ -30,12 +30,12 @@
# #
# 5. Multi-Agent Support # 5. Multi-Agent Support
# - Handles agent-specific file paths and naming conventions # - Handles agent-specific file paths and naming conventions
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI # - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Amazon Q Developer CLI, or Antigravity
# - Can update single agents or all existing agent files # - Can update single agents or all existing agent files
# - Creates default Claude file if no agent files exist # - Creates default Claude file if no agent files exist
# #
# Usage: ./update-agent-context.sh [agent_type] # Usage: ./update-agent-context.sh [agent_type]
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|shai|q|bob|qoder # Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli
# Leave empty to update all existing agent files # Leave empty to update all existing agent files
set -e set -e
@@ -74,6 +74,7 @@ QODER_FILE="$REPO_ROOT/QODER.md"
AMP_FILE="$REPO_ROOT/AGENTS.md" AMP_FILE="$REPO_ROOT/AGENTS.md"
SHAI_FILE="$REPO_ROOT/SHAI.md" SHAI_FILE="$REPO_ROOT/SHAI.md"
Q_FILE="$REPO_ROOT/AGENTS.md" Q_FILE="$REPO_ROOT/AGENTS.md"
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
BOB_FILE="$REPO_ROOT/AGENTS.md" BOB_FILE="$REPO_ROOT/AGENTS.md"
# Template file # Template file
@@ -618,7 +619,7 @@ update_specific_agent() {
codebuddy) codebuddy)
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
;; ;;
qoder) qodercli)
update_agent_file "$QODER_FILE" "Qoder CLI" update_agent_file "$QODER_FILE" "Qoder CLI"
;; ;;
amp) amp)
@@ -630,12 +631,18 @@ update_specific_agent() {
q) q)
update_agent_file "$Q_FILE" "Amazon Q Developer CLI" update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
;; ;;
agy)
update_agent_file "$AGY_FILE" "Antigravity"
;;
bob) bob)
update_agent_file "$BOB_FILE" "IBM Bob" update_agent_file "$BOB_FILE" "IBM Bob"
;; ;;
generic)
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
;;
*) *)
log_error "Unknown agent type '$agent_type'" log_error "Unknown agent type '$agent_type'"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|amp|shai|q|bob|qoder" log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic"
exit 1 exit 1
;; ;;
esac esac
@@ -714,7 +721,11 @@ update_all_existing_agents() {
update_agent_file "$Q_FILE" "Amazon Q Developer CLI" update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
found_agent=true found_agent=true
fi fi
if [[ -f "$AGY_FILE" ]]; then
update_agent_file "$AGY_FILE" "Antigravity"
found_agent=true
fi
if [[ -f "$BOB_FILE" ]]; then if [[ -f "$BOB_FILE" ]]; then
update_agent_file "$BOB_FILE" "IBM Bob" update_agent_file "$BOB_FILE" "IBM Bob"
found_agent=true found_agent=true
@@ -744,7 +755,7 @@ print_summary() {
echo echo
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|codebuddy|shai|q|bob|qoder]" log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli]"
} }
#============================================================================== #==============================================================================

View File

@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
2. Plan Data Extraction 2. Plan Data Extraction
3. Agent File Management (create from template or update existing) 3. Agent File Management (create from template or update existing)
4. Content Generation (technology stack, recent changes, timestamp) 4. Content Generation (technology stack, recent changes, timestamp)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, bob, qoder) 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, agy, bob, qodercli)
.PARAMETER AgentType .PARAMETER AgentType
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
#> #>
param( param(
[Parameter(Position=0)] [Parameter(Position=0)]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','bob','qoder')] [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qodercli','generic')]
[string]$AgentType [string]$AgentType
) )
@@ -59,6 +59,7 @@ $QODER_FILE = Join-Path $REPO_ROOT 'QODER.md'
$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' $SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md'
$Q_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $Q_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
@@ -383,12 +384,14 @@ function Update-SpecificAgent {
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } 'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' }
'qoder' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } 'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' }
'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' }
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' } 'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' }
'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' }
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob|qoder'; return $false } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic'; return $false }
} }
} }
@@ -409,6 +412,7 @@ function Update-AllExistingAgents {
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true } if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true } if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true }
if (Test-Path $Q_FILE) { if (-not (Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI')) { $ok = $false }; $found = $true } if (Test-Path $Q_FILE) { if (-not (Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI')) { $ok = $false }; $found = $true }
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
if (-not $found) { if (-not $found) {
Write-Info 'No existing agent files found, creating default Claude file...' Write-Info 'No existing agent files found, creating default Claude file...'
@@ -424,7 +428,7 @@ function Print-Summary {
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
Write-Host '' Write-Host ''
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob|qoder]' Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic]'
} }
function Main { function Main {

View File

@@ -32,6 +32,7 @@ import tempfile
import shutil import shutil
import shlex import shlex
import json import json
import yaml
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -190,7 +191,7 @@ AGENT_CONFIG = {
"install_url": "https://www.codebuddy.ai/cli", "install_url": "https://www.codebuddy.ai/cli",
"requires_cli": True, "requires_cli": True,
}, },
"qoder": { "qodercli": {
"name": "Qoder CLI", "name": "Qoder CLI",
"folder": ".qoder/", "folder": ".qoder/",
"install_url": "https://qoder.com/cli", "install_url": "https://qoder.com/cli",
@@ -220,12 +221,24 @@ AGENT_CONFIG = {
"install_url": "https://github.com/ovh/shai", "install_url": "https://github.com/ovh/shai",
"requires_cli": True, "requires_cli": True,
}, },
"agy": {
"name": "Antigravity",
"folder": ".agent/",
"install_url": None, # IDE-based
"requires_cli": False,
},
"bob": { "bob": {
"name": "IBM Bob", "name": "IBM Bob",
"folder": ".bob/", "folder": ".bob/",
"install_url": None, # IDE-based "install_url": None, # IDE-based
"requires_cli": False, "requires_cli": False,
}, },
"generic": {
"name": "Generic (bring your own agent)",
"folder": None, # Set dynamically via --ai-commands-dir
"install_url": None,
"requires_cli": False,
},
} }
SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
@@ -663,7 +676,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
except ValueError as je: except ValueError as je:
raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}") raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}")
except Exception as e: except Exception as e:
console.print(f"[red]Error fetching release information[/red]") console.print("[red]Error fetching release information[/red]")
console.print(Panel(str(e), title="Fetch Error", border_style="red")) console.print(Panel(str(e), title="Fetch Error", border_style="red"))
raise typer.Exit(1) raise typer.Exit(1)
@@ -693,7 +706,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
zip_path = download_dir / filename zip_path = download_dir / filename
if verbose: if verbose:
console.print(f"[cyan]Downloading template...[/cyan]") console.print("[cyan]Downloading template...[/cyan]")
try: try:
with client.stream( with client.stream(
@@ -732,7 +745,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
for chunk in response.iter_bytes(chunk_size=8192): for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk) f.write(chunk)
except Exception as e: except Exception as e:
console.print(f"[red]Error downloading template[/red]") console.print("[red]Error downloading template[/red]")
detail = str(e) detail = str(e)
if zip_path.exists(): if zip_path.exists():
zip_path.unlink() zip_path.unlink()
@@ -816,7 +829,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
tracker.add("flatten", "Flatten nested directory") tracker.add("flatten", "Flatten nested directory")
tracker.complete("flatten") tracker.complete("flatten")
elif verbose: elif verbose:
console.print(f"[cyan]Found nested directory structure[/cyan]") console.print("[cyan]Found nested directory structure[/cyan]")
for item in source_dir.iterdir(): for item in source_dir.iterdir():
dest_path = project_path / item.name dest_path = project_path / item.name
@@ -841,7 +854,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
console.print(f"[yellow]Overwriting file:[/yellow] {item.name}") console.print(f"[yellow]Overwriting file:[/yellow] {item.name}")
shutil.copy2(item, dest_path) shutil.copy2(item, dest_path)
if verbose and not tracker: if verbose and not tracker:
console.print(f"[cyan]Template files merged into current directory[/cyan]") console.print("[cyan]Template files merged into current directory[/cyan]")
else: else:
zip_ref.extractall(project_path) zip_ref.extractall(project_path)
@@ -867,7 +880,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
tracker.add("flatten", "Flatten nested directory") tracker.add("flatten", "Flatten nested directory")
tracker.complete("flatten") tracker.complete("flatten")
elif verbose: elif verbose:
console.print(f"[cyan]Flattened nested directory structure[/cyan]") console.print("[cyan]Flattened nested directory structure[/cyan]")
except Exception as e: except Exception as e:
if tracker: if tracker:
@@ -917,13 +930,17 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
continue continue
except Exception: except Exception:
continue continue
st = script.stat(); mode = st.st_mode st = script.stat()
mode = st.st_mode
if mode & 0o111: if mode & 0o111:
continue continue
new_mode = mode new_mode = mode
if mode & 0o400: new_mode |= 0o100 if mode & 0o400:
if mode & 0o040: new_mode |= 0o010 new_mode |= 0o100
if mode & 0o004: new_mode |= 0o001 if mode & 0o040:
new_mode |= 0o010
if mode & 0o004:
new_mode |= 0o001
if not (new_mode & 0o100): if not (new_mode & 0o100):
new_mode |= 0o100 new_mode |= 0o100
os.chmod(script, new_mode) os.chmod(script, new_mode)
@@ -969,7 +986,7 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
tracker.add("constitution", "Constitution setup") tracker.add("constitution", "Constitution setup")
tracker.complete("constitution", "copied from template") tracker.complete("constitution", "copied from template")
else: else:
console.print(f"[cyan]Initialized constitution from template[/cyan]") console.print("[cyan]Initialized constitution from template[/cyan]")
except Exception as e: except Exception as e:
if tracker: if tracker:
tracker.add("constitution", "Constitution setup") tracker.add("constitution", "Constitution setup")
@@ -977,10 +994,208 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
else: else:
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
# Agent-specific skill directory overrides for agents whose skills directory
# doesn't follow the standard <agent_folder>/skills/ pattern
AGENT_SKILLS_DIR_OVERRIDES = {
"codex": ".agents/skills", # Codex agent layout override
}
# Default skills directory for agents not in AGENT_CONFIG
DEFAULT_SKILLS_DIR = ".agents/skills"
# Enhanced descriptions for each spec-kit command skill
SKILL_DESCRIPTIONS = {
"specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.",
"plan": "Generate technical implementation plans from feature specifications. Use after creating a spec to define architecture, tech stack, and implementation phases. Creates plan.md with detailed technical design.",
"tasks": "Break down implementation plans into actionable task lists. Use after planning to create a structured task breakdown. Generates tasks.md with ordered, dependency-aware tasks.",
"implement": "Execute all tasks from the task breakdown to build the feature. Use after task generation to systematically implement the planned solution following TDD approach where applicable.",
"analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md. Use after task generation to identify gaps, duplications, and inconsistencies before implementation.",
"clarify": "Structured clarification workflow for underspecified requirements. Use before planning to resolve ambiguities through coverage-based questioning. Records answers in spec clarifications section.",
"constitution": "Create or update project governing principles and development guidelines. Use at project start to establish code quality, testing standards, and architectural constraints that guide all development.",
"checklist": "Generate custom quality checklists for validating requirements completeness and clarity. Use to create unit tests for English that ensure spec quality before implementation.",
"taskstoissues": "Convert tasks from tasks.md into GitHub issues. Use after task breakdown to track work items in GitHub project management.",
}
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
"""Resolve the agent-specific skills directory for the given AI assistant.
Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to
``AGENT_CONFIG[agent]["folder"] + "skills"``, and finally to
``DEFAULT_SKILLS_DIR``.
"""
if selected_ai in AGENT_SKILLS_DIR_OVERRIDES:
return project_path / AGENT_SKILLS_DIR_OVERRIDES[selected_ai]
agent_config = AGENT_CONFIG.get(selected_ai, {})
agent_folder = agent_config.get("folder", "")
if agent_folder:
return project_path / agent_folder.rstrip("/") / "skills"
return project_path / DEFAULT_SKILLS_DIR
def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool:
"""Install Prompt.MD files from templates/commands/ as agent skills.
Skills are written to the agent-specific skills directory following the
`agentskills.io <https://agentskills.io/specification>`_ specification.
Installation is additive — existing files are never removed and prompt
command files in the agent's commands directory are left untouched.
Args:
project_path: Target project directory.
selected_ai: AI assistant key from ``AGENT_CONFIG``.
tracker: Optional progress tracker.
Returns:
``True`` if at least one skill was installed or all skills were
already present (idempotent re-run), ``False`` otherwise.
"""
# Locate command templates in the agent's extracted commands directory.
# download_and_extract_template() already placed the .md files here.
agent_config = AGENT_CONFIG.get(selected_ai, {})
agent_folder = agent_config.get("folder", "")
if agent_folder:
templates_dir = project_path / agent_folder.rstrip("/") / "commands"
else:
templates_dir = project_path / "commands"
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
# Fallback: try the repo-relative path (for running from source checkout)
# This also covers agents whose extracted commands are in a different
# format (e.g. gemini uses .toml, not .md).
script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/
fallback_dir = script_dir / "templates" / "commands"
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
templates_dir = fallback_dir
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
if tracker:
tracker.error("ai-skills", "command templates not found")
else:
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
return False
command_files = sorted(templates_dir.glob("*.md"))
if not command_files:
if tracker:
tracker.skip("ai-skills", "no command templates found")
else:
console.print("[yellow]No command templates found to install[/yellow]")
return False
# Resolve the correct skills directory for this agent
skills_dir = _get_skills_dir(project_path, selected_ai)
skills_dir.mkdir(parents=True, exist_ok=True)
if tracker:
tracker.start("ai-skills")
installed_count = 0
skipped_count = 0
for command_file in command_files:
try:
content = command_file.read_text(encoding="utf-8")
# Parse YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
frontmatter = yaml.safe_load(parts[1])
if not isinstance(frontmatter, dict):
frontmatter = {}
body = parts[2].strip()
else:
# File starts with --- but has no closing ---
console.print(f"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]")
frontmatter = {}
body = content
else:
frontmatter = {}
body = content
command_name = command_file.stem
# Normalize: extracted commands may be named "speckit.<cmd>.md";
# strip the "speckit." prefix so skill names stay clean and
# SKILL_DESCRIPTIONS lookups work.
if command_name.startswith("speckit."):
command_name = command_name[len("speckit."):]
skill_name = f"speckit-{command_name}"
# Create skill directory (additive — never removes existing content)
skill_dir = skills_dir / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
# Select the best description available
original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(command_name, original_desc or f"Spec-kit workflow command: {command_name}")
# Build SKILL.md following agentskills.io spec
# Use yaml.safe_dump to safely serialise the frontmatter and
# avoid YAML injection from descriptions containing colons,
# quotes, or newlines.
# Normalize source filename for metadata — strip speckit. prefix
# so it matches the canonical templates/commands/<cmd>.md path.
source_name = command_file.name
if source_name.startswith("speckit."):
source_name = source_name[len("speckit."):]
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/{source_name}",
},
}
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_content = (
f"---\n"
f"{frontmatter_text}\n"
f"---\n\n"
f"# Speckit {command_name.title()} Skill\n\n"
f"{body}\n"
)
skill_file = skill_dir / "SKILL.md"
if skill_file.exists():
# Do not overwrite user-customized skills on re-runs
skipped_count += 1
continue
skill_file.write_text(skill_content, encoding="utf-8")
installed_count += 1
except Exception as e:
console.print(f"[yellow]Warning: Failed to install skill {command_file.stem}: {e}[/yellow]")
continue
if tracker:
if installed_count > 0 and skipped_count > 0:
tracker.complete("ai-skills", f"{installed_count} new + {skipped_count} existing skills in {skills_dir.relative_to(project_path)}")
elif installed_count > 0:
tracker.complete("ai-skills", f"{installed_count} skills → {skills_dir.relative_to(project_path)}")
elif skipped_count > 0:
tracker.complete("ai-skills", f"{skipped_count} skills already present")
else:
tracker.error("ai-skills", "no skills installed")
else:
if installed_count > 0:
console.print(f"[green]✓[/green] Installed {installed_count} agent skills to {skills_dir.relative_to(project_path)}/")
elif skipped_count > 0:
console.print(f"[green]✓[/green] {skipped_count} agent skills already present in {skills_dir.relative_to(project_path)}/")
else:
console.print("[yellow]No skills were installed[/yellow]")
return installed_count > 0 or skipped_count > 0
@app.command() @app.command()
def init( def init(
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, bob, or qoder "), ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, qodercli, or generic (requires --ai-commands-dir)"),
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
@@ -989,6 +1204,7 @@ def init(
skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"),
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
): ):
""" """
Initialize a new Specify project from the latest template. Initialize a new Specify project from the latest template.
@@ -1013,6 +1229,9 @@ def init(
specify init --here --ai codebuddy specify init --here --ai codebuddy
specify init --here specify init --here
specify init --here --force # Skip confirmation when current directory not empty specify init --here --force # Skip confirmation when current directory not empty
specify init my-project --ai claude --ai-skills # Install agent skills
specify init --here --ai gemini --ai-skills
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
""" """
show_banner() show_banner()
@@ -1029,6 +1248,11 @@ def init(
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
raise typer.Exit(1) raise typer.Exit(1)
if ai_skills and not ai_assistant:
console.print("[red]Error:[/red] --ai-skills requires --ai to be specified")
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
raise typer.Exit(1)
if here: if here:
project_name = Path.cwd().name project_name = Path.cwd().name
project_path = Path.cwd() project_path = Path.cwd()
@@ -1092,6 +1316,16 @@ def init(
"copilot" "copilot"
) )
# Validate --ai-commands-dir usage
if selected_ai == "generic":
if not ai_commands_dir:
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic")
console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]")
raise typer.Exit(1)
elif ai_commands_dir:
console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')")
raise typer.Exit(1)
if not ignore_agent_tools: if not ignore_agent_tools:
agent_config = AGENT_CONFIG.get(selected_ai) agent_config = AGENT_CONFIG.get(selected_ai)
if agent_config and agent_config["requires_cli"]: if agent_config and agent_config["requires_cli"]:
@@ -1144,6 +1378,11 @@ def init(
("extracted-summary", "Extraction summary"), ("extracted-summary", "Extraction summary"),
("chmod", "Ensure scripts executable"), ("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"), ("constitution", "Constitution setup"),
]:
tracker.add(key, label)
if ai_skills:
tracker.add("ai-skills", "Install agent skills")
for key, label in [
("cleanup", "Cleanup"), ("cleanup", "Cleanup"),
("git", "Initialize git repository"), ("git", "Initialize git repository"),
("final", "Finalize") ("final", "Finalize")
@@ -1162,10 +1401,45 @@ def init(
download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token)
# For generic agent, rename placeholder directory to user-specified path
if selected_ai == "generic" and ai_commands_dir:
placeholder_dir = project_path / ".speckit" / "commands"
target_dir = project_path / ai_commands_dir
if placeholder_dir.is_dir():
target_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(placeholder_dir), str(target_dir))
# Clean up empty .speckit dir if it's now empty
speckit_dir = project_path / ".speckit"
if speckit_dir.is_dir() and not any(speckit_dir.iterdir()):
speckit_dir.rmdir()
ensure_executable_scripts(project_path, tracker=tracker) ensure_executable_scripts(project_path, tracker=tracker)
ensure_constitution_from_template(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker)
if ai_skills:
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
# When --ai-skills is used on a NEW project and skills were
# successfully installed, remove the command files that the
# template archive just created. Skills replace commands, so
# keeping both would be confusing. For --here on an existing
# repo we leave pre-existing commands untouched to avoid a
# breaking change. We only delete AFTER skills succeed so the
# project always has at least one of {commands, skills}.
if skills_ok and not here:
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
agent_folder = agent_cfg.get("folder", "")
if agent_folder:
cmds_dir = project_path / agent_folder.rstrip("/") / "commands"
if cmds_dir.exists():
try:
shutil.rmtree(cmds_dir)
except OSError:
# Best-effort cleanup: skills are already installed,
# so leaving stale commands is non-fatal.
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
if not no_git: if not no_git:
tracker.start("git") tracker.start("git")
if is_git_repo(project_path): if is_git_repo(project_path):
@@ -1224,16 +1498,17 @@ def init(
# Agent folder security notice # Agent folder security notice
agent_config = AGENT_CONFIG.get(selected_ai) agent_config = AGENT_CONFIG.get(selected_ai)
if agent_config: if agent_config:
agent_folder = agent_config["folder"] agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]
security_notice = Panel( if agent_folder:
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" security_notice = Panel(
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
title="[yellow]Agent Folder Security[/yellow]", f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
border_style="yellow", title="[yellow]Agent Folder Security[/yellow]",
padding=(1, 2) border_style="yellow",
) padding=(1, 2)
console.print() )
console.print(security_notice) console.print()
console.print(security_notice)
steps_lines = [] steps_lines = []
if not here: if not here:
@@ -1270,9 +1545,9 @@ def init(
enhancement_lines = [ enhancement_lines = [
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]", "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
"", "",
f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", "○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", "○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" "○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
] ]
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2)) enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
console.print() console.print()
@@ -1291,6 +1566,8 @@ def check():
agent_results = {} agent_results = {}
for agent_key, agent_config in AGENT_CONFIG.items(): for agent_key, agent_config in AGENT_CONFIG.items():
if agent_key == "generic":
continue # Generic is not a real agent to check
agent_name = agent_config["name"] agent_name = agent_config["name"]
requires_cli = agent_config["requires_cli"] requires_cli = agent_config["requires_cli"]
@@ -1305,10 +1582,10 @@ def check():
# Check VS Code variants (not in agent config) # Check VS Code variants (not in agent config)
tracker.add("code", "Visual Studio Code") tracker.add("code", "Visual Studio Code")
code_ok = check_tool("code", tracker=tracker) check_tool("code", tracker=tracker)
tracker.add("code-insiders", "Visual Studio Code Insiders") tracker.add("code-insiders", "Visual Studio Code Insiders")
code_insiders_ok = check_tool("code-insiders", tracker=tracker) check_tool("code-insiders", tracker=tracker)
console.print(tracker.render()) console.print(tracker.render())
@@ -1574,14 +1851,14 @@ def extension_add(
if zip_path.exists(): if zip_path.exists():
zip_path.unlink() zip_path.unlink()
console.print(f"\n[green]✓[/green] Extension installed successfully!") console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
console.print(f" {manifest.description}") console.print(f" {manifest.description}")
console.print(f"\n[bold cyan]Provided commands:[/bold cyan]") console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands: for cmd in manifest.commands:
console.print(f"{cmd['name']} - {cmd.get('description', '')}") console.print(f"{cmd['name']} - {cmd.get('description', '')}")
console.print(f"\n[yellow]⚠[/yellow] Configuration may be required") console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: .specify/extensions/{manifest.id}/") console.print(f" Check: .specify/extensions/{manifest.id}/")
except ValidationError as e: except ValidationError as e:
@@ -1631,11 +1908,11 @@ def extension_remove(
# Confirm removal # Confirm removal
if not force: if not force:
console.print(f"\n[yellow]⚠ This will remove:[/yellow]") console.print("\n[yellow]⚠ This will remove:[/yellow]")
console.print(f"{cmd_count} commands from AI agent") console.print(f"{cmd_count} commands from AI agent")
console.print(f" • Extension directory: .specify/extensions/{extension}/") console.print(f" • Extension directory: .specify/extensions/{extension}/")
if not keep_config: if not keep_config:
console.print(f" • Config files (will be backed up)") console.print(" • Config files (will be backed up)")
console.print() console.print()
confirm = typer.confirm("Continue?") confirm = typer.confirm("Continue?")
@@ -1654,7 +1931,7 @@ def extension_remove(
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/")
console.print(f"\nTo reinstall: specify extension add {extension}") console.print(f"\nTo reinstall: specify extension add {extension}")
else: else:
console.print(f"[red]Error:[/red] Failed to remove extension") console.print("[red]Error:[/red] Failed to remove extension")
raise typer.Exit(1) raise typer.Exit(1)
@@ -1929,8 +2206,8 @@ def extension_update(
# TODO: Implement download and reinstall from URL # TODO: Implement download and reinstall from URL
# For now, just show message # For now, just show message
console.print( console.print(
f"[yellow]Note:[/yellow] Automatic update not yet implemented. " "[yellow]Note:[/yellow] Automatic update not yet implemented. "
f"Please update manually:" "Please update manually:"
) )
console.print(f" specify extension remove {ext_id} --keep-config") console.print(f" specify extension remove {ext_id} --keep-config")
console.print(f" specify extension add {ext_id}") console.print(f" specify extension add {ext_id}")
@@ -2030,7 +2307,7 @@ def extension_disable(
hook_executor.save_project_config(config) hook_executor.save_project_config(config)
console.print(f"[green]✓[/green] Extension '{extension}' disabled") console.print(f"[green]✓[/green] Extension '{extension}' disabled")
console.print(f"\nCommands will no longer be available. Hooks will not execute.") console.print("\nCommands will no longer be available. Hooks will not execute.")
console.print(f"To re-enable: specify extension enable {extension}") console.print(f"To re-enable: specify extension enable {extension}")

View File

@@ -647,7 +647,7 @@ class CommandRegistrar:
"args": "$ARGUMENTS", "args": "$ARGUMENTS",
"extension": ".md" "extension": ".md"
}, },
"qoder": { "qodercli": {
"dir": ".qoder/commands", "dir": ".qoder/commands",
"format": "markdown", "format": "markdown",
"args": "$ARGUMENTS", "args": "$ARGUMENTS",

View File

@@ -3,7 +3,7 @@
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` **Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. **Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary ## Summary

632
tests/test_ai_skills.py Normal file
View File

@@ -0,0 +1,632 @@
"""
Unit tests for AI agent skills installation.
Tests cover:
- Skills directory resolution for different agents (_get_skills_dir)
- YAML frontmatter parsing and SKILL.md generation (install_ai_skills)
- Cleanup of duplicate command files when --ai-skills is used
- Missing templates directory handling
- Malformed template error handling
- CLI validation: --ai-skills requires --ai
"""
import re
import pytest
import tempfile
import shutil
import yaml
from pathlib import Path
from unittest.mock import patch
import specify_cli
from specify_cli import (
_get_skills_dir,
install_ai_skills,
AGENT_SKILLS_DIR_OVERRIDES,
DEFAULT_SKILLS_DIR,
SKILL_DESCRIPTIONS,
AGENT_CONFIG,
app,
)
# ===== Fixtures =====
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
tmpdir = tempfile.mkdtemp()
yield Path(tmpdir)
shutil.rmtree(tmpdir)
@pytest.fixture
def project_dir(temp_dir):
"""Create a mock project directory."""
proj_dir = temp_dir / "test-project"
proj_dir.mkdir()
return proj_dir
@pytest.fixture
def templates_dir(project_dir):
"""Create mock command templates in the project's agent commands directory.
This simulates what download_and_extract_template() does: it places
command .md files into project_path/<agent_folder>/commands/.
install_ai_skills() now reads from here instead of from the repo
source tree.
"""
tpl_root = project_dir / ".claude" / "commands"
tpl_root.mkdir(parents=True, exist_ok=True)
# Template with valid YAML frontmatter
(tpl_root / "specify.md").write_text(
"---\n"
"description: Create or update the feature specification.\n"
"handoffs:\n"
" - label: Build Plan\n"
" agent: speckit.plan\n"
"scripts:\n"
" sh: scripts/bash/create-new-feature.sh\n"
"---\n"
"\n"
"# Specify Command\n"
"\n"
"Run this to create a spec.\n",
encoding="utf-8",
)
# Template with minimal frontmatter
(tpl_root / "plan.md").write_text(
"---\n"
"description: Generate implementation plan.\n"
"---\n"
"\n"
"# Plan Command\n"
"\n"
"Plan body content.\n",
encoding="utf-8",
)
# Template with no frontmatter
(tpl_root / "tasks.md").write_text(
"# Tasks Command\n"
"\n"
"Body without frontmatter.\n",
encoding="utf-8",
)
# Template with empty YAML frontmatter (yaml.safe_load returns None)
(tpl_root / "empty_fm.md").write_text(
"---\n"
"---\n"
"\n"
"# Empty Frontmatter Command\n"
"\n"
"Body with empty frontmatter.\n",
encoding="utf-8",
)
return tpl_root
@pytest.fixture
def commands_dir_claude(project_dir):
"""Create a populated .claude/commands directory simulating template extraction."""
cmd_dir = project_dir / ".claude" / "commands"
cmd_dir.mkdir(parents=True, exist_ok=True)
for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]:
(cmd_dir / name).write_text(f"# {name}\nContent here\n")
return cmd_dir
@pytest.fixture
def commands_dir_gemini(project_dir):
"""Create a populated .gemini/commands directory (TOML format)."""
cmd_dir = project_dir / ".gemini" / "commands"
cmd_dir.mkdir(parents=True)
for name in ["speckit.specify.toml", "speckit.plan.toml", "speckit.tasks.toml"]:
(cmd_dir / name).write_text(f'[command]\nname = "{name}"\n')
return cmd_dir
# ===== _get_skills_dir Tests =====
class TestGetSkillsDir:
"""Test the _get_skills_dir() helper function."""
def test_claude_skills_dir(self, project_dir):
"""Claude should use .claude/skills/."""
result = _get_skills_dir(project_dir, "claude")
assert result == project_dir / ".claude" / "skills"
def test_gemini_skills_dir(self, project_dir):
"""Gemini should use .gemini/skills/."""
result = _get_skills_dir(project_dir, "gemini")
assert result == project_dir / ".gemini" / "skills"
def test_copilot_skills_dir(self, project_dir):
"""Copilot should use .github/skills/."""
result = _get_skills_dir(project_dir, "copilot")
assert result == project_dir / ".github" / "skills"
def test_codex_uses_override(self, project_dir):
"""Codex should use the AGENT_SKILLS_DIR_OVERRIDES value."""
result = _get_skills_dir(project_dir, "codex")
assert result == project_dir / ".agents" / "skills"
def test_cursor_agent_skills_dir(self, project_dir):
"""Cursor should use .cursor/skills/."""
result = _get_skills_dir(project_dir, "cursor-agent")
assert result == project_dir / ".cursor" / "skills"
def test_unknown_agent_uses_default(self, project_dir):
"""Unknown agents should fall back to DEFAULT_SKILLS_DIR."""
result = _get_skills_dir(project_dir, "nonexistent-agent")
assert result == project_dir / DEFAULT_SKILLS_DIR
def test_all_configured_agents_resolve(self, project_dir):
"""Every agent in AGENT_CONFIG should resolve to a valid path."""
for agent_key in AGENT_CONFIG:
result = _get_skills_dir(project_dir, agent_key)
assert result is not None
assert str(result).startswith(str(project_dir))
# Should always end with "skills"
assert result.name == "skills"
def test_override_takes_precedence_over_config(self, project_dir):
"""AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG."""
for agent_key in AGENT_SKILLS_DIR_OVERRIDES:
result = _get_skills_dir(project_dir, agent_key)
expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key]
assert result == expected
# ===== install_ai_skills Tests =====
class TestInstallAiSkills:
"""Test SKILL.md generation and installation logic."""
def test_skills_installed_with_correct_structure(self, project_dir, templates_dir):
"""Verify SKILL.md files have correct agentskills.io structure."""
result = install_ai_skills(project_dir, "claude")
assert result is True
skills_dir = project_dir / ".claude" / "skills"
assert skills_dir.exists()
# Check that skill directories were created
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
assert "speckit-plan" in skill_dirs
assert "speckit-specify" in skill_dirs
assert "speckit-tasks" in skill_dirs
assert "speckit-empty_fm" in skill_dirs
# Verify SKILL.md content for speckit-specify
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
# Check agentskills.io frontmatter
assert content.startswith("---\n")
assert "name: speckit-specify" in content
assert "description:" in content
assert "compatibility:" in content
assert "metadata:" in content
assert "author: github-spec-kit" in content
assert "source: templates/commands/specify.md" in content
# Check body content is included
assert "# Speckit Specify Skill" in content
assert "Run this to create a spec." in content
def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir):
"""Generated SKILL.md should contain valid, parseable YAML frontmatter."""
install_ai_skills(project_dir, "claude")
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
content = skill_file.read_text()
# Extract and parse frontmatter
assert content.startswith("---\n")
parts = content.split("---", 2)
assert len(parts) >= 3
parsed = yaml.safe_load(parts[1])
assert isinstance(parsed, dict)
assert "name" in parsed
assert parsed["name"] == "speckit-specify"
assert "description" in parsed
def test_empty_yaml_frontmatter(self, project_dir, templates_dir):
"""Templates with empty YAML frontmatter (---\\n---) should not crash."""
result = install_ai_skills(project_dir, "claude")
assert result is True
skill_file = project_dir / ".claude" / "skills" / "speckit-empty_fm" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "name: speckit-empty_fm" in content
assert "Body with empty frontmatter." in content
def test_enhanced_descriptions_used_when_available(self, project_dir, templates_dir):
"""SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions."""
install_ai_skills(project_dir, "claude")
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
content = skill_file.read_text()
# Parse the generated YAML to compare the description value
# (yaml.safe_dump may wrap long strings across multiple lines)
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
if "specify" in SKILL_DESCRIPTIONS:
assert parsed["description"] == SKILL_DESCRIPTIONS["specify"]
def test_template_without_frontmatter(self, project_dir, templates_dir):
"""Templates without YAML frontmatter should still produce valid skills."""
install_ai_skills(project_dir, "claude")
skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
# Should still have valid SKILL.md structure
assert "name: speckit-tasks" in content
assert "Body without frontmatter." in content
def test_missing_templates_directory(self, project_dir):
"""Returns False when no command templates exist anywhere."""
# No .claude/commands/ exists, and __file__ fallback won't find anything
fake_init = project_dir / "nonexistent" / "src" / "specify_cli" / "__init__.py"
fake_init.parent.mkdir(parents=True, exist_ok=True)
fake_init.touch()
with patch.object(specify_cli, "__file__", str(fake_init)):
result = install_ai_skills(project_dir, "claude")
assert result is False
# Skills directory should not exist
skills_dir = project_dir / ".claude" / "skills"
assert not skills_dir.exists()
def test_empty_templates_directory(self, project_dir):
"""Returns False when commands directory has no .md files."""
# Create empty .claude/commands/
empty_cmds = project_dir / ".claude" / "commands"
empty_cmds.mkdir(parents=True)
# Block the __file__ fallback so it can't find real templates
fake_init = project_dir / "nowhere" / "src" / "specify_cli" / "__init__.py"
fake_init.parent.mkdir(parents=True, exist_ok=True)
fake_init.touch()
with patch.object(specify_cli, "__file__", str(fake_init)):
result = install_ai_skills(project_dir, "claude")
assert result is False
def test_malformed_yaml_frontmatter(self, project_dir):
"""Malformed YAML in a template should be handled gracefully, not crash."""
# Create .claude/commands/ with a broken template
cmds_dir = project_dir / ".claude" / "commands"
cmds_dir.mkdir(parents=True)
(cmds_dir / "broken.md").write_text(
"---\n"
"description: [unclosed bracket\n"
" invalid: yaml: content: here\n"
"---\n"
"\n"
"# Broken\n",
encoding="utf-8",
)
# Should not raise — errors are caught per-file
result = install_ai_skills(project_dir, "claude")
# The broken template should be skipped but not crash the process
assert result is False
def test_additive_does_not_overwrite_other_files(self, project_dir, templates_dir):
"""Installing skills should not remove non-speckit files in the skills dir."""
# Pre-create a custom skill
custom_dir = project_dir / ".claude" / "skills" / "my-custom-skill"
custom_dir.mkdir(parents=True)
custom_file = custom_dir / "SKILL.md"
custom_file.write_text("# My Custom Skill\n")
install_ai_skills(project_dir, "claude")
# Custom skill should still exist
assert custom_file.exists()
assert custom_file.read_text() == "# My Custom Skill\n"
def test_return_value(self, project_dir, templates_dir):
"""install_ai_skills returns True when skills installed, False otherwise."""
assert install_ai_skills(project_dir, "claude") is True
def test_return_false_when_no_templates(self, project_dir):
"""install_ai_skills returns False when no templates found."""
fake_init = project_dir / "missing" / "src" / "specify_cli" / "__init__.py"
fake_init.parent.mkdir(parents=True, exist_ok=True)
fake_init.touch()
with patch.object(specify_cli, "__file__", str(fake_init)):
assert install_ai_skills(project_dir, "claude") is False
def test_non_md_commands_dir_falls_back(self, project_dir):
"""When extracted commands are .toml (e.g. gemini), fall back to repo templates."""
# Simulate gemini template extraction: .gemini/commands/ with .toml files only
cmds_dir = project_dir / ".gemini" / "commands"
cmds_dir.mkdir(parents=True)
(cmds_dir / "speckit.specify.toml").write_text('[command]\nname = "specify"\n')
(cmds_dir / "speckit.plan.toml").write_text('[command]\nname = "plan"\n')
# The __file__ fallback should find the real repo templates/commands/*.md
result = install_ai_skills(project_dir, "gemini")
assert result is True
skills_dir = project_dir / ".gemini" / "skills"
assert skills_dir.exists()
# Should have installed skills from the fallback .md templates
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
assert len(skill_dirs) >= 1
# .toml commands should be untouched
assert (cmds_dir / "speckit.specify.toml").exists()
@pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"])
def test_skills_install_for_all_agents(self, temp_dir, agent_key):
"""install_ai_skills should produce skills for every configured agent."""
proj = temp_dir / f"proj-{agent_key}"
proj.mkdir()
# Place .md templates in the agent's commands directory
agent_folder = AGENT_CONFIG[agent_key]["folder"]
cmds_dir = proj / agent_folder.rstrip("/") / "commands"
cmds_dir.mkdir(parents=True)
(cmds_dir / "specify.md").write_text(
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
)
result = install_ai_skills(proj, agent_key)
assert result is True
skills_dir = _get_skills_dir(proj, agent_key)
assert skills_dir.exists()
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
assert "speckit-specify" in skill_dirs
assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
class TestCommandCoexistence:
"""Verify install_ai_skills never touches command files.
Cleanup of freshly-extracted commands for NEW projects is handled
in init(), not in install_ai_skills(). These tests confirm that
install_ai_skills leaves existing commands intact.
"""
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
# Verify commands exist before
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3
install_ai_skills(project_dir, "claude")
# Commands must still be there — install_ai_skills never touches them
remaining = list(commands_dir_claude.glob("speckit.*"))
assert len(remaining) == 3
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""
assert len(list(commands_dir_gemini.glob("speckit.*"))) == 3
install_ai_skills(project_dir, "gemini")
remaining = list(commands_dir_gemini.glob("speckit.*"))
assert len(remaining) == 3
def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude):
"""install_ai_skills must not remove the commands directory."""
install_ai_skills(project_dir, "claude")
assert commands_dir_claude.exists()
def test_no_commands_dir_no_error(self, project_dir, templates_dir):
"""No error when installing skills — commands dir has templates and is preserved."""
result = install_ai_skills(project_dir, "claude")
# Should succeed since templates are in .claude/commands/ via fixture
assert result is True
# ===== New-Project Command Skip Tests =====
class TestNewProjectCommandSkip:
"""Test that init() removes extracted commands for new projects only.
These tests run init() end-to-end via CliRunner with
download_and_extract_template patched to create local fixtures.
"""
def _fake_extract(self, agent, project_path, **_kwargs):
"""Simulate template extraction: create agent commands dir."""
agent_cfg = AGENT_CONFIG.get(agent, {})
agent_folder = agent_cfg.get("folder", "")
if agent_folder:
cmds_dir = project_path / agent_folder.rstrip("/") / "commands"
cmds_dir.mkdir(parents=True, exist_ok=True)
(cmds_dir / "speckit.specify.md").write_text("# spec")
def test_new_project_commands_removed_after_skills_succeed(self, tmp_path):
"""For new projects, commands should be removed when skills succeed."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "new-proj"
def fake_download(project_path, *args, **kwargs):
self._fake_extract("claude", project_path)
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \
patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
# Skills should have been called
mock_skills.assert_called_once()
# Commands dir should have been removed after skills succeeded
cmds_dir = target / ".claude" / "commands"
assert not cmds_dir.exists()
def test_commands_preserved_when_skills_fail(self, tmp_path):
"""If skills fail, commands should NOT be removed (safety net)."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "fail-proj"
def fake_download(project_path, *args, **kwargs):
self._fake_extract("claude", project_path)
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \
patch("specify_cli.install_ai_skills", return_value=False), \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
# Commands should still exist since skills failed
cmds_dir = target / ".claude" / "commands"
assert cmds_dir.exists()
assert (cmds_dir / "speckit.specify.md").exists()
def test_here_mode_commands_preserved(self, tmp_path, monkeypatch):
"""For --here on existing repos, commands must NOT be removed."""
from typer.testing import CliRunner
runner = CliRunner()
# Create a mock existing project with commands already present
target = tmp_path / "existing"
target.mkdir()
agent_folder = AGENT_CONFIG["claude"]["folder"]
cmds_dir = target / agent_folder.rstrip("/") / "commands"
cmds_dir.mkdir(parents=True)
(cmds_dir / "speckit.specify.md").write_text("# spec")
# --here uses CWD, so chdir into the target
monkeypatch.chdir(target)
def fake_download(project_path, *args, **kwargs):
pass # commands already exist, no need to re-create
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \
patch("specify_cli.install_ai_skills", return_value=True), \
patch("specify_cli.is_git_repo", return_value=True), \
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
# Commands must remain for --here
assert cmds_dir.exists()
assert (cmds_dir / "speckit.specify.md").exists()
# ===== Skip-If-Exists Tests =====
class TestSkipIfExists:
"""Test that install_ai_skills does not overwrite existing SKILL.md files."""
def test_existing_skill_not_overwritten(self, project_dir, templates_dir):
"""Pre-existing SKILL.md should not be replaced on re-run."""
# Pre-create a custom SKILL.md for speckit-specify
skill_dir = project_dir / ".claude" / "skills" / "speckit-specify"
skill_dir.mkdir(parents=True)
custom_content = "# My Custom Specify Skill\nUser-modified content\n"
(skill_dir / "SKILL.md").write_text(custom_content)
result = install_ai_skills(project_dir, "claude")
# The custom SKILL.md should be untouched
assert (skill_dir / "SKILL.md").read_text() == custom_content
# But other skills should still be installed
assert result is True
assert (project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md").exists()
def test_fresh_install_writes_all_skills(self, project_dir, templates_dir):
"""On first install (no pre-existing skills), all should be written."""
result = install_ai_skills(project_dir, "claude")
assert result is True
skills_dir = project_dir / ".claude" / "skills"
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
# All 4 templates should produce skills (specify, plan, tasks, empty_fm)
assert len(skill_dirs) == 4
# ===== SKILL_DESCRIPTIONS Coverage Tests =====
class TestSkillDescriptions:
"""Test SKILL_DESCRIPTIONS constants."""
def test_all_known_commands_have_descriptions(self):
"""All standard spec-kit commands should have enhanced descriptions."""
expected_commands = [
"specify", "plan", "tasks", "implement", "analyze",
"clarify", "constitution", "checklist", "taskstoissues",
]
for cmd in expected_commands:
assert cmd in SKILL_DESCRIPTIONS, f"Missing description for '{cmd}'"
assert len(SKILL_DESCRIPTIONS[cmd]) > 20, f"Description for '{cmd}' is too short"
# ===== CLI Validation Tests =====
class TestCliValidation:
"""Test --ai-skills CLI flag validation."""
def test_ai_skills_without_ai_fails(self):
"""--ai-skills without --ai should fail with exit code 1."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
assert result.exit_code == 1
assert "--ai-skills requires --ai" in result.output
def test_ai_skills_without_ai_shows_usage(self):
"""Error message should include usage hint."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
assert "Usage:" in result.output
assert "--ai" in result.output
def test_ai_skills_flag_appears_in_help(self):
"""--ai-skills should appear in init --help output."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "--help"])
plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
assert "--ai-skills" in plain
assert "agent skills" in plain.lower()