diff --git a/.github/ISSUE_TEMPLATE/extension_submission.yml b/.github/ISSUE_TEMPLATE/extension_submission.yml
index d298925e74..9d3f15872b 100644
--- a/.github/ISSUE_TEMPLATE/extension_submission.yml
+++ b/.github/ISSUE_TEMPLATE/extension_submission.yml
@@ -12,7 +12,7 @@ body:
- Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md)
- Ensure your extension has a valid `extension.yml` manifest
- Create a GitHub release with a version tag (e.g., v1.0.0)
- - Test installation: `specify extension add --from `
+ - Test installation: `specify extension add --from `
- type: input
id: extension-id
@@ -229,7 +229,7 @@ body:
placeholder: |
```bash
# Install extension
- specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
+ specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
# Use a command
/speckit.your-extension.command-name arg1 arg2
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 3fe3894076..d714b16884 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -64,5 +64,5 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 3b3bcf8d3c..fdece63093 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v6
- name: Run markdownlint-cli2
- uses: DavidAnson/markdownlint-cli2-action@v19
+ uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23
with:
globs: |
'**/*.md'
diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml
index 2b70d89e54..a451accfe6 100644
--- a/.github/workflows/release-trigger.yml
+++ b/.github/workflows/release-trigger.yml
@@ -139,6 +139,22 @@ jobs:
git push origin "${{ steps.version.outputs.tag }}"
echo "Branch ${{ env.branch }} and tag ${{ steps.version.outputs.tag }} pushed"
+ - name: Bump to dev version
+ id: dev_version
+ run: |
+ IFS='.' read -r MAJOR MINOR PATCH <<< "${{ steps.version.outputs.version }}"
+ NEXT_DEV="$MAJOR.$MINOR.$((PATCH + 1)).dev0"
+ echo "dev_version=$NEXT_DEV" >> $GITHUB_OUTPUT
+ sed -i "s/version = \".*\"/version = \"$NEXT_DEV\"/" pyproject.toml
+ git add pyproject.toml
+ if git diff --cached --quiet; then
+ echo "No dev version changes to commit"
+ else
+ git commit -m "chore: begin $NEXT_DEV development"
+ git push origin "${{ env.branch }}"
+ echo "Bumped to dev version $NEXT_DEV"
+ fi
+
- name: Open pull request
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
@@ -146,16 +162,17 @@ jobs:
gh pr create \
--base main \
--head "${{ env.branch }}" \
- --title "chore: bump version to ${{ steps.version.outputs.version }}" \
- --body "Automated version bump to ${{ steps.version.outputs.version }}.
+ --title "chore: release ${{ steps.version.outputs.version }}, begin ${{ steps.dev_version.outputs.dev_version }} development" \
+ --body "Automated release of ${{ steps.version.outputs.version }}.
This PR was created by the Release Trigger workflow. The git tag \`${{ steps.version.outputs.tag }}\` has already been pushed and the release artifacts are being built.
- Merge this PR to record the version bump and changelog update on \`main\`."
+ Merging this PR will set \`main\` to \`${{ steps.dev_version.outputs.dev_version }}\` so that development installs are clearly marked as pre-release."
- name: Summary
run: |
echo "β
Version bumped to ${{ steps.version.outputs.version }}"
echo "β
Tag ${{ steps.version.outputs.tag }} created and pushed"
+ echo "β
Dev version set to ${{ steps.dev_version.outputs.dev_version }}"
echo "β
PR opened to merge version bump into main"
echo "π Release workflow is building artifacts from the tag"
diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1
index 8f3cfec36b..912dd00ecb 100644
--- a/.github/workflows/scripts/create-release-packages.ps1
+++ b/.github/workflows/scripts/create-release-packages.ps1
@@ -202,8 +202,7 @@ agent: $basename
}
# Create skills in \\SKILL.md format.
-# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
-# current dotted-name exception (e.g. speckit.plan).
+# Skills use hyphenated names (e.g. speckit-plan).
#
# Technical debt note:
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
@@ -463,7 +462,7 @@ function Build-Variant {
'kimi' {
$skillsDir = Join-Path $baseDir ".kimi/skills"
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
- New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'
+ New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi'
}
'trae' {
$rulesDir = Join-Path $baseDir ".trae/rules"
@@ -498,13 +497,13 @@ $AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode'
$AllScripts = @('sh', 'ps')
function Normalize-List {
- param([string]$Input)
+ param([string]$Value)
- if ([string]::IsNullOrEmpty($Input)) {
+ if ([string]::IsNullOrEmpty($Value)) {
return @()
}
- $items = $Input -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique
+ $items = $Value -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique
return $items
}
@@ -527,7 +526,7 @@ function Validate-Subset {
# Determine agent list
if (-not [string]::IsNullOrEmpty($Agents)) {
- $AgentList = Normalize-List -Input $Agents
+ $AgentList = Normalize-List -Value $Agents
if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) {
exit 1
}
@@ -537,7 +536,7 @@ if (-not [string]::IsNullOrEmpty($Agents)) {
# Determine script list
if (-not [string]::IsNullOrEmpty($Scripts)) {
- $ScriptList = Normalize-List -Input $Scripts
+ $ScriptList = Normalize-List -Value $Scripts
if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) {
exit 1
}
diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh
index d07e4a2df2..a83494c3a0 100755
--- a/.github/workflows/scripts/create-release-packages.sh
+++ b/.github/workflows/scripts/create-release-packages.sh
@@ -140,8 +140,7 @@ EOF
}
# Create skills in //SKILL.md format.
-# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
-# current dotted-name exception (e.g. speckit.plan).
+# Skills use hyphenated names (e.g. speckit-plan).
#
# Technical debt note:
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
@@ -321,7 +320,7 @@ build_variant() {
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
kimi)
mkdir -p "$base_dir/.kimi/skills"
- create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
+ create_skills "$base_dir/.kimi/skills" "$script" "kimi" ;;
trae)
mkdir -p "$base_dir/.trae/rules"
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9c62304388..f45c5f1071 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
- name: Set up Python
uses: actions/setup-python@v6
@@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
diff --git a/AGENTS.md b/AGENTS.md
index a15e0bc4b7..eb3d27065f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,10 +30,10 @@ Specify supports multiple AI agents by generating agent-specific command files a
| **Claude Code** | `.claude/commands/` | Markdown | `claude` | Anthropic's Claude Code CLI |
| **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI |
| **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code |
-| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI |
+| **Cursor** | `.cursor/commands/` | Markdown | N/A (IDE-based) | Cursor IDE (`--ai cursor-agent`) |
| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI |
| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI |
-| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (skills) |
+| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (`--ai codex --ai-skills`) |
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
| **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains |
| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
@@ -50,6 +50,8 @@ Specify supports multiple AI agents by generating agent-specific command files a
| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) |
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
+| **Antigravity** | `.agent/commands/` | Markdown | N/A (IDE-based) | Antigravity IDE (`--ai agy --ai-skills`) |
+| **Mistral Vibe** | `.vibe/prompts/` | Markdown | `vibe` | Mistral Vibe CLI |
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
### Step-by-Step Integration Guide
@@ -316,32 +318,40 @@ Require a command-line tool to be installed:
- **Claude Code**: `claude` CLI
- **Gemini CLI**: `gemini` CLI
-- **Cursor**: `cursor-agent` CLI
- **Qwen Code**: `qwen` CLI
- **opencode**: `opencode` CLI
+- **Codex CLI**: `codex` CLI (requires `--ai-skills`)
- **Junie**: `junie` CLI
-- **Kiro CLI**: `kiro-cli` CLI
+- **Auggie CLI**: `auggie` CLI
- **CodeBuddy CLI**: `codebuddy` CLI
- **Qoder CLI**: `qodercli` CLI
+- **Kiro CLI**: `kiro-cli` CLI
- **Amp**: `amp` CLI
- **SHAI**: `shai` CLI
- **Tabnine CLI**: `tabnine` CLI
- **Kimi Code**: `kimi` CLI
+- **Mistral Vibe**: `vibe` CLI
- **Pi Coding Agent**: `pi` CLI
+- **iFlow CLI**: `iflow` CLI
### IDE-Based Agents
Work within integrated development environments:
- **GitHub Copilot**: Built into VS Code/compatible editors
+- **Cursor**: Built into Cursor IDE (`--ai cursor-agent`)
- **Windsurf**: Built into Windsurf IDE
+- **Kilo Code**: Built into Kilo Code IDE
+- **Roo Code**: Built into Roo Code IDE
- **IBM Bob**: Built into IBM Bob IDE
+- **Trae**: Built into Trae IDE
+- **Antigravity**: Built into Antigravity IDE (`--ai agy --ai-skills`)
## Command File Formats
### Markdown Format
-Used by: Claude, Cursor, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi
+Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow
**Standard format:**
@@ -379,15 +389,29 @@ Command content with {SCRIPT} and {{args}} placeholders.
## Directory Conventions
- **CLI agents**: Usually `./commands/`
+- **Singular command exception**:
+ - opencode: `.opencode/command/` (singular `command`, not `commands`)
+- **Nested path exception**:
+ - Tabnine: `.tabnine/agent/commands/` (extra `agent/` segment)
+- **Shared `.agents/` folder**:
+ - Amp: `.agents/commands/` (shared folder, not `.amp/`)
+ - Codex: `.agents/skills/` (shared folder; requires `--ai-skills`; invoked as `$speckit-`)
- **Skills-based exceptions**:
- - Codex: `.agents/skills/` (skills, invoked as `$speckit-`)
+ - Kimi Code: `.kimi/skills/` (skills, invoked as `/skill:speckit-`)
- **Prompt-based exceptions**:
- Kiro CLI: `.kiro/prompts/`
- Pi: `.pi/prompts/`
+ - Mistral Vibe: `.vibe/prompts/`
+- **Rules-based exceptions**:
+ - Trae: `.trae/rules/`
- **IDE agents**: Follow IDE-specific patterns:
- Copilot: `.github/agents/`
- Cursor: `.cursor/commands/`
- Windsurf: `.windsurf/workflows/`
+ - Kilo Code: `.kilocode/workflows/`
+ - Roo Code: `.roo/commands/`
+ - IBM Bob: `.bob/commands/`
+ - Antigravity: `.agent/skills/` (`--ai-skills` required; `.agent/commands/` is deprecated)
## Argument Patterns
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf55b42372..8394968a26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,39 @@
+## [0.4.4] - 2026-04-01
+
+### Changed
+
+- Stage 2: Copilot integration β proof of concept with shared template primitives (#2035)
+- docs: sync AGENTS.md with AGENT_CONFIG for missing agents (#2025)
+- docs: ensure manual tests use local specify (#2020)
+- Stage 1: Integration foundation β base classes, manifest system, and registry (#1925)
+- fix: harden GitHub Actions workflows (#2021)
+- chore: use PEP 440 .dev0 versions on main after releases (#2032)
+- feat: add superpowers bridge extension to community catalog (#2023)
+- feat: add product-forge extension to community catalog (#2012)
+- feat(scripts): add --allow-existing-branch flag to create-new-feature (#1999)
+- fix(scripts): add correct path for copilot-instructions.md (#1997)
+- Update README.md (#1995)
+- fix: prevent extension command shadowing (#1994)
+- Fix Claude Code CLI detection for npm-local installs (#1978)
+- fix(scripts): honor PowerShell agent and script filters (#1969)
+- feat: add MAQA extension suite (7 extensions) to community catalog (#1981)
+- feat: add spec-kit-onboard extension to community catalog (#1991)
+- Add plan-review-gate to community catalog (#1993)
+- chore(deps): bump actions/deploy-pages from 4 to 5 (#1990)
+- chore(deps): bump DavidAnson/markdownlint-cli2-action from 19 to 23 (#1989)
+- chore: bump version to 0.4.3 (#1986)
+
+## [0.4.3] - 2026-03-26
+
+### Changed
+
+- Unify Kimi/Codex skill naming and migrate legacy dotted Kimi dirs (#1971)
+- fix(ps1): replace null-conditional operator for PowerShell 5.1 compatibility (#1975)
+- chore: bump version to 0.4.2 (#1973)
+
## [0.4.2] - 2026-03-25
### Changed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2b42e8fd61..9044ef5ff9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,7 +36,7 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler
> If your pull request introduces a large change that materially impacts the work of the CLI or the rest of the repository (e.g., you're introducing new templates, arguments, or otherwise major changes), make sure that it was **discussed and agreed upon** by the project maintainers. Pull requests with large changes that did not have a prior conversation and agreement will be closed.
1. Fork and clone the repository
-1. Configure and install the dependencies: `uv sync`
+1. Configure and install the dependencies: `uv sync --extra test`
1. Make sure the CLI works on your machine: `uv run specify --help`
1. Create a new branch: `git checkout -b my-branch-name`
1. Make your change, add tests, and make sure everything still works
@@ -44,6 +44,9 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler
1. Push to your fork and submit a pull request
1. Wait for your pull request to be reviewed and merged.
+For the detailed test workflow, command-selection prompt, and PR reporting template, see [`TESTING.md`](./TESTING.md).
+Activate the project virtual environment (see the Setup block in [`TESTING.md`](./TESTING.md)), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below.
+
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
- Follow the project's coding conventions.
@@ -62,6 +65,14 @@ When working on spec-kit:
3. Test script functionality in the `scripts/` directory
4. Ensure memory files (`memory/constitution.md`) are updated if major process changes are made
+### Recommended validation flow
+
+For the smoothest review experience, validate changes in this order:
+
+1. **Run focused automated checks first** β use the quick verification commands in [`TESTING.md`](./TESTING.md) to catch packaging, scaffolding, and configuration regressions early.
+2. **Run manual workflow tests second** β if your change affects slash commands or the developer workflow, follow [`TESTING.md`](./TESTING.md) to choose the right commands, run them in an agent, and capture results for your PR.
+3. **Use local release packages when debugging packaged output** β if you need to inspect the exact files CI-style packaging produces, generate local release packages as described below.
+
### Testing template and command changes locally
Running `uv run specify init` pulls released packages, which wonβt include your local changes.
@@ -85,6 +96,8 @@ To test your templates, commands, and other changes locally, follow these steps:
Navigate to your test project folder and open the agent to verify your implementation.
+If you only need to validate generated file structure and content before doing manual agent testing, start with the focused automated checks in [`TESTING.md`](./TESTING.md). Keep this section for the cases where you need to inspect the exact packaged output locally.
+
## AI contributions in Spec Kit
> [!IMPORTANT]
diff --git a/README.md b/README.md
index 4d83483036..129d694130 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -160,11 +160,25 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
## π§© Community Extensions
+> [!NOTE]
+> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
+
+π **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
+
The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json):
-**Categories:** `docs` β reads, validates, or generates spec artifacts Β· `code` β reviews, validates, or modifies source code Β· `process` β orchestrates workflow across phases Β· `integration` β syncs with external platforms Β· `visibility` β reports on project health or progress
+**Categories:**
-**Effect:** `Read-only` β produces reports without modifying files Β· `Read+Write` β modifies files, creates artifacts, or updates specs
+- `docs` β reads, validates, or generates spec artifacts
+- `code` β reviews, validates, or modifies source code
+- `process` β orchestrates workflow across phases
+- `integration` β syncs with external platforms
+- `visibility` β reports on project health or progress
+
+**Effect:**
+
+- `Read-only` β produces reports without modifying files
+- `Read+Write` β modifies files, creates artifacts, or updates specs
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
@@ -173,24 +187,39 @@ The following community-contributed extensions are available in [`catalog.commun
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
-| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application β with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
| DocGuard β CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
+| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow β refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
+| MAQA β Multi-Agent & Quality Assurance | Coordinator β feature β QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
+| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA β syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
+| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
+| MAQA GitHub Projects Integration | GitHub Projects v2 integration for MAQA β syncs draft issues and Status columns as features progress | `integration` | Read+Write | [spec-kit-maqa-github-projects](https://github.com/GenieRobot/spec-kit-maqa-github-projects) |
+| MAQA Jira Integration | Jira integration for MAQA β syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
+| MAQA Linear Integration | Linear integration for MAQA β syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
+| MAQA Trello Integration | Trello board integration for MAQA β populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
+| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
+| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
+| Product Forge | Full product lifecycle: research β product spec β SpecKit β implement β verify β test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress β active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
+| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
+| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
+| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
+| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
+| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
+| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
-| Understanding | Automated requirements quality analysis β 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
@@ -199,6 +228,9 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX
## π¨ Community Presets
+> [!NOTE]
+> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
+
The following community-contributed presets customize how Spec Kit behaves β overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json):
| Preset | Purpose | Provides | Requires | URL |
@@ -210,6 +242,9 @@ To build and publish your own preset, see the [Presets Publishing Guide](presets
## πΆ Community Walkthroughs
+> [!NOTE]
+> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
+
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** β Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
@@ -228,6 +263,9 @@ See Spec-Driven Development in action across different scenarios with these comm
## π οΈ Community Friends
+> [!NOTE]
+> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
+
Community projects that extend, visualize, or build on Spec Kit:
- **[cc-sdd](https://github.com/rhuss/cc-sdd)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams.
diff --git a/TESTING.md b/TESTING.md
index 95b1bde847..1fa6b1c881 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -1,8 +1,59 @@
-# Manual Testing Guide
+# Testing Guide
+
+This document is the detailed testing companion to [`CONTRIBUTING.md`](./CONTRIBUTING.md).
+
+Use it for three things:
+
+1. running quick automated checks before manual testing,
+2. manually testing affected slash commands through an AI agent, and
+3. capturing the results in a PR-friendly format.
Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR.
-## Process
+## Recommended order
+
+1. **Sync your environment** β install the project and test dependencies.
+2. **Run focused automated checks** β especially for packaging, scaffolding, agent config, and generated-file changes.
+3. **Run manual agent tests** β for any affected slash commands.
+4. **Paste results into your PR** β include both command-selection reasoning and manual test results.
+
+## Quick automated checks
+
+Run these before manual testing when your change affects packaging, scaffolding, templates, release artifacts, or agent wiring.
+
+### Environment setup
+
+```bash
+cd
+uv sync --extra test
+source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1
+```
+
+### Generated package structure and content
+
+```bash
+uv run python -m pytest tests/test_core_pack_scaffold.py -q
+```
+
+This validates the generated files that CI-style packaging depends on, including directory layout, file names, frontmatter/TOML validity, placeholder replacement, `.specify/` path rewrites, and parity with `create-release-packages.sh`.
+
+### Agent configuration and release wiring consistency
+
+```bash
+uv run python -m pytest tests/test_agent_config_consistency.py -q
+```
+
+Run this when you change agent metadata, release scripts, context update scripts, or artifact naming.
+
+### Optional single-agent packaging spot check
+
+```bash
+AGENTS=copilot SCRIPTS=sh ./.github/workflows/scripts/create-release-packages.sh v1.0.0
+```
+
+Inspect `.genreleases/sdd-copilot-package-sh/` and the matching ZIP in `.genreleases/` when you want to review the exact packaged output for one agent/script combination.
+
+## Manual testing process
1. **Identify affected commands** β use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing.
2. **Set up a test project** β scaffold from your local branch (see [Setup](#setup)).
@@ -13,19 +64,22 @@ Any change that affects a slash command's behavior requires manually testing tha
## Setup
```bash
-# Install the CLI from your local branch
+# Install the project and test dependencies from your local branch
cd
-uv venv .venv
-source .venv/bin/activate # On Windows: .venv\Scripts\activate
+uv sync --extra test
+source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1
uv pip install -e .
+# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing.
# Initialize a test project using your local changes
-specify init /tmp/speckit-test --ai --offline
+uv run specify init /tmp/speckit-test --ai --offline
cd /tmp/speckit-test
# Open in your agent
```
+If you are testing the packaged output rather than the live source tree, create a local release package first as described in [`CONTRIBUTING.md`](./CONTRIBUTING.md).
+
## Reporting results
Paste this into your PR:
diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md
index 6be3d0633d..721624ab81 100644
--- a/extensions/EXTENSION-API-REFERENCE.md
+++ b/extensions/EXTENSION-API-REFERENCE.md
@@ -44,7 +44,7 @@ provides:
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
file: string # Required, relative path to command file
description: string # Required
- aliases: [string] # Optional, array of alternate names
+ aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands
config: # Optional, array of config files
- name: string # Config file name
diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
index c4af7ed15e..4eb7626d8f 100644
--- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
+++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
@@ -41,7 +41,7 @@ provides:
- name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd}
file: "commands/hello.md"
description: "Say hello"
- aliases: ["speckit.hello"] # Optional aliases
+ aliases: ["speckit.my-ext.hi"] # Optional aliases, same pattern
config: # Optional: Config files
- name: "my-ext-config.yml"
@@ -186,7 +186,7 @@ What the extension provides.
- `name`: Command name (must match `speckit.{ext-id}.{command}`)
- `file`: Path to command file (relative to extension root)
- `description`: Command description (optional)
-- `aliases`: Alternative command names (optional, array)
+- `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`)
### Optional Fields
@@ -514,7 +514,7 @@ zip -r spec-kit-my-ext-1.0.0.zip extension.yml commands/ scripts/ docs/
Users install with:
```bash
-specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip
+specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip
```
### Option 3: Community Reference Catalog
diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md
index 25801ca176..1433738743 100644
--- a/extensions/EXTENSION-PUBLISHING-GUIDE.md
+++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md
@@ -122,7 +122,7 @@ Test that users can install from your release:
specify extension add --dev /path/to/your-extension
# Test from GitHub archive
-specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
+specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
```
---
diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md
index 2fd28191ca..190e263af2 100644
--- a/extensions/EXTENSION-USER-GUIDE.md
+++ b/extensions/EXTENSION-USER-GUIDE.md
@@ -160,7 +160,7 @@ This will:
```bash
# From GitHub release
-specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip
+specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip
```
### Install from Local Directory (Development)
@@ -214,8 +214,8 @@ Extensions add commands that appear in your AI agent (Claude Code):
# In Claude Code
> /speckit.jira.specstoissues
-# Or use short alias (if provided)
-> /speckit.specstoissues
+# Or use a namespaced alias (if provided)
+> /speckit.jira.sync
```
### Extension Configuration
@@ -737,7 +737,7 @@ You can still install extensions not in your catalog using `--from`:
specify extension add jira
# Direct URL (bypasses catalog)
-specify extension add --from https://github.com/someone/spec-kit-ext/archive/v1.0.0.zip
+specify extension add --from https://github.com/someone/spec-kit-ext/archive/v1.0.0.zip
# Local development
specify extension add --dev /path/to/extension
@@ -807,7 +807,7 @@ specify extension add --dev /path/to/extension
2. Install older version of extension:
```bash
- specify extension add --from https://github.com/org/ext/archive/v1.0.0.zip
+ specify extension add --from https://github.com/org/ext/archive/v1.0.0.zip
```
### MCP Tool Not Available
diff --git a/extensions/README.md b/extensions/README.md
index eb8c3c782f..f535ba539a 100644
--- a/extensions/README.md
+++ b/extensions/README.md
@@ -24,6 +24,9 @@ specify extension search # Now uses your organization's catalog instead of the
### Community Reference Catalog (`catalog.community.json`)
+> [!NOTE]
+> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion.
+
- **Purpose**: Browse available community-contributed extensions
- **Status**: Active - contains extensions submitted by the community
- **Location**: `extensions/catalog.community.json`
@@ -59,7 +62,7 @@ Populate your `catalog.json` with approved extensions:
Skip catalog curation - team members install directly using URLs:
```bash
-specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip
+specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip
```
**Benefits**: Quick for one-off testing or private extensions
@@ -68,6 +71,11 @@ specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/ta
## Available Community Extensions
+> [!NOTE]
+> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
+
+π **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
+
See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions.
For the raw catalog data, see [`catalog.community.json`](catalog.community.json).
@@ -108,7 +116,7 @@ specify extension search # See what's in your catalog
specify extension add # Install by name
# Direct from URL (bypasses catalog)
-specify extension add --from https://github.com///archive/refs/tags/.zip
+specify extension add --from https://github.com///archive/refs/tags/.zip
# List installed extensions
specify extension list
diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md
index 5d3d8e9cb2..dd4c97e8a2 100644
--- a/extensions/RFC-EXTENSION-SYSTEM.md
+++ b/extensions/RFC-EXTENSION-SYSTEM.md
@@ -223,7 +223,7 @@ provides:
- name: "speckit.jira.specstoissues"
file: "commands/specstoissues.md"
description: "Create Jira hierarchy from spec and tasks"
- aliases: ["speckit.specstoissues"] # Alternate names
+ aliases: ["speckit.jira.sync"] # Alternate names
- name: "speckit.jira.discover-fields"
file: "commands/discover-fields.md"
@@ -1517,7 +1517,7 @@ specify extension add github-projects
/speckit.github.taskstoissues
```
-**Compatibility shim** (if needed):
+**Migration alias** (if needed):
```yaml
# extension.yml
@@ -1525,10 +1525,10 @@ provides:
commands:
- name: "speckit.github.taskstoissues"
file: "commands/taskstoissues.md"
- aliases: ["speckit.taskstoissues"] # Backward compatibility
+ aliases: ["speckit.github.sync-taskstoissues"] # Alternate namespaced entry point
```
-AI agent registers both names, so old scripts work.
+AI agents register both names, so callers can migrate to the alternate alias without relying on deprecated global shortcuts like `/speckit.taskstoissues`.
---
diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json
index 846254c484..3a98d49fa0 100644
--- a/extensions/catalog.community.json
+++ b/extensions/catalog.community.json
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
- "updated_at": "2026-03-19T12:08:20Z",
+ "updated_at": "2026-04-01T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -167,50 +167,6 @@
"created_at": "2026-02-22T00:00:00Z",
"updated_at": "2026-02-22T00:00:00Z"
},
- "cognitive-squad": {
- "name": "Cognitive Squad",
- "id": "cognitive-squad",
- "description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application β with quality gates, backpropagation verification, and self-healing",
- "author": "Testimonial",
- "version": "0.1.0",
- "download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip",
- "repository": "https://github.com/Testimonial/cognitive-squad",
- "homepage": "https://github.com/Testimonial/cognitive-squad",
- "documentation": "https://github.com/Testimonial/cognitive-squad/blob/main/README.md",
- "changelog": "https://github.com/Testimonial/cognitive-squad/blob/main/CHANGELOG.md",
- "license": "MIT",
- "requires": {
- "speckit_version": ">=0.3.0",
- "tools": [
- {
- "name": "understanding",
- "version": ">=3.4.0",
- "required": false
- },
- {
- "name": "spec-kit-reverse-eng",
- "version": ">=1.0.0",
- "required": false
- }
- ]
- },
- "provides": {
- "commands": 10,
- "hooks": 1
- },
- "tags": [
- "ai-agents",
- "cognitive",
- "full-lifecycle",
- "verification",
- "multi-agent"
- ],
- "verified": false,
- "downloads": 0,
- "stars": 0,
- "created_at": "2026-03-16T00:00:00Z",
- "updated_at": "2026-03-18T00:00:00Z"
- },
"conduct": {
"name": "Conduct Extension",
"id": "conduct",
@@ -241,8 +197,38 @@
"created_at": "2026-03-19T12:08:20Z",
"updated_at": "2026-03-19T12:08:20Z"
},
+ "critique": {
+ "name": "Spec Critique Extension",
+ "id": "critique",
+ "description": "Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives.",
+ "author": "arunt14",
+ "version": "1.0.0",
+ "download_url": "https://github.com/arunt14/spec-kit-critique/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/arunt14/spec-kit-critique",
+ "homepage": "https://github.com/arunt14/spec-kit-critique",
+ "documentation": "https://github.com/arunt14/spec-kit-critique/blob/main/README.md",
+ "changelog": "https://github.com/arunt14/spec-kit-critique/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 1,
+ "hooks": 1
+ },
+ "tags": [
+ "docs",
+ "review",
+ "planning"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-04-01T00:00:00Z",
+ "updated_at": "2026-04-01T00:00:00Z"
+ },
"docguard": {
- "name": "DocGuard \u2014 CDD Enforcement",
+ "name": "DocGuard β CDD Enforcement",
"id": "docguard",
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies.",
"author": "raccioly",
@@ -345,6 +331,38 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
+ "fix-findings": {
+ "name": "Fix Findings",
+ "id": "fix-findings",
+ "description": "Automated analyze-fix-reanalyze loop that resolves spec findings until clean.",
+ "author": "Quratulain-bilal",
+ "version": "1.0.0",
+ "download_url": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/Quratulain-bilal/spec-kit-fix-findings",
+ "homepage": "https://github.com/Quratulain-bilal/spec-kit-fix-findings",
+ "documentation": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/blob/main/README.md",
+ "changelog": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 1,
+ "hooks": 1
+ },
+ "tags": [
+ "code",
+ "analysis",
+ "quality",
+ "automation",
+ "findings"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-04-01T00:00:00Z",
+ "updated_at": "2026-04-01T00:00:00Z"
+ },
"fleet": {
"name": "Fleet Orchestrator",
"id": "fleet",
@@ -437,6 +455,327 @@
"created_at": "2026-03-05T00:00:00Z",
"updated_at": "2026-03-05T00:00:00Z"
},
+ "learn": {
+ "name": "Learning Extension",
+ "id": "learn",
+ "description": "Generate educational guides from implementations and enhance clarifications with mentoring context.",
+ "author": "Vianca Martinez",
+ "version": "1.0.0",
+ "download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/imviancagrace/spec-kit-learn",
+ "homepage": "https://github.com/imviancagrace/spec-kit-learn",
+ "documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md",
+ "changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 1
+ },
+ "tags": [
+ "learning",
+ "education",
+ "mentoring",
+ "knowledge-transfer"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-17T00:00:00Z",
+ "updated_at": "2026-03-17T00:00:00Z"
+ },
+ "maqa": {
+ "name": "MAQA β Multi-Agent & Quality Assurance",
+ "id": "maqa",
+ "description": "Coordinator β feature β QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins (Trello, Linear, GitHub Projects, Jira, Azure DevOps). Optional CI gate.",
+ "author": "GenieRobot",
+ "version": "0.1.3",
+ "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.3/maqa.zip",
+ "repository": "https://github.com/GenieRobot/spec-kit-maqa-ext",
+ "homepage": "https://github.com/GenieRobot/spec-kit-maqa-ext",
+ "documentation": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/README.md",
+ "changelog": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.3.0"
+ },
+ "provides": {
+ "commands": 4,
+ "hooks": 1
+ },
+ "tags": [
+ "multi-agent",
+ "orchestration",
+ "quality-assurance",
+ "workflow",
+ "parallel",
+ "tdd"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-26T00:00:00Z",
+ "updated_at": "2026-03-27T00:00:00Z"
+ },
+ "maqa-azure-devops": {
+ "name": "MAQA Azure DevOps Integration",
+ "id": "maqa-azure-devops",
+ "description": "Azure DevOps Boards integration for the MAQA extension. Populates work items from specs, moves User Stories across columns as features progress, real-time Task child ticking.",
+ "author": "GenieRobot",
+ "version": "0.1.0",
+ "download_url": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/releases/download/maqa-azure-devops-v0.1.0/maqa-azure-devops.zip",
+ "repository": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops",
+ "homepage": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops",
+ "documentation": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/blob/main/README.md",
+ "changelog": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.3.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 0
+ },
+ "tags": [
+ "azure-devops",
+ "project-management",
+ "multi-agent",
+ "maqa",
+ "kanban"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-27T00:00:00Z",
+ "updated_at": "2026-03-27T00:00:00Z"
+ },
+ "maqa-ci": {
+ "name": "MAQA CI/CD Gate",
+ "id": "maqa-ci",
+ "description": "CI/CD pipeline gate for the MAQA extension. Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green.",
+ "author": "GenieRobot",
+ "version": "0.1.0",
+ "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ci/releases/download/maqa-ci-v0.1.0/maqa-ci.zip",
+ "repository": "https://github.com/GenieRobot/spec-kit-maqa-ci",
+ "homepage": "https://github.com/GenieRobot/spec-kit-maqa-ci",
+ "documentation": "https://github.com/GenieRobot/spec-kit-maqa-ci/blob/main/README.md",
+ "changelog": "https://github.com/GenieRobot/spec-kit-maqa-ci/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.3.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 0
+ },
+ "tags": [
+ "ci-cd",
+ "github-actions",
+ "circleci",
+ "gitlab-ci",
+ "quality-gate",
+ "maqa"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-27T00:00:00Z",
+ "updated_at": "2026-03-27T00:00:00Z"
+ },
+ "maqa-github-projects": {
+ "name": "MAQA GitHub Projects Integration",
+ "id": "maqa-github-projects",
+ "description": "GitHub Projects v2 integration for the MAQA extension. Populates draft issues from specs, moves items across Status columns as features progress, real-time task list ticking.",
+ "author": "GenieRobot",
+ "version": "0.1.0",
+ "download_url": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/releases/download/maqa-github-projects-v0.1.0/maqa-github-projects.zip",
+ "repository": "https://github.com/GenieRobot/spec-kit-maqa-github-projects",
+ "homepage": "https://github.com/GenieRobot/spec-kit-maqa-github-projects",
+ "documentation": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/blob/main/README.md",
+ "changelog": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.3.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 0
+ },
+ "tags": [
+ "github-projects",
+ "project-management",
+ "multi-agent",
+ "maqa",
+ "kanban"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-27T00:00:00Z",
+ "updated_at": "2026-03-27T00:00:00Z"
+ },
+ "maqa-jira": {
+ "name": "MAQA Jira Integration",
+ "id": "maqa-jira",
+ "description": "Jira integration for the MAQA extension. Populates Stories from specs, moves issues across board columns as features progress, real-time Subtask ticking.",
+ "author": "GenieRobot",
+ "version": "0.1.0",
+ "download_url": "https://github.com/GenieRobot/spec-kit-maqa-jira/releases/download/maqa-jira-v0.1.0/maqa-jira.zip",
+ "repository": "https://github.com/GenieRobot/spec-kit-maqa-jira",
+ "homepage": "https://github.com/GenieRobot/spec-kit-maqa-jira",
+ "documentation": "https://github.com/GenieRobot/spec-kit-maqa-jira/blob/main/README.md",
+ "changelog": "https://github.com/GenieRobot/spec-kit-maqa-jira/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.3.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 0
+ },
+ "tags": [
+ "jira",
+ "project-management",
+ "multi-agent",
+ "maqa",
+ "kanban"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-27T00:00:00Z",
+ "updated_at": "2026-03-27T00:00:00Z"
+ },
+ "maqa-linear": {
+ "name": "MAQA Linear Integration",
+ "id": "maqa-linear",
+ "description": "Linear integration for the MAQA extension. Populates issues from specs, moves items across workflow states as features progress, real-time sub-issue ticking.",
+ "author": "GenieRobot",
+ "version": "0.1.0",
+ "download_url": "https://github.com/GenieRobot/spec-kit-maqa-linear/releases/download/maqa-linear-v0.1.0/maqa-linear.zip",
+ "repository": "https://github.com/GenieRobot/spec-kit-maqa-linear",
+ "homepage": "https://github.com/GenieRobot/spec-kit-maqa-linear",
+ "documentation": "https://github.com/GenieRobot/spec-kit-maqa-linear/blob/main/README.md",
+ "changelog": "https://github.com/GenieRobot/spec-kit-maqa-linear/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.3.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 0
+ },
+ "tags": [
+ "linear",
+ "project-management",
+ "multi-agent",
+ "maqa",
+ "kanban"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-27T00:00:00Z",
+ "updated_at": "2026-03-27T00:00:00Z"
+ },
+ "maqa-trello": {
+ "name": "MAQA Trello Integration",
+ "id": "maqa-trello",
+ "description": "Trello board integration for the MAQA extension. Populates board from specs, moves cards between lists as features progress, real-time checklist ticking.",
+ "author": "GenieRobot",
+ "version": "0.1.1",
+ "download_url": "https://github.com/GenieRobot/spec-kit-maqa-trello/releases/download/maqa-trello-v0.1.1/maqa-trello.zip",
+ "repository": "https://github.com/GenieRobot/spec-kit-maqa-trello",
+ "homepage": "https://github.com/GenieRobot/spec-kit-maqa-trello",
+ "documentation": "https://github.com/GenieRobot/spec-kit-maqa-trello/blob/main/README.md",
+ "changelog": "https://github.com/GenieRobot/spec-kit-maqa-trello/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.3.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 0
+ },
+ "tags": [
+ "trello",
+ "project-management",
+ "multi-agent",
+ "maqa",
+ "kanban"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-26T00:00:00Z",
+ "updated_at": "2026-03-26T00:00:00Z"
+ },
+ "onboard": {
+ "name": "Onboard",
+ "id": "onboard",
+ "description": "Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step.",
+ "author": "Rafael Sales",
+ "version": "2.1.0",
+ "download_url": "https://github.com/dmux/spec-kit-onboard/archive/refs/tags/v2.1.0.zip",
+ "repository": "https://github.com/dmux/spec-kit-onboard",
+ "homepage": "https://github.com/dmux/spec-kit-onboard",
+ "documentation": "https://github.com/dmux/spec-kit-onboard/blob/main/README.md",
+ "changelog": "https://github.com/dmux/spec-kit-onboard/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 7,
+ "hooks": 3
+ },
+ "tags": [
+ "onboarding",
+ "learning",
+ "mentoring",
+ "developer-experience",
+ "gamification",
+ "knowledge-transfer"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-26T00:00:00Z",
+ "updated_at": "2026-03-26T00:00:00Z"
+ },
+ "plan-review-gate": {
+ "name": "Plan Review Gate",
+ "id": "plan-review-gate",
+ "description": "Require spec.md and plan.md to be merged via MR/PR before allowing task generation",
+ "author": "luno",
+ "version": "1.0.0",
+ "download_url": "https://github.com/luno/spec-kit-plan-review-gate/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/luno/spec-kit-plan-review-gate",
+ "homepage": "https://github.com/luno/spec-kit-plan-review-gate",
+ "documentation": "https://github.com/luno/spec-kit-plan-review-gate/blob/main/README.md",
+ "changelog": "https://github.com/luno/spec-kit-plan-review-gate/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 1,
+ "hooks": 1
+ },
+ "tags": [
+ "review",
+ "quality",
+ "workflow",
+ "gate"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-27T08:22:30Z",
+ "updated_at": "2026-03-27T08:22:30Z"
+ },
"presetify": {
"name": "Presetify",
"id": "presetify",
@@ -468,6 +807,68 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
+ "product-forge": {
+ "name": "Product Forge",
+ "id": "product-forge",
+ "description": "Full product lifecycle: research β product spec β SpecKit β implement β verify β test",
+ "author": "VaiYav",
+ "version": "1.1.1",
+ "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.1.1.zip",
+ "repository": "https://github.com/VaiYav/speckit-product-forge",
+ "homepage": "https://github.com/VaiYav/speckit-product-forge",
+ "documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md",
+ "changelog": "https://github.com/VaiYav/speckit-product-forge/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 10,
+ "hooks": 0
+ },
+ "tags": [
+ "process",
+ "research",
+ "product-spec",
+ "lifecycle",
+ "testing"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-28T00:00:00Z",
+ "updated_at": "2026-03-28T00:00:00Z"
+ },
+ "qa": {
+ "name": "QA Testing Extension",
+ "id": "qa",
+ "description": "Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec.",
+ "author": "arunt14",
+ "version": "1.0.0",
+ "download_url": "https://github.com/arunt14/spec-kit-qa/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/arunt14/spec-kit-qa",
+ "homepage": "https://github.com/arunt14/spec-kit-qa",
+ "documentation": "https://github.com/arunt14/spec-kit-qa/blob/main/README.md",
+ "changelog": "https://github.com/arunt14/spec-kit-qa/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 1,
+ "hooks": 1
+ },
+ "tags": [
+ "code",
+ "testing",
+ "qa"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-04-01T00:00:00Z",
+ "updated_at": "2026-04-01T00:00:00Z"
+ },
"ralph": {
"name": "Ralph Loop",
"id": "ralph",
@@ -540,6 +941,73 @@
"created_at": "2026-03-14T00:00:00Z",
"updated_at": "2026-03-14T00:00:00Z"
},
+ "repoindex":{
+ "name": "Repository Index",
+ "id": "repoindex",
+ "description": "Generate index of your repo for overview, architecuture and module",
+ "author": "Yiyu Liu",
+ "version": "1.0.0",
+ "download_url": "https://github.com/liuyiyu/spec-kit-repoindex/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/liuyiyu/spec-kit-repoindex",
+ "homepage": "https://github.com/liuyiyu/spec-kit-repoindex",
+ "documentation": "https://github.com/liuyiyu/spec-kit-repoindex/blob/main/docs/",
+ "changelog": "https://github.com/liuyiyu/spec-kit-repoindex/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0",
+ "tools": [
+ {
+ "name": "no need",
+ "version": ">=1.0.0",
+ "required": false
+ }
+ ]
+ },
+ "provides": {
+ "commands": 3,
+ "hooks": 0
+ },
+ "tags": [
+ "utility",
+ "brownfield",
+ "analysis"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-23T13:30:00Z",
+ "updated_at": "2026-03-23T13:30:00Z"
+ },
+ "retro": {
+ "name": "Retro Extension",
+ "id": "retro",
+ "description": "Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions.",
+ "author": "arunt14",
+ "version": "1.0.0",
+ "download_url": "https://github.com/arunt14/spec-kit-retro/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/arunt14/spec-kit-retro",
+ "homepage": "https://github.com/arunt14/spec-kit-retro",
+ "documentation": "https://github.com/arunt14/spec-kit-retro/blob/main/README.md",
+ "changelog": "https://github.com/arunt14/spec-kit-retro/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 1,
+ "hooks": 0
+ },
+ "tags": [
+ "process",
+ "retrospective",
+ "metrics"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-04-01T00:00:00Z",
+ "updated_at": "2026-04-01T00:00:00Z"
+ },
"retrospective": {
"name": "Retrospective Extension",
"id": "retrospective",
@@ -606,6 +1074,36 @@
"created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-06T00:00:00Z"
},
+ "ship": {
+ "name": "Ship Release Extension",
+ "id": "ship",
+ "description": "Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation.",
+ "author": "arunt14",
+ "version": "1.0.0",
+ "download_url": "https://github.com/arunt14/spec-kit-ship/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/arunt14/spec-kit-ship",
+ "homepage": "https://github.com/arunt14/spec-kit-ship",
+ "documentation": "https://github.com/arunt14/spec-kit-ship/blob/main/README.md",
+ "changelog": "https://github.com/arunt14/spec-kit-ship/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 1,
+ "hooks": 1
+ },
+ "tags": [
+ "process",
+ "release",
+ "automation"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-04-01T00:00:00Z",
+ "updated_at": "2026-04-01T00:00:00Z"
+ },
"speckit-utils": {
"name": "SDD Utilities",
"id": "speckit-utils",
@@ -638,78 +1136,35 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
- "sync": {
- "name": "Spec Sync",
- "id": "sync",
- "description": "Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval.",
- "author": "bgervin",
- "version": "0.1.0",
- "download_url": "https://github.com/bgervin/spec-kit-sync/archive/refs/tags/v0.1.0.zip",
- "repository": "https://github.com/bgervin/spec-kit-sync",
- "homepage": "https://github.com/bgervin/spec-kit-sync",
- "documentation": "https://github.com/bgervin/spec-kit-sync/blob/main/README.md",
- "changelog": "https://github.com/bgervin/spec-kit-sync/blob/main/CHANGELOG.md",
+ "staff-review": {
+ "name": "Staff Review Extension",
+ "id": "staff-review",
+ "description": "Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage.",
+ "author": "arunt14",
+ "version": "1.0.0",
+ "download_url": "https://github.com/arunt14/spec-kit-staff-review/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/arunt14/spec-kit-staff-review",
+ "homepage": "https://github.com/arunt14/spec-kit-staff-review",
+ "documentation": "https://github.com/arunt14/spec-kit-staff-review/blob/main/README.md",
+ "changelog": "https://github.com/arunt14/spec-kit-staff-review/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
- "commands": 5,
- "hooks": 1
- },
- "tags": [
- "sync",
- "drift",
- "validation",
- "bidirectional",
- "backfill"
- ],
- "verified": false,
- "downloads": 0,
- "stars": 0,
- "created_at": "2026-03-02T00:00:00Z",
- "updated_at": "2026-03-02T00:00:00Z"
- },
- "understanding": {
- "name": "Understanding",
- "id": "understanding",
- "description": "Automated requirements quality analysis \u2014 validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.",
- "author": "Ladislav Bihari",
- "version": "3.4.0",
- "download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
- "repository": "https://github.com/Testimonial/understanding",
- "homepage": "https://github.com/Testimonial/understanding",
- "documentation": "https://github.com/Testimonial/understanding/blob/main/extension/README.md",
- "changelog": "https://github.com/Testimonial/understanding/blob/main/extension/CHANGELOG.md",
- "license": "MIT",
- "requires": {
- "speckit_version": ">=0.1.0",
- "tools": [
- {
- "name": "understanding",
- "version": ">=3.4.0",
- "required": true
- }
- ]
- },
- "provides": {
- "commands": 3,
+ "commands": 1,
"hooks": 1
},
"tags": [
- "quality",
- "metrics",
- "requirements",
- "validation",
- "readability",
- "IEEE-830",
- "ISO-29148"
+ "code",
+ "review",
+ "quality"
],
"verified": false,
"downloads": 0,
"stars": 0,
- "created_at": "2026-03-07T00:00:00Z",
- "updated_at": "2026-03-07T00:00:00Z"
+ "created_at": "2026-04-01T00:00:00Z",
+ "updated_at": "2026-04-01T00:00:00Z"
},
"status": {
"name": "Project Status",
@@ -743,6 +1198,81 @@
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
+ "superb": {
+ "name": "Superpowers Bridge",
+ "id": "superb",
+ "description": "Orchestrates obra/superpowers skills within the spec-kit SDD workflow. Thin bridge commands delegate to superpowers' authoritative SKILL.md files at runtime (with graceful fallback), while bridge-original commands provide spec-kit-native value. Eight commands cover the full lifecycle: intent clarification, TDD enforcement, task review, verification, critique, systematic debugging, branch completion, and review response. Hook-bound commands fire automatically; standalone commands are invoked when needed.",
+ "author": "rbbtsn0w",
+ "version": "1.0.0",
+ "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.0.0/superpowers-bridge.zip",
+ "repository": "https://github.com/RbBtSn0w/spec-kit-extensions",
+ "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions",
+ "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/README.md",
+ "changelog": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.4.3",
+ "tools": [
+ {
+ "name": "superpowers",
+ "version": ">=5.0.0",
+ "required": false
+ }
+ ]
+ },
+ "provides": {
+ "commands": 8,
+ "hooks": 4
+ },
+ "tags": [
+ "methodology",
+ "tdd",
+ "code-review",
+ "workflow",
+ "superpowers",
+ "brainstorming",
+ "verification",
+ "debugging",
+ "branch-management"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-30T00:00:00Z",
+ "updated_at": "2026-03-30T00:00:00Z"
+ },
+ "sync": {
+ "name": "Spec Sync",
+ "id": "sync",
+ "description": "Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval.",
+ "author": "bgervin",
+ "version": "0.1.0",
+ "download_url": "https://github.com/bgervin/spec-kit-sync/archive/refs/tags/v0.1.0.zip",
+ "repository": "https://github.com/bgervin/spec-kit-sync",
+ "homepage": "https://github.com/bgervin/spec-kit-sync",
+ "documentation": "https://github.com/bgervin/spec-kit-sync/blob/main/README.md",
+ "changelog": "https://github.com/bgervin/spec-kit-sync/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 5,
+ "hooks": 1
+ },
+ "tags": [
+ "sync",
+ "drift",
+ "validation",
+ "bidirectional",
+ "backfill"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-03-02T00:00:00Z",
+ "updated_at": "2026-03-02T00:00:00Z"
+ },
"v-model": {
"name": "V-Model Extension Pack",
"id": "v-model",
@@ -775,37 +1305,6 @@
"created_at": "2026-02-20T00:00:00Z",
"updated_at": "2026-02-22T00:00:00Z"
},
- "learn": {
- "name": "Learning Extension",
- "id": "learn",
- "description": "Generate educational guides from implementations and enhance clarifications with mentoring context.",
- "author": "Vianca Martinez",
- "version": "1.0.0",
- "download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip",
- "repository": "https://github.com/imviancagrace/spec-kit-learn",
- "homepage": "https://github.com/imviancagrace/spec-kit-learn",
- "documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md",
- "changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md",
- "license": "MIT",
- "requires": {
- "speckit_version": ">=0.1.0"
- },
- "provides": {
- "commands": 2,
- "hooks": 1
- },
- "tags": [
- "learning",
- "education",
- "mentoring",
- "knowledge-transfer"
- ],
- "verified": false,
- "downloads": 0,
- "stars": 0,
- "created_at": "2026-03-17T00:00:00Z",
- "updated_at": "2026-03-17T00:00:00Z"
- },
"verify": {
"name": "Verify Extension",
"id": "verify",
@@ -869,5 +1368,6 @@
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
}
+
}
}
diff --git a/extensions/template/extension.yml b/extensions/template/extension.yml
index 2f51ae7fd5..abf7e45afc 100644
--- a/extensions/template/extension.yml
+++ b/extensions/template/extension.yml
@@ -47,8 +47,8 @@ provides:
- name: "speckit.my-extension.example"
file: "commands/example.md"
description: "Example command that demonstrates functionality"
- # Optional: Add aliases for shorter command names
- aliases: ["speckit.example"]
+ # Optional: Add aliases in the same namespaced format
+ aliases: ["speckit.my-extension.example-short"]
# ADD MORE COMMANDS: Copy this block for each command
# - name: "speckit.my-extension.another-command"
diff --git a/presets/README.md b/presets/README.md
index f039b83d43..dd3997b239 100644
--- a/presets/README.md
+++ b/presets/README.md
@@ -67,6 +67,9 @@ Presets **override**, they don't merge. If two presets both provide `spec-templa
Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs:
+> [!NOTE]
+> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
+
```bash
# List active catalogs
specify preset catalog list
diff --git a/pyproject.toml b/pyproject.toml
index a7b27109a5..dbb24e59fe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
-version = "0.4.2"
+version = "0.4.5.dev0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [
diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh
index 416fcadfc2..5e45e8708c 100644
--- a/scripts/bash/common.sh
+++ b/scripts/bash/common.sh
@@ -78,7 +78,7 @@ get_current_branch() {
latest_timestamp="$ts"
latest_feature=$dirname
fi
- elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then
+ elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
local number=${BASH_REMATCH[1]}
number=$((10#$number))
if [[ "$number" -gt "$highest" ]]; then
@@ -124,9 +124,15 @@ check_feature_branch() {
return 0
fi
- if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
+ # Accept sequential prefix (3+ digits) but exclude malformed timestamps
+ # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
+ local is_sequential=false
+ if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
+ is_sequential=true
+ fi
+ if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
- echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
+ echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
@@ -146,7 +152,7 @@ find_feature_dir_by_prefix() {
local prefix=""
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
prefix="${BASH_REMATCH[1]}"
- elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then
+ elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
prefix="${BASH_REMATCH[1]}"
else
# If branch doesn't have a recognized prefix, fall back to exact match
diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh
index 579d347523..36ea537991 100644
--- a/scripts/bash/create-new-feature.sh
+++ b/scripts/bash/create-new-feature.sh
@@ -3,6 +3,8 @@
set -e
JSON_MODE=false
+DRY_RUN=false
+ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
@@ -14,6 +16,12 @@ while [ $i -le $# ]; do
--json)
JSON_MODE=true
;;
+ --dry-run)
+ DRY_RUN=true
+ ;;
+ --allow-existing-branch)
+ ALLOW_EXISTING=true
+ ;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
@@ -45,10 +53,12 @@ while [ $i -le $# ]; do
USE_TIMESTAMP=true
;;
--help|-h)
- echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] "
+ echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] "
echo ""
echo "Options:"
echo " --json Output in JSON format"
+ echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
+ echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
echo " --short-name Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
@@ -69,7 +79,7 @@ done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
- echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " >&2
+ echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2
exit 1
fi
@@ -89,9 +99,9 @@ get_highest_from_specs() {
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
dirname=$(basename "$dir")
- # Only match sequential prefixes (###-*), skip timestamp dirs
- if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
- number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
+ # Match sequential prefixes (>=3 digits), but skip timestamp dirs.
+ if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
+ number=$(echo "$dirname" | grep -Eo '^[0-9]+')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
@@ -105,39 +115,59 @@ get_highest_from_specs() {
# Function to get highest number from git branches
get_highest_from_branches() {
+ git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
+}
+
+# Extract the highest sequential feature number from a list of ref names (one per line).
+# Shared by get_highest_from_branches and get_highest_from_remote_refs.
+_extract_highest_number() {
local highest=0
-
- # Get all branches (local and remote)
- branches=$(git branch -a 2>/dev/null || echo "")
-
- if [ -n "$branches" ]; then
- while IFS= read -r branch; do
- # Clean branch name: remove leading markers and remote prefixes
- clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
-
- # Extract feature number if branch matches pattern ###-*
- if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
- number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
- number=$((10#$number))
- if [ "$number" -gt "$highest" ]; then
- highest=$number
- fi
+ while IFS= read -r name; do
+ [ -z "$name" ] && continue
+ if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
+ number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
+ number=$((10#$number))
+ if [ "$number" -gt "$highest" ]; then
+ highest=$number
fi
- done <<< "$branches"
- fi
-
+ fi
+ done
echo "$highest"
}
-# Function to check existing branches (local and remote) and return next available number
+# Function to get highest number from remote branches without fetching (side-effect-free)
+get_highest_from_remote_refs() {
+ local highest=0
+
+ for remote in $(git remote 2>/dev/null); do
+ local remote_highest
+ remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
+ if [ "$remote_highest" -gt "$highest" ]; then
+ highest=$remote_highest
+ fi
+ done
+
+ echo "$highest"
+}
+
+# Function to check existing branches (local and remote) and return next available number.
+# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
check_existing_branches() {
local specs_dir="$1"
+ local skip_fetch="${2:-false}"
- # Fetch all remotes to get latest branch info (suppress errors if no remotes)
- git fetch --all --prune >/dev/null 2>&1 || true
-
- # Get highest number from ALL branches (not just matching short name)
- local highest_branch=$(get_highest_from_branches)
+ if [ "$skip_fetch" = true ]; then
+ # Side-effect-free: query remotes via ls-remote
+ local highest_remote=$(get_highest_from_remote_refs)
+ local highest_branch=$(get_highest_from_branches)
+ if [ "$highest_remote" -gt "$highest_branch" ]; then
+ highest_branch=$highest_remote
+ fi
+ else
+ # Fetch all remotes to get latest branch info (suppress errors if no remotes)
+ git fetch --all --prune >/dev/null 2>&1 || true
+ local highest_branch=$(get_highest_from_branches)
+ fi
# Get highest number from ALL specs (not just matching short name)
local highest_spec=$(get_highest_from_specs "$specs_dir")
@@ -174,7 +204,9 @@ fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
-mkdir -p "$SPECS_DIR"
+if [ "$DRY_RUN" != true ]; then
+ mkdir -p "$SPECS_DIR"
+fi
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
@@ -246,7 +278,14 @@ if [ "$USE_TIMESTAMP" = true ]; then
else
# Determine branch number
if [ -z "$BRANCH_NUMBER" ]; then
- if [ "$HAS_GIT" = true ]; then
+ if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
+ # Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
+ BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
+ elif [ "$DRY_RUN" = true ]; then
+ # Dry-run without git: local spec dirs only
+ HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
+ BRANCH_NUMBER=$((HIGHEST + 1))
+ elif [ "$HAS_GIT" = true ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
@@ -283,53 +322,79 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
-if [ "$HAS_GIT" = true ]; then
- if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
- # Check if branch already exists
- if git branch --list "$BRANCH_NAME" | grep -q .; then
- if [ "$USE_TIMESTAMP" = true ]; then
- >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
+FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
+SPEC_FILE="$FEATURE_DIR/spec.md"
+
+if [ "$DRY_RUN" != true ]; then
+ if [ "$HAS_GIT" = true ]; then
+ if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
+ # Check if branch already exists
+ if git branch --list "$BRANCH_NAME" | grep -q .; then
+ if [ "$ALLOW_EXISTING" = true ]; then
+ # Switch to the existing branch instead of failing
+ if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
+ >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
+ exit 1
+ fi
+ elif [ "$USE_TIMESTAMP" = true ]; then
+ >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
+ exit 1
+ else
+ >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
+ exit 1
+ fi
else
- >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
+ >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
+ exit 1
fi
- exit 1
- else
- >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
- exit 1
fi
+ else
+ >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
-else
- >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
-fi
-FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
-mkdir -p "$FEATURE_DIR"
+ mkdir -p "$FEATURE_DIR"
-TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
-SPEC_FILE="$FEATURE_DIR/spec.md"
-if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
- cp "$TEMPLATE" "$SPEC_FILE"
-else
- echo "Warning: Spec template not found; created empty spec file" >&2
- touch "$SPEC_FILE"
-fi
+ if [ ! -f "$SPEC_FILE" ]; then
+ TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
+ if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
+ cp "$TEMPLATE" "$SPEC_FILE"
+ else
+ echo "Warning: Spec template not found; created empty spec file" >&2
+ touch "$SPEC_FILE"
+ fi
+ fi
-# Inform the user how to persist the feature variable in their own shell
-printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
+ # Inform the user how to persist the feature variable in their own shell
+ printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
+fi
if $JSON_MODE; then
if command -v jq >/dev/null 2>&1; then
- jq -cn \
- --arg branch_name "$BRANCH_NAME" \
- --arg spec_file "$SPEC_FILE" \
- --arg feature_num "$FEATURE_NUM" \
- '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
+ if [ "$DRY_RUN" = true ]; then
+ jq -cn \
+ --arg branch_name "$BRANCH_NAME" \
+ --arg spec_file "$SPEC_FILE" \
+ --arg feature_num "$FEATURE_NUM" \
+ '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
+ else
+ jq -cn \
+ --arg branch_name "$BRANCH_NAME" \
+ --arg spec_file "$SPEC_FILE" \
+ --arg feature_num "$FEATURE_NUM" \
+ '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
+ fi
else
- printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
+ if [ "$DRY_RUN" = true ]; then
+ printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
+ else
+ printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
+ fi
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
- printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
+ if [ "$DRY_RUN" != true ]; then
+ printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
+ fi
fi
diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh
index ede8d77d0f..831850f440 100644
--- a/scripts/bash/update-agent-context.sh
+++ b/scripts/bash/update-agent-context.sh
@@ -63,7 +63,7 @@ AGENT_TYPE="${1:-}"
# Agent-specific file paths
CLAUDE_FILE="$REPO_ROOT/CLAUDE.md"
GEMINI_FILE="$REPO_ROOT/GEMINI.md"
-COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md"
+COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md"
CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc"
QWEN_FILE="$REPO_ROOT/QWEN.md"
AGENTS_FILE="$REPO_ROOT/AGENTS.md"
diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1
index 7a96d3fac8..8c8c801ee3 100644
--- a/scripts/powershell/common.ps1
+++ b/scripts/powershell/common.ps1
@@ -83,8 +83,8 @@ function Get-CurrentBranch {
$latestTimestamp = $ts
$latestFeature = $_.Name
}
- } elseif ($_.Name -match '^(\d{3})-') {
- $num = [int]$matches[1]
+ } elseif ($_.Name -match '^(\d{3,})-') {
+ $num = [long]$matches[1]
if ($num -gt $highest) {
$highest = $num
# Only update if no timestamp branch found yet
@@ -139,9 +139,13 @@ function Test-FeatureBranch {
return $true
}
- if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') {
+ # Accept sequential prefix (3+ digits) but exclude malformed timestamps
+ # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
+ $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
+ $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
+ if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
- Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
+ Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name"
return $false
}
return $true
diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1
index 9adae131d5..2cfa351399 100644
--- a/scripts/powershell/create-new-feature.ps1
+++ b/scripts/powershell/create-new-feature.ps1
@@ -3,9 +3,11 @@
[CmdletBinding()]
param(
[switch]$Json,
+ [switch]$AllowExistingBranch,
+ [switch]$DryRun,
[string]$ShortName,
[Parameter()]
- [int]$Number = 0,
+ [long]$Number = 0,
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
@@ -15,10 +17,12 @@ $ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
- Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] [-Timestamp] "
+ Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] "
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
+ Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
+ Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
@@ -33,7 +37,7 @@ if ($Help) {
# Check if feature description provided
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
- Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] [-Timestamp] "
+ Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] "
exit 1
}
@@ -47,13 +51,33 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) {
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
-
- $highest = 0
+
+ [long]$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
- if ($_.Name -match '^(\d{3})-') {
- $num = [int]$matches[1]
- if ($num -gt $highest) { $highest = $num }
+ # Match sequential prefixes (>=3 digits), but skip timestamp dirs.
+ if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
+ [long]$num = 0
+ if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
+ $highest = $num
+ }
+ }
+ }
+ }
+ return $highest
+}
+
+# Extract the highest sequential feature number from a list of branch/ref names.
+# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
+function Get-HighestNumberFromNames {
+ param([string[]]$Names)
+
+ [long]$highest = 0
+ foreach ($name in $Names) {
+ if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
+ [long]$num = 0
+ if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
+ $highest = $num
}
}
}
@@ -62,44 +86,68 @@ function Get-HighestNumberFromSpecs {
function Get-HighestNumberFromBranches {
param()
-
- $highest = 0
+
try {
$branches = git branch -a 2>$null
- if ($LASTEXITCODE -eq 0) {
- foreach ($branch in $branches) {
- # Clean branch name: remove leading markers and remote prefixes
- $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
-
- # Extract feature number if branch matches pattern ###-*
- if ($cleanBranch -match '^(\d{3})-') {
- $num = [int]$matches[1]
- if ($num -gt $highest) { $highest = $num }
- }
+ if ($LASTEXITCODE -eq 0 -and $branches) {
+ $cleanNames = $branches | ForEach-Object {
+ $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
}
+ return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
- # If git command fails, return 0
Write-Verbose "Could not check Git branches: $_"
}
+ return 0
+}
+
+function Get-HighestNumberFromRemoteRefs {
+ [long]$highest = 0
+ try {
+ $remotes = git remote 2>$null
+ if ($remotes) {
+ foreach ($remote in $remotes) {
+ $env:GIT_TERMINAL_PROMPT = '0'
+ $refs = git ls-remote --heads $remote 2>$null
+ $env:GIT_TERMINAL_PROMPT = $null
+ if ($LASTEXITCODE -eq 0 -and $refs) {
+ $refNames = $refs | ForEach-Object {
+ if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
+ } | Where-Object { $_ }
+ $remoteHighest = Get-HighestNumberFromNames -Names $refNames
+ if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
+ }
+ }
+ }
+ } catch {
+ Write-Verbose "Could not query remote refs: $_"
+ }
return $highest
}
+# Return next available branch number. When SkipFetch is true, queries remotes
+# via ls-remote (read-only) instead of fetching.
function Get-NextBranchNumber {
param(
- [string]$SpecsDir
+ [string]$SpecsDir,
+ [switch]$SkipFetch
)
- # Fetch all remotes to get latest branch info (suppress errors if no remotes)
- try {
- git fetch --all --prune 2>$null | Out-Null
- } catch {
- # Ignore fetch errors
+ if ($SkipFetch) {
+ # Side-effect-free: query remotes via ls-remote
+ $highestBranch = Get-HighestNumberFromBranches
+ $highestRemote = Get-HighestNumberFromRemoteRefs
+ $highestBranch = [Math]::Max($highestBranch, $highestRemote)
+ } else {
+ # Fetch all remotes to get latest branch info (suppress errors if no remotes)
+ try {
+ git fetch --all --prune 2>$null | Out-Null
+ } catch {
+ # Ignore fetch errors
+ }
+ $highestBranch = Get-HighestNumberFromBranches
}
- # Get highest number from ALL branches (not just matching short name)
- $highestBranch = Get-HighestNumberFromBranches
-
# Get highest number from ALL specs (not just matching short name)
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
@@ -112,7 +160,7 @@ function Get-NextBranchNumber {
function ConvertTo-CleanBranchName {
param([string]$Name)
-
+
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
@@ -127,12 +175,14 @@ $hasGit = Test-HasGit
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
-New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
+if (-not $DryRun) {
+ New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
+}
# Function to generate branch name with stop word filtering and length filtering
function Get-BranchName {
param([string]$Description)
-
+
# Common stop words to filter out
$stopWords = @(
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
@@ -141,17 +191,17 @@ function Get-BranchName {
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
-
+
# Convert to lowercase and extract words (alphanumeric only)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
-
+
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
$meaningfulWords = @()
foreach ($word in $words) {
# Skip stop words
if ($stopWords -contains $word) { continue }
-
+
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
@@ -160,7 +210,7 @@ function Get-BranchName {
$meaningfulWords += $word
}
}
-
+
# If we have meaningful words, use first 3-4 of them
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
@@ -196,7 +246,13 @@ if ($Timestamp) {
} else {
# Determine branch number
if ($Number -eq 0) {
- if ($hasGit) {
+ if ($DryRun -and $hasGit) {
+ # Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
+ $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
+ } elseif ($DryRun) {
+ # Dry-run without git: local spec dirs only
+ $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
+ } elseif ($hasGit) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
@@ -217,77 +273,94 @@ if ($branchName.Length -gt $maxBranchLength) {
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
-
+
# Truncate suffix
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
# Remove trailing hyphen if truncation created one
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
-
+
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
-
+
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}
-if ($hasGit) {
- $branchCreated = $false
- try {
- git checkout -q -b $branchName 2>$null | Out-Null
- if ($LASTEXITCODE -eq 0) {
- $branchCreated = $true
+$featureDir = Join-Path $specsDir $branchName
+$specFile = Join-Path $featureDir 'spec.md'
+
+if (-not $DryRun) {
+ if ($hasGit) {
+ $branchCreated = $false
+ try {
+ git checkout -q -b $branchName 2>$null | Out-Null
+ if ($LASTEXITCODE -eq 0) {
+ $branchCreated = $true
+ }
+ } catch {
+ # Exception during git command
}
- } catch {
- # Exception during git command
- }
- if (-not $branchCreated) {
- # Check if branch already exists
- $existingBranch = git branch --list $branchName 2>$null
- if ($existingBranch) {
- if ($Timestamp) {
- Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
+ if (-not $branchCreated) {
+ # Check if branch already exists
+ $existingBranch = git branch --list $branchName 2>$null
+ if ($existingBranch) {
+ if ($AllowExistingBranch) {
+ # Switch to the existing branch instead of failing
+ git checkout -q $branchName 2>$null | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
+ exit 1
+ }
+ } elseif ($Timestamp) {
+ Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
+ exit 1
+ } else {
+ Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
+ exit 1
+ }
} else {
- Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
+ Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
+ exit 1
}
- exit 1
- } else {
- Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
- exit 1
}
+ } else {
+ Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
}
-} else {
- Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
-}
-$featureDir = Join-Path $specsDir $branchName
-New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
+ New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
-$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
-$specFile = Join-Path $featureDir 'spec.md'
-if ($template -and (Test-Path $template)) {
- Copy-Item $template $specFile -Force
-} else {
- New-Item -ItemType File -Path $specFile | Out-Null
-}
+ if (-not (Test-Path -PathType Leaf $specFile)) {
+ $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
+ if ($template -and (Test-Path $template)) {
+ Copy-Item $template $specFile -Force
+ } else {
+ New-Item -ItemType File -Path $specFile -Force | Out-Null
+ }
+ }
-# Set the SPECIFY_FEATURE environment variable for the current session
-$env:SPECIFY_FEATURE = $branchName
+ # Set the SPECIFY_FEATURE environment variable for the current session
+ $env:SPECIFY_FEATURE = $branchName
+}
if ($Json) {
- $obj = [PSCustomObject]@{
+ $obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
+ if ($DryRun) {
+ $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
+ }
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
- Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
+ if (-not $DryRun) {
+ Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
+ }
}
-
diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1
index c495d3c74d..61df427c7c 100644
--- a/scripts/powershell/update-agent-context.ps1
+++ b/scripts/powershell/update-agent-context.ps1
@@ -46,7 +46,7 @@ $NEW_PLAN = $IMPL_PLAN
# Agent file paths
$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md'
$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md'
-$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md'
+$COPILOT_FILE = Join-Path $REPO_ROOT '.github/copilot-instructions.md'
$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc'
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md'
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py
index d78609ad66..7d1ecbc007 100644
--- a/src/specify_cli/__init__.py
+++ b/src/specify_cli/__init__.py
@@ -345,6 +345,7 @@ def _build_ai_assistant_help() -> str:
SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
+CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
BANNER = """
βββββββββββββββ ββββββββ βββββββββββββββββββββ βββ
@@ -605,13 +606,15 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool:
Returns:
True if tool is found, False otherwise
"""
- # Special handling for Claude CLI after `claude migrate-installer`
+ # Special handling for Claude CLI local installs
# See: https://github.com/github/spec-kit/issues/123
- # The migrate-installer command REMOVES the original executable from PATH
- # and creates an alias at ~/.claude/local/claude instead
- # This path should be prioritized over other claude executables in PATH
+ # See: https://github.com/github/spec-kit/issues/550
+ # Claude Code can be installed in two local paths:
+ # 1. ~/.claude/local/claude (after `claude migrate-installer`)
+ # 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm)
+ # Neither path may be on the system PATH, so we check them explicitly.
if tool == "claude":
- if CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file():
+ if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file():
if tracker:
tracker.complete(tool, "available")
return True
@@ -1194,6 +1197,84 @@ def _locate_release_script() -> tuple[Path, str]:
raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout")
+def _install_shared_infra(
+ project_path: Path,
+ script_type: str,
+ tracker: StepTracker | None = None,
+) -> bool:
+ """Install shared infrastructure files into *project_path*.
+
+ Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
+ bundled core_pack or source checkout. Tracks all installed files
+ in ``speckit.manifest.json``.
+ Returns ``True`` on success.
+ """
+ from .integrations.manifest import IntegrationManifest
+
+ core = _locate_core_pack()
+ manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version())
+
+ # Scripts
+ if core and (core / "scripts").is_dir():
+ scripts_src = core / "scripts"
+ else:
+ repo_root = Path(__file__).parent.parent.parent
+ scripts_src = repo_root / "scripts"
+
+ skipped_files: list[str] = []
+
+ if scripts_src.is_dir():
+ dest_scripts = project_path / ".specify" / "scripts"
+ dest_scripts.mkdir(parents=True, exist_ok=True)
+ variant_dir = "bash" if script_type == "sh" else "powershell"
+ variant_src = scripts_src / variant_dir
+ if variant_src.is_dir():
+ dest_variant = dest_scripts / variant_dir
+ dest_variant.mkdir(parents=True, exist_ok=True)
+ # Merge without overwriting β only add files that don't exist yet
+ for src_path in variant_src.rglob("*"):
+ if src_path.is_file():
+ rel_path = src_path.relative_to(variant_src)
+ dst_path = dest_variant / rel_path
+ if dst_path.exists():
+ skipped_files.append(str(dst_path.relative_to(project_path)))
+ else:
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(src_path, dst_path)
+ rel = dst_path.relative_to(project_path).as_posix()
+ manifest.record_existing(rel)
+
+ # Page templates (not command templates, not vscode-settings.json)
+ if core and (core / "templates").is_dir():
+ templates_src = core / "templates"
+ else:
+ repo_root = Path(__file__).parent.parent.parent
+ templates_src = repo_root / "templates"
+
+ if templates_src.is_dir():
+ dest_templates = project_path / ".specify" / "templates"
+ dest_templates.mkdir(parents=True, exist_ok=True)
+ for f in templates_src.iterdir():
+ if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
+ dst = dest_templates / f.name
+ if dst.exists():
+ skipped_files.append(str(dst.relative_to(project_path)))
+ else:
+ shutil.copy2(f, dst)
+ rel = dst.relative_to(project_path).as_posix()
+ manifest.record_existing(rel)
+
+ if skipped_files:
+ import logging
+ logging.getLogger(__name__).warning(
+ "The following shared files already exist and were not overwritten:\n%s",
+ "\n".join(f" {f}" for f in skipped_files),
+ )
+
+ manifest.save()
+ return True
+
+
def scaffold_from_core_pack(
project_path: Path,
ai_assistant: str,
@@ -1490,12 +1571,6 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
return {}
-# Agent-specific skill directory overrides for agents whose skills directory
-# doesn't follow the standard /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"
@@ -1528,13 +1603,9 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
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``.
+ Uses ``AGENT_CONFIG[agent]["folder"] + "skills"`` and falls back to
+ ``DEFAULT_SKILLS_DIR`` for unknown agents.
"""
- 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:
@@ -1648,10 +1719,7 @@ def install_ai_skills(
command_name = command_name[len("speckit."):]
if command_name.endswith(".agent"):
command_name = command_name[:-len(".agent")]
- if selected_ai == "kimi":
- skill_name = f"speckit.{command_name}"
- else:
- skill_name = f"speckit-{command_name}"
+ skill_name = f"speckit-{command_name.replace('.', '-')}"
# Create skill directory (additive β never removes existing content)
skill_dir = skills_dir / skill_name
@@ -1730,8 +1798,64 @@ def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
if not skills_dir.is_dir():
return False
- pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
- return any(skills_dir.glob(pattern))
+ return any(skills_dir.glob("speckit-*/SKILL.md"))
+
+
+def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
+ """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
+
+ Temporary migration helper:
+ - Intended removal window: after 2026-06-25.
+ - Purpose: one-time cleanup for projects initialized before Kimi moved to
+ hyphenated skills (speckit-xxx).
+
+ Returns:
+ Tuple[migrated_count, removed_count]
+ - migrated_count: old dotted dir renamed to hyphenated dir
+ - removed_count: old dotted dir deleted when equivalent hyphenated dir existed
+ """
+ if not skills_dir.is_dir():
+ return (0, 0)
+
+ migrated_count = 0
+ removed_count = 0
+
+ for legacy_dir in sorted(skills_dir.glob("speckit.*")):
+ if not legacy_dir.is_dir():
+ continue
+ if not (legacy_dir / "SKILL.md").exists():
+ continue
+
+ suffix = legacy_dir.name[len("speckit."):]
+ if not suffix:
+ continue
+
+ target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
+
+ if not target_dir.exists():
+ shutil.move(str(legacy_dir), str(target_dir))
+ migrated_count += 1
+ continue
+
+ # If the new target already exists, avoid destructive cleanup unless
+ # both SKILL.md files are byte-identical.
+ target_skill = target_dir / "SKILL.md"
+ legacy_skill = legacy_dir / "SKILL.md"
+ if target_skill.is_file():
+ try:
+ if target_skill.read_bytes() == legacy_skill.read_bytes():
+ # Preserve legacy directory when it contains extra user files.
+ has_extra_entries = any(
+ child.name != "SKILL.md" for child in legacy_dir.iterdir()
+ )
+ if not has_extra_entries:
+ shutil.rmtree(legacy_dir)
+ removed_count += 1
+ except OSError:
+ # Best-effort migration: preserve legacy dir on read failures.
+ pass
+
+ return (migrated_count, removed_count)
AGENT_SKILLS_MIGRATIONS = {
@@ -1782,6 +1906,8 @@ def init(
offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"),
+ integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
+ integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):
"""
Initialize a new Specify project.
@@ -1843,6 +1969,55 @@ def init(
if ai_assistant:
ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)
+ # --integration and --ai are mutually exclusive
+ if integration and ai_assistant:
+ console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
+ console.print("[yellow]Use:[/yellow] --integration for the new integration system, or --ai for the legacy path")
+ raise typer.Exit(1)
+
+ # Auto-promote: --ai β integration path with a nudge (if registered)
+ use_integration = False
+ if integration:
+ from .integrations import INTEGRATION_REGISTRY, get_integration
+ resolved_integration = get_integration(integration)
+ if not resolved_integration:
+ console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
+ available = ", ".join(sorted(INTEGRATION_REGISTRY))
+ console.print(f"[yellow]Available integrations:[/yellow] {available}")
+ raise typer.Exit(1)
+ use_integration = True
+ # Map integration key to the ai_assistant variable for downstream compatibility
+ ai_assistant = integration
+ elif ai_assistant:
+ from .integrations import get_integration
+ resolved_integration = get_integration(ai_assistant)
+ if resolved_integration:
+ use_integration = True
+ console.print(
+ f"[dim]Tip: Use [bold]--integration {ai_assistant}[/bold] instead of "
+ f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]"
+ )
+
+ # Deprecation warnings for --ai-skills and --ai-commands-dir when using integration path
+ if use_integration:
+ if ai_skills:
+ from .integrations.base import SkillsIntegration as _SkillsCheck
+ if isinstance(resolved_integration, _SkillsCheck):
+ console.print(
+ "[dim]Note: --ai-skills is not needed with --integration; "
+ "skills are the default for this integration.[/dim]"
+ )
+ else:
+ console.print(
+ "[dim]Note: --ai-skills has no effect with --integration "
+ f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
+ )
+ if ai_commands_dir and resolved_integration.key != "generic":
+ console.print(
+ "[dim]Note: --ai-commands-dir is deprecated; "
+ 'use [bold]--integration generic --integration-options="--commands-dir "[/bold] instead.[/dim]'
+ )
+
if project_name == ".":
here = True
project_name = None # Clear project_name to use existing validation logic
@@ -1908,8 +2083,18 @@ def init(
"copilot"
)
+ # Auto-promote interactively selected agents to the integration path
+ # when a matching integration is registered (same behavior as --ai).
+ if not use_integration:
+ from .integrations import get_integration as _get_int
+ _resolved = _get_int(selected_ai)
+ if _resolved:
+ use_integration = True
+ resolved_integration = _resolved
+
# Agents that have moved from explicit commands/prompts to agent skills.
- if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
+ # Skip this check when using the integration path β skills are the default.
+ if not use_integration and selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
# If selected interactively (no --ai provided), automatically enable
# ai_skills so the agent remains usable without requiring an extra flag.
# Preserve fail-fast behavior only for explicit '--ai ' without skills.
@@ -1919,14 +2104,20 @@ def init(
ai_skills = True
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
- # Validate --ai-commands-dir usage
- if selected_ai == "generic":
+ # Validate --ai-commands-dir usage.
+ # Skip validation when --integration-options is provided β the integration
+ # will validate its own options in setup().
+ if selected_ai == "generic" and not integration_options:
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]")
+ console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
+ console.print("[dim]Example: specify init my-project --integration generic --integration-options=\"--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}')")
+ elif ai_commands_dir and not use_integration:
+ console.print(
+ f"[red]Error:[/red] --ai-commands-dir can only be used with the "
+ f"'generic' integration via --ai generic or --integration generic "
+ f"(not '{selected_ai}')"
+ )
raise typer.Exit(1)
current_dir = Path.cwd()
@@ -2011,7 +2202,10 @@ def init(
"This will become the default in v0.6.0."
)
- if use_github:
+ if use_integration:
+ tracker.add("integration", "Install integration")
+ tracker.add("shared-infra", "Install shared infrastructure")
+ elif use_github:
for key, label in [
("fetch", "Fetch latest release"),
("download", "Download template"),
@@ -2046,7 +2240,51 @@ def init(
verify = not skip_tls
local_ssl_context = ssl_context if verify else False
- if use_github:
+ if use_integration:
+ # Integration-based scaffolding (new path)
+ from .integrations.manifest import IntegrationManifest
+ tracker.start("integration")
+ manifest = IntegrationManifest(
+ resolved_integration.key, project_path, version=get_speckit_version()
+ )
+
+ # Forward all legacy CLI flags to the integration as parsed_options.
+ # Integrations receive every option and decide what to use;
+ # irrelevant keys are simply ignored by the integration's setup().
+ integration_parsed_options: dict[str, Any] = {}
+ if ai_commands_dir:
+ integration_parsed_options["commands_dir"] = ai_commands_dir
+ if ai_skills:
+ integration_parsed_options["skills"] = True
+
+ resolved_integration.setup(
+ project_path, manifest,
+ parsed_options=integration_parsed_options or None,
+ script_type=selected_script,
+ raw_options=integration_options,
+ )
+ manifest.save()
+
+ # Write .specify/integration.json
+ script_ext = "sh" if selected_script == "sh" else "ps1"
+ integration_json = project_path / ".specify" / "integration.json"
+ integration_json.parent.mkdir(parents=True, exist_ok=True)
+ integration_json.write_text(json.dumps({
+ "integration": resolved_integration.key,
+ "version": get_speckit_version(),
+ "scripts": {
+ "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}",
+ },
+ }, indent=2) + "\n", encoding="utf-8")
+
+ tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key))
+
+ # Install shared infrastructure (scripts, templates)
+ tracker.start("shared-infra")
+ _install_shared_infra(project_path, selected_script, tracker=tracker)
+ tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
+
+ elif use_github:
with httpx.Client(verify=local_ssl_context) as local_client:
download_and_extract_template(
project_path,
@@ -2079,7 +2317,7 @@ def init(
shutil.rmtree(project_path)
raise typer.Exit(1)
# For generic agent, rename placeholder directory to user-specified path
- if selected_ai == "generic" and ai_commands_dir:
+ if not use_integration and 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():
@@ -2094,16 +2332,34 @@ def init(
ensure_constitution_from_template(project_path, tracker=tracker)
- if ai_skills:
+ # Determine skills directory and migrate any legacy Kimi dotted skills.
+ # (Legacy path only β integration path handles skills in setup().)
+ migrated_legacy_kimi_skills = 0
+ removed_legacy_kimi_skills = 0
+ skills_dir: Optional[Path] = None
+ if not use_integration and selected_ai in NATIVE_SKILLS_AGENTS:
+ skills_dir = _get_skills_dir(project_path, selected_ai)
+ if selected_ai == "kimi" and skills_dir.is_dir():
+ (
+ migrated_legacy_kimi_skills,
+ removed_legacy_kimi_skills,
+ ) = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ if not use_integration and ai_skills:
if selected_ai in NATIVE_SKILLS_AGENTS:
- skills_dir = _get_skills_dir(project_path, selected_ai)
bundled_found = _has_bundled_skills(project_path, selected_ai)
if bundled_found:
+ detail = f"bundled skills β {skills_dir.relative_to(project_path)}"
+ if migrated_legacy_kimi_skills or removed_legacy_kimi_skills:
+ detail += (
+ f" (migrated {migrated_legacy_kimi_skills}, "
+ f"removed {removed_legacy_kimi_skills} legacy Kimi dotted skills)"
+ )
if tracker:
tracker.start("ai-skills")
- tracker.complete("ai-skills", f"bundled skills β {skills_dir.relative_to(project_path)}")
+ tracker.complete("ai-skills", detail)
else:
- console.print(f"[green]β[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
+ console.print(f"[green]β[/green] Using {detail}")
else:
# Compatibility fallback: convert command templates to skills
# when an older template archive does not include native skills.
@@ -2164,7 +2420,7 @@ def init(
# Persist the CLI options so later operations (e.g. preset add)
# can adapt their behaviour without re-scanning the filesystem.
# Must be saved BEFORE preset install so _get_skills_dir() works.
- save_init_options(project_path, {
+ init_opts = {
"ai": selected_ai,
"ai_skills": ai_skills,
"ai_commands_dir": ai_commands_dir,
@@ -2174,7 +2430,15 @@ def init(
"offline": offline,
"script": selected_script,
"speckit_version": get_speckit_version(),
- })
+ }
+ if use_integration:
+ init_opts["integration"] = resolved_integration.key
+ # Ensure ai_skills is set for SkillsIntegration so downstream
+ # tools (extensions, presets) emit SKILL.md overrides correctly.
+ from .integrations.base import SkillsIntegration as _SkillsPersist
+ if isinstance(resolved_integration, _SkillsPersist):
+ init_opts["ai_skills"] = True
+ save_init_options(project_path, init_opts)
# Install preset if specified
if preset:
@@ -2275,20 +2539,30 @@ def init(
steps_lines.append("1. You're already in the project directory!")
step_num = 2
- if selected_ai == "codex" and ai_skills:
- steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
- step_num += 1
+ # Determine skill display mode for the next-steps panel.
+ # Skills integrations (codex, kimi, agy) should show skill invocation syntax
+ # regardless of whether --ai-skills was explicitly passed.
+ _is_skills_integration = False
+ if use_integration:
+ from .integrations.base import SkillsIntegration as _SkillsInt
+ _is_skills_integration = isinstance(resolved_integration, _SkillsInt)
- codex_skill_mode = selected_ai == "codex" and ai_skills
+ codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
kimi_skill_mode = selected_ai == "kimi"
- native_skill_mode = codex_skill_mode or kimi_skill_mode
+ agy_skill_mode = selected_ai == "agy" and _is_skills_integration
+ native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode
+
+ if codex_skill_mode and not ai_skills:
+ # Integration path installed skills; show the helpful notice
+ steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
+ step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"
def _display_cmd(name: str) -> str:
- if codex_skill_mode:
+ if codex_skill_mode or agy_skill_mode:
return f"$speckit-{name}"
if kimi_skill_mode:
- return f"/skill:speckit.{name}"
+ return f"/skill:speckit-{name}"
return f"/speckit.{name}"
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py
index 7fe5316066..8107ae7017 100644
--- a/src/specify_cli/agents.py
+++ b/src/specify_cli/agents.py
@@ -10,6 +10,8 @@
from typing import Dict, List, Any
import platform
+import re
+from copy import deepcopy
import yaml
@@ -41,7 +43,7 @@ class CommandRegistrar:
"args": "$ARGUMENTS",
"extension": ".agent.md"
},
- "cursor": {
+ "cursor-agent": {
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
@@ -160,6 +162,18 @@ class CommandRegistrar:
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
+ },
+ "vibe": {
+ "dir": ".vibe/prompts",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md"
+ },
+ "agy": {
+ "dir": ".agent/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
}
}
@@ -211,24 +225,52 @@ def render_frontmatter(fm: dict) -> str:
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
- """Adjust script paths from extension-relative to repo-relative.
+ """Normalize script paths in frontmatter to generated project locations.
+
+ Rewrites known repo-relative and top-level script paths under the
+ `scripts` and `agent_scripts` keys (for example `../../scripts/`,
+ `../../templates/`, `../../memory/`, `scripts/`, `templates/`, and
+ `memory/`) to the `.specify/...` paths used in generated projects.
Args:
frontmatter: Frontmatter dictionary
Returns:
- Modified frontmatter with adjusted paths
+ Modified frontmatter with normalized project paths
"""
+ frontmatter = deepcopy(frontmatter)
+
for script_key in ("scripts", "agent_scripts"):
scripts = frontmatter.get(script_key)
if not isinstance(scripts, dict):
continue
for key, script_path in scripts.items():
- if isinstance(script_path, str) and script_path.startswith("../../scripts/"):
- scripts[key] = f".specify/scripts/{script_path[14:]}"
+ if isinstance(script_path, str):
+ scripts[key] = self.rewrite_project_relative_paths(script_path)
return frontmatter
+ @staticmethod
+ def rewrite_project_relative_paths(text: str) -> str:
+ """Rewrite repo-relative paths to their generated project locations."""
+ if not isinstance(text, str) or not text:
+ return text
+
+ for old, new in (
+ ("../../memory/", ".specify/memory/"),
+ ("../../scripts/", ".specify/scripts/"),
+ ("../../templates/", ".specify/templates/"),
+ ):
+ text = text.replace(old, new)
+
+ # Only rewrite top-level style references so extension-local paths like
+ # ".specify/extensions//scripts/..." remain intact.
+ text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
+ text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
+ text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text)
+
+ return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/")
+
def render_markdown_command(
self,
frontmatter: dict,
@@ -277,9 +319,25 @@ def render_toml_command(
toml_lines.append(f"# Source: {source_id}")
toml_lines.append("")
- toml_lines.append('prompt = """')
- toml_lines.append(body)
- toml_lines.append('"""')
+ # Keep TOML output valid even when body contains triple-quote delimiters.
+ # Prefer multiline forms, then fall back to escaped basic string.
+ if '"""' not in body:
+ toml_lines.append('prompt = """')
+ toml_lines.append(body)
+ toml_lines.append('"""')
+ elif "'''" not in body:
+ toml_lines.append("prompt = '''")
+ toml_lines.append(body)
+ toml_lines.append("'''")
+ else:
+ escaped_body = (
+ body.replace("\\", "\\\\")
+ .replace('"', '\\"')
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ )
+ toml_lines.append(f'prompt = "{escaped_body}"')
return "\n".join(toml_lines)
@@ -308,8 +366,8 @@ def render_skill_command(
if not isinstance(frontmatter, dict):
frontmatter = {}
- if agent_name == "codex":
- body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)
+ if agent_name in {"codex", "kimi"}:
+ body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
skill_frontmatter = {
@@ -324,13 +382,8 @@ def render_skill_command(
return self.render_frontmatter(skill_frontmatter) + "\n" + body
@staticmethod
- def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str:
- """Resolve script placeholders for Codex skill overrides.
-
- This intentionally scopes the fix to Codex, which is the newly
- migrated runtime path in this PR. Existing Kimi behavior is left
- unchanged for now.
- """
+ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str:
+ """Resolve script placeholders for skills-backed agents."""
try:
from . import load_init_options
except ImportError:
@@ -346,7 +399,11 @@ def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root
if not isinstance(agent_scripts, dict):
agent_scripts = {}
- script_variant = load_init_options(project_root).get("script")
+ init_opts = load_init_options(project_root)
+ if not isinstance(init_opts, dict):
+ init_opts = {}
+
+ script_variant = init_opts.get("script")
if script_variant not in {"sh", "ps"}:
fallback_order = []
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
@@ -376,7 +433,8 @@ def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
- return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex")
+ body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
+ return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
"""Convert argument placeholder format.
@@ -400,8 +458,9 @@ def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str,
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
+ short_name = short_name.replace(".", "-")
- return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}"
+ return f"speckit-{short_name}"
def register_commands(
self,
diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py
index d71480ac47..b898c65f2a 100644
--- a/src/specify_cli/extensions.py
+++ b/src/specify_cli/extensions.py
@@ -25,6 +25,49 @@
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
+_FALLBACK_CORE_COMMAND_NAMES = frozenset({
+ "analyze",
+ "checklist",
+ "clarify",
+ "constitution",
+ "implement",
+ "plan",
+ "specify",
+ "tasks",
+ "taskstoissues",
+})
+EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
+
+
+def _load_core_command_names() -> frozenset[str]:
+ """Discover bundled core command names from the packaged templates.
+
+ Prefer the wheel-time ``core_pack`` bundle when present, and fall back to
+ the source checkout when running from the repository. If neither is
+ available, use the baked-in fallback set so validation still works.
+ """
+ candidate_dirs = [
+ Path(__file__).parent / "core_pack" / "commands",
+ Path(__file__).resolve().parent.parent.parent / "templates" / "commands",
+ ]
+
+ for commands_dir in candidate_dirs:
+ if not commands_dir.is_dir():
+ continue
+
+ command_names = {
+ command_file.stem
+ for command_file in commands_dir.iterdir()
+ if command_file.is_file() and command_file.suffix == ".md"
+ }
+ if command_names:
+ return frozenset(command_names)
+
+ return _FALLBACK_CORE_COMMAND_NAMES
+
+
+CORE_COMMAND_NAMES = _load_core_command_names()
+
class ExtensionError(Exception):
"""Base exception for extension-related errors."""
@@ -149,7 +192,7 @@ def _validate(self):
raise ValidationError("Command missing 'name' or 'file'")
# Validate command name format
- if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]):
+ if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
raise ValidationError(
f"Invalid command name '{cmd['name']}': "
"must follow pattern 'speckit.{extension}.{command}'"
@@ -446,6 +489,126 @@ def __init__(self, project_root: Path):
self.extensions_dir = project_root / ".specify" / "extensions"
self.registry = ExtensionRegistry(self.extensions_dir)
+ @staticmethod
+ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
+ """Collect command and alias names declared by a manifest.
+
+ Performs install-time validation for extension-specific constraints:
+ - commands and aliases must use the canonical `speckit.{extension}.{command}` shape
+ - commands and aliases must use this extension's namespace
+ - command namespaces must not shadow core commands
+ - duplicate command/alias names inside one manifest are rejected
+
+ Args:
+ manifest: Parsed extension manifest
+
+ Returns:
+ Mapping of declared command/alias name -> kind ("command"/"alias")
+
+ Raises:
+ ValidationError: If any declared name is invalid
+ """
+ if manifest.id in CORE_COMMAND_NAMES:
+ raise ValidationError(
+ f"Extension ID '{manifest.id}' conflicts with core command namespace '{manifest.id}'"
+ )
+
+ declared_names: Dict[str, str] = {}
+
+ for cmd in manifest.commands:
+ primary_name = cmd["name"]
+ aliases = cmd.get("aliases", [])
+
+ if aliases is None:
+ aliases = []
+ if not isinstance(aliases, list):
+ raise ValidationError(
+ f"Aliases for command '{primary_name}' must be a list"
+ )
+
+ for kind, name in [("command", primary_name)] + [
+ ("alias", alias) for alias in aliases
+ ]:
+ if not isinstance(name, str):
+ raise ValidationError(
+ f"{kind.capitalize()} for command '{primary_name}' must be a string"
+ )
+
+ match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
+ if match is None:
+ raise ValidationError(
+ f"Invalid {kind} '{name}': "
+ "must follow pattern 'speckit.{extension}.{command}'"
+ )
+
+ namespace = match.group(1)
+ if namespace != manifest.id:
+ raise ValidationError(
+ f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
+ )
+
+ if namespace in CORE_COMMAND_NAMES:
+ raise ValidationError(
+ f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
+ )
+
+ if name in declared_names:
+ raise ValidationError(
+ f"Duplicate command or alias '{name}' in extension manifest"
+ )
+
+ declared_names[name] = kind
+
+ return declared_names
+
+ def _get_installed_command_name_map(
+ self,
+ exclude_extension_id: Optional[str] = None,
+ ) -> Dict[str, str]:
+ """Return registered command and alias names for installed extensions."""
+ installed_names: Dict[str, str] = {}
+
+ for ext_id in self.registry.keys():
+ if ext_id == exclude_extension_id:
+ continue
+
+ manifest = self.get_extension(ext_id)
+ if manifest is None:
+ continue
+
+ for cmd in manifest.commands:
+ cmd_name = cmd.get("name")
+ if isinstance(cmd_name, str):
+ installed_names.setdefault(cmd_name, ext_id)
+
+ aliases = cmd.get("aliases", [])
+ if not isinstance(aliases, list):
+ continue
+
+ for alias in aliases:
+ if isinstance(alias, str):
+ installed_names.setdefault(alias, ext_id)
+
+ return installed_names
+
+ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None:
+ """Reject installs that would shadow core or installed extension commands."""
+ declared_names = self._collect_manifest_command_names(manifest)
+ installed_names = self._get_installed_command_name_map(
+ exclude_extension_id=manifest.id
+ )
+
+ collisions = [
+ f"{name} (already provided by extension '{installed_names[name]}')"
+ for name in sorted(declared_names)
+ if name in installed_names
+ ]
+ if collisions:
+ raise ValidationError(
+ "Extension commands conflict with installed extensions:\n- "
+ + "\n- ".join(collisions)
+ )
+
@staticmethod
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
"""Load .extensionignore and return an ignore function for shutil.copytree.
@@ -511,24 +674,32 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]:
return _ignore
def _get_skills_dir(self) -> Optional[Path]:
- """Return the skills directory if ``--ai-skills`` was used during init.
+ """Return the active skills directory for extension skill registration.
Reads ``.specify/init-options.json`` to determine whether skills
are enabled and which agent was selected, then delegates to
the module-level ``_get_skills_dir()`` helper for the concrete path.
+ Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
+ ``.kimi/skills`` exists, extension installs should still propagate
+ command skills even when ``ai_skills`` is false.
+
Returns:
The skills directory ``Path``, or ``None`` if skills were not
- enabled or the init-options file is missing.
+ enabled and no native-skills fallback applies.
"""
from . import load_init_options, _get_skills_dir as resolve_skills_dir
opts = load_init_options(self.project_root)
- if not opts.get("ai_skills"):
- return None
+ if not isinstance(opts, dict):
+ opts = {}
agent = opts.get("ai")
- if not agent:
+ if not isinstance(agent, str) or not agent:
+ return None
+
+ ai_skills_enabled = bool(opts.get("ai_skills"))
+ if not ai_skills_enabled and agent != "kimi":
return None
skills_dir = resolve_skills_dir(self.project_root, agent)
@@ -561,12 +732,17 @@ def _register_extension_skills(
return []
from . import load_init_options
+ from .agents import CommandRegistrar
import yaml
- opts = load_init_options(self.project_root)
- selected_ai = opts.get("ai", "")
-
written: List[str] = []
+ opts = load_init_options(self.project_root)
+ if not isinstance(opts, dict):
+ opts = {}
+ selected_ai = opts.get("ai")
+ if not isinstance(selected_ai, str) or not selected_ai:
+ return []
+ registrar = CommandRegistrar()
for cmd_info in manifest.commands:
cmd_name = cmd_info["name"]
@@ -587,17 +763,12 @@ def _register_extension_skills(
if not source_file.is_file():
continue
- # Derive skill name from command name, matching the convention used by
- # presets.py: strip the leading "speckit." prefix, then form:
- # Kimi β "speckit.{short_name}" (dot preserved for Kimi agent)
- # other β "speckit-{short_name}" (hyphen separator)
+ # Derive skill name from command name using the same hyphenated
+ # convention as hook rendering and preset skill registration.
short_name_raw = cmd_name
if short_name_raw.startswith("speckit."):
short_name_raw = short_name_raw[len("speckit."):]
- if selected_ai == "kimi":
- skill_name = f"speckit.{short_name_raw}"
- else:
- skill_name = f"speckit-{short_name_raw}"
+ skill_name = f"speckit-{short_name_raw.replace('.', '-')}"
# Check if skill already exists before creating the directory
skill_subdir = skills_dir / skill_name
@@ -621,22 +792,11 @@ def _register_extension_skills(
except OSError:
pass # best-effort cleanup
continue
- if content.startswith("---"):
- parts = content.split("---", 2)
- if len(parts) >= 3:
- try:
- frontmatter = yaml.safe_load(parts[1])
- except yaml.YAMLError:
- frontmatter = {}
- if not isinstance(frontmatter, dict):
- frontmatter = {}
- body = parts[2].strip()
- else:
- frontmatter = {}
- body = content
- else:
- frontmatter = {}
- body = content
+ frontmatter, body = registrar.parse_frontmatter(content)
+ frontmatter = registrar._adjust_script_paths(frontmatter)
+ body = registrar.resolve_skill_placeholders(
+ selected_ai, frontmatter, body, self.project_root
+ )
original_desc = frontmatter.get("description", "")
description = original_desc or f"Extension command: {cmd_name}"
@@ -738,11 +898,9 @@ def _unregister_extension_skills(self, skill_names: List[str], extension_id: str
shutil.rmtree(skill_subdir)
else:
# Fallback: scan all possible agent skills directories
- from . import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR
+ from . import AGENT_CONFIG, DEFAULT_SKILLS_DIR
candidate_dirs: set[Path] = set()
- for override_path in AGENT_SKILLS_DIR_OVERRIDES.values():
- candidate_dirs.add(self.project_root / override_path)
for cfg in AGENT_CONFIG.values():
folder = cfg.get("folder", "")
if folder:
@@ -866,6 +1024,9 @@ def install_from_directory(
f"Use 'specify extension remove {manifest.id}' first."
)
+ # Reject manifests that would shadow core commands or installed extensions.
+ self._validate_install_conflicts(manifest)
+
# Install extension
dest_dir = self.extensions_dir / manifest.id
if dest_dir.exists():
@@ -1940,6 +2101,52 @@ def __init__(self, project_root: Path):
self.project_root = project_root
self.extensions_dir = project_root / ".specify" / "extensions"
self.config_file = project_root / ".specify" / "extensions.yml"
+ self._init_options_cache: Optional[Dict[str, Any]] = None
+
+ def _load_init_options(self) -> Dict[str, Any]:
+ """Load persisted init options used to determine invocation style.
+
+ Uses the shared helper from specify_cli and caches values per executor
+ instance to avoid repeated filesystem reads during hook rendering.
+ """
+ if self._init_options_cache is None:
+ from . import load_init_options
+
+ payload = load_init_options(self.project_root)
+ self._init_options_cache = payload if isinstance(payload, dict) else {}
+ return self._init_options_cache
+
+ @staticmethod
+ def _skill_name_from_command(command: Any) -> str:
+ """Map a command id like speckit.plan to speckit-plan skill name."""
+ if not isinstance(command, str):
+ return ""
+ command_id = command.strip()
+ if not command_id.startswith("speckit."):
+ return ""
+ return f"speckit-{command_id[len('speckit.'):].replace('.', '-')}"
+
+ def _render_hook_invocation(self, command: Any) -> str:
+ """Render an agent-specific invocation string for a hook command."""
+ if not isinstance(command, str):
+ return ""
+
+ command_id = command.strip()
+ if not command_id:
+ return ""
+
+ init_options = self._load_init_options()
+ selected_ai = init_options.get("ai")
+ codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
+ kimi_skill_mode = selected_ai == "kimi"
+
+ skill_name = self._skill_name_from_command(command_id)
+ if codex_skill_mode and skill_name:
+ return f"${skill_name}"
+ if kimi_skill_mode and skill_name:
+ return f"/skill:{skill_name}"
+
+ return f"/{command_id}"
def get_project_config(self) -> Dict[str, Any]:
"""Load project-level extension configuration.
@@ -2183,21 +2390,27 @@ def format_hook_message(
for hook in hooks:
extension = hook.get("extension")
command = hook.get("command")
+ invocation = self._render_hook_invocation(command)
+ command_text = command if isinstance(command, str) and command.strip() else ""
+ display_invocation = invocation or (
+ f"/{command_text}" if command_text != "" else "/"
+ )
optional = hook.get("optional", True)
prompt = hook.get("prompt", "")
description = hook.get("description", "")
if optional:
lines.append(f"\n**Optional Hook**: {extension}")
- lines.append(f"Command: `/{command}`")
+ lines.append(f"Command: `{display_invocation}`")
if description:
lines.append(f"Description: {description}")
lines.append(f"\nPrompt: {prompt}")
- lines.append(f"To execute: `/{command}`")
+ lines.append(f"To execute: `{display_invocation}`")
else:
lines.append(f"\n**Automatic Hook**: {extension}")
- lines.append(f"Executing: `/{command}`")
- lines.append(f"EXECUTE_COMMAND: {command}")
+ lines.append(f"Executing: `{display_invocation}`")
+ lines.append(f"EXECUTE_COMMAND: {command_text}")
+ lines.append(f"EXECUTE_COMMAND_INVOCATION: {display_invocation}")
return "\n".join(lines)
@@ -2261,6 +2474,7 @@ def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]:
"""
return {
"command": hook.get("command"),
+ "invocation": self._render_hook_invocation(hook.get("command")),
"extension": hook.get("extension"),
"optional": hook.get("optional", True),
"description": hook.get("description", ""),
@@ -2304,4 +2518,3 @@ def disable_hooks(self, extension_id: str):
hook["enabled"] = False
self.save_project_config(config)
-
diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py
new file mode 100644
index 0000000000..bb87cec996
--- /dev/null
+++ b/src/specify_cli/integrations/__init__.py
@@ -0,0 +1,105 @@
+"""Integration registry for AI coding assistants.
+
+Each integration is a self-contained subpackage that handles setup/teardown
+for a specific AI assistant (Copilot, Claude, Gemini, etc.).
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .base import IntegrationBase
+
+# Maps integration key β IntegrationBase instance.
+# Populated by later stages as integrations are migrated.
+INTEGRATION_REGISTRY: dict[str, IntegrationBase] = {}
+
+
+def _register(integration: IntegrationBase) -> None:
+ """Register an integration instance in the global registry.
+
+ Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
+ """
+ key = integration.key
+ if not key:
+ raise ValueError("Cannot register integration with an empty key.")
+ if key in INTEGRATION_REGISTRY:
+ raise KeyError(f"Integration with key {key!r} is already registered.")
+ INTEGRATION_REGISTRY[key] = integration
+
+
+def get_integration(key: str) -> IntegrationBase | None:
+ """Return the integration for *key*, or ``None`` if not registered."""
+ return INTEGRATION_REGISTRY.get(key)
+
+
+# -- Register built-in integrations --------------------------------------
+
+def _register_builtins() -> None:
+ """Register all built-in integrations.
+
+ Package directories use Python-safe identifiers (e.g. ``kiro_cli``,
+ ``cursor_agent``). The user-facing integration key stored in
+ ``IntegrationBase.key`` stays hyphenated (``"kiro-cli"``,
+ ``"cursor-agent"``) to match the actual CLI tool / binary name that
+ users install and invoke.
+ """
+ # -- Imports (alphabetical) -------------------------------------------
+ from .agy import AgyIntegration
+ from .amp import AmpIntegration
+ from .auggie import AuggieIntegration
+ from .bob import BobIntegration
+ from .claude import ClaudeIntegration
+ from .codex import CodexIntegration
+ from .codebuddy import CodebuddyIntegration
+ from .copilot import CopilotIntegration
+ from .cursor_agent import CursorAgentIntegration
+ from .gemini import GeminiIntegration
+ from .generic import GenericIntegration
+ from .iflow import IflowIntegration
+ from .junie import JunieIntegration
+ from .kilocode import KilocodeIntegration
+ from .kimi import KimiIntegration
+ from .kiro_cli import KiroCliIntegration
+ from .opencode import OpencodeIntegration
+ from .pi import PiIntegration
+ from .qodercli import QodercliIntegration
+ from .qwen import QwenIntegration
+ from .roo import RooIntegration
+ from .shai import ShaiIntegration
+ from .tabnine import TabnineIntegration
+ from .trae import TraeIntegration
+ from .vibe import VibeIntegration
+ from .windsurf import WindsurfIntegration
+
+ # -- Registration (alphabetical) --------------------------------------
+ _register(AgyIntegration())
+ _register(AmpIntegration())
+ _register(AuggieIntegration())
+ _register(BobIntegration())
+ _register(ClaudeIntegration())
+ _register(CodexIntegration())
+ _register(CodebuddyIntegration())
+ _register(CopilotIntegration())
+ _register(CursorAgentIntegration())
+ _register(GeminiIntegration())
+ _register(GenericIntegration())
+ _register(IflowIntegration())
+ _register(JunieIntegration())
+ _register(KilocodeIntegration())
+ _register(KimiIntegration())
+ _register(KiroCliIntegration())
+ _register(OpencodeIntegration())
+ _register(PiIntegration())
+ _register(QodercliIntegration())
+ _register(QwenIntegration())
+ _register(RooIntegration())
+ _register(ShaiIntegration())
+ _register(TabnineIntegration())
+ _register(TraeIntegration())
+ _register(VibeIntegration())
+ _register(WindsurfIntegration())
+
+
+_register_builtins()
diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py
new file mode 100644
index 0000000000..9cd522745e
--- /dev/null
+++ b/src/specify_cli/integrations/agy/__init__.py
@@ -0,0 +1,41 @@
+"""Antigravity (agy) integration β skills-based agent.
+
+Antigravity uses ``.agent/skills/speckit-/SKILL.md`` layout.
+Explicit command support was deprecated in version 1.20.5;
+``--skills`` defaults to ``True``.
+"""
+
+from __future__ import annotations
+
+from ..base import IntegrationOption, SkillsIntegration
+
+
+class AgyIntegration(SkillsIntegration):
+ """Integration for Antigravity IDE."""
+
+ key = "agy"
+ config = {
+ "name": "Antigravity",
+ "folder": ".agent/",
+ "commands_subdir": "skills",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".agent/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
+ }
+ context_file = "AGENTS.md"
+
+ @classmethod
+ def options(cls) -> list[IntegrationOption]:
+ return [
+ IntegrationOption(
+ "--skills",
+ is_flag=True,
+ default=True,
+ help="Install as agent skills (default for Antigravity since v1.20.5)",
+ ),
+ ]
diff --git a/src/specify_cli/integrations/agy/scripts/update-context.ps1 b/src/specify_cli/integrations/agy/scripts/update-context.ps1
new file mode 100644
index 0000000000..9eeb461657
--- /dev/null
+++ b/src/specify_cli/integrations/agy/scripts/update-context.ps1
@@ -0,0 +1,17 @@
+# update-context.ps1 β Antigravity (agy) integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+
+$ErrorActionPreference = 'Stop'
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy
diff --git a/src/specify_cli/integrations/agy/scripts/update-context.sh b/src/specify_cli/integrations/agy/scripts/update-context.sh
new file mode 100755
index 0000000000..d7303f6197
--- /dev/null
+++ b/src/specify_cli/integrations/agy/scripts/update-context.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# update-context.sh β Antigravity (agy) integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+
+set -euo pipefail
+
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy
diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py
new file mode 100644
index 0000000000..39df0a9bbf
--- /dev/null
+++ b/src/specify_cli/integrations/amp/__init__.py
@@ -0,0 +1,21 @@
+"""Amp CLI integration."""
+
+from ..base import MarkdownIntegration
+
+
+class AmpIntegration(MarkdownIntegration):
+ key = "amp"
+ config = {
+ "name": "Amp",
+ "folder": ".agents/",
+ "commands_subdir": "commands",
+ "install_url": "https://ampcode.com/manual#install",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".agents/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "AGENTS.md"
diff --git a/src/specify_cli/integrations/amp/scripts/update-context.ps1 b/src/specify_cli/integrations/amp/scripts/update-context.ps1
new file mode 100644
index 0000000000..c217b99f9a
--- /dev/null
+++ b/src/specify_cli/integrations/amp/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Amp integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType amp
diff --git a/src/specify_cli/integrations/amp/scripts/update-context.sh b/src/specify_cli/integrations/amp/scripts/update-context.sh
new file mode 100755
index 0000000000..56cbf6e787
--- /dev/null
+++ b/src/specify_cli/integrations/amp/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Amp integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" amp
diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py
new file mode 100644
index 0000000000..9715e936ef
--- /dev/null
+++ b/src/specify_cli/integrations/auggie/__init__.py
@@ -0,0 +1,21 @@
+"""Auggie CLI integration."""
+
+from ..base import MarkdownIntegration
+
+
+class AuggieIntegration(MarkdownIntegration):
+ key = "auggie"
+ config = {
+ "name": "Auggie CLI",
+ "folder": ".augment/",
+ "commands_subdir": "commands",
+ "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".augment/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = ".augment/rules/specify-rules.md"
diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 b/src/specify_cli/integrations/auggie/scripts/update-context.ps1
new file mode 100644
index 0000000000..49e7e6b5f3
--- /dev/null
+++ b/src/specify_cli/integrations/auggie/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Auggie CLI integration: create/update .augment/rules/specify-rules.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType auggie
diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.sh b/src/specify_cli/integrations/auggie/scripts/update-context.sh
new file mode 100755
index 0000000000..4cf80bba2b
--- /dev/null
+++ b/src/specify_cli/integrations/auggie/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Auggie CLI integration: create/update .augment/rules/specify-rules.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" auggie
diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py
new file mode 100644
index 0000000000..dac5063f5c
--- /dev/null
+++ b/src/specify_cli/integrations/base.py
@@ -0,0 +1,793 @@
+"""Base classes for AI-assistant integrations.
+
+Provides:
+- ``IntegrationOption`` β declares a CLI option an integration accepts.
+- ``IntegrationBase`` β abstract base every integration must implement.
+- ``MarkdownIntegration`` β concrete base for standard Markdown-format
+ integrations (the common case β subclass, set three class attrs, done).
+- ``TomlIntegration`` β concrete base for TOML-format integrations
+ (Gemini, Tabnine β subclass, set three class attrs, done).
+- ``SkillsIntegration`` β concrete base for integrations that install
+ commands as agent skills (``speckit-/SKILL.md`` layout).
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+from abc import ABC
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from .manifest import IntegrationManifest
+
+
+# ---------------------------------------------------------------------------
+# IntegrationOption
+# ---------------------------------------------------------------------------
+
+@dataclass(frozen=True)
+class IntegrationOption:
+ """Declares an option that an integration accepts via ``--integration-options``.
+
+ Attributes:
+ name: The flag name (e.g. ``"--commands-dir"``).
+ is_flag: ``True`` for boolean flags (``--skills``).
+ required: ``True`` if the option must be supplied.
+ default: Default value when not supplied (``None`` β no default).
+ help: One-line description shown in ``specify integrate info``.
+ """
+
+ name: str
+ is_flag: bool = False
+ required: bool = False
+ default: Any = None
+ help: str = ""
+
+
+# ---------------------------------------------------------------------------
+# IntegrationBase β abstract base class
+# ---------------------------------------------------------------------------
+
+class IntegrationBase(ABC):
+ """Abstract base class every integration must implement.
+
+ Subclasses must set the following class-level attributes:
+
+ * ``key`` β unique identifier, matches actual CLI tool name
+ * ``config`` β dict compatible with ``AGENT_CONFIG`` entries
+ * ``registrar_config`` β dict compatible with ``CommandRegistrar.AGENT_CONFIGS``
+
+ And may optionally set:
+
+ * ``context_file`` β path (relative to project root) of the agent
+ context/instructions file (e.g. ``"CLAUDE.md"``)
+ """
+
+ # -- Must be set by every subclass ------------------------------------
+
+ key: str = ""
+ """Unique integration key β should match the actual CLI tool name."""
+
+ config: dict[str, Any] | None = None
+ """Metadata dict matching the ``AGENT_CONFIG`` shape."""
+
+ registrar_config: dict[str, Any] | None = None
+ """Registration dict matching ``CommandRegistrar.AGENT_CONFIGS`` shape."""
+
+ # -- Optional ---------------------------------------------------------
+
+ context_file: str | None = None
+ """Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
+
+ # -- Public API -------------------------------------------------------
+
+ @classmethod
+ def options(cls) -> list[IntegrationOption]:
+ """Return options this integration accepts. Default: none."""
+ return []
+
+ # -- Primitives β building blocks for setup() -------------------------
+
+ def shared_commands_dir(self) -> Path | None:
+ """Return path to the shared command templates directory.
+
+ Checks ``core_pack/commands/`` (wheel install) first, then
+ ``templates/commands/`` (source checkout). Returns ``None``
+ if neither exists.
+ """
+ import inspect
+
+ pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent
+ for candidate in [
+ pkg_dir / "core_pack" / "commands",
+ pkg_dir.parent.parent / "templates" / "commands",
+ ]:
+ if candidate.is_dir():
+ return candidate
+ return None
+
+ def shared_templates_dir(self) -> Path | None:
+ """Return path to the shared page templates directory.
+
+ Contains ``vscode-settings.json``, ``spec-template.md``, etc.
+ Checks ``core_pack/templates/`` then ``templates/``.
+ """
+ import inspect
+
+ pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent
+ for candidate in [
+ pkg_dir / "core_pack" / "templates",
+ pkg_dir.parent.parent / "templates",
+ ]:
+ if candidate.is_dir():
+ return candidate
+ return None
+
+ def list_command_templates(self) -> list[Path]:
+ """Return sorted list of command template files from the shared directory."""
+ cmd_dir = self.shared_commands_dir()
+ if not cmd_dir or not cmd_dir.is_dir():
+ return []
+ return sorted(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md")
+
+ def command_filename(self, template_name: str) -> str:
+ """Return the destination filename for a command template.
+
+ *template_name* is the stem of the source file (e.g. ``"plan"``).
+ Default: ``speckit.{template_name}.md``. Subclasses override
+ to change the extension or naming convention.
+ """
+ return f"speckit.{template_name}.md"
+
+ def commands_dest(self, project_root: Path) -> Path:
+ """Return the absolute path to the commands output directory.
+
+ Derived from ``config["folder"]`` and ``config["commands_subdir"]``.
+ Raises ``ValueError`` if ``config`` or ``folder`` is missing.
+ """
+ if not self.config:
+ raise ValueError(
+ f"{type(self).__name__}.config is not set; integration "
+ "subclasses must define a non-empty 'config' mapping."
+ )
+ folder = self.config.get("folder")
+ if not folder:
+ raise ValueError(
+ f"{type(self).__name__}.config is missing required 'folder' entry."
+ )
+ subdir = self.config.get("commands_subdir", "commands")
+ return project_root / folder / subdir
+
+ # -- File operations β granular primitives for setup() ----------------
+
+ @staticmethod
+ def copy_command_to_directory(
+ src: Path,
+ dest_dir: Path,
+ filename: str,
+ ) -> Path:
+ """Copy a command template to *dest_dir* with the given *filename*.
+
+ Creates *dest_dir* if needed. Returns the absolute path of the
+ written file. The caller can post-process the file before
+ recording it in the manifest.
+ """
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ dst = dest_dir / filename
+ shutil.copy2(src, dst)
+ return dst
+
+ @staticmethod
+ def record_file_in_manifest(
+ file_path: Path,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ ) -> None:
+ """Hash *file_path* and record it in *manifest*.
+
+ *file_path* must be inside *project_root*.
+ """
+ rel = file_path.resolve().relative_to(project_root.resolve())
+ manifest.record_existing(rel)
+
+ @staticmethod
+ def write_file_and_record(
+ content: str,
+ dest: Path,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ ) -> Path:
+ """Write *content* to *dest*, hash it, and record in *manifest*.
+
+ Creates parent directories as needed. Writes bytes directly to
+ avoid platform newline translation (CRLF on Windows). Any
+ ``\r\n`` sequences in *content* are normalised to ``\n`` before
+ writing. Returns *dest*.
+ """
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ normalized = content.replace("\r\n", "\n")
+ dest.write_bytes(normalized.encode("utf-8"))
+ rel = dest.resolve().relative_to(project_root.resolve())
+ manifest.record_existing(rel)
+ return dest
+
+ def integration_scripts_dir(self) -> Path | None:
+ """Return path to this integration's bundled ``scripts/`` directory.
+
+ Looks for a ``scripts/`` sibling of the module that defines the
+ concrete subclass (not ``IntegrationBase`` itself).
+ Returns ``None`` if the directory doesn't exist.
+ """
+ import inspect
+
+ cls_file = inspect.getfile(type(self))
+ scripts = Path(cls_file).resolve().parent / "scripts"
+ return scripts if scripts.is_dir() else None
+
+ def install_scripts(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ ) -> list[Path]:
+ """Copy integration-specific scripts into the project.
+
+ Copies files from this integration's ``scripts/`` directory to
+ ``.specify/integrations//scripts/`` in the project. Shell
+ scripts are made executable. All copied files are recorded in
+ *manifest*.
+
+ Returns the list of files created.
+ """
+ scripts_src = self.integration_scripts_dir()
+ if not scripts_src:
+ return []
+
+ created: list[Path] = []
+ scripts_dest = project_root / ".specify" / "integrations" / self.key / "scripts"
+ scripts_dest.mkdir(parents=True, exist_ok=True)
+
+ for src_script in sorted(scripts_src.iterdir()):
+ if not src_script.is_file():
+ continue
+ dst_script = scripts_dest / src_script.name
+ shutil.copy2(src_script, dst_script)
+ if dst_script.suffix == ".sh":
+ dst_script.chmod(dst_script.stat().st_mode | 0o111)
+ self.record_file_in_manifest(dst_script, project_root, manifest)
+ created.append(dst_script)
+
+ return created
+
+ @staticmethod
+ def process_template(
+ content: str,
+ agent_name: str,
+ script_type: str,
+ arg_placeholder: str = "$ARGUMENTS",
+ ) -> str:
+ """Process a raw command template into agent-ready content.
+
+ Performs the same transformations as the release script:
+ 1. Extract ``scripts.`` value from YAML frontmatter
+ 2. Replace ``{SCRIPT}`` with the extracted script command
+ 3. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}``
+ 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter
+ 5. Replace ``{ARGS}`` with *arg_placeholder*
+ 6. Replace ``__AGENT__`` with *agent_name*
+ 7. Rewrite paths: ``scripts/`` β ``.specify/scripts/`` etc.
+ """
+ # 1. Extract script command from frontmatter
+ script_command = ""
+ script_pattern = re.compile(
+ rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
+ )
+ # Find the scripts: block
+ in_scripts = False
+ for line in content.splitlines():
+ if line.strip() == "scripts:":
+ in_scripts = True
+ continue
+ if in_scripts and line and not line[0].isspace():
+ in_scripts = False
+ if in_scripts:
+ m = script_pattern.match(line)
+ if m:
+ script_command = m.group(1).strip()
+ break
+
+ # 2. Replace {SCRIPT}
+ if script_command:
+ content = content.replace("{SCRIPT}", script_command)
+
+ # 3. Extract agent_script command
+ agent_script_command = ""
+ in_agent_scripts = False
+ for line in content.splitlines():
+ if line.strip() == "agent_scripts:":
+ in_agent_scripts = True
+ continue
+ if in_agent_scripts and line and not line[0].isspace():
+ in_agent_scripts = False
+ if in_agent_scripts:
+ m = script_pattern.match(line)
+ if m:
+ agent_script_command = m.group(1).strip()
+ break
+
+ if agent_script_command:
+ content = content.replace("{AGENT_SCRIPT}", agent_script_command)
+
+ # 4. Strip scripts: and agent_scripts: sections from frontmatter
+ lines = content.splitlines(keepends=True)
+ output_lines: list[str] = []
+ in_frontmatter = False
+ skip_section = False
+ dash_count = 0
+ for line in lines:
+ stripped = line.rstrip("\n\r")
+ if stripped == "---":
+ dash_count += 1
+ if dash_count == 1:
+ in_frontmatter = True
+ else:
+ in_frontmatter = False
+ skip_section = False
+ output_lines.append(line)
+ continue
+ if in_frontmatter:
+ if stripped in ("scripts:", "agent_scripts:"):
+ skip_section = True
+ continue
+ if skip_section:
+ if line[0:1].isspace():
+ continue # skip indented content under scripts/agent_scripts
+ skip_section = False
+ output_lines.append(line)
+ content = "".join(output_lines)
+
+ # 5. Replace {ARGS}
+ content = content.replace("{ARGS}", arg_placeholder)
+
+ # 6. Replace __AGENT__
+ content = content.replace("__AGENT__", agent_name)
+
+ # 7. Rewrite paths β delegate to the shared implementation in
+ # CommandRegistrar so extension-local paths are preserved and
+ # boundary rules stay consistent across the codebase.
+ from specify_cli.agents import CommandRegistrar
+ content = CommandRegistrar.rewrite_project_relative_paths(content)
+
+ return content
+
+ def setup(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ """Install integration command files into *project_root*.
+
+ Returns the list of files created. Copies raw templates without
+ processing. Integrations that need placeholder replacement
+ (e.g. ``{SCRIPT}``, ``__AGENT__``) should override ``setup()``
+ and call ``process_template()`` in their own loop β see
+ ``CopilotIntegration`` for an example.
+ """
+ templates = self.list_command_templates()
+ if not templates:
+ return []
+
+ project_root_resolved = project_root.resolve()
+ if manifest.project_root != project_root_resolved:
+ raise ValueError(
+ f"manifest.project_root ({manifest.project_root}) does not match "
+ f"project_root ({project_root_resolved})"
+ )
+
+ dest = self.commands_dest(project_root).resolve()
+ try:
+ dest.relative_to(project_root_resolved)
+ except ValueError as exc:
+ raise ValueError(
+ f"Integration destination {dest} escapes "
+ f"project root {project_root_resolved}"
+ ) from exc
+
+ created: list[Path] = []
+
+ for src_file in templates:
+ dst_name = self.command_filename(src_file.stem)
+ dst_file = self.copy_command_to_directory(src_file, dest, dst_name)
+ self.record_file_in_manifest(dst_file, project_root, manifest)
+ created.append(dst_file)
+
+ return created
+
+ def teardown(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ *,
+ force: bool = False,
+ ) -> tuple[list[Path], list[Path]]:
+ """Uninstall integration files from *project_root*.
+
+ Delegates to ``manifest.uninstall()`` which only removes files
+ whose hash still matches the recorded value (unless *force*).
+
+ Returns ``(removed, skipped)`` file lists.
+ """
+ return manifest.uninstall(project_root, force=force)
+
+ # -- Convenience helpers for subclasses -------------------------------
+
+ def install(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ """High-level install β calls ``setup()`` and returns created files."""
+ return self.setup(
+ project_root, manifest, parsed_options=parsed_options, **opts
+ )
+
+ def uninstall(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ *,
+ force: bool = False,
+ ) -> tuple[list[Path], list[Path]]:
+ """High-level uninstall β calls ``teardown()``."""
+ return self.teardown(project_root, manifest, force=force)
+
+
+# ---------------------------------------------------------------------------
+# MarkdownIntegration β covers ~20 standard agents
+# ---------------------------------------------------------------------------
+
+class MarkdownIntegration(IntegrationBase):
+ """Concrete base for integrations that use standard Markdown commands.
+
+ Subclasses only need to set ``key``, ``config``, ``registrar_config``
+ (and optionally ``context_file``). Everything else is inherited.
+
+ ``setup()`` processes command templates (replacing ``{SCRIPT}``,
+ ``{ARGS}``, ``__AGENT__``, rewriting paths) and installs
+ integration-specific scripts (``update-context.sh`` / ``.ps1``).
+ """
+
+ def setup(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ templates = self.list_command_templates()
+ if not templates:
+ return []
+
+ project_root_resolved = project_root.resolve()
+ if manifest.project_root != project_root_resolved:
+ raise ValueError(
+ f"manifest.project_root ({manifest.project_root}) does not match "
+ f"project_root ({project_root_resolved})"
+ )
+
+ dest = self.commands_dest(project_root).resolve()
+ try:
+ dest.relative_to(project_root_resolved)
+ except ValueError as exc:
+ raise ValueError(
+ f"Integration destination {dest} escapes "
+ f"project root {project_root_resolved}"
+ ) from exc
+ dest.mkdir(parents=True, exist_ok=True)
+
+ script_type = opts.get("script_type", "sh")
+ arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS"
+ created: list[Path] = []
+
+ for src_file in templates:
+ raw = src_file.read_text(encoding="utf-8")
+ processed = self.process_template(raw, self.key, script_type, arg_placeholder)
+ dst_name = self.command_filename(src_file.stem)
+ dst_file = self.write_file_and_record(
+ processed, dest / dst_name, project_root, manifest
+ )
+ created.append(dst_file)
+
+ created.extend(self.install_scripts(project_root, manifest))
+ return created
+
+
+# ---------------------------------------------------------------------------
+# TomlIntegration β TOML-format agents (Gemini, Tabnine)
+# ---------------------------------------------------------------------------
+
+class TomlIntegration(IntegrationBase):
+ """Concrete base for integrations that use TOML command format.
+
+ Mirrors ``MarkdownIntegration`` closely: subclasses only need to set
+ ``key``, ``config``, ``registrar_config`` (and optionally
+ ``context_file``). Everything else is inherited.
+
+ ``setup()`` processes command templates through the same placeholder
+ pipeline as ``MarkdownIntegration``, then converts the result to
+ TOML format (``description`` key + ``prompt`` multiline string).
+ """
+
+ def command_filename(self, template_name: str) -> str:
+ """TOML commands use ``.toml`` extension."""
+ return f"speckit.{template_name}.toml"
+
+ @staticmethod
+ def _extract_description(content: str) -> str:
+ """Extract the ``description`` value from YAML frontmatter.
+
+ Scans lines between the first pair of ``---`` delimiters for a
+ top-level ``description:`` key. Returns the value (with
+ surrounding quotes stripped) or an empty string if not found.
+ """
+ in_frontmatter = False
+ for line in content.splitlines():
+ stripped = line.rstrip("\n\r")
+ if stripped == "---":
+ if not in_frontmatter:
+ in_frontmatter = True
+ continue
+ break # second ---
+ if in_frontmatter and stripped.startswith("description:"):
+ _, _, value = stripped.partition(":")
+ return value.strip().strip('"').strip("'")
+ return ""
+
+ @staticmethod
+ def _render_toml(description: str, body: str) -> str:
+ """Render a TOML command file from description and body.
+
+ Uses multiline basic strings (``\"\"\"``) with backslashes
+ escaped, matching the output of the release script. Falls back
+ to multiline literal strings (``'''``) if the body contains
+ ``\"\"\"``, then to an escaped basic string as a last resort.
+
+ The body is rstrip'd so the closing delimiter appears on the line
+ immediately after the last content line β matching the release
+ script's ``echo "$body"; echo '\"\"\"'`` pattern.
+ """
+ toml_lines: list[str] = []
+
+ if description:
+ desc = description.replace('"', '\\"')
+ toml_lines.append(f'description = "{desc}"')
+ toml_lines.append("")
+
+ body = body.rstrip("\n")
+
+ # Escape backslashes for basic multiline strings.
+ escaped = body.replace("\\", "\\\\")
+
+ if '"""' not in escaped:
+ toml_lines.append('prompt = """')
+ toml_lines.append(escaped)
+ toml_lines.append('"""')
+ elif "'''" not in body:
+ toml_lines.append("prompt = '''")
+ toml_lines.append(body)
+ toml_lines.append("'''")
+ else:
+ escaped_body = (
+ body.replace("\\", "\\\\")
+ .replace('"', '\\"')
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ )
+ toml_lines.append(f'prompt = "{escaped_body}"')
+
+ return "\n".join(toml_lines) + "\n"
+
+ def setup(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ templates = self.list_command_templates()
+ if not templates:
+ return []
+
+ project_root_resolved = project_root.resolve()
+ if manifest.project_root != project_root_resolved:
+ raise ValueError(
+ f"manifest.project_root ({manifest.project_root}) does not match "
+ f"project_root ({project_root_resolved})"
+ )
+
+ dest = self.commands_dest(project_root).resolve()
+ try:
+ dest.relative_to(project_root_resolved)
+ except ValueError as exc:
+ raise ValueError(
+ f"Integration destination {dest} escapes "
+ f"project root {project_root_resolved}"
+ ) from exc
+ dest.mkdir(parents=True, exist_ok=True)
+
+ script_type = opts.get("script_type", "sh")
+ arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}"
+ created: list[Path] = []
+
+ for src_file in templates:
+ raw = src_file.read_text(encoding="utf-8")
+ description = self._extract_description(raw)
+ processed = self.process_template(raw, self.key, script_type, arg_placeholder)
+ toml_content = self._render_toml(description, processed)
+ dst_name = self.command_filename(src_file.stem)
+ dst_file = self.write_file_and_record(
+ toml_content, dest / dst_name, project_root, manifest
+ )
+ created.append(dst_file)
+
+ created.extend(self.install_scripts(project_root, manifest))
+ return created
+
+
+# ---------------------------------------------------------------------------
+# SkillsIntegration β skills-format agents (Codex, Kimi, Agy)
+# ---------------------------------------------------------------------------
+
+
+class SkillsIntegration(IntegrationBase):
+ """Concrete base for integrations that install commands as agent skills.
+
+ Skills use the ``speckit-/SKILL.md`` directory layout following
+ the `agentskills.io `_ spec.
+
+ Subclasses set ``key``, ``config``, ``registrar_config`` (and
+ optionally ``context_file``) like any integration. They may also
+ override ``options()`` to declare additional CLI flags (e.g.
+ ``--skills``, ``--migrate-legacy``).
+
+ ``setup()`` processes each shared command template into a
+ ``speckit-/SKILL.md`` file with skills-oriented frontmatter.
+ """
+
+ def skills_dest(self, project_root: Path) -> Path:
+ """Return the absolute path to the skills output directory.
+
+ Derived from ``config["folder"]`` and the configured
+ ``commands_subdir`` (defaults to ``"skills"``).
+
+ Raises ``ValueError`` when ``config`` or ``folder`` is missing.
+ """
+ if not self.config:
+ raise ValueError(
+ f"{type(self).__name__}.config is not set."
+ )
+ folder = self.config.get("folder")
+ if not folder:
+ raise ValueError(
+ f"{type(self).__name__}.config is missing required 'folder' entry."
+ )
+ subdir = self.config.get("commands_subdir", "skills")
+ return project_root / folder / subdir
+
+ def setup(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ """Install command templates as agent skills.
+
+ Creates ``speckit-/SKILL.md`` for each shared command
+ template. Each SKILL.md has normalised frontmatter containing
+ ``name``, ``description``, ``compatibility``, and ``metadata``.
+ """
+ import yaml
+
+ templates = self.list_command_templates()
+ if not templates:
+ return []
+
+ project_root_resolved = project_root.resolve()
+ if manifest.project_root != project_root_resolved:
+ raise ValueError(
+ f"manifest.project_root ({manifest.project_root}) does not match "
+ f"project_root ({project_root_resolved})"
+ )
+
+ skills_dir = self.skills_dest(project_root).resolve()
+ try:
+ skills_dir.relative_to(project_root_resolved)
+ except ValueError as exc:
+ raise ValueError(
+ f"Skills destination {skills_dir} escapes "
+ f"project root {project_root_resolved}"
+ ) from exc
+
+ script_type = opts.get("script_type", "sh")
+ arg_placeholder = (
+ self.registrar_config.get("args", "$ARGUMENTS")
+ if self.registrar_config
+ else "$ARGUMENTS"
+ )
+ created: list[Path] = []
+
+ for src_file in templates:
+ raw = src_file.read_text(encoding="utf-8")
+
+ # Derive the skill name from the template stem
+ command_name = src_file.stem # e.g. "plan"
+ skill_name = f"speckit-{command_name.replace('.', '-')}"
+
+ # Parse frontmatter for description
+ frontmatter: dict[str, Any] = {}
+ if raw.startswith("---"):
+ parts = raw.split("---", 2)
+ if len(parts) >= 3:
+ try:
+ fm = yaml.safe_load(parts[1])
+ if isinstance(fm, dict):
+ frontmatter = fm
+ except yaml.YAMLError:
+ pass
+
+ # Process body through the standard template pipeline
+ processed_body = self.process_template(
+ raw, self.key, script_type, arg_placeholder
+ )
+ # Strip the processed frontmatter β we rebuild it for skills.
+ # Preserve leading whitespace in the body to match release ZIP
+ # output byte-for-byte (the template body starts with \n after
+ # the closing ---).
+ if processed_body.startswith("---"):
+ parts = processed_body.split("---", 2)
+ if len(parts) >= 3:
+ processed_body = parts[2]
+
+ # Select description β use the original template description
+ # to stay byte-for-byte identical with release ZIP output.
+ description = frontmatter.get("description", "")
+ if not description:
+ description = f"Spec Kit: {command_name} workflow"
+
+ # Build SKILL.md with manually formatted frontmatter to match
+ # the release packaging script output exactly (double-quoted
+ # values, no yaml.safe_dump quoting differences).
+ def _quote(v: str) -> str:
+ escaped = v.replace("\\", "\\\\").replace('"', '\\"')
+ return f'"{escaped}"'
+
+ skill_content = (
+ f"---\n"
+ f"name: {_quote(skill_name)}\n"
+ f"description: {_quote(description)}\n"
+ f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
+ f"metadata:\n"
+ f" author: {_quote('github-spec-kit')}\n"
+ f" source: {_quote('templates/commands/' + src_file.name)}\n"
+ f"---\n"
+ f"{processed_body}"
+ )
+
+ # Write speckit-/SKILL.md
+ skill_dir = skills_dir / skill_name
+ skill_file = skill_dir / "SKILL.md"
+ dst = self.write_file_and_record(
+ skill_content, skill_file, project_root, manifest
+ )
+ created.append(dst)
+
+ created.extend(self.install_scripts(project_root, manifest))
+ return created
diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py
new file mode 100644
index 0000000000..78f2df0379
--- /dev/null
+++ b/src/specify_cli/integrations/bob/__init__.py
@@ -0,0 +1,21 @@
+"""IBM Bob integration."""
+
+from ..base import MarkdownIntegration
+
+
+class BobIntegration(MarkdownIntegration):
+ key = "bob"
+ config = {
+ "name": "IBM Bob",
+ "folder": ".bob/",
+ "commands_subdir": "commands",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".bob/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "AGENTS.md"
diff --git a/src/specify_cli/integrations/bob/scripts/update-context.ps1 b/src/specify_cli/integrations/bob/scripts/update-context.ps1
new file mode 100644
index 0000000000..188860899f
--- /dev/null
+++ b/src/specify_cli/integrations/bob/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β IBM Bob integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType bob
diff --git a/src/specify_cli/integrations/bob/scripts/update-context.sh b/src/specify_cli/integrations/bob/scripts/update-context.sh
new file mode 100755
index 0000000000..0228603fea
--- /dev/null
+++ b/src/specify_cli/integrations/bob/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β IBM Bob integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" bob
diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py
new file mode 100644
index 0000000000..00375ead51
--- /dev/null
+++ b/src/specify_cli/integrations/claude/__init__.py
@@ -0,0 +1,21 @@
+"""Claude Code integration."""
+
+from ..base import MarkdownIntegration
+
+
+class ClaudeIntegration(MarkdownIntegration):
+ key = "claude"
+ config = {
+ "name": "Claude Code",
+ "folder": ".claude/",
+ "commands_subdir": "commands",
+ "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".claude/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "CLAUDE.md"
diff --git a/src/specify_cli/integrations/claude/scripts/update-context.ps1 b/src/specify_cli/integrations/claude/scripts/update-context.ps1
new file mode 100644
index 0000000000..837974d47a
--- /dev/null
+++ b/src/specify_cli/integrations/claude/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Claude Code integration: create/update CLAUDE.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType claude
diff --git a/src/specify_cli/integrations/claude/scripts/update-context.sh b/src/specify_cli/integrations/claude/scripts/update-context.sh
new file mode 100755
index 0000000000..4b83855a27
--- /dev/null
+++ b/src/specify_cli/integrations/claude/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Claude Code integration: create/update CLAUDE.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" claude
diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py
new file mode 100644
index 0000000000..061ac7641f
--- /dev/null
+++ b/src/specify_cli/integrations/codebuddy/__init__.py
@@ -0,0 +1,21 @@
+"""CodeBuddy CLI integration."""
+
+from ..base import MarkdownIntegration
+
+
+class CodebuddyIntegration(MarkdownIntegration):
+ key = "codebuddy"
+ config = {
+ "name": "CodeBuddy",
+ "folder": ".codebuddy/",
+ "commands_subdir": "commands",
+ "install_url": "https://www.codebuddy.ai/cli",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".codebuddy/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "CODEBUDDY.md"
diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 b/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1
new file mode 100644
index 0000000000..0269392c09
--- /dev/null
+++ b/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β CodeBuddy integration: create/update CODEBUDDY.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codebuddy
diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh b/src/specify_cli/integrations/codebuddy/scripts/update-context.sh
new file mode 100755
index 0000000000..d57ddc3560
--- /dev/null
+++ b/src/specify_cli/integrations/codebuddy/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β CodeBuddy integration: create/update CODEBUDDY.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codebuddy
diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py
new file mode 100644
index 0000000000..f6415f9bb2
--- /dev/null
+++ b/src/specify_cli/integrations/codex/__init__.py
@@ -0,0 +1,40 @@
+"""Codex CLI integration β skills-based agent.
+
+Codex uses the ``.agents/skills/speckit-/SKILL.md`` layout.
+Commands are deprecated; ``--skills`` defaults to ``True``.
+"""
+
+from __future__ import annotations
+
+from ..base import IntegrationOption, SkillsIntegration
+
+
+class CodexIntegration(SkillsIntegration):
+ """Integration for OpenAI Codex CLI."""
+
+ key = "codex"
+ config = {
+ "name": "Codex CLI",
+ "folder": ".agents/",
+ "commands_subdir": "skills",
+ "install_url": "https://github.com/openai/codex",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".agents/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
+ }
+ context_file = "AGENTS.md"
+
+ @classmethod
+ def options(cls) -> list[IntegrationOption]:
+ return [
+ IntegrationOption(
+ "--skills",
+ is_flag=True,
+ default=True,
+ help="Install as agent skills (default for Codex)",
+ ),
+ ]
diff --git a/src/specify_cli/integrations/codex/scripts/update-context.ps1 b/src/specify_cli/integrations/codex/scripts/update-context.ps1
new file mode 100644
index 0000000000..d73a5a4d34
--- /dev/null
+++ b/src/specify_cli/integrations/codex/scripts/update-context.ps1
@@ -0,0 +1,17 @@
+# update-context.ps1 β Codex CLI integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+
+$ErrorActionPreference = 'Stop'
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex
diff --git a/src/specify_cli/integrations/codex/scripts/update-context.sh b/src/specify_cli/integrations/codex/scripts/update-context.sh
new file mode 100755
index 0000000000..512d6e91d3
--- /dev/null
+++ b/src/specify_cli/integrations/codex/scripts/update-context.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# update-context.sh β Codex CLI integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+
+set -euo pipefail
+
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex
diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py
new file mode 100644
index 0000000000..036f2e1db7
--- /dev/null
+++ b/src/specify_cli/integrations/copilot/__init__.py
@@ -0,0 +1,185 @@
+"""Copilot integration β GitHub Copilot in VS Code.
+
+Copilot has several unique behaviors compared to standard markdown agents:
+- Commands use ``.agent.md`` extension (not ``.md``)
+- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
+- Installs ``.vscode/settings.json`` with prompt file recommendations
+- Context file lives at ``.github/copilot-instructions.md``
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+from pathlib import Path
+from typing import Any
+
+from ..base import IntegrationBase
+from ..manifest import IntegrationManifest
+
+
+class CopilotIntegration(IntegrationBase):
+ """Integration for GitHub Copilot in VS Code."""
+
+ key = "copilot"
+ config = {
+ "name": "GitHub Copilot",
+ "folder": ".github/",
+ "commands_subdir": "agents",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".github/agents",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".agent.md",
+ }
+ context_file = ".github/copilot-instructions.md"
+
+ def command_filename(self, template_name: str) -> str:
+ """Copilot commands use ``.agent.md`` extension."""
+ return f"speckit.{template_name}.agent.md"
+
+ def setup(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ """Install copilot commands, companion prompts, and VS Code settings.
+
+ Uses base class primitives to: read templates, process them
+ (replace placeholders, strip script blocks, rewrite paths),
+ write as ``.agent.md``, then add companion prompts and VS Code settings.
+ """
+ project_root_resolved = project_root.resolve()
+ if manifest.project_root != project_root_resolved:
+ raise ValueError(
+ f"manifest.project_root ({manifest.project_root}) does not match "
+ f"project_root ({project_root_resolved})"
+ )
+
+ templates = self.list_command_templates()
+ if not templates:
+ return []
+
+ dest = self.commands_dest(project_root)
+ dest_resolved = dest.resolve()
+ try:
+ dest_resolved.relative_to(project_root_resolved)
+ except ValueError as exc:
+ raise ValueError(
+ f"Integration destination {dest_resolved} escapes "
+ f"project root {project_root_resolved}"
+ ) from exc
+ dest.mkdir(parents=True, exist_ok=True)
+ created: list[Path] = []
+
+ script_type = opts.get("script_type", "sh")
+ arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
+
+ # 1. Process and write command files as .agent.md
+ for src_file in templates:
+ raw = src_file.read_text(encoding="utf-8")
+ processed = self.process_template(raw, self.key, script_type, arg_placeholder)
+ dst_name = self.command_filename(src_file.stem)
+ dst_file = self.write_file_and_record(
+ processed, dest / dst_name, project_root, manifest
+ )
+ created.append(dst_file)
+
+ # 2. Generate companion .prompt.md files from the templates we just wrote
+ prompts_dir = project_root / ".github" / "prompts"
+ for src_file in templates:
+ cmd_name = f"speckit.{src_file.stem}"
+ prompt_content = f"---\nagent: {cmd_name}\n---\n"
+ prompt_file = self.write_file_and_record(
+ prompt_content,
+ prompts_dir / f"{cmd_name}.prompt.md",
+ project_root,
+ manifest,
+ )
+ created.append(prompt_file)
+
+ # Write .vscode/settings.json
+ settings_src = self._vscode_settings_path()
+ if settings_src and settings_src.is_file():
+ dst_settings = project_root / ".vscode" / "settings.json"
+ dst_settings.parent.mkdir(parents=True, exist_ok=True)
+ if dst_settings.exists():
+ # Merge into existing β don't track since we can't safely
+ # remove the user's settings file on uninstall.
+ self._merge_vscode_settings(settings_src, dst_settings)
+ else:
+ shutil.copy2(settings_src, dst_settings)
+ self.record_file_in_manifest(dst_settings, project_root, manifest)
+ created.append(dst_settings)
+
+ # 4. Install integration-specific update-context scripts
+ created.extend(self.install_scripts(project_root, manifest))
+
+ return created
+
+ def _vscode_settings_path(self) -> Path | None:
+ """Return path to the bundled vscode-settings.json template."""
+ tpl_dir = self.shared_templates_dir()
+ if tpl_dir:
+ candidate = tpl_dir / "vscode-settings.json"
+ if candidate.is_file():
+ return candidate
+ return None
+
+ @staticmethod
+ def _merge_vscode_settings(src: Path, dst: Path) -> None:
+ """Merge settings from *src* into existing *dst* JSON file.
+
+ Top-level keys from *src* are added only if missing in *dst*.
+ For dict-valued keys, sub-keys are merged the same way.
+
+ If *dst* cannot be parsed (e.g. JSONC with comments), the merge
+ is skipped to avoid overwriting user settings.
+ """
+ try:
+ existing = json.loads(dst.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError):
+ # Cannot parse existing file (likely JSONC with comments).
+ # Skip merge to preserve the user's settings, but show
+ # what they should add manually.
+ import logging
+ template_content = src.read_text(encoding="utf-8")
+ logging.getLogger(__name__).warning(
+ "Could not parse %s (may contain JSONC comments). "
+ "Skipping settings merge to preserve existing file.\n"
+ "Please add the following settings manually:\n%s",
+ dst, template_content,
+ )
+ return
+
+ new_settings = json.loads(src.read_text(encoding="utf-8"))
+
+ if not isinstance(existing, dict) or not isinstance(new_settings, dict):
+ import logging
+ logging.getLogger(__name__).warning(
+ "Skipping settings merge: %s or template is not a JSON object.", dst
+ )
+ return
+
+ changed = False
+ for key, value in new_settings.items():
+ if key not in existing:
+ existing[key] = value
+ changed = True
+ elif isinstance(existing[key], dict) and isinstance(value, dict):
+ for sub_key, sub_value in value.items():
+ if sub_key not in existing[key]:
+ existing[key][sub_key] = sub_value
+ changed = True
+
+ if not changed:
+ return
+
+ dst.write_text(
+ json.dumps(existing, indent=4) + "\n", encoding="utf-8"
+ )
diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 b/src/specify_cli/integrations/copilot/scripts/update-context.ps1
new file mode 100644
index 0000000000..26e746a789
--- /dev/null
+++ b/src/specify_cli/integrations/copilot/scripts/update-context.ps1
@@ -0,0 +1,32 @@
+# update-context.ps1 β Copilot integration: create/update .github/copilot-instructions.md
+#
+# This is the copilot-specific implementation that produces the GitHub
+# Copilot instructions file. The shared dispatcher reads
+# .specify/integration.json and calls this script.
+#
+# NOTE: This script is not yet active. It will be activated in Stage 7
+# when the shared update-agent-context.ps1 replaces its switch statement
+# with integration.json-based dispatch. The shared script must also be
+# refactored to support SPECKIT_SOURCE_ONLY (guard the Main call) before
+# dot-sourcing will work.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+# Invoke shared update-agent-context script as a separate process.
+# Dot-sourcing is unsafe until that script guards its Main call.
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType copilot
diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.sh b/src/specify_cli/integrations/copilot/scripts/update-context.sh
new file mode 100644
index 0000000000..c7f3bc60b5
--- /dev/null
+++ b/src/specify_cli/integrations/copilot/scripts/update-context.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# update-context.sh β Copilot integration: create/update .github/copilot-instructions.md
+#
+# This is the copilot-specific implementation that produces the GitHub
+# Copilot instructions file. The shared dispatcher reads
+# .specify/integration.json and calls this script.
+#
+# NOTE: This script is not yet active. It will be activated in Stage 7
+# when the shared update-agent-context.sh replaces its case statement
+# with integration.json-based dispatch. The shared script must also be
+# refactored to support SPECKIT_SOURCE_ONLY (guard the main logic)
+# before sourcing will work.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+# Invoke shared update-agent-context script as a separate process.
+# Sourcing is unsafe until that script guards its main logic.
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" copilot
diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py
new file mode 100644
index 0000000000..c244a7c01a
--- /dev/null
+++ b/src/specify_cli/integrations/cursor_agent/__init__.py
@@ -0,0 +1,21 @@
+"""Cursor IDE integration."""
+
+from ..base import MarkdownIntegration
+
+
+class CursorAgentIntegration(MarkdownIntegration):
+ key = "cursor-agent"
+ config = {
+ "name": "Cursor",
+ "folder": ".cursor/",
+ "commands_subdir": "commands",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".cursor/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = ".cursor/rules/specify-rules.mdc"
diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 b/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1
new file mode 100644
index 0000000000..4ce50a4873
--- /dev/null
+++ b/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Cursor integration: create/update .cursor/rules/specify-rules.mdc
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType cursor-agent
diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh b/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh
new file mode 100755
index 0000000000..597ca2289c
--- /dev/null
+++ b/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Cursor integration: create/update .cursor/rules/specify-rules.mdc
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" cursor-agent
diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py
new file mode 100644
index 0000000000..d66f0b80bc
--- /dev/null
+++ b/src/specify_cli/integrations/gemini/__init__.py
@@ -0,0 +1,21 @@
+"""Gemini CLI integration."""
+
+from ..base import TomlIntegration
+
+
+class GeminiIntegration(TomlIntegration):
+ key = "gemini"
+ config = {
+ "name": "Gemini CLI",
+ "folder": ".gemini/",
+ "commands_subdir": "commands",
+ "install_url": "https://github.com/google-gemini/gemini-cli",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".gemini/commands",
+ "format": "toml",
+ "args": "{{args}}",
+ "extension": ".toml",
+ }
+ context_file = "GEMINI.md"
diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 b/src/specify_cli/integrations/gemini/scripts/update-context.ps1
new file mode 100644
index 0000000000..51c9e0bc83
--- /dev/null
+++ b/src/specify_cli/integrations/gemini/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Gemini CLI integration: create/update GEMINI.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType gemini
diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.sh b/src/specify_cli/integrations/gemini/scripts/update-context.sh
new file mode 100644
index 0000000000..c4e5003a55
--- /dev/null
+++ b/src/specify_cli/integrations/gemini/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Gemini CLI integration: create/update GEMINI.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" gemini
diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py
new file mode 100644
index 0000000000..4107c48690
--- /dev/null
+++ b/src/specify_cli/integrations/generic/__init__.py
@@ -0,0 +1,133 @@
+"""Generic integration β bring your own agent.
+
+Requires ``--commands-dir`` to specify the output directory for command
+files. No longer special-cased in the core CLI β just another
+integration with its own required option.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from ..base import IntegrationOption, MarkdownIntegration
+from ..manifest import IntegrationManifest
+
+
+class GenericIntegration(MarkdownIntegration):
+ """Integration for user-specified (generic) agents."""
+
+ key = "generic"
+ config = {
+ "name": "Generic (bring your own agent)",
+ "folder": None, # Set dynamically from --commands-dir
+ "commands_subdir": "commands",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": "", # Set dynamically from --commands-dir
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = None
+
+ @classmethod
+ def options(cls) -> list[IntegrationOption]:
+ return [
+ IntegrationOption(
+ "--commands-dir",
+ required=True,
+ help="Directory for command files (e.g. .myagent/commands/)",
+ ),
+ ]
+
+ @staticmethod
+ def _resolve_commands_dir(
+ parsed_options: dict[str, Any] | None,
+ opts: dict[str, Any],
+ ) -> str:
+ """Extract ``--commands-dir`` from parsed options or raw_options.
+
+ Returns the directory string or raises ``ValueError``.
+ """
+ parsed_options = parsed_options or {}
+
+ commands_dir = parsed_options.get("commands_dir")
+ if commands_dir:
+ return commands_dir
+
+ # Fall back to raw_options (--integration-options="--commands-dir ...")
+ raw = opts.get("raw_options")
+ if raw:
+ import shlex
+ tokens = shlex.split(raw)
+ for i, token in enumerate(tokens):
+ if token == "--commands-dir" and i + 1 < len(tokens):
+ return tokens[i + 1]
+ if token.startswith("--commands-dir="):
+ return token.split("=", 1)[1]
+
+ raise ValueError(
+ "--commands-dir is required for the generic integration"
+ )
+
+ def commands_dest(self, project_root: Path) -> Path:
+ """Not supported for GenericIntegration β use setup() directly.
+
+ GenericIntegration is stateless; the output directory comes from
+ ``parsed_options`` or ``raw_options`` at call time, not from
+ instance state.
+ """
+ raise ValueError(
+ "GenericIntegration.commands_dest() cannot be called directly; "
+ "the output directory is resolved from parsed_options in setup()"
+ )
+
+ def setup(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ """Install commands to the user-provided commands directory."""
+ commands_dir = self._resolve_commands_dir(parsed_options, opts)
+
+ templates = self.list_command_templates()
+ if not templates:
+ return []
+
+ project_root_resolved = project_root.resolve()
+ if manifest.project_root != project_root_resolved:
+ raise ValueError(
+ f"manifest.project_root ({manifest.project_root}) does not match "
+ f"project_root ({project_root_resolved})"
+ )
+
+ dest = (project_root / commands_dir).resolve()
+ try:
+ dest.relative_to(project_root_resolved)
+ except ValueError as exc:
+ raise ValueError(
+ f"Integration destination {dest} escapes "
+ f"project root {project_root_resolved}"
+ ) from exc
+ dest.mkdir(parents=True, exist_ok=True)
+
+ script_type = opts.get("script_type", "sh")
+ arg_placeholder = "$ARGUMENTS"
+ created: list[Path] = []
+
+ for src_file in templates:
+ raw = src_file.read_text(encoding="utf-8")
+ processed = self.process_template(raw, self.key, script_type, arg_placeholder)
+ dst_name = self.command_filename(src_file.stem)
+ dst_file = self.write_file_and_record(
+ processed, dest / dst_name, project_root, manifest
+ )
+ created.append(dst_file)
+
+ created.extend(self.install_scripts(project_root, manifest))
+ return created
diff --git a/src/specify_cli/integrations/generic/scripts/update-context.ps1 b/src/specify_cli/integrations/generic/scripts/update-context.ps1
new file mode 100644
index 0000000000..2e9467f801
--- /dev/null
+++ b/src/specify_cli/integrations/generic/scripts/update-context.ps1
@@ -0,0 +1,17 @@
+# update-context.ps1 β Generic integration: create/update context file
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+
+$ErrorActionPreference = 'Stop'
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic
diff --git a/src/specify_cli/integrations/generic/scripts/update-context.sh b/src/specify_cli/integrations/generic/scripts/update-context.sh
new file mode 100755
index 0000000000..d8ad30a7b8
--- /dev/null
+++ b/src/specify_cli/integrations/generic/scripts/update-context.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# update-context.sh β Generic integration: create/update context file
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+
+set -euo pipefail
+
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic
diff --git a/src/specify_cli/integrations/iflow/__init__.py b/src/specify_cli/integrations/iflow/__init__.py
new file mode 100644
index 0000000000..4acc2cf372
--- /dev/null
+++ b/src/specify_cli/integrations/iflow/__init__.py
@@ -0,0 +1,21 @@
+"""iFlow CLI integration."""
+
+from ..base import MarkdownIntegration
+
+
+class IflowIntegration(MarkdownIntegration):
+ key = "iflow"
+ config = {
+ "name": "iFlow CLI",
+ "folder": ".iflow/",
+ "commands_subdir": "commands",
+ "install_url": "https://docs.iflow.cn/en/cli/quickstart",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".iflow/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "IFLOW.md"
diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 b/src/specify_cli/integrations/iflow/scripts/update-context.ps1
new file mode 100644
index 0000000000..b502d4182a
--- /dev/null
+++ b/src/specify_cli/integrations/iflow/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β iFlow CLI integration: create/update IFLOW.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType iflow
diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.sh b/src/specify_cli/integrations/iflow/scripts/update-context.sh
new file mode 100755
index 0000000000..5080402071
--- /dev/null
+++ b/src/specify_cli/integrations/iflow/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β iFlow CLI integration: create/update IFLOW.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" iflow
diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py
new file mode 100644
index 0000000000..0cc3b3f0ff
--- /dev/null
+++ b/src/specify_cli/integrations/junie/__init__.py
@@ -0,0 +1,21 @@
+"""Junie integration (JetBrains)."""
+
+from ..base import MarkdownIntegration
+
+
+class JunieIntegration(MarkdownIntegration):
+ key = "junie"
+ config = {
+ "name": "Junie",
+ "folder": ".junie/",
+ "commands_subdir": "commands",
+ "install_url": "https://junie.jetbrains.com/",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".junie/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = ".junie/AGENTS.md"
diff --git a/src/specify_cli/integrations/junie/scripts/update-context.ps1 b/src/specify_cli/integrations/junie/scripts/update-context.ps1
new file mode 100644
index 0000000000..5a32432132
--- /dev/null
+++ b/src/specify_cli/integrations/junie/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Junie integration: create/update .junie/AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType junie
diff --git a/src/specify_cli/integrations/junie/scripts/update-context.sh b/src/specify_cli/integrations/junie/scripts/update-context.sh
new file mode 100755
index 0000000000..f4c8ba6c0e
--- /dev/null
+++ b/src/specify_cli/integrations/junie/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Junie integration: create/update .junie/AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" junie
diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py
new file mode 100644
index 0000000000..ffd38f741a
--- /dev/null
+++ b/src/specify_cli/integrations/kilocode/__init__.py
@@ -0,0 +1,21 @@
+"""Kilo Code integration."""
+
+from ..base import MarkdownIntegration
+
+
+class KilocodeIntegration(MarkdownIntegration):
+ key = "kilocode"
+ config = {
+ "name": "Kilo Code",
+ "folder": ".kilocode/",
+ "commands_subdir": "workflows",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".kilocode/workflows",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = ".kilocode/rules/specify-rules.md"
diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 b/src/specify_cli/integrations/kilocode/scripts/update-context.ps1
new file mode 100644
index 0000000000..d87e7ef59f
--- /dev/null
+++ b/src/specify_cli/integrations/kilocode/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Kilo Code integration: create/update .kilocode/rules/specify-rules.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kilocode
diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.sh b/src/specify_cli/integrations/kilocode/scripts/update-context.sh
new file mode 100755
index 0000000000..132c0403f3
--- /dev/null
+++ b/src/specify_cli/integrations/kilocode/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Kilo Code integration: create/update .kilocode/rules/specify-rules.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kilocode
diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py
new file mode 100644
index 0000000000..5421d48012
--- /dev/null
+++ b/src/specify_cli/integrations/kimi/__init__.py
@@ -0,0 +1,124 @@
+"""Kimi Code integration β skills-based agent (Moonshot AI).
+
+Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with
+``/skill:speckit-`` invocation syntax.
+
+Includes legacy migration logic for projects initialised before Kimi
+moved from dotted skill directories (``speckit.xxx``) to hyphenated
+(``speckit-xxx``).
+"""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Any
+
+from ..base import IntegrationOption, SkillsIntegration
+from ..manifest import IntegrationManifest
+
+
+class KimiIntegration(SkillsIntegration):
+ """Integration for Kimi Code CLI (Moonshot AI)."""
+
+ key = "kimi"
+ config = {
+ "name": "Kimi Code",
+ "folder": ".kimi/",
+ "commands_subdir": "skills",
+ "install_url": "https://code.kimi.com/",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".kimi/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
+ }
+ context_file = "KIMI.md"
+
+ @classmethod
+ def options(cls) -> list[IntegrationOption]:
+ return [
+ IntegrationOption(
+ "--skills",
+ is_flag=True,
+ default=True,
+ help="Install as agent skills (default for Kimi)",
+ ),
+ IntegrationOption(
+ "--migrate-legacy",
+ is_flag=True,
+ default=False,
+ help="Migrate legacy dotted skill dirs (speckit.xxx β speckit-xxx)",
+ ),
+ ]
+
+ def setup(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ """Install skills with optional legacy dotted-name migration."""
+ parsed_options = parsed_options or {}
+
+ # Run base setup first so hyphenated targets (speckit-*) exist,
+ # then migrate/clean legacy dotted dirs without risking user content loss.
+ created = super().setup(
+ project_root, manifest, parsed_options=parsed_options, **opts
+ )
+
+ if parsed_options.get("migrate_legacy", False):
+ skills_dir = self.skills_dest(project_root)
+ if skills_dir.is_dir():
+ _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ return created
+
+
+def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
+ """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
+
+ Returns ``(migrated_count, removed_count)``.
+ """
+ if not skills_dir.is_dir():
+ return (0, 0)
+
+ migrated_count = 0
+ removed_count = 0
+
+ for legacy_dir in sorted(skills_dir.glob("speckit.*")):
+ if not legacy_dir.is_dir():
+ continue
+ if not (legacy_dir / "SKILL.md").exists():
+ continue
+
+ suffix = legacy_dir.name[len("speckit."):]
+ if not suffix:
+ continue
+
+ target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
+
+ if not target_dir.exists():
+ shutil.move(str(legacy_dir), str(target_dir))
+ migrated_count += 1
+ continue
+
+ # Target exists β only remove legacy if SKILL.md is identical
+ target_skill = target_dir / "SKILL.md"
+ legacy_skill = legacy_dir / "SKILL.md"
+ if target_skill.is_file():
+ try:
+ if target_skill.read_bytes() == legacy_skill.read_bytes():
+ has_extra = any(
+ child.name != "SKILL.md" for child in legacy_dir.iterdir()
+ )
+ if not has_extra:
+ shutil.rmtree(legacy_dir)
+ removed_count += 1
+ except OSError:
+ pass
+
+ return (migrated_count, removed_count)
diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 b/src/specify_cli/integrations/kimi/scripts/update-context.ps1
new file mode 100644
index 0000000000..aa6678d052
--- /dev/null
+++ b/src/specify_cli/integrations/kimi/scripts/update-context.ps1
@@ -0,0 +1,17 @@
+# update-context.ps1 β Kimi Code integration: create/update KIMI.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+
+$ErrorActionPreference = 'Stop'
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi
diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.sh b/src/specify_cli/integrations/kimi/scripts/update-context.sh
new file mode 100755
index 0000000000..2f81bc2a48
--- /dev/null
+++ b/src/specify_cli/integrations/kimi/scripts/update-context.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# update-context.sh β Kimi Code integration: create/update KIMI.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+
+set -euo pipefail
+
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi
diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py
new file mode 100644
index 0000000000..b316cb4bd2
--- /dev/null
+++ b/src/specify_cli/integrations/kiro_cli/__init__.py
@@ -0,0 +1,21 @@
+"""Kiro CLI integration."""
+
+from ..base import MarkdownIntegration
+
+
+class KiroCliIntegration(MarkdownIntegration):
+ key = "kiro-cli"
+ config = {
+ "name": "Kiro CLI",
+ "folder": ".kiro/",
+ "commands_subdir": "prompts",
+ "install_url": "https://kiro.dev/docs/cli/",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".kiro/prompts",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "AGENTS.md"
diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 b/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1
new file mode 100644
index 0000000000..7dd2b35fb7
--- /dev/null
+++ b/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Kiro CLI integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kiro-cli
diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh b/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh
new file mode 100755
index 0000000000..fa258edc75
--- /dev/null
+++ b/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Kiro CLI integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kiro-cli
diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py
new file mode 100644
index 0000000000..50ac08ea3d
--- /dev/null
+++ b/src/specify_cli/integrations/manifest.py
@@ -0,0 +1,265 @@
+"""Hash-tracked installation manifest for integrations.
+
+Each installed integration records the files it created together with
+their SHA-256 hashes. On uninstall only files whose hash still matches
+the recorded value are removed β modified files are left in place and
+reported to the caller.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+def _sha256(path: Path) -> str:
+ """Return the hex SHA-256 digest of *path*."""
+ h = hashlib.sha256()
+ with open(path, "rb") as fh:
+ for chunk in iter(lambda: fh.read(8192), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+def _validate_rel_path(rel: Path, root: Path) -> Path:
+ """Resolve *rel* against *root* and verify it stays within *root*.
+
+ Raises ``ValueError`` if *rel* is absolute, contains ``..`` segments
+ that escape *root*, or otherwise resolves outside the project root.
+ """
+ if rel.is_absolute():
+ raise ValueError(
+ f"Absolute paths are not allowed in manifests: {rel}"
+ )
+ resolved = (root / rel).resolve()
+ root_resolved = root.resolve()
+ try:
+ resolved.relative_to(root_resolved)
+ except ValueError:
+ raise ValueError(
+ f"Path {rel} resolves to {resolved} which is outside "
+ f"the project root {root_resolved}"
+ ) from None
+ return resolved
+
+
+class IntegrationManifest:
+ """Tracks files installed by a single integration.
+
+ Parameters:
+ key: Integration identifier (e.g. ``"copilot"``).
+ project_root: Absolute path to the project directory.
+ version: CLI version string recorded in the manifest.
+ """
+
+ def __init__(self, key: str, project_root: Path, version: str = "") -> None:
+ self.key = key
+ self.project_root = project_root.resolve()
+ self.version = version
+ self._files: dict[str, str] = {} # rel_path β sha256 hex
+ self._installed_at: str = ""
+
+ # -- Manifest file location -------------------------------------------
+
+ @property
+ def manifest_path(self) -> Path:
+ """Path to the on-disk manifest JSON."""
+ return self.project_root / ".specify" / "integrations" / f"{self.key}.manifest.json"
+
+ # -- Recording files --------------------------------------------------
+
+ def record_file(self, rel_path: str | Path, content: bytes | str) -> Path:
+ """Write *content* to *rel_path* (relative to project root) and record its hash.
+
+ Creates parent directories as needed. Returns the absolute path
+ of the written file.
+
+ Raises ``ValueError`` if *rel_path* resolves outside the project root.
+ """
+ rel = Path(rel_path)
+ abs_path = _validate_rel_path(rel, self.project_root)
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if isinstance(content, str):
+ content = content.encode("utf-8")
+ abs_path.write_bytes(content)
+
+ normalized = abs_path.relative_to(self.project_root).as_posix()
+ self._files[normalized] = hashlib.sha256(content).hexdigest()
+ return abs_path
+
+ def record_existing(self, rel_path: str | Path) -> None:
+ """Record the hash of an already-existing file at *rel_path*.
+
+ Raises ``ValueError`` if *rel_path* resolves outside the project root.
+ """
+ rel = Path(rel_path)
+ abs_path = _validate_rel_path(rel, self.project_root)
+ normalized = abs_path.relative_to(self.project_root).as_posix()
+ self._files[normalized] = _sha256(abs_path)
+
+ # -- Querying ---------------------------------------------------------
+
+ @property
+ def files(self) -> dict[str, str]:
+ """Return a copy of the ``{rel_path: sha256}`` mapping."""
+ return dict(self._files)
+
+ def check_modified(self) -> list[str]:
+ """Return relative paths of tracked files whose content changed on disk."""
+ modified: list[str] = []
+ for rel, expected_hash in self._files.items():
+ rel_path = Path(rel)
+ # Skip paths that are absolute or attempt to escape the project root
+ if rel_path.is_absolute() or ".." in rel_path.parts:
+ continue
+ abs_path = self.project_root / rel_path
+ if not abs_path.exists() and not abs_path.is_symlink():
+ continue
+ # Treat symlinks and non-regular-files as modified
+ if abs_path.is_symlink() or not abs_path.is_file():
+ modified.append(rel)
+ continue
+ if _sha256(abs_path) != expected_hash:
+ modified.append(rel)
+ return modified
+
+ # -- Uninstall --------------------------------------------------------
+
+ def uninstall(
+ self,
+ project_root: Path | None = None,
+ *,
+ force: bool = False,
+ ) -> tuple[list[Path], list[Path]]:
+ """Remove tracked files whose hash still matches.
+
+ Parameters:
+ project_root: Override for the project root.
+ force: If ``True``, remove files even if modified.
+
+ Returns:
+ ``(removed, skipped)`` β absolute paths.
+ """
+ root = (project_root or self.project_root).resolve()
+ removed: list[Path] = []
+ skipped: list[Path] = []
+
+ for rel, expected_hash in self._files.items():
+ # Use non-resolved path for deletion so symlinks themselves
+ # are removed, not their targets.
+ path = root / rel
+ # Validate containment lexically (without following symlinks)
+ # by collapsing .. segments via Path resolution on the string parts.
+ try:
+ normed = Path(os.path.normpath(path))
+ normed.relative_to(root)
+ except (ValueError, OSError):
+ continue
+ if not path.exists() and not path.is_symlink():
+ continue
+ # Skip directories β manifest only tracks files
+ if not path.is_file() and not path.is_symlink():
+ skipped.append(path)
+ continue
+ # Never follow symlinks when comparing hashes. Only remove
+ # symlinks when forced, to avoid acting on tampered entries.
+ if path.is_symlink():
+ if not force:
+ skipped.append(path)
+ continue
+ else:
+ if not force and _sha256(path) != expected_hash:
+ skipped.append(path)
+ continue
+ try:
+ path.unlink()
+ except OSError:
+ skipped.append(path)
+ continue
+ removed.append(path)
+ # Clean up empty parent directories up to project root
+ parent = path.parent
+ while parent != root:
+ try:
+ parent.rmdir() # only succeeds if empty
+ except OSError:
+ break
+ parent = parent.parent
+
+ # Remove the manifest file itself
+ manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
+ if manifest.exists():
+ manifest.unlink()
+ parent = manifest.parent
+ while parent != root:
+ try:
+ parent.rmdir()
+ except OSError:
+ break
+ parent = parent.parent
+
+ return removed, skipped
+
+ # -- Persistence ------------------------------------------------------
+
+ def save(self) -> Path:
+ """Write the manifest to disk. Returns the manifest path."""
+ self._installed_at = self._installed_at or datetime.now(timezone.utc).isoformat()
+ data: dict[str, Any] = {
+ "integration": self.key,
+ "version": self.version,
+ "installed_at": self._installed_at,
+ "files": self._files,
+ }
+ path = self.manifest_path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+ return path
+
+ @classmethod
+ def load(cls, key: str, project_root: Path) -> IntegrationManifest:
+ """Load an existing manifest from disk.
+
+ Raises ``FileNotFoundError`` if the manifest does not exist.
+ """
+ inst = cls(key, project_root)
+ path = inst.manifest_path
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(
+ f"Integration manifest at {path} contains invalid JSON"
+ ) from exc
+
+ if not isinstance(data, dict):
+ raise ValueError(
+ f"Integration manifest at {path} must be a JSON object, "
+ f"got {type(data).__name__}"
+ )
+
+ files = data.get("files", {})
+ if not isinstance(files, dict) or not all(
+ isinstance(k, str) and isinstance(v, str) for k, v in files.items()
+ ):
+ raise ValueError(
+ f"Integration manifest 'files' at {path} must be a "
+ "mapping of string paths to string hashes"
+ )
+
+ inst.version = data.get("version", "")
+ inst._installed_at = data.get("installed_at", "")
+ inst._files = files
+
+ stored_key = data.get("integration", "")
+ if stored_key and stored_key != key:
+ raise ValueError(
+ f"Manifest at {path} belongs to integration {stored_key!r}, "
+ f"not {key!r}"
+ )
+
+ return inst
diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py
new file mode 100644
index 0000000000..be4dcc3094
--- /dev/null
+++ b/src/specify_cli/integrations/opencode/__init__.py
@@ -0,0 +1,21 @@
+"""opencode integration."""
+
+from ..base import MarkdownIntegration
+
+
+class OpencodeIntegration(MarkdownIntegration):
+ key = "opencode"
+ config = {
+ "name": "opencode",
+ "folder": ".opencode/",
+ "commands_subdir": "command",
+ "install_url": "https://opencode.ai",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".opencode/command",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "AGENTS.md"
diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 b/src/specify_cli/integrations/opencode/scripts/update-context.ps1
new file mode 100644
index 0000000000..4bba02b455
--- /dev/null
+++ b/src/specify_cli/integrations/opencode/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β opencode integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType opencode
diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.sh b/src/specify_cli/integrations/opencode/scripts/update-context.sh
new file mode 100755
index 0000000000..24c7e60251
--- /dev/null
+++ b/src/specify_cli/integrations/opencode/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β opencode integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" opencode
diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py
new file mode 100644
index 0000000000..8a25f326ba
--- /dev/null
+++ b/src/specify_cli/integrations/pi/__init__.py
@@ -0,0 +1,21 @@
+"""Pi Coding Agent integration."""
+
+from ..base import MarkdownIntegration
+
+
+class PiIntegration(MarkdownIntegration):
+ key = "pi"
+ config = {
+ "name": "Pi Coding Agent",
+ "folder": ".pi/",
+ "commands_subdir": "prompts",
+ "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".pi/prompts",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "AGENTS.md"
diff --git a/src/specify_cli/integrations/pi/scripts/update-context.ps1 b/src/specify_cli/integrations/pi/scripts/update-context.ps1
new file mode 100644
index 0000000000..6362118a5b
--- /dev/null
+++ b/src/specify_cli/integrations/pi/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Pi Coding Agent integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType pi
diff --git a/src/specify_cli/integrations/pi/scripts/update-context.sh b/src/specify_cli/integrations/pi/scripts/update-context.sh
new file mode 100755
index 0000000000..1ad84c95a2
--- /dev/null
+++ b/src/specify_cli/integrations/pi/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Pi Coding Agent integration: create/update AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" pi
diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py
new file mode 100644
index 0000000000..541001be17
--- /dev/null
+++ b/src/specify_cli/integrations/qodercli/__init__.py
@@ -0,0 +1,21 @@
+"""Qoder CLI integration."""
+
+from ..base import MarkdownIntegration
+
+
+class QodercliIntegration(MarkdownIntegration):
+ key = "qodercli"
+ config = {
+ "name": "Qoder CLI",
+ "folder": ".qoder/",
+ "commands_subdir": "commands",
+ "install_url": "https://qoder.com/cli",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".qoder/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "QODER.md"
diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 b/src/specify_cli/integrations/qodercli/scripts/update-context.ps1
new file mode 100644
index 0000000000..1fa007a168
--- /dev/null
+++ b/src/specify_cli/integrations/qodercli/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Qoder CLI integration: create/update QODER.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qodercli
diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.sh b/src/specify_cli/integrations/qodercli/scripts/update-context.sh
new file mode 100755
index 0000000000..d371ad7952
--- /dev/null
+++ b/src/specify_cli/integrations/qodercli/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Qoder CLI integration: create/update QODER.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qodercli
diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py
new file mode 100644
index 0000000000..d9d930152c
--- /dev/null
+++ b/src/specify_cli/integrations/qwen/__init__.py
@@ -0,0 +1,21 @@
+"""Qwen Code integration."""
+
+from ..base import MarkdownIntegration
+
+
+class QwenIntegration(MarkdownIntegration):
+ key = "qwen"
+ config = {
+ "name": "Qwen Code",
+ "folder": ".qwen/",
+ "commands_subdir": "commands",
+ "install_url": "https://github.com/QwenLM/qwen-code",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".qwen/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "QWEN.md"
diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 b/src/specify_cli/integrations/qwen/scripts/update-context.ps1
new file mode 100644
index 0000000000..24e4c90fab
--- /dev/null
+++ b/src/specify_cli/integrations/qwen/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Qwen Code integration: create/update QWEN.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qwen
diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.sh b/src/specify_cli/integrations/qwen/scripts/update-context.sh
new file mode 100755
index 0000000000..d1c62eb161
--- /dev/null
+++ b/src/specify_cli/integrations/qwen/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Qwen Code integration: create/update QWEN.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qwen
diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py
new file mode 100644
index 0000000000..3c680e7e35
--- /dev/null
+++ b/src/specify_cli/integrations/roo/__init__.py
@@ -0,0 +1,21 @@
+"""Roo Code integration."""
+
+from ..base import MarkdownIntegration
+
+
+class RooIntegration(MarkdownIntegration):
+ key = "roo"
+ config = {
+ "name": "Roo Code",
+ "folder": ".roo/",
+ "commands_subdir": "commands",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".roo/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = ".roo/rules/specify-rules.md"
diff --git a/src/specify_cli/integrations/roo/scripts/update-context.ps1 b/src/specify_cli/integrations/roo/scripts/update-context.ps1
new file mode 100644
index 0000000000..d1dec923ed
--- /dev/null
+++ b/src/specify_cli/integrations/roo/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Roo Code integration: create/update .roo/rules/specify-rules.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType roo
diff --git a/src/specify_cli/integrations/roo/scripts/update-context.sh b/src/specify_cli/integrations/roo/scripts/update-context.sh
new file mode 100755
index 0000000000..8fe255cb1b
--- /dev/null
+++ b/src/specify_cli/integrations/roo/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Roo Code integration: create/update .roo/rules/specify-rules.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" roo
diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py
new file mode 100644
index 0000000000..7a9d1deb02
--- /dev/null
+++ b/src/specify_cli/integrations/shai/__init__.py
@@ -0,0 +1,21 @@
+"""SHAI CLI integration."""
+
+from ..base import MarkdownIntegration
+
+
+class ShaiIntegration(MarkdownIntegration):
+ key = "shai"
+ config = {
+ "name": "SHAI",
+ "folder": ".shai/",
+ "commands_subdir": "commands",
+ "install_url": "https://github.com/ovh/shai",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".shai/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "SHAI.md"
diff --git a/src/specify_cli/integrations/shai/scripts/update-context.ps1 b/src/specify_cli/integrations/shai/scripts/update-context.ps1
new file mode 100644
index 0000000000..2c621c76ac
--- /dev/null
+++ b/src/specify_cli/integrations/shai/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β SHAI integration: create/update SHAI.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType shai
diff --git a/src/specify_cli/integrations/shai/scripts/update-context.sh b/src/specify_cli/integrations/shai/scripts/update-context.sh
new file mode 100755
index 0000000000..093b9d1f76
--- /dev/null
+++ b/src/specify_cli/integrations/shai/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β SHAI integration: create/update SHAI.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" shai
diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py
new file mode 100644
index 0000000000..2928a214a7
--- /dev/null
+++ b/src/specify_cli/integrations/tabnine/__init__.py
@@ -0,0 +1,21 @@
+"""Tabnine CLI integration."""
+
+from ..base import TomlIntegration
+
+
+class TabnineIntegration(TomlIntegration):
+ key = "tabnine"
+ config = {
+ "name": "Tabnine CLI",
+ "folder": ".tabnine/agent/",
+ "commands_subdir": "commands",
+ "install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".tabnine/agent/commands",
+ "format": "toml",
+ "args": "{{args}}",
+ "extension": ".toml",
+ }
+ context_file = "TABNINE.md"
diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1
new file mode 100644
index 0000000000..0ffb3a1649
--- /dev/null
+++ b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Tabnine CLI integration: create/update TABNINE.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType tabnine
diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.sh b/src/specify_cli/integrations/tabnine/scripts/update-context.sh
new file mode 100644
index 0000000000..fe5050b6e9
--- /dev/null
+++ b/src/specify_cli/integrations/tabnine/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Tabnine CLI integration: create/update TABNINE.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" tabnine
diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py
new file mode 100644
index 0000000000..7037eecb8c
--- /dev/null
+++ b/src/specify_cli/integrations/trae/__init__.py
@@ -0,0 +1,21 @@
+"""Trae IDE integration."""
+
+from ..base import MarkdownIntegration
+
+
+class TraeIntegration(MarkdownIntegration):
+ key = "trae"
+ config = {
+ "name": "Trae",
+ "folder": ".trae/",
+ "commands_subdir": "rules",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".trae/rules",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = ".trae/rules/AGENTS.md"
diff --git a/src/specify_cli/integrations/trae/scripts/update-context.ps1 b/src/specify_cli/integrations/trae/scripts/update-context.ps1
new file mode 100644
index 0000000000..f72d96318e
--- /dev/null
+++ b/src/specify_cli/integrations/trae/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Trae integration: create/update .trae/rules/AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType trae
diff --git a/src/specify_cli/integrations/trae/scripts/update-context.sh b/src/specify_cli/integrations/trae/scripts/update-context.sh
new file mode 100755
index 0000000000..b868a7c983
--- /dev/null
+++ b/src/specify_cli/integrations/trae/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Trae integration: create/update .trae/rules/AGENTS.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" trae
diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py
new file mode 100644
index 0000000000..dcc4a60dda
--- /dev/null
+++ b/src/specify_cli/integrations/vibe/__init__.py
@@ -0,0 +1,21 @@
+"""Mistral Vibe CLI integration."""
+
+from ..base import MarkdownIntegration
+
+
+class VibeIntegration(MarkdownIntegration):
+ key = "vibe"
+ config = {
+ "name": "Mistral Vibe",
+ "folder": ".vibe/",
+ "commands_subdir": "prompts",
+ "install_url": "https://github.com/mistralai/mistral-vibe",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".vibe/prompts",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = ".vibe/agents/specify-agents.md"
diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 b/src/specify_cli/integrations/vibe/scripts/update-context.ps1
new file mode 100644
index 0000000000..d82ce3389c
--- /dev/null
+++ b/src/specify_cli/integrations/vibe/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Mistral Vibe integration: create/update .vibe/agents/specify-agents.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType vibe
diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.sh b/src/specify_cli/integrations/vibe/scripts/update-context.sh
new file mode 100755
index 0000000000..f924cdb896
--- /dev/null
+++ b/src/specify_cli/integrations/vibe/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Mistral Vibe integration: create/update .vibe/agents/specify-agents.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" vibe
diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py
new file mode 100644
index 0000000000..f0f77d318e
--- /dev/null
+++ b/src/specify_cli/integrations/windsurf/__init__.py
@@ -0,0 +1,21 @@
+"""Windsurf IDE integration."""
+
+from ..base import MarkdownIntegration
+
+
+class WindsurfIntegration(MarkdownIntegration):
+ key = "windsurf"
+ config = {
+ "name": "Windsurf",
+ "folder": ".windsurf/",
+ "commands_subdir": "workflows",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".windsurf/workflows",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = ".windsurf/rules/specify-rules.md"
diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 b/src/specify_cli/integrations/windsurf/scripts/update-context.ps1
new file mode 100644
index 0000000000..b5fe1d0c0a
--- /dev/null
+++ b/src/specify_cli/integrations/windsurf/scripts/update-context.ps1
@@ -0,0 +1,23 @@
+# update-context.ps1 β Windsurf integration: create/update .windsurf/rules/specify-rules.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+$ErrorActionPreference = 'Stop'
+
+# Derive repo root from script location (walks up to find .specify/)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
+# If git did not return a repo root, or the git root does not contain .specify,
+# fall back to walking up from the script directory to find the initialized project root.
+if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = $scriptDir
+ $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
+ while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
+ $repoRoot = Split-Path -Parent $repoRoot
+ }
+}
+
+& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType windsurf
diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.sh b/src/specify_cli/integrations/windsurf/scripts/update-context.sh
new file mode 100755
index 0000000000..b9a78d320e
--- /dev/null
+++ b/src/specify_cli/integrations/windsurf/scripts/update-context.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# update-context.sh β Windsurf integration: create/update .windsurf/rules/specify-rules.md
+#
+# Thin wrapper that delegates to the shared update-agent-context script.
+# Activated in Stage 7 when the shared script uses integration.json dispatch.
+#
+# Until then, this delegates to the shared script as a subprocess.
+
+set -euo pipefail
+
+# Derive repo root from script location (walks up to find .specify/)
+_script_dir="$(cd "$(dirname "$0")" && pwd)"
+_root="$_script_dir"
+while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
+if [ -z "${REPO_ROOT:-}" ]; then
+ if [ -d "$_root/.specify" ]; then
+ REPO_ROOT="$_root"
+ else
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+ if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
+ REPO_ROOT="$git_root"
+ else
+ REPO_ROOT="$_root"
+ fi
+ fi
+fi
+
+exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" windsurf
diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py
index 24d523aa89..a3f6406287 100644
--- a/src/specify_cli/presets.py
+++ b/src/specify_cli/presets.py
@@ -556,24 +556,31 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non
registrar.unregister_commands(registered_commands, self.project_root)
def _get_skills_dir(self) -> Optional[Path]:
- """Return the skills directory if ``--ai-skills`` was used during init.
+ """Return the active skills directory for preset skill overrides.
Reads ``.specify/init-options.json`` to determine whether skills
are enabled and which agent was selected, then delegates to
the module-level ``_get_skills_dir()`` helper for the concrete path.
+ Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
+ ``.kimi/skills`` exists, presets should still propagate command
+ overrides to skills even when ``ai_skills`` is false.
+
Returns:
The skills directory ``Path``, or ``None`` if skills were not
- enabled or the init-options file is missing.
+ enabled and no native-skills fallback applies.
"""
from . import load_init_options, _get_skills_dir
opts = load_init_options(self.project_root)
- if not opts.get("ai_skills"):
+ if not isinstance(opts, dict):
+ opts = {}
+ agent = opts.get("ai")
+ if not isinstance(agent, str) or not agent:
return None
- agent = opts.get("ai")
- if not agent:
+ ai_skills_enabled = bool(opts.get("ai_skills"))
+ if not ai_skills_enabled and agent != "kimi":
return None
skills_dir = _get_skills_dir(self.project_root, agent)
@@ -582,6 +589,76 @@ def _get_skills_dir(self) -> Optional[Path]:
return skills_dir
+ @staticmethod
+ def _skill_names_for_command(cmd_name: str) -> tuple[str, str]:
+ """Return the modern and legacy skill directory names for a command."""
+ raw_short_name = cmd_name
+ if raw_short_name.startswith("speckit."):
+ raw_short_name = raw_short_name[len("speckit."):]
+
+ modern_skill_name = f"speckit-{raw_short_name.replace('.', '-')}"
+ legacy_skill_name = f"speckit.{raw_short_name}"
+ return modern_skill_name, legacy_skill_name
+
+ @staticmethod
+ def _skill_title_from_command(cmd_name: str) -> str:
+ """Return a human-friendly title for a skill command name."""
+ title_name = cmd_name
+ if title_name.startswith("speckit."):
+ title_name = title_name[len("speckit."):]
+ return title_name.replace(".", " ").replace("-", " ").title()
+
+ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
+ """Index extension-backed skill restore data by skill directory name."""
+ from .extensions import ExtensionManifest, ValidationError
+
+ resolver = PresetResolver(self.project_root)
+ extensions_dir = self.project_root / ".specify" / "extensions"
+ restore_index: Dict[str, Dict[str, Any]] = {}
+
+ for _priority, ext_id, _metadata in resolver._get_all_extensions_by_priority():
+ ext_dir = extensions_dir / ext_id
+ manifest_path = ext_dir / "extension.yml"
+ if not manifest_path.is_file():
+ continue
+
+ try:
+ manifest = ExtensionManifest(manifest_path)
+ except ValidationError:
+ continue
+
+ ext_root = ext_dir.resolve()
+ for cmd_info in manifest.commands:
+ cmd_name = cmd_info.get("name")
+ cmd_file_rel = cmd_info.get("file")
+ if not isinstance(cmd_name, str) or not isinstance(cmd_file_rel, str):
+ continue
+
+ cmd_path = Path(cmd_file_rel)
+ if cmd_path.is_absolute():
+ continue
+
+ try:
+ source_file = (ext_root / cmd_path).resolve()
+ source_file.relative_to(ext_root)
+ except (OSError, ValueError):
+ continue
+
+ if not source_file.is_file():
+ continue
+
+ restore_info = {
+ "command_name": cmd_name,
+ "source_file": source_file,
+ "source": f"extension:{manifest.id}",
+ }
+ modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
+ restore_index.setdefault(modern_skill_name, restore_info)
+ if legacy_skill_name != modern_skill_name:
+ restore_index.setdefault(legacy_skill_name, restore_info)
+
+ return restore_index
+
def _register_skills(
self,
manifest: "PresetManifest",
@@ -629,9 +706,15 @@ def _register_skills(
return []
from . import SKILL_DESCRIPTIONS, load_init_options
+ from .agents import CommandRegistrar
- opts = load_init_options(self.project_root)
- selected_ai = opts.get("ai", "")
+ init_opts = load_init_options(self.project_root)
+ if not isinstance(init_opts, dict):
+ init_opts = {}
+ selected_ai = init_opts.get("ai")
+ if not isinstance(selected_ai, str):
+ return []
+ registrar = CommandRegistrar()
written: List[str] = []
@@ -643,62 +726,61 @@ def _register_skills(
continue
# Derive the short command name (e.g. "specify" from "speckit.specify")
- short_name = cmd_name
- if short_name.startswith("speckit."):
- short_name = short_name[len("speckit."):]
- if selected_ai == "kimi":
- skill_name = f"speckit.{short_name}"
- else:
- skill_name = f"speckit-{short_name}"
-
- # Only overwrite if the skill already exists (i.e. --ai-skills was used)
- skill_subdir = skills_dir / skill_name
- if not skill_subdir.exists():
+ raw_short_name = cmd_name
+ if raw_short_name.startswith("speckit."):
+ raw_short_name = raw_short_name[len("speckit."):]
+ short_name = raw_short_name.replace(".", "-")
+ skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
+ skill_title = self._skill_title_from_command(cmd_name)
+
+ # Only overwrite skills that already exist under skills_dir,
+ # including Kimi native skills when ai_skills is false.
+ # If both modern and legacy directories exist, update both.
+ target_skill_names: List[str] = []
+ if (skills_dir / skill_name).is_dir():
+ target_skill_names.append(skill_name)
+ if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir():
+ target_skill_names.append(legacy_skill_name)
+ if not target_skill_names:
continue
# Parse the command file
content = source_file.read_text(encoding="utf-8")
- 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:
- frontmatter = {}
- body = content
- else:
- frontmatter = {}
- body = content
+ frontmatter, body = registrar.parse_frontmatter(content)
original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(
short_name,
original_desc or f"Spec-kit workflow command: {short_name}",
)
-
- frontmatter_data = {
- "name": skill_name,
- "description": enhanced_desc,
- "compatibility": "Requires spec-kit project structure with .specify/ directory",
- "metadata": {
- "author": "github-spec-kit",
- "source": f"preset:{manifest.id}",
- },
- }
- frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
- skill_content = (
- f"---\n"
- f"{frontmatter_text}\n"
- f"---\n\n"
- f"# Speckit {short_name.title()} Skill\n\n"
- f"{body}\n"
+ frontmatter = dict(frontmatter)
+ frontmatter["description"] = enhanced_desc
+ body = registrar.resolve_skill_placeholders(
+ selected_ai, frontmatter, body, self.project_root
)
- skill_file = skill_subdir / "SKILL.md"
- skill_file.write_text(skill_content, encoding="utf-8")
- written.append(skill_name)
+ for target_skill_name in target_skill_names:
+ frontmatter_data = {
+ "name": target_skill_name,
+ "description": enhanced_desc,
+ "compatibility": "Requires spec-kit project structure with .specify/ directory",
+ "metadata": {
+ "author": "github-spec-kit",
+ "source": f"preset:{manifest.id}",
+ },
+ }
+ frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
+ skill_content = (
+ f"---\n"
+ f"{frontmatter_text}\n"
+ f"---\n\n"
+ f"# Speckit {skill_title} Skill\n\n"
+ f"{body}\n"
+ )
+
+ skill_file = skills_dir / target_skill_name / "SKILL.md"
+ skill_file.write_text(skill_content, encoding="utf-8")
+ written.append(target_skill_name)
return written
@@ -720,10 +802,17 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
if not skills_dir:
return
- from . import SKILL_DESCRIPTIONS
+ from . import SKILL_DESCRIPTIONS, load_init_options
+ from .agents import CommandRegistrar
# Locate core command templates from the project's installed templates
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
+ init_opts = load_init_options(self.project_root)
+ if not isinstance(init_opts, dict):
+ init_opts = {}
+ selected_ai = init_opts.get("ai")
+ registrar = CommandRegistrar()
+ extension_restore_index = self._build_extension_skill_restore_index()
for skill_name in skill_names:
# Derive command name from skill name (speckit-specify -> specify)
@@ -735,7 +824,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
skill_subdir = skills_dir / skill_name
skill_file = skill_subdir / "SKILL.md"
- if not skill_file.exists():
+ if not skill_subdir.is_dir():
+ continue
+ if not skill_file.is_file():
+ # Only manage directories that contain the expected skill entrypoint.
continue
# Try to find the core command template
@@ -746,19 +838,11 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
if core_file:
# Restore from core template
content = core_file.read_text(encoding="utf-8")
- 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:
- frontmatter = {}
- body = content
- else:
- frontmatter = {}
- body = content
+ frontmatter, body = registrar.parse_frontmatter(content)
+ if isinstance(selected_ai, str):
+ body = registrar.resolve_skill_placeholders(
+ selected_ai, frontmatter, body, self.project_root
+ )
original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(
@@ -776,16 +860,49 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
},
}
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
+ skill_title = self._skill_title_from_command(short_name)
skill_content = (
f"---\n"
f"{frontmatter_text}\n"
f"---\n\n"
- f"# Speckit {short_name.title()} Skill\n\n"
+ f"# Speckit {skill_title} Skill\n\n"
+ f"{body}\n"
+ )
+ skill_file.write_text(skill_content, encoding="utf-8")
+ continue
+
+ extension_restore = extension_restore_index.get(skill_name)
+ if extension_restore:
+ content = extension_restore["source_file"].read_text(encoding="utf-8")
+ frontmatter, body = registrar.parse_frontmatter(content)
+ if isinstance(selected_ai, str):
+ body = registrar.resolve_skill_placeholders(
+ selected_ai, frontmatter, body, self.project_root
+ )
+
+ command_name = extension_restore["command_name"]
+ title_name = self._skill_title_from_command(command_name)
+
+ frontmatter_data = {
+ "name": skill_name,
+ "description": frontmatter.get("description", f"Extension command: {command_name}"),
+ "compatibility": "Requires spec-kit project structure with .specify/ directory",
+ "metadata": {
+ "author": "github-spec-kit",
+ "source": extension_restore["source"],
+ },
+ }
+ frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
+ skill_content = (
+ f"---\n"
+ f"{frontmatter_text}\n"
+ f"---\n\n"
+ f"# {title_name} Skill\n\n"
f"{body}\n"
)
skill_file.write_text(skill_content, encoding="utf-8")
else:
- # No core template β remove the skill entirely
+ # No core or extension template β remove the skill entirely
shutil.rmtree(skill_subdir)
def install_from_directory(
@@ -915,17 +1032,26 @@ def remove(self, pack_id: str) -> bool:
if not self.registry.is_installed(pack_id):
return False
- # Unregister commands from AI agents
metadata = self.registry.get(pack_id)
- registered_commands = metadata.get("registered_commands", {}) if metadata else {}
- if registered_commands:
- self._unregister_commands(registered_commands)
-
# Restore original skills when preset is removed
registered_skills = metadata.get("registered_skills", []) if metadata else []
+ registered_commands = metadata.get("registered_commands", {}) if metadata else {}
pack_dir = self.presets_dir / pack_id
if registered_skills:
self._unregister_skills(registered_skills, pack_dir)
+ try:
+ from . import NATIVE_SKILLS_AGENTS
+ except ImportError:
+ NATIVE_SKILLS_AGENTS = set()
+ registered_commands = {
+ agent_name: cmd_names
+ for agent_name, cmd_names in registered_commands.items()
+ if agent_name not in NATIVE_SKILLS_AGENTS
+ }
+
+ # Unregister non-skill command files from AI agents.
+ if registered_commands:
+ self._unregister_commands(registered_commands)
if pack_dir.exists():
shutil.rmtree(pack_dir)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000..4387c9ac8f
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,10 @@
+"""Shared test helpers for the Spec Kit test suite."""
+
+import re
+
+_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
+
+
+def strip_ansi(text: str) -> str:
+ """Remove ANSI escape codes from Rich-formatted CLI output."""
+ return _ANSI_ESCAPE_RE.sub("", text)
diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py
new file mode 100644
index 0000000000..54f59e23a7
--- /dev/null
+++ b/tests/integrations/conftest.py
@@ -0,0 +1,23 @@
+"""Shared test helpers for integration tests."""
+
+from specify_cli.integrations.base import MarkdownIntegration
+
+
+class StubIntegration(MarkdownIntegration):
+ """Minimal concrete integration for testing."""
+
+ key = "stub"
+ config = {
+ "name": "Stub Agent",
+ "folder": ".stub/",
+ "commands_subdir": "commands",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".stub/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+ context_file = "STUB.md"
diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py
new file mode 100644
index 0000000000..03b5eb3068
--- /dev/null
+++ b/tests/integrations/test_base.py
@@ -0,0 +1,169 @@
+"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
+
+import pytest
+
+from specify_cli.integrations.base import (
+ IntegrationBase,
+ IntegrationOption,
+ MarkdownIntegration,
+)
+from specify_cli.integrations.manifest import IntegrationManifest
+from .conftest import StubIntegration
+
+
+class TestIntegrationOption:
+ def test_defaults(self):
+ opt = IntegrationOption(name="--flag")
+ assert opt.name == "--flag"
+ assert opt.is_flag is False
+ assert opt.required is False
+ assert opt.default is None
+ assert opt.help == ""
+
+ def test_flag_option(self):
+ opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills")
+ assert opt.is_flag is True
+ assert opt.default is True
+ assert opt.help == "Enable skills"
+
+ def test_required_option(self):
+ opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path")
+ assert opt.required is True
+
+ def test_frozen(self):
+ opt = IntegrationOption(name="--x")
+ with pytest.raises(AttributeError):
+ opt.name = "--y" # type: ignore[misc]
+
+
+class TestIntegrationBase:
+ def test_key_and_config(self):
+ i = StubIntegration()
+ assert i.key == "stub"
+ assert i.config["name"] == "Stub Agent"
+ assert i.registrar_config["format"] == "markdown"
+ assert i.context_file == "STUB.md"
+
+ def test_options_default_empty(self):
+ assert StubIntegration.options() == []
+
+ def test_shared_commands_dir(self):
+ i = StubIntegration()
+ cmd_dir = i.shared_commands_dir()
+ assert cmd_dir is not None
+ assert cmd_dir.is_dir()
+
+ def test_setup_uses_shared_templates(self, tmp_path):
+ i = StubIntegration()
+ manifest = IntegrationManifest("stub", tmp_path)
+ created = i.setup(tmp_path, manifest)
+ assert len(created) > 0
+ for f in created:
+ assert f.parent == tmp_path / ".stub" / "commands"
+ assert f.name.startswith("speckit.")
+ assert f.name.endswith(".md")
+
+ def test_setup_copies_templates(self, tmp_path, monkeypatch):
+ tpl = tmp_path / "_templates"
+ tpl.mkdir()
+ (tpl / "plan.md").write_text("plan content", encoding="utf-8")
+ (tpl / "specify.md").write_text("spec content", encoding="utf-8")
+
+ i = StubIntegration()
+ monkeypatch.setattr(type(i), "list_command_templates", lambda self: sorted(tpl.glob("*.md")))
+
+ project = tmp_path / "project"
+ project.mkdir()
+ created = i.setup(project, IntegrationManifest("stub", project))
+ assert len(created) == 2
+ assert (project / ".stub" / "commands" / "speckit.plan.md").exists()
+ assert (project / ".stub" / "commands" / "speckit.specify.md").exists()
+
+ def test_install_delegates_to_setup(self, tmp_path):
+ i = StubIntegration()
+ manifest = IntegrationManifest("stub", tmp_path)
+ result = i.install(tmp_path, manifest)
+ assert len(result) > 0
+
+ def test_uninstall_delegates_to_teardown(self, tmp_path):
+ i = StubIntegration()
+ manifest = IntegrationManifest("stub", tmp_path)
+ removed, skipped = i.uninstall(tmp_path, manifest)
+ assert removed == []
+ assert skipped == []
+
+
+class TestMarkdownIntegration:
+ def test_is_subclass_of_base(self):
+ assert issubclass(MarkdownIntegration, IntegrationBase)
+
+ def test_stub_is_markdown(self):
+ assert isinstance(StubIntegration(), MarkdownIntegration)
+
+
+class TestBasePrimitives:
+ def test_shared_commands_dir_returns_path(self):
+ i = StubIntegration()
+ cmd_dir = i.shared_commands_dir()
+ assert cmd_dir is not None
+ assert cmd_dir.is_dir()
+
+ def test_shared_templates_dir_returns_path(self):
+ i = StubIntegration()
+ tpl_dir = i.shared_templates_dir()
+ assert tpl_dir is not None
+ assert tpl_dir.is_dir()
+
+ def test_list_command_templates_returns_md_files(self):
+ i = StubIntegration()
+ templates = i.list_command_templates()
+ assert len(templates) > 0
+ assert all(t.suffix == ".md" for t in templates)
+
+ def test_command_filename_default(self):
+ i = StubIntegration()
+ assert i.command_filename("plan") == "speckit.plan.md"
+
+ def test_commands_dest(self, tmp_path):
+ i = StubIntegration()
+ dest = i.commands_dest(tmp_path)
+ assert dest == tmp_path / ".stub" / "commands"
+
+ def test_commands_dest_no_config_raises(self, tmp_path):
+ class NoConfig(MarkdownIntegration):
+ key = "noconfig"
+ with pytest.raises(ValueError, match="config is not set"):
+ NoConfig().commands_dest(tmp_path)
+
+ def test_copy_command_to_directory(self, tmp_path):
+ src = tmp_path / "source.md"
+ src.write_text("content", encoding="utf-8")
+ dest_dir = tmp_path / "output"
+ result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md")
+ assert result == dest_dir / "speckit.plan.md"
+ assert result.read_text(encoding="utf-8") == "content"
+
+ def test_record_file_in_manifest(self, tmp_path):
+ f = tmp_path / "f.txt"
+ f.write_text("hello", encoding="utf-8")
+ m = IntegrationManifest("test", tmp_path)
+ IntegrationBase.record_file_in_manifest(f, tmp_path, m)
+ assert "f.txt" in m.files
+
+ def test_write_file_and_record(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ dest = tmp_path / "sub" / "f.txt"
+ result = IntegrationBase.write_file_and_record("content", dest, tmp_path, m)
+ assert result == dest
+ assert dest.read_text(encoding="utf-8") == "content"
+ assert "sub/f.txt" in m.files
+
+ def test_setup_copies_shared_templates(self, tmp_path):
+ i = StubIntegration()
+ m = IntegrationManifest("stub", tmp_path)
+ created = i.setup(tmp_path, m)
+ assert len(created) > 0
+ for f in created:
+ assert f.parent.name == "commands"
+ assert f.name.startswith("speckit.")
+ assert f.name.endswith(".md")
diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py
new file mode 100644
index 0000000000..cd0071783f
--- /dev/null
+++ b/tests/integrations/test_cli.py
@@ -0,0 +1,120 @@
+"""Tests for --integration flag on specify init (CLI-level)."""
+
+import json
+import os
+
+
+class TestInitIntegrationFlag:
+ def test_integration_and_ai_mutually_exclusive(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot",
+ ])
+ assert result.exit_code != 0
+ assert "mutually exclusive" in result.output
+
+ def test_unknown_integration_rejected(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", str(tmp_path / "test-project"), "--integration", "nonexistent",
+ ])
+ assert result.exit_code != 0
+ assert "Unknown integration" in result.output
+
+ def test_integration_copilot_creates_files(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+ runner = CliRunner()
+ project = tmp_path / "int-test"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = runner.invoke(app, [
+ "init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
+ assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists()
+ assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists()
+
+ data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
+ assert data["integration"] == "copilot"
+ assert "scripts" in data
+ assert "update-context" in data["scripts"]
+
+ opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
+ assert opts["integration"] == "copilot"
+
+ assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
+ assert (project / ".specify" / "integrations" / "copilot" / "scripts" / "update-context.sh").exists()
+
+ shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
+ assert shared_manifest.exists()
+
+ def test_ai_copilot_auto_promotes(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+ project = tmp_path / "promote-test"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0
+ assert "--integration copilot" in result.output
+ assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
+
+ def test_shared_infra_skips_existing_files(self, tmp_path):
+ """Pre-existing shared files are not overwritten by _install_shared_infra."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "skip-test"
+ project.mkdir()
+
+ # Pre-create a shared script with custom content
+ scripts_dir = project / ".specify" / "scripts" / "bash"
+ scripts_dir.mkdir(parents=True)
+ custom_content = "# user-modified common.sh\n"
+ (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
+
+ # Pre-create a shared template with custom content
+ templates_dir = project / ".specify" / "templates"
+ templates_dir.mkdir(parents=True)
+ custom_template = "# user-modified spec-template\n"
+ (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
+
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--force",
+ "--integration", "copilot",
+ "--script", "sh",
+ "--no-git",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+
+ assert result.exit_code == 0
+
+ # User's files should be preserved
+ assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
+ assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
+
+ # Other shared files should still be installed
+ assert (scripts_dir / "setup-plan.sh").exists()
+ assert (templates_dir / "plan-template.md").exists()
diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py
new file mode 100644
index 0000000000..3efaa99362
--- /dev/null
+++ b/tests/integrations/test_integration_agy.py
@@ -0,0 +1,25 @@
+"""Tests for AgyIntegration (Antigravity)."""
+
+from .test_integration_base_skills import SkillsIntegrationTests
+
+
+class TestAgyIntegration(SkillsIntegrationTests):
+ KEY = "agy"
+ FOLDER = ".agent/"
+ COMMANDS_SUBDIR = "skills"
+ REGISTRAR_DIR = ".agent/skills"
+ CONTEXT_FILE = "AGENTS.md"
+
+
+class TestAgyAutoPromote:
+ """--ai agy auto-promotes to integration path."""
+
+ def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path):
+ """--ai agy (without --ai-skills) should auto-promote to integration."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ runner = CliRunner()
+ result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "agy"])
+
+ assert "--integration agy" in result.output
diff --git a/tests/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py
new file mode 100644
index 0000000000..a36dd47136
--- /dev/null
+++ b/tests/integrations/test_integration_amp.py
@@ -0,0 +1,11 @@
+"""Tests for AmpIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestAmpIntegration(MarkdownIntegrationTests):
+ KEY = "amp"
+ FOLDER = ".agents/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".agents/commands"
+ CONTEXT_FILE = "AGENTS.md"
diff --git a/tests/integrations/test_integration_auggie.py b/tests/integrations/test_integration_auggie.py
new file mode 100644
index 0000000000..e4033a23e8
--- /dev/null
+++ b/tests/integrations/test_integration_auggie.py
@@ -0,0 +1,11 @@
+"""Tests for AuggieIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestAuggieIntegration(MarkdownIntegrationTests):
+ KEY = "auggie"
+ FOLDER = ".augment/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".augment/commands"
+ CONTEXT_FILE = ".augment/rules/specify-rules.md"
diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py
new file mode 100644
index 0000000000..75319eb944
--- /dev/null
+++ b/tests/integrations/test_integration_base_markdown.py
@@ -0,0 +1,296 @@
+"""Reusable test mixin for standard MarkdownIntegration subclasses.
+
+Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
+``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
+logic from ``MarkdownIntegrationTests``.
+"""
+
+import os
+
+from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
+from specify_cli.integrations.base import MarkdownIntegration
+from specify_cli.integrations.manifest import IntegrationManifest
+
+
+class MarkdownIntegrationTests:
+ """Mixin β set class-level constants and inherit these tests.
+
+ Required class attrs on subclass::
+
+ KEY: str β integration registry key
+ FOLDER: str β e.g. ".claude/"
+ COMMANDS_SUBDIR: str β e.g. "commands"
+ REGISTRAR_DIR: str β e.g. ".claude/commands"
+ CONTEXT_FILE: str β e.g. "CLAUDE.md"
+ """
+
+ KEY: str
+ FOLDER: str
+ COMMANDS_SUBDIR: str
+ REGISTRAR_DIR: str
+ CONTEXT_FILE: str
+
+ # -- Registration -----------------------------------------------------
+
+ def test_registered(self):
+ assert self.KEY in INTEGRATION_REGISTRY
+ assert get_integration(self.KEY) is not None
+
+ def test_is_markdown_integration(self):
+ assert isinstance(get_integration(self.KEY), MarkdownIntegration)
+
+ # -- Config -----------------------------------------------------------
+
+ def test_config_folder(self):
+ i = get_integration(self.KEY)
+ assert i.config["folder"] == self.FOLDER
+
+ def test_config_commands_subdir(self):
+ i = get_integration(self.KEY)
+ assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
+
+ def test_registrar_config(self):
+ i = get_integration(self.KEY)
+ assert i.registrar_config["dir"] == self.REGISTRAR_DIR
+ assert i.registrar_config["format"] == "markdown"
+ assert i.registrar_config["args"] == "$ARGUMENTS"
+ assert i.registrar_config["extension"] == ".md"
+
+ def test_context_file(self):
+ i = get_integration(self.KEY)
+ assert i.context_file == self.CONTEXT_FILE
+
+ # -- Setup / teardown -------------------------------------------------
+
+ def test_setup_creates_files(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ assert len(created) > 0
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ for f in cmd_files:
+ assert f.exists()
+ assert f.name.startswith("speckit.")
+ assert f.name.endswith(".md")
+
+ def test_setup_writes_to_correct_directory(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ expected_dir = i.commands_dest(tmp_path)
+ assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ assert len(cmd_files) > 0, "No command files were created"
+ for f in cmd_files:
+ assert f.resolve().parent == expected_dir.resolve(), (
+ f"{f} is not under {expected_dir}"
+ )
+
+ def test_templates_are_processed(self, tmp_path):
+ """Command files must have placeholders replaced, not raw templates."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ assert len(cmd_files) > 0
+ for f in cmd_files:
+ content = f.read_text(encoding="utf-8")
+ assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
+ assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
+ assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
+ assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
+ assert "\nagent_scripts:\n" not in content, f"{f.name} has unstripped agent_scripts: block"
+
+ def test_all_files_tracked_in_manifest(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ for f in created:
+ rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
+ assert rel in m.files, f"{rel} not tracked in manifest"
+
+ def test_install_uninstall_roundtrip(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.install(tmp_path, m)
+ assert len(created) > 0
+ m.save()
+ for f in created:
+ assert f.exists()
+ removed, skipped = i.uninstall(tmp_path, m)
+ assert len(removed) == len(created)
+ assert skipped == []
+
+ def test_modified_file_survives_uninstall(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.install(tmp_path, m)
+ m.save()
+ modified_file = created[0]
+ modified_file.write_text("user modified this", encoding="utf-8")
+ removed, skipped = i.uninstall(tmp_path, m)
+ assert modified_file.exists()
+ assert modified_file in skipped
+
+ # -- Scripts ----------------------------------------------------------
+
+ def test_setup_installs_update_context_scripts(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
+ assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
+ assert (scripts_dir / "update-context.sh").exists()
+ assert (scripts_dir / "update-context.ps1").exists()
+
+ def test_scripts_tracked_in_manifest(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ i.setup(tmp_path, m)
+ script_rels = [k for k in m.files if "update-context" in k]
+ assert len(script_rels) >= 2
+
+ def test_sh_script_is_executable(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ i.setup(tmp_path, m)
+ sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
+ assert os.access(sh, os.X_OK)
+
+ # -- CLI auto-promote -------------------------------------------------
+
+ def test_ai_flag_auto_promotes(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"promote-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
+ "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
+ assert f"--integration {self.KEY}" in result.output
+
+ def test_integration_flag_creates_files(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"int-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
+ "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
+ i = get_integration(self.KEY)
+ cmd_dir = i.commands_dest(project)
+ assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
+ commands = sorted(cmd_dir.glob("speckit.*"))
+ assert len(commands) > 0, f"No command files in {cmd_dir}"
+
+ # -- Complete file inventory ------------------------------------------
+
+ COMMAND_STEMS = [
+ "analyze", "checklist", "clarify", "constitution",
+ "implement", "plan", "specify", "tasks", "taskstoissues",
+ ]
+
+ def _expected_files(self, script_variant: str) -> list[str]:
+ """Build the expected file list for this integration + script variant."""
+ i = get_integration(self.KEY)
+ cmd_dir = i.registrar_config["dir"]
+ files = []
+
+ # Command files
+ for stem in self.COMMAND_STEMS:
+ files.append(f"{cmd_dir}/speckit.{stem}.md")
+
+ # Integration scripts
+ files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
+ files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
+
+ # Framework files
+ files.append(f".specify/integration.json")
+ files.append(f".specify/init-options.json")
+ files.append(f".specify/integrations/{self.KEY}.manifest.json")
+ files.append(f".specify/integrations/speckit.manifest.json")
+
+ if script_variant == "sh":
+ for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
+ "setup-plan.sh", "update-agent-context.sh"]:
+ files.append(f".specify/scripts/bash/{name}")
+ else:
+ for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
+ "setup-plan.ps1", "update-agent-context.ps1"]:
+ files.append(f".specify/scripts/powershell/{name}")
+
+ for name in ["agent-file-template.md", "checklist-template.md",
+ "constitution-template.md", "plan-template.md",
+ "spec-template.md", "tasks-template.md"]:
+ files.append(f".specify/templates/{name}")
+
+ files.append(".specify/memory/constitution.md")
+ return sorted(files)
+
+ def test_complete_file_inventory_sh(self, tmp_path):
+ """Every file produced by specify init --integration --script sh."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"inventory-sh-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", self.KEY, "--script", "sh",
+ "--no-git", "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ actual = sorted(p.relative_to(project).as_posix()
+ for p in project.rglob("*") if p.is_file())
+ expected = self._expected_files("sh")
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
+
+ def test_complete_file_inventory_ps(self, tmp_path):
+ """Every file produced by specify init --integration --script ps."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"inventory-ps-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", self.KEY, "--script", "ps",
+ "--no-git", "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ actual = sorted(p.relative_to(project).as_posix()
+ for p in project.rglob("*") if p.is_file())
+ expected = self._expected_files("ps")
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py
new file mode 100644
index 0000000000..23505c3062
--- /dev/null
+++ b/tests/integrations/test_integration_base_skills.py
@@ -0,0 +1,402 @@
+"""Reusable test mixin for standard SkillsIntegration subclasses.
+
+Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
+``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
+logic from ``SkillsIntegrationTests``.
+
+Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely,
+adapted for the ``speckit-/SKILL.md`` skills layout.
+"""
+
+import os
+
+import yaml
+
+from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
+from specify_cli.integrations.base import SkillsIntegration
+from specify_cli.integrations.manifest import IntegrationManifest
+
+
+class SkillsIntegrationTests:
+ """Mixin β set class-level constants and inherit these tests.
+
+ Required class attrs on subclass::
+
+ KEY: str β integration registry key
+ FOLDER: str β e.g. ".agents/"
+ COMMANDS_SUBDIR: str β e.g. "skills"
+ REGISTRAR_DIR: str β e.g. ".agents/skills"
+ CONTEXT_FILE: str β e.g. "AGENTS.md"
+ """
+
+ KEY: str
+ FOLDER: str
+ COMMANDS_SUBDIR: str
+ REGISTRAR_DIR: str
+ CONTEXT_FILE: str
+
+ # -- Registration -----------------------------------------------------
+
+ def test_registered(self):
+ assert self.KEY in INTEGRATION_REGISTRY
+ assert get_integration(self.KEY) is not None
+
+ def test_is_skills_integration(self):
+ assert isinstance(get_integration(self.KEY), SkillsIntegration)
+
+ # -- Config -----------------------------------------------------------
+
+ def test_config_folder(self):
+ i = get_integration(self.KEY)
+ assert i.config["folder"] == self.FOLDER
+
+ def test_config_commands_subdir(self):
+ i = get_integration(self.KEY)
+ assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
+
+ def test_registrar_config(self):
+ i = get_integration(self.KEY)
+ assert i.registrar_config["dir"] == self.REGISTRAR_DIR
+ assert i.registrar_config["format"] == "markdown"
+ assert i.registrar_config["args"] == "$ARGUMENTS"
+ assert i.registrar_config["extension"] == "/SKILL.md"
+
+ def test_context_file(self):
+ i = get_integration(self.KEY)
+ assert i.context_file == self.CONTEXT_FILE
+
+ # -- Setup / teardown -------------------------------------------------
+
+ def test_setup_creates_files(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ assert len(created) > 0
+ skill_files = [f for f in created if "scripts" not in f.parts]
+ for f in skill_files:
+ assert f.exists()
+ assert f.name == "SKILL.md"
+ assert f.parent.name.startswith("speckit-")
+
+ def test_setup_writes_to_correct_directory(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ expected_dir = i.skills_dest(tmp_path)
+ assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
+ skill_files = [f for f in created if "scripts" not in f.parts]
+ assert len(skill_files) > 0, "No skill files were created"
+ for f in skill_files:
+ # Each SKILL.md is in speckit-/ under the skills directory
+ assert f.resolve().parent.parent == expected_dir.resolve(), (
+ f"{f} is not under {expected_dir}"
+ )
+
+ def test_skill_directory_structure(self, tmp_path):
+ """Each command produces speckit-/SKILL.md."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ skill_files = [f for f in created if "scripts" not in f.parts]
+
+ expected_commands = {
+ "analyze", "checklist", "clarify", "constitution",
+ "implement", "plan", "specify", "tasks", "taskstoissues",
+ }
+
+ # Derive command names from the skill directory names
+ actual_commands = set()
+ for f in skill_files:
+ skill_dir_name = f.parent.name # e.g. "speckit-plan"
+ assert skill_dir_name.startswith("speckit-")
+ actual_commands.add(skill_dir_name.removeprefix("speckit-"))
+
+ assert actual_commands == expected_commands
+
+ def test_skill_frontmatter_structure(self, tmp_path):
+ """SKILL.md must have name, description, compatibility, metadata."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ skill_files = [f for f in created if "scripts" not in f.parts]
+
+ for f in skill_files:
+ content = f.read_text(encoding="utf-8")
+ assert content.startswith("---\n"), f"{f} missing frontmatter"
+ parts = content.split("---", 2)
+ fm = yaml.safe_load(parts[1])
+ assert "name" in fm, f"{f} frontmatter missing 'name'"
+ assert "description" in fm, f"{f} frontmatter missing 'description'"
+ assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
+ assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
+ assert fm["metadata"]["author"] == "github-spec-kit"
+ assert "source" in fm["metadata"]
+
+ def test_skill_uses_template_descriptions(self, tmp_path):
+ """SKILL.md should use the original template description for ZIP parity."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ skill_files = [f for f in created if "scripts" not in f.parts]
+
+ for f in skill_files:
+ content = f.read_text(encoding="utf-8")
+ parts = content.split("---", 2)
+ fm = yaml.safe_load(parts[1])
+ # Description must be a non-empty string (from the template)
+ assert isinstance(fm["description"], str)
+ assert len(fm["description"]) > 0, f"{f} has empty description"
+
+ def test_templates_are_processed(self, tmp_path):
+ """Skill body must have placeholders replaced, not raw templates."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ skill_files = [f for f in created if "scripts" not in f.parts]
+ assert len(skill_files) > 0
+ for f in skill_files:
+ content = f.read_text(encoding="utf-8")
+ assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
+ assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
+ assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
+
+ def test_skill_body_has_content(self, tmp_path):
+ """Each SKILL.md body should contain template content after the frontmatter."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ skill_files = [f for f in created if "scripts" not in f.parts]
+ for f in skill_files:
+ content = f.read_text(encoding="utf-8")
+ # Body is everything after the second ---
+ parts = content.split("---", 2)
+ body = parts[2].strip() if len(parts) >= 3 else ""
+ assert len(body) > 0, f"{f} has empty body"
+
+ def test_all_files_tracked_in_manifest(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ for f in created:
+ rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
+ assert rel in m.files, f"{rel} not tracked in manifest"
+
+ def test_install_uninstall_roundtrip(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.install(tmp_path, m)
+ assert len(created) > 0
+ m.save()
+ for f in created:
+ assert f.exists()
+ removed, skipped = i.uninstall(tmp_path, m)
+ assert len(removed) == len(created)
+ assert skipped == []
+
+ def test_modified_file_survives_uninstall(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.install(tmp_path, m)
+ m.save()
+ modified_file = created[0]
+ modified_file.write_text("user modified this", encoding="utf-8")
+ removed, skipped = i.uninstall(tmp_path, m)
+ assert modified_file.exists()
+ assert modified_file in skipped
+
+ def test_pre_existing_skills_not_removed(self, tmp_path):
+ """Pre-existing non-speckit skills should be left untouched."""
+ i = get_integration(self.KEY)
+ skills_dir = i.skills_dest(tmp_path)
+ foreign_dir = skills_dir / "other-tool"
+ foreign_dir.mkdir(parents=True)
+ (foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
+
+ m = IntegrationManifest(self.KEY, tmp_path)
+ i.setup(tmp_path, m)
+
+ assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
+
+ # -- Scripts ----------------------------------------------------------
+
+ def test_setup_installs_update_context_scripts(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ i.setup(tmp_path, m)
+ scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
+ assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
+ assert (scripts_dir / "update-context.sh").exists()
+ assert (scripts_dir / "update-context.ps1").exists()
+
+ def test_scripts_tracked_in_manifest(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ i.setup(tmp_path, m)
+ script_rels = [k for k in m.files if "update-context" in k]
+ assert len(script_rels) >= 2
+
+ def test_sh_script_is_executable(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ i.setup(tmp_path, m)
+ sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
+ assert os.access(sh, os.X_OK)
+
+ # -- CLI auto-promote -------------------------------------------------
+
+ def test_ai_flag_auto_promotes(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"promote-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
+ "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
+ assert f"--integration {self.KEY}" in result.output
+
+ def test_integration_flag_creates_files(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"int-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
+ "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
+ i = get_integration(self.KEY)
+ skills_dir = i.skills_dest(project)
+ assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
+
+ # -- IntegrationOption ------------------------------------------------
+
+ def test_options_include_skills_flag(self):
+ i = get_integration(self.KEY)
+ opts = i.options()
+ skills_opts = [o for o in opts if o.name == "--skills"]
+ assert len(skills_opts) == 1
+ assert skills_opts[0].is_flag is True
+
+ # -- Complete file inventory ------------------------------------------
+
+ _SKILL_COMMANDS = [
+ "analyze", "checklist", "clarify", "constitution",
+ "implement", "plan", "specify", "tasks", "taskstoissues",
+ ]
+
+ def _expected_files(self, script_variant: str) -> list[str]:
+ """Build the full expected file list for a given script variant."""
+ i = get_integration(self.KEY)
+ skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
+
+ files = []
+ # Skill files
+ for cmd in self._SKILL_COMMANDS:
+ files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
+ # Integration metadata
+ files += [
+ ".specify/init-options.json",
+ ".specify/integration.json",
+ f".specify/integrations/{self.KEY}.manifest.json",
+ f".specify/integrations/{self.KEY}/scripts/update-context.ps1",
+ f".specify/integrations/{self.KEY}/scripts/update-context.sh",
+ ".specify/integrations/speckit.manifest.json",
+ ".specify/memory/constitution.md",
+ ]
+ # Script variant
+ if script_variant == "sh":
+ files += [
+ ".specify/scripts/bash/check-prerequisites.sh",
+ ".specify/scripts/bash/common.sh",
+ ".specify/scripts/bash/create-new-feature.sh",
+ ".specify/scripts/bash/setup-plan.sh",
+ ".specify/scripts/bash/update-agent-context.sh",
+ ]
+ else:
+ files += [
+ ".specify/scripts/powershell/check-prerequisites.ps1",
+ ".specify/scripts/powershell/common.ps1",
+ ".specify/scripts/powershell/create-new-feature.ps1",
+ ".specify/scripts/powershell/setup-plan.ps1",
+ ".specify/scripts/powershell/update-agent-context.ps1",
+ ]
+ # Templates
+ files += [
+ ".specify/templates/agent-file-template.md",
+ ".specify/templates/checklist-template.md",
+ ".specify/templates/constitution-template.md",
+ ".specify/templates/plan-template.md",
+ ".specify/templates/spec-template.md",
+ ".specify/templates/tasks-template.md",
+ ]
+ return sorted(files)
+
+ def test_complete_file_inventory_sh(self, tmp_path):
+ """Every file produced by specify init --integration --script sh."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"inventory-sh-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", self.KEY,
+ "--script", "sh", "--no-git", "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ actual = sorted(
+ p.relative_to(project).as_posix()
+ for p in project.rglob("*") if p.is_file()
+ )
+ expected = self._expected_files("sh")
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
+
+ def test_complete_file_inventory_ps(self, tmp_path):
+ """Every file produced by specify init --integration --script ps."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"inventory-ps-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", self.KEY,
+ "--script", "ps", "--no-git", "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ actual = sorted(
+ p.relative_to(project).as_posix()
+ for p in project.rglob("*") if p.is_file()
+ )
+ expected = self._expected_files("ps")
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py
new file mode 100644
index 0000000000..e7b506782f
--- /dev/null
+++ b/tests/integrations/test_integration_base_toml.py
@@ -0,0 +1,346 @@
+"""Reusable test mixin for standard TomlIntegration subclasses.
+
+Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
+``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
+logic from ``TomlIntegrationTests``.
+
+Mirrors ``MarkdownIntegrationTests`` closely β same test structure,
+adapted for TOML output format.
+"""
+
+import os
+
+from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
+from specify_cli.integrations.base import TomlIntegration
+from specify_cli.integrations.manifest import IntegrationManifest
+
+
+class TomlIntegrationTests:
+ """Mixin β set class-level constants and inherit these tests.
+
+ Required class attrs on subclass::
+
+ KEY: str β integration registry key
+ FOLDER: str β e.g. ".gemini/"
+ COMMANDS_SUBDIR: str β e.g. "commands"
+ REGISTRAR_DIR: str β e.g. ".gemini/commands"
+ CONTEXT_FILE: str β e.g. "GEMINI.md"
+ """
+
+ KEY: str
+ FOLDER: str
+ COMMANDS_SUBDIR: str
+ REGISTRAR_DIR: str
+ CONTEXT_FILE: str
+
+ # -- Registration -----------------------------------------------------
+
+ def test_registered(self):
+ assert self.KEY in INTEGRATION_REGISTRY
+ assert get_integration(self.KEY) is not None
+
+ def test_is_toml_integration(self):
+ assert isinstance(get_integration(self.KEY), TomlIntegration)
+
+ # -- Config -----------------------------------------------------------
+
+ def test_config_folder(self):
+ i = get_integration(self.KEY)
+ assert i.config["folder"] == self.FOLDER
+
+ def test_config_commands_subdir(self):
+ i = get_integration(self.KEY)
+ assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
+
+ def test_registrar_config(self):
+ i = get_integration(self.KEY)
+ assert i.registrar_config["dir"] == self.REGISTRAR_DIR
+ assert i.registrar_config["format"] == "toml"
+ assert i.registrar_config["args"] == "{{args}}"
+ assert i.registrar_config["extension"] == ".toml"
+
+ def test_context_file(self):
+ i = get_integration(self.KEY)
+ assert i.context_file == self.CONTEXT_FILE
+
+ # -- Setup / teardown -------------------------------------------------
+
+ def test_setup_creates_files(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ assert len(created) > 0
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ for f in cmd_files:
+ assert f.exists()
+ assert f.name.startswith("speckit.")
+ assert f.name.endswith(".toml")
+
+ def test_setup_writes_to_correct_directory(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ expected_dir = i.commands_dest(tmp_path)
+ assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ assert len(cmd_files) > 0, "No command files were created"
+ for f in cmd_files:
+ assert f.resolve().parent == expected_dir.resolve(), (
+ f"{f} is not under {expected_dir}"
+ )
+
+ def test_templates_are_processed(self, tmp_path):
+ """Command files must have placeholders replaced and be valid TOML."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ assert len(cmd_files) > 0
+ for f in cmd_files:
+ content = f.read_text(encoding="utf-8")
+ assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
+ assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
+ assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
+
+ def test_toml_has_description(self, tmp_path):
+ """Every TOML command file should have a description key."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ for f in cmd_files:
+ content = f.read_text(encoding="utf-8")
+ assert 'description = "' in content, f"{f.name} missing description key"
+
+ def test_toml_has_prompt(self, tmp_path):
+ """Every TOML command file should have a prompt key."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ for f in cmd_files:
+ content = f.read_text(encoding="utf-8")
+ assert "prompt = " in content, f"{f.name} missing prompt key"
+
+ def test_toml_uses_correct_arg_placeholder(self, tmp_path):
+ """TOML commands must use {{args}} (from {ARGS} replacement)."""
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ # At least one file should contain {{args}} from the {ARGS} placeholder
+ has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
+ assert has_args, "No TOML command file contains {{args}} placeholder"
+
+ def test_toml_is_valid(self, tmp_path):
+ """Every generated TOML file must parse without errors."""
+ try:
+ import tomllib
+ except ModuleNotFoundError:
+ import tomli as tomllib # type: ignore[no-redef]
+
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ for f in cmd_files:
+ raw = f.read_bytes()
+ try:
+ parsed = tomllib.loads(raw.decode("utf-8"))
+ except Exception as exc:
+ raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc
+ assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key"
+
+ def test_all_files_tracked_in_manifest(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ for f in created:
+ rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
+ assert rel in m.files, f"{rel} not tracked in manifest"
+
+ def test_install_uninstall_roundtrip(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.install(tmp_path, m)
+ assert len(created) > 0
+ m.save()
+ for f in created:
+ assert f.exists()
+ removed, skipped = i.uninstall(tmp_path, m)
+ assert len(removed) == len(created)
+ assert skipped == []
+
+ def test_modified_file_survives_uninstall(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.install(tmp_path, m)
+ m.save()
+ modified_file = created[0]
+ modified_file.write_text("user modified this", encoding="utf-8")
+ removed, skipped = i.uninstall(tmp_path, m)
+ assert modified_file.exists()
+ assert modified_file in skipped
+
+ # -- Scripts ----------------------------------------------------------
+
+ def test_setup_installs_update_context_scripts(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ created = i.setup(tmp_path, m)
+ scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
+ assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
+ assert (scripts_dir / "update-context.sh").exists()
+ assert (scripts_dir / "update-context.ps1").exists()
+
+ def test_scripts_tracked_in_manifest(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ i.setup(tmp_path, m)
+ script_rels = [k for k in m.files if "update-context" in k]
+ assert len(script_rels) >= 2
+
+ def test_sh_script_is_executable(self, tmp_path):
+ i = get_integration(self.KEY)
+ m = IntegrationManifest(self.KEY, tmp_path)
+ i.setup(tmp_path, m)
+ sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
+ assert os.access(sh, os.X_OK)
+
+ # -- CLI auto-promote -------------------------------------------------
+
+ def test_ai_flag_auto_promotes(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"promote-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
+ "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
+ assert f"--integration {self.KEY}" in result.output
+
+ def test_integration_flag_creates_files(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"int-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
+ "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
+ i = get_integration(self.KEY)
+ cmd_dir = i.commands_dest(project)
+ assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
+ commands = sorted(cmd_dir.glob("speckit.*.toml"))
+ assert len(commands) > 0, f"No command files in {cmd_dir}"
+
+ # -- Complete file inventory ------------------------------------------
+
+ COMMAND_STEMS = [
+ "analyze", "checklist", "clarify", "constitution",
+ "implement", "plan", "specify", "tasks", "taskstoissues",
+ ]
+
+ def _expected_files(self, script_variant: str) -> list[str]:
+ """Build the expected file list for this integration + script variant."""
+ i = get_integration(self.KEY)
+ cmd_dir = i.registrar_config["dir"]
+ files = []
+
+ # Command files (.toml)
+ for stem in self.COMMAND_STEMS:
+ files.append(f"{cmd_dir}/speckit.{stem}.toml")
+
+ # Integration scripts
+ files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
+ files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
+
+ # Framework files
+ files.append(f".specify/integration.json")
+ files.append(f".specify/init-options.json")
+ files.append(f".specify/integrations/{self.KEY}.manifest.json")
+ files.append(f".specify/integrations/speckit.manifest.json")
+
+ if script_variant == "sh":
+ for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
+ "setup-plan.sh", "update-agent-context.sh"]:
+ files.append(f".specify/scripts/bash/{name}")
+ else:
+ for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
+ "setup-plan.ps1", "update-agent-context.ps1"]:
+ files.append(f".specify/scripts/powershell/{name}")
+
+ for name in ["agent-file-template.md", "checklist-template.md",
+ "constitution-template.md", "plan-template.md",
+ "spec-template.md", "tasks-template.md"]:
+ files.append(f".specify/templates/{name}")
+
+ files.append(".specify/memory/constitution.md")
+ return sorted(files)
+
+ def test_complete_file_inventory_sh(self, tmp_path):
+ """Every file produced by specify init --integration --script sh."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"inventory-sh-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", self.KEY, "--script", "sh",
+ "--no-git", "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ actual = sorted(p.relative_to(project).as_posix()
+ for p in project.rglob("*") if p.is_file())
+ expected = self._expected_files("sh")
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
+
+ def test_complete_file_inventory_ps(self, tmp_path):
+ """Every file produced by specify init --integration --script ps."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / f"inventory-ps-{self.KEY}"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", self.KEY, "--script", "ps",
+ "--no-git", "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ actual = sorted(p.relative_to(project).as_posix()
+ for p in project.rglob("*") if p.is_file())
+ expected = self._expected_files("ps")
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py
new file mode 100644
index 0000000000..1562f0100c
--- /dev/null
+++ b/tests/integrations/test_integration_bob.py
@@ -0,0 +1,11 @@
+"""Tests for BobIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestBobIntegration(MarkdownIntegrationTests):
+ KEY = "bob"
+ FOLDER = ".bob/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".bob/commands"
+ CONTEXT_FILE = "AGENTS.md"
diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py
new file mode 100644
index 0000000000..6867a295ea
--- /dev/null
+++ b/tests/integrations/test_integration_claude.py
@@ -0,0 +1,11 @@
+"""Tests for ClaudeIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestClaudeIntegration(MarkdownIntegrationTests):
+ KEY = "claude"
+ FOLDER = ".claude/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".claude/commands"
+ CONTEXT_FILE = "CLAUDE.md"
diff --git a/tests/integrations/test_integration_codebuddy.py b/tests/integrations/test_integration_codebuddy.py
new file mode 100644
index 0000000000..dcc2153a7b
--- /dev/null
+++ b/tests/integrations/test_integration_codebuddy.py
@@ -0,0 +1,11 @@
+"""Tests for CodebuddyIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestCodebuddyIntegration(MarkdownIntegrationTests):
+ KEY = "codebuddy"
+ FOLDER = ".codebuddy/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".codebuddy/commands"
+ CONTEXT_FILE = "CODEBUDDY.md"
diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py
new file mode 100644
index 0000000000..eb633f02ba
--- /dev/null
+++ b/tests/integrations/test_integration_codex.py
@@ -0,0 +1,25 @@
+"""Tests for CodexIntegration."""
+
+from .test_integration_base_skills import SkillsIntegrationTests
+
+
+class TestCodexIntegration(SkillsIntegrationTests):
+ KEY = "codex"
+ FOLDER = ".agents/"
+ COMMANDS_SUBDIR = "skills"
+ REGISTRAR_DIR = ".agents/skills"
+ CONTEXT_FILE = "AGENTS.md"
+
+
+class TestCodexAutoPromote:
+ """--ai codex auto-promotes to integration path."""
+
+ def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path):
+ """--ai codex (without --ai-skills) should auto-promote to integration."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ runner = CliRunner()
+ result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "codex"])
+
+ assert "--integration codex" in result.output
diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py
new file mode 100644
index 0000000000..5db0155bdb
--- /dev/null
+++ b/tests/integrations/test_integration_copilot.py
@@ -0,0 +1,266 @@
+"""Tests for CopilotIntegration."""
+
+import json
+import os
+
+from specify_cli.integrations import get_integration
+from specify_cli.integrations.manifest import IntegrationManifest
+
+
+class TestCopilotIntegration:
+ def test_copilot_key_and_config(self):
+ copilot = get_integration("copilot")
+ assert copilot is not None
+ assert copilot.key == "copilot"
+ assert copilot.config["folder"] == ".github/"
+ assert copilot.config["commands_subdir"] == "agents"
+ assert copilot.registrar_config["extension"] == ".agent.md"
+ assert copilot.context_file == ".github/copilot-instructions.md"
+
+ def test_command_filename_agent_md(self):
+ copilot = get_integration("copilot")
+ assert copilot.command_filename("plan") == "speckit.plan.agent.md"
+
+ def test_setup_creates_agent_md_files(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ m = IntegrationManifest("copilot", tmp_path)
+ created = copilot.setup(tmp_path, m)
+ assert len(created) > 0
+ agent_files = [f for f in created if ".agent." in f.name]
+ assert len(agent_files) > 0
+ for f in agent_files:
+ assert f.parent == tmp_path / ".github" / "agents"
+ assert f.name.endswith(".agent.md")
+
+ def test_setup_creates_companion_prompts(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ m = IntegrationManifest("copilot", tmp_path)
+ created = copilot.setup(tmp_path, m)
+ prompt_files = [f for f in created if f.parent.name == "prompts"]
+ assert len(prompt_files) > 0
+ for f in prompt_files:
+ assert f.name.endswith(".prompt.md")
+ content = f.read_text(encoding="utf-8")
+ assert content.startswith("---\nagent: speckit.")
+
+ def test_agent_and_prompt_counts_match(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ m = IntegrationManifest("copilot", tmp_path)
+ created = copilot.setup(tmp_path, m)
+ agents = [f for f in created if ".agent.md" in f.name]
+ prompts = [f for f in created if ".prompt.md" in f.name]
+ assert len(agents) == len(prompts)
+
+ def test_setup_creates_vscode_settings_new(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ assert copilot._vscode_settings_path() is not None
+ m = IntegrationManifest("copilot", tmp_path)
+ created = copilot.setup(tmp_path, m)
+ settings = tmp_path / ".vscode" / "settings.json"
+ assert settings.exists()
+ assert settings in created
+ assert any("settings.json" in k for k in m.files)
+
+ def test_setup_merges_existing_vscode_settings(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ vscode_dir = tmp_path / ".vscode"
+ vscode_dir.mkdir(parents=True)
+ existing = {"editor.fontSize": 14, "custom.setting": True}
+ (vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8")
+ m = IntegrationManifest("copilot", tmp_path)
+ created = copilot.setup(tmp_path, m)
+ settings = tmp_path / ".vscode" / "settings.json"
+ data = json.loads(settings.read_text(encoding="utf-8"))
+ assert data["editor.fontSize"] == 14
+ assert data["custom.setting"] is True
+ assert settings not in created
+ assert not any("settings.json" in k for k in m.files)
+
+ def test_all_created_files_tracked_in_manifest(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ m = IntegrationManifest("copilot", tmp_path)
+ created = copilot.setup(tmp_path, m)
+ for f in created:
+ rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
+ assert rel in m.files, f"Created file {rel} not tracked in manifest"
+
+ def test_install_uninstall_roundtrip(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ m = IntegrationManifest("copilot", tmp_path)
+ created = copilot.install(tmp_path, m)
+ assert len(created) > 0
+ m.save()
+ for f in created:
+ assert f.exists()
+ removed, skipped = copilot.uninstall(tmp_path, m)
+ assert len(removed) == len(created)
+ assert skipped == []
+
+ def test_modified_file_survives_uninstall(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ m = IntegrationManifest("copilot", tmp_path)
+ created = copilot.install(tmp_path, m)
+ m.save()
+ modified_file = created[0]
+ modified_file.write_text("user modified this", encoding="utf-8")
+ removed, skipped = copilot.uninstall(tmp_path, m)
+ assert modified_file.exists()
+ assert modified_file in skipped
+
+ def test_directory_structure(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ m = IntegrationManifest("copilot", tmp_path)
+ copilot.setup(tmp_path, m)
+ agents_dir = tmp_path / ".github" / "agents"
+ assert agents_dir.is_dir()
+ agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
+ assert len(agent_files) == 9
+ expected_commands = {
+ "analyze", "checklist", "clarify", "constitution",
+ "implement", "plan", "specify", "tasks", "taskstoissues",
+ }
+ actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
+ assert actual_commands == expected_commands
+
+ def test_templates_are_processed(self, tmp_path):
+ from specify_cli.integrations.copilot import CopilotIntegration
+ copilot = CopilotIntegration()
+ m = IntegrationManifest("copilot", tmp_path)
+ copilot.setup(tmp_path, m)
+ agents_dir = tmp_path / ".github" / "agents"
+ for agent_file in agents_dir.glob("speckit.*.agent.md"):
+ content = agent_file.read_text(encoding="utf-8")
+ assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
+ assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
+ assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
+ assert "\nscripts:\n" not in content
+ assert "\nagent_scripts:\n" not in content
+
+ def test_complete_file_inventory_sh(self, tmp_path):
+ """Every file produced by specify init --integration copilot --script sh."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+ project = tmp_path / "inventory-sh"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0
+ actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
+ expected = sorted([
+ ".github/agents/speckit.analyze.agent.md",
+ ".github/agents/speckit.checklist.agent.md",
+ ".github/agents/speckit.clarify.agent.md",
+ ".github/agents/speckit.constitution.agent.md",
+ ".github/agents/speckit.implement.agent.md",
+ ".github/agents/speckit.plan.agent.md",
+ ".github/agents/speckit.specify.agent.md",
+ ".github/agents/speckit.tasks.agent.md",
+ ".github/agents/speckit.taskstoissues.agent.md",
+ ".github/prompts/speckit.analyze.prompt.md",
+ ".github/prompts/speckit.checklist.prompt.md",
+ ".github/prompts/speckit.clarify.prompt.md",
+ ".github/prompts/speckit.constitution.prompt.md",
+ ".github/prompts/speckit.implement.prompt.md",
+ ".github/prompts/speckit.plan.prompt.md",
+ ".github/prompts/speckit.specify.prompt.md",
+ ".github/prompts/speckit.tasks.prompt.md",
+ ".github/prompts/speckit.taskstoissues.prompt.md",
+ ".vscode/settings.json",
+ ".specify/integration.json",
+ ".specify/init-options.json",
+ ".specify/integrations/copilot.manifest.json",
+ ".specify/integrations/speckit.manifest.json",
+ ".specify/integrations/copilot/scripts/update-context.ps1",
+ ".specify/integrations/copilot/scripts/update-context.sh",
+ ".specify/scripts/bash/check-prerequisites.sh",
+ ".specify/scripts/bash/common.sh",
+ ".specify/scripts/bash/create-new-feature.sh",
+ ".specify/scripts/bash/setup-plan.sh",
+ ".specify/scripts/bash/update-agent-context.sh",
+ ".specify/templates/agent-file-template.md",
+ ".specify/templates/checklist-template.md",
+ ".specify/templates/constitution-template.md",
+ ".specify/templates/plan-template.md",
+ ".specify/templates/spec-template.md",
+ ".specify/templates/tasks-template.md",
+ ".specify/memory/constitution.md",
+ ])
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
+
+ def test_complete_file_inventory_ps(self, tmp_path):
+ """Every file produced by specify init --integration copilot --script ps."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+ project = tmp_path / "inventory-ps"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", "copilot", "--script", "ps", "--no-git",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0
+ actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
+ expected = sorted([
+ ".github/agents/speckit.analyze.agent.md",
+ ".github/agents/speckit.checklist.agent.md",
+ ".github/agents/speckit.clarify.agent.md",
+ ".github/agents/speckit.constitution.agent.md",
+ ".github/agents/speckit.implement.agent.md",
+ ".github/agents/speckit.plan.agent.md",
+ ".github/agents/speckit.specify.agent.md",
+ ".github/agents/speckit.tasks.agent.md",
+ ".github/agents/speckit.taskstoissues.agent.md",
+ ".github/prompts/speckit.analyze.prompt.md",
+ ".github/prompts/speckit.checklist.prompt.md",
+ ".github/prompts/speckit.clarify.prompt.md",
+ ".github/prompts/speckit.constitution.prompt.md",
+ ".github/prompts/speckit.implement.prompt.md",
+ ".github/prompts/speckit.plan.prompt.md",
+ ".github/prompts/speckit.specify.prompt.md",
+ ".github/prompts/speckit.tasks.prompt.md",
+ ".github/prompts/speckit.taskstoissues.prompt.md",
+ ".vscode/settings.json",
+ ".specify/integration.json",
+ ".specify/init-options.json",
+ ".specify/integrations/copilot.manifest.json",
+ ".specify/integrations/speckit.manifest.json",
+ ".specify/integrations/copilot/scripts/update-context.ps1",
+ ".specify/integrations/copilot/scripts/update-context.sh",
+ ".specify/scripts/powershell/check-prerequisites.ps1",
+ ".specify/scripts/powershell/common.ps1",
+ ".specify/scripts/powershell/create-new-feature.ps1",
+ ".specify/scripts/powershell/setup-plan.ps1",
+ ".specify/scripts/powershell/update-agent-context.ps1",
+ ".specify/templates/agent-file-template.md",
+ ".specify/templates/checklist-template.md",
+ ".specify/templates/constitution-template.md",
+ ".specify/templates/plan-template.md",
+ ".specify/templates/spec-template.md",
+ ".specify/templates/tasks-template.md",
+ ".specify/memory/constitution.md",
+ ])
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py
new file mode 100644
index 0000000000..71b7db1c98
--- /dev/null
+++ b/tests/integrations/test_integration_cursor_agent.py
@@ -0,0 +1,11 @@
+"""Tests for CursorAgentIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestCursorAgentIntegration(MarkdownIntegrationTests):
+ KEY = "cursor-agent"
+ FOLDER = ".cursor/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".cursor/commands"
+ CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
diff --git a/tests/integrations/test_integration_gemini.py b/tests/integrations/test_integration_gemini.py
new file mode 100644
index 0000000000..9be5985e29
--- /dev/null
+++ b/tests/integrations/test_integration_gemini.py
@@ -0,0 +1,11 @@
+"""Tests for GeminiIntegration."""
+
+from .test_integration_base_toml import TomlIntegrationTests
+
+
+class TestGeminiIntegration(TomlIntegrationTests):
+ KEY = "gemini"
+ FOLDER = ".gemini/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".gemini/commands"
+ CONTEXT_FILE = "GEMINI.md"
diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py
new file mode 100644
index 0000000000..2815456f21
--- /dev/null
+++ b/tests/integrations/test_integration_generic.py
@@ -0,0 +1,311 @@
+"""Tests for GenericIntegration."""
+
+import os
+
+import pytest
+
+from specify_cli.integrations import get_integration
+from specify_cli.integrations.base import MarkdownIntegration
+from specify_cli.integrations.manifest import IntegrationManifest
+
+
+class TestGenericIntegration:
+ """Tests for GenericIntegration β requires --commands-dir option."""
+
+ # -- Registration -----------------------------------------------------
+
+ def test_registered(self):
+ from specify_cli.integrations import INTEGRATION_REGISTRY
+ assert "generic" in INTEGRATION_REGISTRY
+
+ def test_is_markdown_integration(self):
+ assert isinstance(get_integration("generic"), MarkdownIntegration)
+
+ # -- Config -----------------------------------------------------------
+
+ def test_config_folder_is_none(self):
+ i = get_integration("generic")
+ assert i.config["folder"] is None
+
+ def test_config_requires_cli_false(self):
+ i = get_integration("generic")
+ assert i.config["requires_cli"] is False
+
+ def test_context_file_is_none(self):
+ i = get_integration("generic")
+ assert i.context_file is None
+
+ # -- Options ----------------------------------------------------------
+
+ def test_options_include_commands_dir(self):
+ i = get_integration("generic")
+ opts = i.options()
+ assert len(opts) == 1
+ assert opts[0].name == "--commands-dir"
+ assert opts[0].required is True
+ assert opts[0].is_flag is False
+
+ # -- Setup / teardown -------------------------------------------------
+
+ def test_setup_requires_commands_dir(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ with pytest.raises(ValueError, match="--commands-dir is required"):
+ i.setup(tmp_path, m, parsed_options={})
+
+ def test_setup_requires_nonempty_commands_dir(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ with pytest.raises(ValueError, match="--commands-dir is required"):
+ i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
+
+ def test_setup_writes_to_correct_directory(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ created = i.setup(
+ tmp_path, m,
+ parsed_options={"commands_dir": ".myagent/commands"},
+ )
+ expected_dir = tmp_path / ".myagent" / "commands"
+ assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ assert len(cmd_files) > 0, "No command files were created"
+ for f in cmd_files:
+ assert f.resolve().parent == expected_dir.resolve(), (
+ f"{f} is not under {expected_dir}"
+ )
+
+ def test_setup_creates_md_files(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ created = i.setup(
+ tmp_path, m,
+ parsed_options={"commands_dir": ".custom/cmds"},
+ )
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ assert len(cmd_files) > 0
+ for f in cmd_files:
+ assert f.name.startswith("speckit.")
+ assert f.name.endswith(".md")
+
+ def test_templates_are_processed(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ created = i.setup(
+ tmp_path, m,
+ parsed_options={"commands_dir": ".custom/cmds"},
+ )
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ for f in cmd_files:
+ content = f.read_text(encoding="utf-8")
+ assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
+ assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
+ assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
+
+ def test_all_files_tracked_in_manifest(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ created = i.setup(
+ tmp_path, m,
+ parsed_options={"commands_dir": ".custom/cmds"},
+ )
+ for f in created:
+ rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
+ assert rel in m.files, f"{rel} not tracked in manifest"
+
+ def test_install_uninstall_roundtrip(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ created = i.install(
+ tmp_path, m,
+ parsed_options={"commands_dir": ".custom/cmds"},
+ )
+ assert len(created) > 0
+ m.save()
+ for f in created:
+ assert f.exists()
+ removed, skipped = i.uninstall(tmp_path, m)
+ assert len(removed) == len(created)
+ assert skipped == []
+
+ def test_modified_file_survives_uninstall(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ created = i.install(
+ tmp_path, m,
+ parsed_options={"commands_dir": ".custom/cmds"},
+ )
+ m.save()
+ modified = created[0]
+ modified.write_text("user modified this", encoding="utf-8")
+ removed, skipped = i.uninstall(tmp_path, m)
+ assert modified.exists()
+ assert modified in skipped
+
+ def test_different_commands_dirs(self, tmp_path):
+ """Generic should work with various user-specified paths."""
+ for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]:
+ project = tmp_path / path.replace("/", "-")
+ project.mkdir()
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", project)
+ created = i.setup(
+ project, m,
+ parsed_options={"commands_dir": path},
+ )
+ expected = project / path
+ assert expected.is_dir(), f"Dir {expected} not created for {path}"
+ cmd_files = [f for f in created if "scripts" not in f.parts]
+ assert len(cmd_files) > 0
+
+ # -- Scripts ----------------------------------------------------------
+
+ def test_setup_installs_update_context_scripts(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
+ scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts"
+ assert scripts_dir.is_dir(), "Scripts directory not created for generic"
+ assert (scripts_dir / "update-context.sh").exists()
+ assert (scripts_dir / "update-context.ps1").exists()
+
+ def test_scripts_tracked_in_manifest(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
+ script_rels = [k for k in m.files if "update-context" in k]
+ assert len(script_rels) >= 2
+
+ def test_sh_script_is_executable(self, tmp_path):
+ i = get_integration("generic")
+ m = IntegrationManifest("generic", tmp_path)
+ i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
+ sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh"
+ assert os.access(sh, os.X_OK)
+
+ # -- CLI --------------------------------------------------------------
+
+ def test_cli_generic_without_commands_dir_fails(self, tmp_path):
+ """--integration generic without --ai-commands-dir should fail."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", str(tmp_path / "test-generic"), "--integration", "generic",
+ "--script", "sh", "--no-git",
+ ])
+ # Generic requires --commands-dir / --ai-commands-dir
+ # The integration path validates via setup()
+ assert result.exit_code != 0
+
+ def test_complete_file_inventory_sh(self, tmp_path):
+ """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "inventory-generic-sh"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", "generic",
+ "--ai-commands-dir", ".myagent/commands",
+ "--script", "sh", "--no-git",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ actual = sorted(
+ p.relative_to(project).as_posix()
+ for p in project.rglob("*") if p.is_file()
+ )
+ expected = sorted([
+ ".myagent/commands/speckit.analyze.md",
+ ".myagent/commands/speckit.checklist.md",
+ ".myagent/commands/speckit.clarify.md",
+ ".myagent/commands/speckit.constitution.md",
+ ".myagent/commands/speckit.implement.md",
+ ".myagent/commands/speckit.plan.md",
+ ".myagent/commands/speckit.specify.md",
+ ".myagent/commands/speckit.tasks.md",
+ ".myagent/commands/speckit.taskstoissues.md",
+ ".specify/init-options.json",
+ ".specify/integration.json",
+ ".specify/integrations/generic.manifest.json",
+ ".specify/integrations/generic/scripts/update-context.ps1",
+ ".specify/integrations/generic/scripts/update-context.sh",
+ ".specify/integrations/speckit.manifest.json",
+ ".specify/memory/constitution.md",
+ ".specify/scripts/bash/check-prerequisites.sh",
+ ".specify/scripts/bash/common.sh",
+ ".specify/scripts/bash/create-new-feature.sh",
+ ".specify/scripts/bash/setup-plan.sh",
+ ".specify/scripts/bash/update-agent-context.sh",
+ ".specify/templates/agent-file-template.md",
+ ".specify/templates/checklist-template.md",
+ ".specify/templates/constitution-template.md",
+ ".specify/templates/plan-template.md",
+ ".specify/templates/spec-template.md",
+ ".specify/templates/tasks-template.md",
+ ])
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
+
+ def test_complete_file_inventory_ps(self, tmp_path):
+ """Every file produced by specify init --integration generic --ai-commands-dir ... --script ps."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "inventory-generic-ps"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", "generic",
+ "--ai-commands-dir", ".myagent/commands",
+ "--script", "ps", "--no-git",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ actual = sorted(
+ p.relative_to(project).as_posix()
+ for p in project.rglob("*") if p.is_file()
+ )
+ expected = sorted([
+ ".myagent/commands/speckit.analyze.md",
+ ".myagent/commands/speckit.checklist.md",
+ ".myagent/commands/speckit.clarify.md",
+ ".myagent/commands/speckit.constitution.md",
+ ".myagent/commands/speckit.implement.md",
+ ".myagent/commands/speckit.plan.md",
+ ".myagent/commands/speckit.specify.md",
+ ".myagent/commands/speckit.tasks.md",
+ ".myagent/commands/speckit.taskstoissues.md",
+ ".specify/init-options.json",
+ ".specify/integration.json",
+ ".specify/integrations/generic.manifest.json",
+ ".specify/integrations/generic/scripts/update-context.ps1",
+ ".specify/integrations/generic/scripts/update-context.sh",
+ ".specify/integrations/speckit.manifest.json",
+ ".specify/memory/constitution.md",
+ ".specify/scripts/powershell/check-prerequisites.ps1",
+ ".specify/scripts/powershell/common.ps1",
+ ".specify/scripts/powershell/create-new-feature.ps1",
+ ".specify/scripts/powershell/setup-plan.ps1",
+ ".specify/scripts/powershell/update-agent-context.ps1",
+ ".specify/templates/agent-file-template.md",
+ ".specify/templates/checklist-template.md",
+ ".specify/templates/constitution-template.md",
+ ".specify/templates/plan-template.md",
+ ".specify/templates/spec-template.md",
+ ".specify/templates/tasks-template.md",
+ ])
+ assert actual == expected, (
+ f"Missing: {sorted(set(expected) - set(actual))}\n"
+ f"Extra: {sorted(set(actual) - set(expected))}"
+ )
diff --git a/tests/integrations/test_integration_iflow.py b/tests/integrations/test_integration_iflow.py
new file mode 100644
index 0000000000..ea2f5ef97a
--- /dev/null
+++ b/tests/integrations/test_integration_iflow.py
@@ -0,0 +1,11 @@
+"""Tests for IflowIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestIflowIntegration(MarkdownIntegrationTests):
+ KEY = "iflow"
+ FOLDER = ".iflow/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".iflow/commands"
+ CONTEXT_FILE = "IFLOW.md"
diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py
new file mode 100644
index 0000000000..2b924ce434
--- /dev/null
+++ b/tests/integrations/test_integration_junie.py
@@ -0,0 +1,11 @@
+"""Tests for JunieIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestJunieIntegration(MarkdownIntegrationTests):
+ KEY = "junie"
+ FOLDER = ".junie/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".junie/commands"
+ CONTEXT_FILE = ".junie/AGENTS.md"
diff --git a/tests/integrations/test_integration_kilocode.py b/tests/integrations/test_integration_kilocode.py
new file mode 100644
index 0000000000..8e441c0833
--- /dev/null
+++ b/tests/integrations/test_integration_kilocode.py
@@ -0,0 +1,11 @@
+"""Tests for KilocodeIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestKilocodeIntegration(MarkdownIntegrationTests):
+ KEY = "kilocode"
+ FOLDER = ".kilocode/"
+ COMMANDS_SUBDIR = "workflows"
+ REGISTRAR_DIR = ".kilocode/workflows"
+ CONTEXT_FILE = ".kilocode/rules/specify-rules.md"
diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py
new file mode 100644
index 0000000000..25787e612e
--- /dev/null
+++ b/tests/integrations/test_integration_kimi.py
@@ -0,0 +1,149 @@
+"""Tests for KimiIntegration β skills integration with legacy migration."""
+
+from specify_cli.integrations import get_integration
+from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills
+from specify_cli.integrations.manifest import IntegrationManifest
+
+from .test_integration_base_skills import SkillsIntegrationTests
+
+
+class TestKimiIntegration(SkillsIntegrationTests):
+ KEY = "kimi"
+ FOLDER = ".kimi/"
+ COMMANDS_SUBDIR = "skills"
+ REGISTRAR_DIR = ".kimi/skills"
+ CONTEXT_FILE = "KIMI.md"
+
+
+class TestKimiOptions:
+ """Kimi declares --skills and --migrate-legacy options."""
+
+ def test_migrate_legacy_option(self):
+ i = get_integration("kimi")
+ opts = i.options()
+ migrate_opts = [o for o in opts if o.name == "--migrate-legacy"]
+ assert len(migrate_opts) == 1
+ assert migrate_opts[0].is_flag is True
+ assert migrate_opts[0].default is False
+
+
+class TestKimiLegacyMigration:
+ """Test Kimi dotted β hyphenated skill directory migration."""
+
+ def test_migrate_dotted_to_hyphenated(self, tmp_path):
+ skills_dir = tmp_path / ".kimi" / "skills"
+ legacy = skills_dir / "speckit.plan"
+ legacy.mkdir(parents=True)
+ (legacy / "SKILL.md").write_text("# Plan Skill\n")
+
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ assert migrated == 1
+ assert removed == 0
+ assert not legacy.exists()
+ assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
+
+ def test_skip_when_target_exists_different_content(self, tmp_path):
+ skills_dir = tmp_path / ".kimi" / "skills"
+ legacy = skills_dir / "speckit.plan"
+ legacy.mkdir(parents=True)
+ (legacy / "SKILL.md").write_text("# Old\n")
+
+ target = skills_dir / "speckit-plan"
+ target.mkdir(parents=True)
+ (target / "SKILL.md").write_text("# New (different)\n")
+
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ assert migrated == 0
+ assert removed == 0
+ assert legacy.exists()
+ assert target.exists()
+
+ def test_remove_when_target_exists_same_content(self, tmp_path):
+ skills_dir = tmp_path / ".kimi" / "skills"
+ content = "# Identical\n"
+ legacy = skills_dir / "speckit.plan"
+ legacy.mkdir(parents=True)
+ (legacy / "SKILL.md").write_text(content)
+
+ target = skills_dir / "speckit-plan"
+ target.mkdir(parents=True)
+ (target / "SKILL.md").write_text(content)
+
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ assert migrated == 0
+ assert removed == 1
+ assert not legacy.exists()
+ assert target.exists()
+
+ def test_preserve_legacy_with_extra_files(self, tmp_path):
+ skills_dir = tmp_path / ".kimi" / "skills"
+ content = "# Same\n"
+ legacy = skills_dir / "speckit.plan"
+ legacy.mkdir(parents=True)
+ (legacy / "SKILL.md").write_text(content)
+ (legacy / "extra.md").write_text("user file")
+
+ target = skills_dir / "speckit-plan"
+ target.mkdir(parents=True)
+ (target / "SKILL.md").write_text(content)
+
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ assert migrated == 0
+ assert removed == 0
+ assert legacy.exists()
+
+ def test_nonexistent_dir_returns_zeros(self, tmp_path):
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(
+ tmp_path / ".kimi" / "skills"
+ )
+ assert migrated == 0
+ assert removed == 0
+
+ def test_setup_with_migrate_legacy_option(self, tmp_path):
+ """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs."""
+ i = get_integration("kimi")
+
+ skills_dir = tmp_path / ".kimi" / "skills"
+ legacy = skills_dir / "speckit.oldcmd"
+ legacy.mkdir(parents=True)
+ (legacy / "SKILL.md").write_text("# Legacy\n")
+
+ m = IntegrationManifest("kimi", tmp_path)
+ i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
+
+ assert not legacy.exists()
+ assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
+ # New skills from templates should also exist
+ assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
+
+
+class TestKimiNextSteps:
+ """CLI output tests for kimi next-steps display."""
+
+ def test_next_steps_show_skill_invocation(self, tmp_path):
+ """Kimi next-steps guidance should display /skill:speckit-* usage."""
+ import os
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "kimi-next-steps"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--ai", "kimi", "--no-git",
+ "--ignore-agent-tools", "--script", "sh",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+
+ assert result.exit_code == 0
+ assert "/skill:speckit-constitution" in result.output
+ assert "/speckit.constitution" not in result.output
+ assert "Optional skills that you can use for your specs" in result.output
diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py
new file mode 100644
index 0000000000..6b2b27b777
--- /dev/null
+++ b/tests/integrations/test_integration_kiro_cli.py
@@ -0,0 +1,40 @@
+"""Tests for KiroCliIntegration."""
+
+import os
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestKiroCliIntegration(MarkdownIntegrationTests):
+ KEY = "kiro-cli"
+ FOLDER = ".kiro/"
+ COMMANDS_SUBDIR = "prompts"
+ REGISTRAR_DIR = ".kiro/prompts"
+ CONTEXT_FILE = "AGENTS.md"
+
+
+class TestKiroAlias:
+ """--ai kiro alias normalizes to kiro-cli and auto-promotes."""
+
+ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):
+ """--ai kiro should normalize to canonical kiro-cli and auto-promote."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ target = tmp_path / "kiro-alias-proj"
+ target.mkdir()
+
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(target)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--ai", "kiro",
+ "--ignore-agent-tools", "--script", "sh", "--no-git",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+
+ assert result.exit_code == 0
+ assert "--integration kiro-cli" in result.output
+ assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py
new file mode 100644
index 0000000000..4f3aee5d9b
--- /dev/null
+++ b/tests/integrations/test_integration_opencode.py
@@ -0,0 +1,11 @@
+"""Tests for OpencodeIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestOpencodeIntegration(MarkdownIntegrationTests):
+ KEY = "opencode"
+ FOLDER = ".opencode/"
+ COMMANDS_SUBDIR = "command"
+ REGISTRAR_DIR = ".opencode/command"
+ CONTEXT_FILE = "AGENTS.md"
diff --git a/tests/integrations/test_integration_pi.py b/tests/integrations/test_integration_pi.py
new file mode 100644
index 0000000000..5ac5676501
--- /dev/null
+++ b/tests/integrations/test_integration_pi.py
@@ -0,0 +1,11 @@
+"""Tests for PiIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestPiIntegration(MarkdownIntegrationTests):
+ KEY = "pi"
+ FOLDER = ".pi/"
+ COMMANDS_SUBDIR = "prompts"
+ REGISTRAR_DIR = ".pi/prompts"
+ CONTEXT_FILE = "AGENTS.md"
diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py
new file mode 100644
index 0000000000..1dbee480a0
--- /dev/null
+++ b/tests/integrations/test_integration_qodercli.py
@@ -0,0 +1,11 @@
+"""Tests for QodercliIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestQodercliIntegration(MarkdownIntegrationTests):
+ KEY = "qodercli"
+ FOLDER = ".qoder/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".qoder/commands"
+ CONTEXT_FILE = "QODER.md"
diff --git a/tests/integrations/test_integration_qwen.py b/tests/integrations/test_integration_qwen.py
new file mode 100644
index 0000000000..10a3c083f4
--- /dev/null
+++ b/tests/integrations/test_integration_qwen.py
@@ -0,0 +1,11 @@
+"""Tests for QwenIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestQwenIntegration(MarkdownIntegrationTests):
+ KEY = "qwen"
+ FOLDER = ".qwen/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".qwen/commands"
+ CONTEXT_FILE = "QWEN.md"
diff --git a/tests/integrations/test_integration_roo.py b/tests/integrations/test_integration_roo.py
new file mode 100644
index 0000000000..69d859c42f
--- /dev/null
+++ b/tests/integrations/test_integration_roo.py
@@ -0,0 +1,11 @@
+"""Tests for RooIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestRooIntegration(MarkdownIntegrationTests):
+ KEY = "roo"
+ FOLDER = ".roo/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".roo/commands"
+ CONTEXT_FILE = ".roo/rules/specify-rules.md"
diff --git a/tests/integrations/test_integration_shai.py b/tests/integrations/test_integration_shai.py
new file mode 100644
index 0000000000..74f93396b1
--- /dev/null
+++ b/tests/integrations/test_integration_shai.py
@@ -0,0 +1,11 @@
+"""Tests for ShaiIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestShaiIntegration(MarkdownIntegrationTests):
+ KEY = "shai"
+ FOLDER = ".shai/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".shai/commands"
+ CONTEXT_FILE = "SHAI.md"
diff --git a/tests/integrations/test_integration_tabnine.py b/tests/integrations/test_integration_tabnine.py
new file mode 100644
index 0000000000..95eb47cc16
--- /dev/null
+++ b/tests/integrations/test_integration_tabnine.py
@@ -0,0 +1,11 @@
+"""Tests for TabnineIntegration."""
+
+from .test_integration_base_toml import TomlIntegrationTests
+
+
+class TestTabnineIntegration(TomlIntegrationTests):
+ KEY = "tabnine"
+ FOLDER = ".tabnine/agent/"
+ COMMANDS_SUBDIR = "commands"
+ REGISTRAR_DIR = ".tabnine/agent/commands"
+ CONTEXT_FILE = "TABNINE.md"
diff --git a/tests/integrations/test_integration_trae.py b/tests/integrations/test_integration_trae.py
new file mode 100644
index 0000000000..307c3481db
--- /dev/null
+++ b/tests/integrations/test_integration_trae.py
@@ -0,0 +1,11 @@
+"""Tests for TraeIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestTraeIntegration(MarkdownIntegrationTests):
+ KEY = "trae"
+ FOLDER = ".trae/"
+ COMMANDS_SUBDIR = "rules"
+ REGISTRAR_DIR = ".trae/rules"
+ CONTEXT_FILE = ".trae/rules/AGENTS.md"
diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py
new file mode 100644
index 0000000000..ea6dc85a88
--- /dev/null
+++ b/tests/integrations/test_integration_vibe.py
@@ -0,0 +1,11 @@
+"""Tests for VibeIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestVibeIntegration(MarkdownIntegrationTests):
+ KEY = "vibe"
+ FOLDER = ".vibe/"
+ COMMANDS_SUBDIR = "prompts"
+ REGISTRAR_DIR = ".vibe/prompts"
+ CONTEXT_FILE = ".vibe/agents/specify-agents.md"
diff --git a/tests/integrations/test_integration_windsurf.py b/tests/integrations/test_integration_windsurf.py
new file mode 100644
index 0000000000..fa8d1e622a
--- /dev/null
+++ b/tests/integrations/test_integration_windsurf.py
@@ -0,0 +1,11 @@
+"""Tests for WindsurfIntegration."""
+
+from .test_integration_base_markdown import MarkdownIntegrationTests
+
+
+class TestWindsurfIntegration(MarkdownIntegrationTests):
+ KEY = "windsurf"
+ FOLDER = ".windsurf/"
+ COMMANDS_SUBDIR = "workflows"
+ REGISTRAR_DIR = ".windsurf/workflows"
+ CONTEXT_FILE = ".windsurf/rules/specify-rules.md"
diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py
new file mode 100644
index 0000000000..b5d5bc39f5
--- /dev/null
+++ b/tests/integrations/test_manifest.py
@@ -0,0 +1,245 @@
+"""Tests for IntegrationManifest β record, hash, save, load, uninstall, modified detection."""
+
+import hashlib
+import json
+
+import pytest
+
+from specify_cli.integrations.manifest import IntegrationManifest, _sha256
+
+
+class TestManifestRecordFile:
+ def test_record_file_writes_and_hashes(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ content = "hello world"
+ abs_path = m.record_file("a/b.txt", content)
+ assert abs_path == tmp_path / "a" / "b.txt"
+ assert abs_path.read_text(encoding="utf-8") == content
+ expected_hash = hashlib.sha256(content.encode()).hexdigest()
+ assert m.files["a/b.txt"] == expected_hash
+
+ def test_record_file_bytes(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ data = b"\x00\x01\x02"
+ abs_path = m.record_file("bin.dat", data)
+ assert abs_path.read_bytes() == data
+ assert m.files["bin.dat"] == hashlib.sha256(data).hexdigest()
+
+ def test_record_existing(self, tmp_path):
+ f = tmp_path / "existing.txt"
+ f.write_text("content", encoding="utf-8")
+ m = IntegrationManifest("test", tmp_path)
+ m.record_existing("existing.txt")
+ assert m.files["existing.txt"] == _sha256(f)
+
+
+class TestManifestPathTraversal:
+ def test_record_file_rejects_parent_traversal(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ with pytest.raises(ValueError, match="outside"):
+ m.record_file("../escape.txt", "bad")
+
+ def test_record_file_rejects_absolute_path(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ with pytest.raises(ValueError, match="Absolute paths"):
+ m.record_file("/tmp/escape.txt", "bad")
+
+ def test_record_existing_rejects_parent_traversal(self, tmp_path):
+ escape = tmp_path.parent / "escape.txt"
+ escape.write_text("evil", encoding="utf-8")
+ try:
+ m = IntegrationManifest("test", tmp_path)
+ with pytest.raises(ValueError, match="outside"):
+ m.record_existing("../escape.txt")
+ finally:
+ escape.unlink(missing_ok=True)
+
+ def test_uninstall_skips_traversal_paths(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("safe.txt", "good")
+ m._files["../outside.txt"] = "fakehash"
+ m.save()
+ removed, skipped = m.uninstall()
+ assert len(removed) == 1
+ assert removed[0].name == "safe.txt"
+
+
+class TestManifestCheckModified:
+ def test_unmodified_file(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "original")
+ assert m.check_modified() == []
+
+ def test_modified_file(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "original")
+ (tmp_path / "f.txt").write_text("changed", encoding="utf-8")
+ assert m.check_modified() == ["f.txt"]
+
+ def test_deleted_file_not_reported(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "original")
+ (tmp_path / "f.txt").unlink()
+ assert m.check_modified() == []
+
+ def test_symlink_treated_as_modified(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "original")
+ target = tmp_path / "target.txt"
+ target.write_text("target", encoding="utf-8")
+ (tmp_path / "f.txt").unlink()
+ (tmp_path / "f.txt").symlink_to(target)
+ assert m.check_modified() == ["f.txt"]
+
+
+class TestManifestUninstall:
+ def test_removes_unmodified(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("d/f.txt", "content")
+ m.save()
+ removed, skipped = m.uninstall()
+ assert len(removed) == 1
+ assert not (tmp_path / "d" / "f.txt").exists()
+ assert not (tmp_path / "d").exists()
+ assert skipped == []
+
+ def test_skips_modified(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "original")
+ m.save()
+ (tmp_path / "f.txt").write_text("modified", encoding="utf-8")
+ removed, skipped = m.uninstall()
+ assert removed == []
+ assert len(skipped) == 1
+ assert (tmp_path / "f.txt").exists()
+
+ def test_force_removes_modified(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "original")
+ m.save()
+ (tmp_path / "f.txt").write_text("modified", encoding="utf-8")
+ removed, skipped = m.uninstall(force=True)
+ assert len(removed) == 1
+ assert skipped == []
+
+ def test_already_deleted_file(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "content")
+ m.save()
+ (tmp_path / "f.txt").unlink()
+ removed, skipped = m.uninstall()
+ assert removed == []
+ assert skipped == []
+
+ def test_removes_manifest_file(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path, version="1.0")
+ m.record_file("f.txt", "content")
+ m.save()
+ assert m.manifest_path.exists()
+ m.uninstall()
+ assert not m.manifest_path.exists()
+
+ def test_cleans_empty_parent_dirs(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("a/b/c/f.txt", "content")
+ m.save()
+ m.uninstall()
+ assert not (tmp_path / "a").exists()
+
+ def test_preserves_nonempty_parent_dirs(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("a/b/tracked.txt", "content")
+ (tmp_path / "a" / "b" / "other.txt").write_text("keep", encoding="utf-8")
+ m.save()
+ m.uninstall()
+ assert not (tmp_path / "a" / "b" / "tracked.txt").exists()
+ assert (tmp_path / "a" / "b" / "other.txt").exists()
+
+ def test_symlink_skipped_without_force(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "original")
+ m.save()
+ target = tmp_path / "target.txt"
+ target.write_text("target", encoding="utf-8")
+ (tmp_path / "f.txt").unlink()
+ (tmp_path / "f.txt").symlink_to(target)
+ removed, skipped = m.uninstall()
+ assert removed == []
+ assert len(skipped) == 1
+
+ def test_symlink_removed_with_force(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "original")
+ m.save()
+ target = tmp_path / "target.txt"
+ target.write_text("target", encoding="utf-8")
+ (tmp_path / "f.txt").unlink()
+ (tmp_path / "f.txt").symlink_to(target)
+ removed, skipped = m.uninstall(force=True)
+ assert len(removed) == 1
+ assert target.exists()
+
+
+class TestManifestPersistence:
+ def test_save_and_load_roundtrip(self, tmp_path):
+ m = IntegrationManifest("myagent", tmp_path, version="2.0.1")
+ m.record_file("dir/file.md", "# Hello")
+ m.save()
+ loaded = IntegrationManifest.load("myagent", tmp_path)
+ assert loaded.key == "myagent"
+ assert loaded.version == "2.0.1"
+ assert loaded.files == m.files
+
+ def test_manifest_path(self, tmp_path):
+ m = IntegrationManifest("copilot", tmp_path)
+ assert m.manifest_path == tmp_path / ".specify" / "integrations" / "copilot.manifest.json"
+
+ def test_load_missing_raises(self, tmp_path):
+ with pytest.raises(FileNotFoundError):
+ IntegrationManifest.load("nonexistent", tmp_path)
+
+ def test_save_creates_directories(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "content")
+ path = m.save()
+ assert path.exists()
+ data = json.loads(path.read_text(encoding="utf-8"))
+ assert data["integration"] == "test"
+
+ def test_save_preserves_installed_at(self, tmp_path):
+ m = IntegrationManifest("test", tmp_path)
+ m.record_file("f.txt", "content")
+ m.save()
+ first_ts = m._installed_at
+ m.save()
+ assert m._installed_at == first_ts
+
+
+class TestManifestLoadValidation:
+ def test_load_non_dict_raises(self, tmp_path):
+ path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
+ path.parent.mkdir(parents=True)
+ path.write_text('"just a string"', encoding="utf-8")
+ with pytest.raises(ValueError, match="JSON object"):
+ IntegrationManifest.load("bad", tmp_path)
+
+ def test_load_bad_files_type_raises(self, tmp_path):
+ path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
+ path.parent.mkdir(parents=True)
+ path.write_text(json.dumps({"files": ["not", "a", "dict"]}), encoding="utf-8")
+ with pytest.raises(ValueError, match="mapping"):
+ IntegrationManifest.load("bad", tmp_path)
+
+ def test_load_bad_files_values_raises(self, tmp_path):
+ path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
+ path.parent.mkdir(parents=True)
+ path.write_text(json.dumps({"files": {"a.txt": 123}}), encoding="utf-8")
+ with pytest.raises(ValueError, match="mapping"):
+ IntegrationManifest.load("bad", tmp_path)
+
+ def test_load_invalid_json_raises(self, tmp_path):
+ path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
+ path.parent.mkdir(parents=True)
+ path.write_text("{not valid json", encoding="utf-8")
+ with pytest.raises(ValueError, match="invalid JSON"):
+ IntegrationManifest.load("bad", tmp_path)
diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py
new file mode 100644
index 0000000000..8ab1425148
--- /dev/null
+++ b/tests/integrations/test_registry.py
@@ -0,0 +1,87 @@
+"""Tests for INTEGRATION_REGISTRY β mechanics, completeness, and registrar alignment."""
+
+import pytest
+
+from specify_cli.integrations import (
+ INTEGRATION_REGISTRY,
+ _register,
+ get_integration,
+)
+from specify_cli.integrations.base import MarkdownIntegration
+from .conftest import StubIntegration
+
+
+# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5).
+ALL_INTEGRATION_KEYS = [
+ "copilot",
+ # Stage 3 β standard markdown integrations
+ "claude", "qwen", "opencode", "junie", "kilocode", "auggie",
+ "roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
+ "pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
+ # Stage 4 β TOML integrations
+ "gemini", "tabnine",
+ # Stage 5 β skills, generic & option-driven integrations
+ "codex", "kimi", "agy", "generic",
+]
+
+
+class TestRegistry:
+ def test_registry_is_dict(self):
+ assert isinstance(INTEGRATION_REGISTRY, dict)
+
+ def test_register_and_get(self):
+ stub = StubIntegration()
+ _register(stub)
+ try:
+ assert get_integration("stub") is stub
+ finally:
+ INTEGRATION_REGISTRY.pop("stub", None)
+
+ def test_get_missing_returns_none(self):
+ assert get_integration("nonexistent-xyz") is None
+
+ def test_register_empty_key_raises(self):
+ class EmptyKey(MarkdownIntegration):
+ key = ""
+ with pytest.raises(ValueError, match="empty key"):
+ _register(EmptyKey())
+
+ def test_register_duplicate_raises(self):
+ stub = StubIntegration()
+ _register(stub)
+ try:
+ with pytest.raises(KeyError, match="already registered"):
+ _register(StubIntegration())
+ finally:
+ INTEGRATION_REGISTRY.pop("stub", None)
+
+
+class TestRegistryCompleteness:
+ """Every expected integration must be registered."""
+
+ @pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS)
+ def test_key_registered(self, key):
+ assert key in INTEGRATION_REGISTRY, f"{key} missing from registry"
+
+
+class TestRegistrarKeyAlignment:
+ """Every integration key must have a matching AGENT_CONFIGS entry.
+
+ ``generic`` is excluded because it has no fixed directory β its
+ output path comes from ``--commands-dir`` at runtime.
+ """
+
+ @pytest.mark.parametrize(
+ "key",
+ [k for k in ALL_INTEGRATION_KEYS if k != "generic"],
+ )
+ def test_integration_key_in_registrar(self, key):
+ from specify_cli.agents import CommandRegistrar
+ assert key in CommandRegistrar.AGENT_CONFIGS, (
+ f"Integration '{key}' is registered but has no AGENT_CONFIGS entry"
+ )
+
+ def test_no_stale_cursor_shorthand(self):
+ """The old 'cursor' shorthand must not appear in AGENT_CONFIGS."""
+ from specify_cli.agents import CommandRegistrar
+ assert "cursor" not in CommandRegistrar.AGENT_CONFIGS
diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py
index cf2b6b7b9c..e4ee41828c 100644
--- a/tests/test_ai_skills.py
+++ b/tests/test_ai_skills.py
@@ -10,7 +10,6 @@
- CLI validation: --ai-skills requires --ai
"""
-import re
import zipfile
import pytest
import tempfile
@@ -21,11 +20,12 @@
from unittest.mock import patch
import specify_cli
+from tests.conftest import strip_ansi
from specify_cli import (
_get_skills_dir,
+ _migrate_legacy_kimi_dotted_skills,
install_ai_skills,
- AGENT_SKILLS_DIR_OVERRIDES,
DEFAULT_SKILLS_DIR,
SKILL_DESCRIPTIONS,
AGENT_CONFIG,
@@ -169,8 +169,8 @@ def test_copilot_skills_dir(self, project_dir):
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."""
+ def test_codex_skills_dir_from_agent_config(self, project_dir):
+ """Codex should resolve skills directory from AGENT_CONFIG folder."""
result = _get_skills_dir(project_dir, "codex")
assert result == project_dir / ".agents" / "skills"
@@ -203,12 +203,71 @@ def test_all_configured_agents_resolve(self, 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
+class TestKimiLegacySkillMigration:
+ """Test temporary migration from Kimi dotted skill names to hyphenated names."""
+
+ def test_migrates_legacy_dotted_skill_directory(self, project_dir):
+ skills_dir = project_dir / ".kimi" / "skills"
+ legacy_dir = skills_dir / "speckit.plan"
+ legacy_dir.mkdir(parents=True)
+ (legacy_dir / "SKILL.md").write_text("legacy")
+
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ assert migrated == 1
+ assert removed == 0
+ assert not legacy_dir.exists()
+ assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
+
+ def test_removes_legacy_dir_when_hyphenated_target_exists_with_same_content(self, project_dir):
+ skills_dir = project_dir / ".kimi" / "skills"
+ legacy_dir = skills_dir / "speckit.plan"
+ legacy_dir.mkdir(parents=True)
+ (legacy_dir / "SKILL.md").write_text("legacy")
+ target_dir = skills_dir / "speckit-plan"
+ target_dir.mkdir(parents=True)
+ (target_dir / "SKILL.md").write_text("legacy")
+
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ assert migrated == 0
+ assert removed == 1
+ assert not legacy_dir.exists()
+ assert (target_dir / "SKILL.md").read_text() == "legacy"
+
+ def test_keeps_legacy_dir_when_hyphenated_target_differs(self, project_dir):
+ skills_dir = project_dir / ".kimi" / "skills"
+ legacy_dir = skills_dir / "speckit.plan"
+ legacy_dir.mkdir(parents=True)
+ (legacy_dir / "SKILL.md").write_text("legacy")
+ target_dir = skills_dir / "speckit-plan"
+ target_dir.mkdir(parents=True)
+ (target_dir / "SKILL.md").write_text("new")
+
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ assert migrated == 0
+ assert removed == 0
+ assert legacy_dir.exists()
+ assert (legacy_dir / "SKILL.md").read_text() == "legacy"
+ assert (target_dir / "SKILL.md").read_text() == "new"
+
+ def test_keeps_legacy_dir_when_matching_target_but_extra_files_exist(self, project_dir):
+ skills_dir = project_dir / ".kimi" / "skills"
+ legacy_dir = skills_dir / "speckit.plan"
+ legacy_dir.mkdir(parents=True)
+ (legacy_dir / "SKILL.md").write_text("legacy")
+ (legacy_dir / "notes.txt").write_text("custom")
+ target_dir = skills_dir / "speckit-plan"
+ target_dir.mkdir(parents=True)
+ (target_dir / "SKILL.md").write_text("legacy")
+
+ migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
+
+ assert migrated == 0
+ assert removed == 0
+ assert legacy_dir.exists()
+ assert (legacy_dir / "notes.txt").read_text() == "custom"
# ===== install_ai_skills Tests =====
@@ -473,8 +532,7 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key):
skills_dir = _get_skills_dir(proj, agent_key)
assert skills_dir.exists()
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
- # Kimi uses dotted skill names; other agents use hyphen-separated names.
- expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
+ expected_skill_name = "speckit-specify"
assert expected_skill_name in skill_dirs
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
@@ -626,181 +684,15 @@ def test_no_commands_dir_no_error(self, project_dir, templates_dir):
assert result is True
-# ===== New-Project Command Skip Tests =====
+# ===== Legacy Download Path Tests =====
-class TestNewProjectCommandSkip:
- """Test that init() removes extracted commands for new projects only.
+class TestLegacyDownloadPath:
+ """Tests for download_and_extract_template() called directly.
- These tests run init() end-to-end via CliRunner with
- download_and_extract_template patched to create local fixtures.
+ These test the legacy download/extract code that still exists in
+ __init__.py. They do NOT go through CLI auto-promote.
"""
- 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", "")
- commands_subdir = agent_cfg.get("commands_subdir", "commands")
- if agent_folder:
- cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
- 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"])
-
- assert result.exit_code == 0
- # 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_new_project_nonstandard_commands_subdir_removed_after_skills_succeed(self, tmp_path):
- """For non-standard agents, configured commands_subdir should be removed on success."""
- from typer.testing import CliRunner
-
- runner = CliRunner()
- target = tmp_path / "new-kiro-proj"
-
- def fake_download(project_path, *args, **kwargs):
- self._fake_extract("kiro-cli", 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", "kiro-cli", "--ai-skills", "--script", "sh", "--no-git"])
-
- assert result.exit_code == 0
- mock_skills.assert_called_once()
-
- prompts_dir = target / ".kiro" / "prompts"
- assert not prompts_dir.exists()
-
- def test_codex_native_skills_preserved_without_conversion(self, tmp_path):
- """Codex should keep bundled .agents/skills and skip install_ai_skills conversion."""
- from typer.testing import CliRunner
-
- runner = CliRunner()
- target = tmp_path / "new-codex-proj"
-
- def fake_download(project_path, *args, **kwargs):
- skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
- skill_dir.mkdir(parents=True, exist_ok=True)
- (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
-
- with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
- patch("specify_cli.ensure_executable_scripts"), \
- patch("specify_cli.ensure_constitution_from_template"), \
- patch("specify_cli.install_ai_skills") as mock_skills, \
- patch("specify_cli.is_git_repo", return_value=False), \
- patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
- result = runner.invoke(
- app,
- ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
- )
-
- assert result.exit_code == 0
- mock_skills.assert_not_called()
- assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
-
- def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path):
- """Codex should attempt fallback conversion when bundled skills are missing."""
- from typer.testing import CliRunner
-
- runner = CliRunner()
- target = tmp_path / "missing-codex-skills"
-
- with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
- patch("specify_cli.ensure_executable_scripts"), \
- patch("specify_cli.ensure_constitution_from_template"), \
- patch("specify_cli.install_ai_skills", return_value=False) as mock_skills, \
- patch("specify_cli.is_git_repo", return_value=False), \
- patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
- result = runner.invoke(
- app,
- ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
- )
-
- assert result.exit_code == 1
- mock_skills.assert_called_once()
- assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
- assert "Expected bundled agent skills" in result.output
- assert "fallback conversion failed" in result.output
-
- def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):
- """Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail."""
- from typer.testing import CliRunner
-
- runner = CliRunner()
- target = tmp_path / "foreign-codex-skills"
-
- def fake_download(project_path, *args, **kwargs):
- skill_dir = project_path / ".agents" / "skills" / "other-tool"
- skill_dir.mkdir(parents=True, exist_ok=True)
- (skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n")
-
- with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
- patch("specify_cli.ensure_executable_scripts"), \
- patch("specify_cli.ensure_constitution_from_template"), \
- patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
- patch("specify_cli.is_git_repo", return_value=False), \
- patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
- result = runner.invoke(
- app,
- ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
- )
-
- assert result.exit_code == 0
- mock_skills.assert_called_once()
- assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
-
- def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch):
- """Codex --here skills init should not delete a pre-existing .codex directory."""
- from typer.testing import CliRunner
-
- runner = CliRunner()
- target = tmp_path / "codex-preserve-here"
- target.mkdir()
- existing_prompts = target / ".codex" / "prompts"
- existing_prompts.mkdir(parents=True)
- (existing_prompts / "custom.md").write_text("custom")
- monkeypatch.chdir(target)
-
- with patch("specify_cli.download_and_extract_template", return_value=target), \
- patch("specify_cli.ensure_executable_scripts"), \
- patch("specify_cli.ensure_constitution_from_template"), \
- patch("specify_cli.install_ai_skills", return_value=True), \
- patch("specify_cli.is_git_repo", return_value=True), \
- patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
- result = runner.invoke(
- app,
- ["init", "--here", "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
- input="y\n",
- )
-
- assert result.exit_code == 0
- assert (target / ".codex").exists()
- assert (existing_prompts / "custom.md").exists()
-
def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path):
"""Fresh-directory Codex skills init should not leave legacy .codex from archive."""
target = tmp_path / "fresh-codex-proj"
@@ -864,62 +756,6 @@ def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path,
assert not (tmp_path / "evil.txt").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"])
-
- assert result.exit_code == 0
- # 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"], input="y\n")
-
- assert result.exit_code == 0
- # Commands must remain for --here
- assert cmds_dir.exists()
- assert (cmds_dir / "speckit.specify.md").exists()
-
# ===== Skip-If-Exists Tests =====
@@ -991,92 +827,61 @@ def test_all_known_commands_have_descriptions(self):
class TestCliValidation:
"""Test --ai-skills CLI flag validation."""
- def test_ai_skills_without_ai_fails(self):
+ def test_ai_skills_without_ai_fails(self, tmp_path):
"""--ai-skills without --ai should fail with exit code 1."""
from typer.testing import CliRunner
runner = CliRunner()
- result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
+ result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"])
assert result.exit_code == 1
assert "--ai-skills requires --ai" in result.output
- def test_ai_skills_without_ai_shows_usage(self):
+ def test_ai_skills_without_ai_shows_usage(self, tmp_path):
"""Error message should include usage hint."""
from typer.testing import CliRunner
runner = CliRunner()
- result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
+ result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"])
assert "Usage:" in result.output
assert "--ai" in result.output
- def test_agy_without_ai_skills_fails(self):
- """--ai agy without --ai-skills should fail with exit code 1."""
- from typer.testing import CliRunner
-
- runner = CliRunner()
- result = runner.invoke(app, ["init", "test-proj", "--ai", "agy"])
-
- assert result.exit_code == 1
- assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
- assert "--ai-skills" in result.output
-
- def test_codex_without_ai_skills_fails(self):
- """--ai codex without --ai-skills should fail with exit code 1."""
- from typer.testing import CliRunner
-
- runner = CliRunner()
- result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"])
-
- assert result.exit_code == 1
- assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" in result.output
- assert "--ai-skills" in result.output
-
- def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):
- """Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
+ def test_interactive_agy_without_ai_skills_uses_integration(self, tmp_path, monkeypatch):
+ """Interactive selector returning agy should auto-promote to integration path."""
from typer.testing import CliRunner
- # Mock select_with_arrows to simulate the user picking 'agy' for AI,
- # and return a deterministic default for any other prompts to avoid
- # calling the real interactive implementation.
def _fake_select_with_arrows(*args, **kwargs):
options = kwargs.get("options")
if options is None and len(args) >= 1:
options = args[0]
- # If the options include 'agy', simulate selecting it.
if isinstance(options, dict) and "agy" in options:
return "agy"
if isinstance(options, (list, tuple)) and "agy" in options:
return "agy"
- # For any other prompt, return a deterministic, non-interactive default:
- # pick the first option if available.
if isinstance(options, dict) and options:
return next(iter(options.keys()))
if isinstance(options, (list, tuple)) and options:
return options[0]
- # If no options are provided, fall back to None (should not occur in normal use).
return None
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
-
- # Mock download_and_extract_template to prevent real HTTP downloads during testing
- monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None)
- # We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe?
+
runner = CliRunner()
- # Create temp dir to avoid directory already exists errors or whatever
- with runner.isolated_filesystem():
- result = runner.invoke(app, ["init", "test-proj", "--no-git"])
+ target = tmp_path / "test-agy-interactive"
+ result = runner.invoke(app, ["init", str(target), "--no-git"])
- # Interactive selection should NOT raise the deprecation error!
- assert result.exit_code == 0
- assert "Explicit command support was deprecated" not in result.output
+ assert result.exit_code == 0
+ # Should NOT raise the old deprecation error
+ assert "Explicit command support was deprecated" not in result.output
+ # Should use integration path (same as --ai agy)
+ assert "agy" in result.output
- def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch):
- """Interactive selector returning codex without --ai-skills should automatically enable --ai-skills."""
+ def test_interactive_codex_without_ai_skills_uses_integration(self, tmp_path, monkeypatch):
+ """Interactive selector returning codex should auto-promote to integration path."""
from typer.testing import CliRunner
def _fake_select_with_arrows(*args, **kwargs):
@@ -1098,48 +903,18 @@ def _fake_select_with_arrows(*args, **kwargs):
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
- def _fake_download(*args, **kwargs):
- project_path = Path(args[0])
- skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
- skill_dir.mkdir(parents=True, exist_ok=True)
- (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
-
- monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
-
runner = CliRunner()
- with runner.isolated_filesystem():
- result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"])
-
- assert result.exit_code == 0
- assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
- assert ".agents/skills" in result.output
- assert "$speckit-constitution" in result.output
- assert "/speckit.constitution" not in result.output
- assert "Optional skills that you can use for your specs" in result.output
-
- def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
- """Kimi next-steps guidance should display /skill:speckit.* usage."""
- from typer.testing import CliRunner
-
- def _fake_download(*args, **kwargs):
- project_path = Path(args[0])
- skill_dir = project_path / ".kimi" / "skills" / "speckit.specify"
- skill_dir.mkdir(parents=True, exist_ok=True)
- (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
+ target = tmp_path / "test-codex-interactive"
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--ignore-agent-tools"])
- monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
-
- runner = CliRunner()
- with runner.isolated_filesystem():
- result = runner.invoke(
- app,
- ["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"],
- )
-
- assert result.exit_code == 0
- assert "/skill:speckit.constitution" in result.output
- assert "/speckit.constitution" not in result.output
- assert "Optional skills that you can use for your specs" in result.output
+ assert result.exit_code == 0
+ # Should NOT raise the old deprecation error
+ assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
+ # Skills should be installed via integration path
+ assert ".agents/skills" in result.output
+ assert "$speckit-constitution" in result.output
+ assert "/speckit.constitution" not in result.output
+ assert "Optional skills that you can use for your specs" in result.output
def test_ai_skills_flag_appears_in_help(self):
"""--ai-skills should appear in init --help output."""
@@ -1148,48 +923,10 @@ def test_ai_skills_flag_appears_in_help(self):
runner = CliRunner()
result = runner.invoke(app, ["init", "--help"])
- plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
+ plain = strip_ansi(result.output)
assert "--ai-skills" in plain
assert "agent skills" in plain.lower()
- def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):
- """--ai kiro should normalize to canonical kiro-cli agent key."""
- from typer.testing import CliRunner
-
- runner = CliRunner()
- target = tmp_path / "kiro-alias-proj"
-
- with patch("specify_cli.download_and_extract_template") as mock_download, \
- patch("specify_cli.scaffold_from_core_pack", create=True) as mock_scaffold, \
- patch("specify_cli.ensure_executable_scripts"), \
- patch("specify_cli.ensure_constitution_from_template"), \
- patch("specify_cli.is_git_repo", return_value=False), \
- patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
- mock_scaffold.return_value = True
- result = runner.invoke(
- app,
- [
- "init",
- str(target),
- "--ai",
- "kiro",
- "--ignore-agent-tools",
- "--script",
- "sh",
- "--no-git",
- ],
- )
-
- assert result.exit_code == 0
- # Without --offline, the download path should be taken.
- assert mock_download.called, (
- "Expected download_and_extract_template to be called (default non-offline path)"
- )
- assert mock_download.call_args.args[1] == "kiro-cli"
- assert not mock_scaffold.called, (
- "scaffold_from_core_pack should not be called without --offline"
- )
-
def test_q_removed_from_agent_config(self):
"""Amazon Q legacy key should not remain in AGENT_CONFIG."""
assert "q" not in AGENT_CONFIG
@@ -1246,12 +983,12 @@ def test_error_message_lists_available_agents(self):
output_lower = result.output.lower()
assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"])
- def test_ai_commands_dir_consuming_flag(self):
+ def test_ai_commands_dir_consuming_flag(self, tmp_path):
"""--ai-commands-dir without value should not consume next flag."""
from typer.testing import CliRunner
runner = CliRunner()
- result = runner.invoke(app, ["init", "myproject", "--ai", "generic", "--ai-commands-dir", "--here"])
+ result = runner.invoke(app, ["init", str(tmp_path / "myproject"), "--ai", "generic", "--ai-commands-dir", "--here"])
assert result.exit_code == 1
assert "Invalid value for --ai-commands-dir" in result.output
diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py
new file mode 100644
index 0000000000..0eb267ba24
--- /dev/null
+++ b/tests/test_check_tool.py
@@ -0,0 +1,96 @@
+"""Tests for check_tool() β Claude Code CLI detection across install methods.
+
+Covers issue https://github.com/github/spec-kit/issues/550:
+ `specify check` reports "Claude Code CLI (not found)" even when claude is
+ installed via npm-local (the default `claude` installer path).
+"""
+
+from unittest.mock import patch, MagicMock
+
+from specify_cli import check_tool
+
+
+class TestCheckToolClaude:
+ """Claude CLI detection must work for all install methods."""
+
+ def test_detected_via_migrate_installer_path(self, tmp_path):
+ """claude migrate-installer puts binary at ~/.claude/local/claude."""
+ fake_claude = tmp_path / "claude"
+ fake_claude.touch()
+
+ # Ensure npm-local path is missing so we only exercise migrate-installer path
+ fake_missing = tmp_path / "nonexistent" / "claude"
+
+ with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \
+ patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
+ patch("shutil.which", return_value=None):
+ assert check_tool("claude") is True
+
+ def test_detected_via_npm_local_path(self, tmp_path):
+ """npm-local install puts binary at ~/.claude/local/node_modules/.bin/claude."""
+ fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude"
+ fake_npm_claude.parent.mkdir(parents=True)
+ fake_npm_claude.touch()
+
+ # Neither the migrate-installer path nor PATH has claude
+ fake_migrate = tmp_path / "nonexistent" / "claude"
+
+ with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \
+ patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
+ patch("shutil.which", return_value=None):
+ assert check_tool("claude") is True
+
+ def test_detected_via_path(self, tmp_path):
+ """claude on PATH (global npm install) should still work."""
+ fake_missing = tmp_path / "nonexistent" / "claude"
+
+ with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
+ patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
+ patch("shutil.which", return_value="/usr/local/bin/claude"):
+ assert check_tool("claude") is True
+
+ def test_not_found_when_nowhere(self, tmp_path):
+ """Should return False when claude is genuinely not installed."""
+ fake_missing = tmp_path / "nonexistent" / "claude"
+
+ with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
+ patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
+ patch("shutil.which", return_value=None):
+ assert check_tool("claude") is False
+
+ def test_tracker_updated_on_npm_local_detection(self, tmp_path):
+ """StepTracker should be marked 'available' for npm-local installs."""
+ fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude"
+ fake_npm_claude.parent.mkdir(parents=True)
+ fake_npm_claude.touch()
+
+ fake_missing = tmp_path / "nonexistent" / "claude"
+ tracker = MagicMock()
+
+ with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
+ patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
+ patch("shutil.which", return_value=None):
+ result = check_tool("claude", tracker=tracker)
+
+ assert result is True
+ tracker.complete.assert_called_once_with("claude", "available")
+
+
+class TestCheckToolOther:
+ """Non-Claude tools should be unaffected by the fix."""
+
+ def test_git_detected_via_path(self):
+ with patch("shutil.which", return_value="/usr/bin/git"):
+ assert check_tool("git") is True
+
+ def test_missing_tool(self):
+ with patch("shutil.which", return_value=None):
+ assert check_tool("nonexistent-tool") is False
+
+ def test_kiro_fallback(self):
+ """kiro-cli detection should try both kiro-cli and kiro."""
+ def fake_which(name):
+ return "/usr/bin/kiro" if name == "kiro" else None
+
+ with patch("shutil.which", side_effect=fake_which):
+ assert check_tool("kiro-cli") is True
\ No newline at end of file
diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py
index 92848bb163..92b747a296 100644
--- a/tests/test_core_pack_scaffold.py
+++ b/tests/test_core_pack_scaffold.py
@@ -142,7 +142,7 @@ def _expected_cmd_dir(project_path: Path, agent: str) -> Path:
# Agents whose commands are laid out as //SKILL.md.
# Maps agent -> separator used in skill directory names.
-_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "."}
+_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "-"}
def _expected_ext(agent: str) -> str:
diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py
index b8d5202f52..47d40a3b93 100644
--- a/tests/test_extension_skills.py
+++ b/tests/test_extension_skills.py
@@ -41,17 +41,14 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
"""Create and return the expected skills directory for the given agent."""
# Match the logic in _get_skills_dir() from specify_cli
- from specify_cli import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR
+ from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR
- if ai in AGENT_SKILLS_DIR_OVERRIDES:
- skills_dir = project_root / AGENT_SKILLS_DIR_OVERRIDES[ai]
+ agent_config = AGENT_CONFIG.get(ai, {})
+ agent_folder = agent_config.get("folder", "")
+ if agent_folder:
+ skills_dir = project_root / agent_folder.rstrip("/") / "skills"
else:
- agent_config = AGENT_CONFIG.get(ai, {})
- agent_folder = agent_config.get("folder", "")
- if agent_folder:
- skills_dir = project_root / agent_folder.rstrip("/") / "skills"
- else:
- skills_dir = project_root / DEFAULT_SKILLS_DIR
+ skills_dir = project_root / DEFAULT_SKILLS_DIR
skills_dir.mkdir(parents=True, exist_ok=True)
return skills_dir
@@ -195,6 +192,24 @@ def test_returns_none_when_skills_dir_missing(self, project_dir):
result = manager._get_skills_dir()
assert result is None
+ def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir):
+ """Kimi should still use its native skills dir when ai_skills is false."""
+ _create_init_options(project_dir, ai="kimi", ai_skills=False)
+ skills_dir = _create_skills_dir(project_dir, ai="kimi")
+ manager = ExtensionManager(project_dir)
+ result = manager._get_skills_dir()
+ assert result == skills_dir
+
+ def test_returns_none_for_non_dict_init_options(self, project_dir):
+ """Corrupted-but-parseable init-options should not crash skill-dir lookup."""
+ opts_file = project_dir / ".specify" / "init-options.json"
+ opts_file.parent.mkdir(parents=True, exist_ok=True)
+ opts_file.write_text("[]")
+ _create_skills_dir(project_dir, ai="claude")
+ manager = ExtensionManager(project_dir)
+ result = manager._get_skills_dir()
+ assert result is None
+
# ===== Extension Skill Registration Tests =====
@@ -211,8 +226,8 @@ def test_skills_created_when_ai_skills_active(self, skills_project, extension_di
# Check that skill directories were created
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
- assert "speckit-test-ext.hello" in skill_dirs
- assert "speckit-test-ext.world" in skill_dirs
+ assert "speckit-test-ext-hello" in skill_dirs
+ assert "speckit-test-ext-world" in skill_dirs
def test_skill_md_content_correct(self, skills_project, extension_dir):
"""SKILL.md should have correct agentskills.io structure."""
@@ -222,13 +237,13 @@ def test_skill_md_content_correct(self, skills_project, extension_dir):
extension_dir, "0.1.0", register_commands=False
)
- skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md"
+ skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
# Check structure
assert content.startswith("---\n")
- assert "name: speckit-test-ext.hello" in content
+ assert "name: speckit-test-ext-hello" in content
assert "description:" in content
assert "Test hello command" in content
assert "source: extension:test-ext" in content
@@ -244,7 +259,7 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):
extension_dir, "0.1.0", register_commands=False
)
- skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md"
+ skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
content = skill_file.read_text()
assert content.startswith("---\n")
@@ -252,7 +267,7 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):
assert len(parts) >= 3
parsed = yaml.safe_load(parts[1])
assert isinstance(parsed, dict)
- assert parsed["name"] == "speckit-test-ext.hello"
+ assert parsed["name"] == "speckit-test-ext-hello"
assert "description" in parsed
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
@@ -281,7 +296,7 @@ def test_existing_skill_not_overwritten(self, skills_project, extension_dir):
project_dir, skills_dir = skills_project
# Pre-create a custom skill
- custom_dir = skills_dir / "speckit-test-ext.hello"
+ custom_dir = skills_dir / "speckit-test-ext-hello"
custom_dir.mkdir(parents=True)
custom_content = "# My Custom Hello Skill\nUser-modified content\n"
(custom_dir / "SKILL.md").write_text(custom_content)
@@ -296,9 +311,9 @@ def test_existing_skill_not_overwritten(self, skills_project, extension_dir):
# But the other skill should still be created
metadata = manager.registry.get(manifest.id)
- assert "speckit-test-ext.world" in metadata["registered_skills"]
+ assert "speckit-test-ext-world" in metadata["registered_skills"]
# The pre-existing one should NOT be in registered_skills (it was skipped)
- assert "speckit-test-ext.hello" not in metadata["registered_skills"]
+ assert "speckit-test-ext-hello" not in metadata["registered_skills"]
def test_registered_skills_in_registry(self, skills_project, extension_dir):
"""Registry should contain registered_skills list."""
@@ -311,11 +326,11 @@ def test_registered_skills_in_registry(self, skills_project, extension_dir):
metadata = manager.registry.get(manifest.id)
assert "registered_skills" in metadata
assert len(metadata["registered_skills"]) == 2
- assert "speckit-test-ext.hello" in metadata["registered_skills"]
- assert "speckit-test-ext.world" in metadata["registered_skills"]
+ assert "speckit-test-ext-hello" in metadata["registered_skills"]
+ assert "speckit-test-ext-world" in metadata["registered_skills"]
- def test_kimi_uses_dot_notation(self, project_dir, temp_dir):
- """Kimi agent should use dot notation for skill names."""
+ def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir):
+ """Kimi agent should use the same hyphenated skill names as hooks."""
_create_init_options(project_dir, ai="kimi", ai_skills=True)
_create_skills_dir(project_dir, ai="kimi")
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
@@ -326,9 +341,80 @@ def test_kimi_uses_dot_notation(self, project_dir, temp_dir):
)
metadata = manager.registry.get(manifest.id)
- # Kimi should use dots, not hyphens
- assert "speckit.test-ext.hello" in metadata["registered_skills"]
- assert "speckit.test-ext.world" in metadata["registered_skills"]
+ assert "speckit-test-ext-hello" in metadata["registered_skills"]
+ assert "speckit-test-ext-world" in metadata["registered_skills"]
+
+ def test_kimi_creates_skills_when_ai_skills_disabled(self, project_dir, temp_dir):
+ """Kimi should still auto-register extension skills in native-skills mode."""
+ _create_init_options(project_dir, ai="kimi", ai_skills=False)
+ skills_dir = _create_skills_dir(project_dir, ai="kimi")
+ ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
+
+ manager = ExtensionManager(project_dir)
+ manifest = manager.install_from_directory(
+ ext_dir, "0.1.0", register_commands=False
+ )
+
+ metadata = manager.registry.get(manifest.id)
+ assert "speckit-test-ext-hello" in metadata["registered_skills"]
+ assert "speckit-test-ext-world" in metadata["registered_skills"]
+ assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
+
+ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
+ """Auto-registered extension skills should resolve script placeholders."""
+ _create_init_options(project_dir, ai="claude", ai_skills=True)
+ skills_dir = _create_skills_dir(project_dir, ai="claude")
+
+ ext_dir = temp_dir / "scripted-ext"
+ ext_dir.mkdir()
+ manifest_data = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "scripted-ext",
+ "name": "Scripted Extension",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.scripted-ext.plan",
+ "file": "commands/plan.md",
+ "description": "Scripted plan command",
+ }
+ ]
+ },
+ }
+ with open(ext_dir / "extension.yml", "w") as f:
+ yaml.dump(manifest_data, f)
+
+ (ext_dir / "commands").mkdir()
+ (ext_dir / "commands" / "plan.md").write_text(
+ "---\n"
+ "description: Scripted plan command\n"
+ "scripts:\n"
+ " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
+ "agent_scripts:\n"
+ " sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n"
+ "---\n\n"
+ "Run {SCRIPT}\n"
+ "Then {AGENT_SCRIPT}\n"
+ "Review templates/checklist.md and memory/constitution.md for __AGENT__.\n"
+ )
+
+ manager = ExtensionManager(project_dir)
+ manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
+
+ content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text()
+ assert "{SCRIPT}" not in content
+ assert "{AGENT_SCRIPT}" not in content
+ assert "{ARGS}" not in content
+ assert "__AGENT__" not in content
+ assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
+ assert ".specify/scripts/bash/update-agent-context.sh claude" in content
+ assert ".specify/templates/checklist.md" in content
+ assert ".specify/memory/constitution.md" in content
def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
@@ -375,8 +461,8 @@ def test_missing_command_file_skipped(self, skills_project, temp_dir):
)
metadata = manager.registry.get(manifest.id)
- assert "speckit-missing-cmd-ext.exists" in metadata["registered_skills"]
- assert "speckit-missing-cmd-ext.ghost" not in metadata["registered_skills"]
+ assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"]
+ assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"]
# ===== Extension Skill Unregistration Tests =====
@@ -393,16 +479,16 @@ def test_skills_removed_on_extension_remove(self, skills_project, extension_dir)
)
# Verify skills exist
- assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
- assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists()
+ assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
+ assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
# Remove extension
result = manager.remove(manifest.id, keep_config=False)
assert result is True
# Skills should be gone
- assert not (skills_dir / "speckit-test-ext.hello").exists()
- assert not (skills_dir / "speckit-test-ext.world").exists()
+ assert not (skills_dir / "speckit-test-ext-hello").exists()
+ assert not (skills_dir / "speckit-test-ext-world").exists()
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
"""Non-extension skills should not be affected by extension removal."""
@@ -433,8 +519,8 @@ def test_remove_handles_already_deleted_skills(self, skills_project, extension_d
)
# Manually delete skill dirs before calling remove
- shutil.rmtree(skills_dir / "speckit-test-ext.hello")
- shutil.rmtree(skills_dir / "speckit-test-ext.world")
+ shutil.rmtree(skills_dir / "speckit-test-ext-hello")
+ shutil.rmtree(skills_dir / "speckit-test-ext-world")
# Should not raise
result = manager.remove(manifest.id, keep_config=False)
@@ -457,6 +543,21 @@ def test_remove_no_skills_when_not_active(self, no_skills_project, extension_dir
class TestExtensionSkillEdgeCases:
"""Test edge cases in extension skill registration."""
+ def test_install_with_non_dict_init_options_does_not_crash(self, project_dir, extension_dir):
+ """Corrupted init-options payloads should disable skill registration, not crash install."""
+ opts_file = project_dir / ".specify" / "init-options.json"
+ opts_file.parent.mkdir(parents=True, exist_ok=True)
+ opts_file.write_text("[]")
+ _create_skills_dir(project_dir, ai="claude")
+
+ manager = ExtensionManager(project_dir)
+ manifest = manager.install_from_directory(
+ extension_dir, "0.1.0", register_commands=False
+ )
+
+ metadata = manager.registry.get(manifest.id)
+ assert metadata["registered_skills"] == []
+
def test_command_without_frontmatter(self, skills_project, temp_dir):
"""Commands without YAML frontmatter should still produce valid skills."""
project_dir, skills_dir = skills_project
@@ -495,10 +596,10 @@ def test_command_without_frontmatter(self, skills_project, temp_dir):
ext_dir, "0.1.0", register_commands=False
)
- skill_file = skills_dir / "speckit-nofm-ext.plain" / "SKILL.md"
+ skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
- assert "name: speckit-nofm-ext.plain" in content
+ assert "name: speckit-nofm-ext-plain" in content
# Fallback description when no frontmatter description
assert "Extension command: speckit.nofm-ext.plain" in content
assert "Body without frontmatter." in content
@@ -515,8 +616,8 @@ def test_gemini_agent_skills(self, project_dir, temp_dir):
)
skills_dir = project_dir / ".gemini" / "skills"
- assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
- assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists()
+ assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
+ assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
def test_multiple_extensions_independent_skills(self, skills_project, temp_dir):
"""Installing and removing different extensions should be independent."""
@@ -534,15 +635,15 @@ def test_multiple_extensions_independent_skills(self, skills_project, temp_dir):
)
# Both should have skills
- assert (skills_dir / "speckit-ext-a.hello" / "SKILL.md").exists()
- assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists()
+ assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists()
+ assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
# Remove ext-a
manager.remove("ext-a", keep_config=False)
# ext-a skills gone, ext-b skills preserved
- assert not (skills_dir / "speckit-ext-a.hello").exists()
- assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists()
+ assert not (skills_dir / "speckit-ext-a-hello").exists()
+ assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
"""Commands with invalid YAML frontmatter should still produce valid skills."""
@@ -591,7 +692,7 @@ def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
ext_dir, "0.1.0", register_commands=False
)
- skill_file = skills_dir / "speckit-badfm-ext.broken" / "SKILL.md"
+ skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
# Fallback description since frontmatter was invalid
@@ -607,7 +708,7 @@ def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extens
)
# Verify skills exist
- assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
+ assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
# Delete init-options.json to simulate user change
init_opts = project_dir / ".specify" / "init-options.json"
@@ -616,8 +717,8 @@ def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extens
# Remove should still clean up via fallback scan
result = manager.remove(manifest.id, keep_config=False)
assert result is True
- assert not (skills_dir / "speckit-test-ext.hello").exists()
- assert not (skills_dir / "speckit-test-ext.world").exists()
+ assert not (skills_dir / "speckit-test-ext-hello").exists()
+ assert not (skills_dir / "speckit-test-ext-world").exists()
def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir):
"""Skills should be cleaned up even if ai_skills is toggled to false after install."""
@@ -628,7 +729,7 @@ def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension
)
# Verify skills exist
- assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
+ assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
# Toggle ai_skills to false
_create_init_options(project_dir, ai="claude", ai_skills=False)
@@ -636,5 +737,5 @@ def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension
# Remove should still clean up via fallback scan
result = manager.remove(manifest.id, keep_config=False)
assert result is True
- assert not (skills_dir / "speckit-test-ext.hello").exists()
- assert not (skills_dir / "speckit-test-ext.world").exists()
+ assert not (skills_dir / "speckit-test-ext-hello").exists()
+ assert not (skills_dir / "speckit-test-ext-world").exists()
diff --git a/tests/test_extensions.py b/tests/test_extensions.py
index cd0f9ba443..df269d86c4 100644
--- a/tests/test_extensions.py
+++ b/tests/test_extensions.py
@@ -16,12 +16,15 @@
from pathlib import Path
from datetime import datetime, timezone
+from tests.conftest import strip_ansi
from specify_cli.extensions import (
CatalogEntry,
+ CORE_COMMAND_NAMES,
ExtensionManifest,
ExtensionRegistry,
ExtensionManager,
CommandRegistrar,
+ HookExecutor,
ExtensionCatalog,
ExtensionError,
ValidationError,
@@ -62,7 +65,7 @@ def valid_manifest_data():
"provides": {
"commands": [
{
- "name": "speckit.test.hello",
+ "name": "speckit.test-ext.hello",
"file": "commands/hello.md",
"description": "Test command",
}
@@ -70,7 +73,7 @@ def valid_manifest_data():
},
"hooks": {
"after_tasks": {
- "command": "speckit.test.hello",
+ "command": "speckit.test-ext.hello",
"optional": True,
"prompt": "Run test?",
}
@@ -188,7 +191,18 @@ def test_valid_manifest(self, extension_dir):
assert manifest.version == "1.0.0"
assert manifest.description == "A test extension"
assert len(manifest.commands) == 1
- assert manifest.commands[0]["name"] == "speckit.test.hello"
+ assert manifest.commands[0]["name"] == "speckit.test-ext.hello"
+
+ def test_core_command_names_match_bundled_templates(self):
+ """Core command reservations should stay aligned with bundled templates."""
+ commands_dir = Path(__file__).resolve().parent.parent / "templates" / "commands"
+ expected = {
+ command_file.stem
+ for command_file in commands_dir.iterdir()
+ if command_file.is_file() and command_file.suffix == ".md"
+ }
+
+ assert CORE_COMMAND_NAMES == expected
def test_missing_required_field(self, temp_dir):
"""Test manifest missing required field."""
@@ -588,6 +602,172 @@ def test_install_duplicate(self, extension_dir, project_dir):
with pytest.raises(ExtensionError, match="already installed"):
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
+ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir):
+ """Install should reject extension IDs that shadow core commands."""
+ import yaml
+
+ ext_dir = temp_dir / "analyze-ext"
+ ext_dir.mkdir()
+ (ext_dir / "commands").mkdir()
+
+ manifest_data = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "analyze",
+ "name": "Analyze Extension",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.analyze.extra",
+ "file": "commands/cmd.md",
+ }
+ ]
+ },
+ }
+
+ (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
+ (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
+
+ manager = ExtensionManager(project_dir)
+ with pytest.raises(ValidationError, match="conflicts with core command namespace"):
+ manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
+
+ def test_install_rejects_alias_without_extension_namespace(self, temp_dir, project_dir):
+ """Install should reject legacy short aliases that can shadow core commands."""
+ import yaml
+
+ ext_dir = temp_dir / "alias-shortcut"
+ ext_dir.mkdir()
+ (ext_dir / "commands").mkdir()
+
+ manifest_data = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "alias-shortcut",
+ "name": "Alias Shortcut",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.alias-shortcut.cmd",
+ "file": "commands/cmd.md",
+ "aliases": ["speckit.shortcut"],
+ }
+ ]
+ },
+ }
+
+ (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
+ (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
+
+ manager = ExtensionManager(project_dir)
+ with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"):
+ manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
+
+ def test_install_rejects_namespace_squatting(self, temp_dir, project_dir):
+ """Install should reject commands and aliases outside the extension namespace."""
+ import yaml
+
+ ext_dir = temp_dir / "squat-ext"
+ ext_dir.mkdir()
+ (ext_dir / "commands").mkdir()
+
+ manifest_data = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "squat-ext",
+ "name": "Squat Extension",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.other-ext.cmd",
+ "file": "commands/cmd.md",
+ "aliases": ["speckit.squat-ext.ok"],
+ }
+ ]
+ },
+ }
+
+ (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
+ (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
+
+ manager = ExtensionManager(project_dir)
+ with pytest.raises(ValidationError, match="must use extension namespace 'squat-ext'"):
+ manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
+
+ def test_install_rejects_command_collision_with_installed_extension(self, temp_dir, project_dir):
+ """Install should reject names already claimed by an installed legacy extension."""
+ import yaml
+
+ first_dir = temp_dir / "ext-one"
+ first_dir.mkdir()
+ (first_dir / "commands").mkdir()
+ first_manifest = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "ext-one",
+ "name": "Extension One",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.ext-one.sync",
+ "file": "commands/cmd.md",
+ "aliases": ["speckit.shared.sync"],
+ }
+ ]
+ },
+ }
+ (first_dir / "extension.yml").write_text(yaml.dump(first_manifest))
+ (first_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
+ installed_ext_dir = project_dir / ".specify" / "extensions" / "ext-one"
+ installed_ext_dir.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copytree(first_dir, installed_ext_dir)
+
+ second_dir = temp_dir / "ext-two"
+ second_dir.mkdir()
+ (second_dir / "commands").mkdir()
+ second_manifest = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "shared",
+ "name": "Shared Extension",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.shared.sync",
+ "file": "commands/cmd.md",
+ }
+ ]
+ },
+ }
+ (second_dir / "extension.yml").write_text(yaml.dump(second_manifest))
+ (second_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
+
+ manager = ExtensionManager(project_dir)
+ manager.registry.add("ext-one", {"version": "1.0.0", "source": "local"})
+
+ with pytest.raises(ValidationError, match="already provided by extension 'ext-one'"):
+ manager.install_from_directory(second_dir, "0.1.0", register_commands=False)
+
def test_remove_extension(self, extension_dir, project_dir):
"""Test removing an installed extension."""
manager = ExtensionManager(project_dir)
@@ -759,6 +939,81 @@ def test_render_frontmatter_unicode(self):
assert "PrΓΌfe KonformitΓ€t" in output
assert "\\u" not in output
+ def test_adjust_script_paths_does_not_mutate_input(self):
+ """Path adjustments should not mutate caller-owned frontmatter dicts."""
+ from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
+ registrar = AgentCommandRegistrar()
+ original = {
+ "scripts": {
+ "sh": "../../scripts/bash/setup-plan.sh {ARGS}",
+ "ps": "../../scripts/powershell/setup-plan.ps1 {ARGS}",
+ }
+ }
+ before = json.loads(json.dumps(original))
+
+ adjusted = registrar._adjust_script_paths(original)
+
+ assert original == before
+ assert adjusted["scripts"]["sh"] == ".specify/scripts/bash/setup-plan.sh {ARGS}"
+ assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
+
+ def test_adjust_script_paths_preserves_extension_local_paths(self):
+ """Extension-local script paths should not be rewritten into .specify/.specify."""
+ from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
+ registrar = AgentCommandRegistrar()
+ original = {
+ "scripts": {
+ "sh": ".specify/extensions/test-ext/scripts/setup.sh {ARGS}",
+ "ps": "scripts/powershell/setup-plan.ps1 {ARGS}",
+ }
+ }
+
+ adjusted = registrar._adjust_script_paths(original)
+
+ assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
+ assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
+
+ def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
+ """Body rewrites should preserve extension-local assets while fixing top-level refs."""
+ from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
+
+ body = (
+ "Read `.specify/extensions/test-ext/templates/spec.md`\n"
+ "Run scripts/bash/setup-plan.sh\n"
+ )
+
+ rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(body)
+
+ assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
+ assert ".specify/scripts/bash/setup-plan.sh" in rewritten
+
+ def test_render_toml_command_handles_embedded_triple_double_quotes(self):
+ """TOML renderer should stay valid when body includes triple double-quotes."""
+ from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
+ registrar = AgentCommandRegistrar()
+ output = registrar.render_toml_command(
+ {"description": "x"},
+ 'line1\n"""danger"""\nline2',
+ "extension:test-ext",
+ )
+
+ assert "prompt = '''" in output
+ assert '"""danger"""' in output
+
+ def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self):
+ """If body has both triple quote styles, fall back to escaped basic string."""
+ from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
+ registrar = AgentCommandRegistrar()
+ output = registrar.render_toml_command(
+ {"description": "x"},
+ 'a """ b\nc \'\'\' d',
+ "extension:test-ext",
+ )
+
+ assert 'prompt = "' in output
+ assert "\\n" in output
+ assert "\\\"\\\"\\\"" in output
+
def test_register_commands_for_claude(self, extension_dir, project_dir):
"""Test registering commands for Claude agent."""
# Create .claude directory
@@ -776,10 +1031,10 @@ def test_register_commands_for_claude(self, extension_dir, project_dir):
)
assert len(registered) == 1
- assert "speckit.test.hello" in registered
+ assert "speckit.test-ext.hello" in registered
# Check command file was created
- cmd_file = claude_dir / "speckit.test.hello.md"
+ cmd_file = claude_dir / "speckit.test-ext.hello.md"
assert cmd_file.exists()
content = cmd_file.read_text()
@@ -809,9 +1064,9 @@ def test_command_with_aliases(self, project_dir, temp_dir):
"provides": {
"commands": [
{
- "name": "speckit.alias.cmd",
+ "name": "speckit.ext-alias.cmd",
"file": "commands/cmd.md",
- "aliases": ["speckit.shortcut"],
+ "aliases": ["speckit.ext-alias.shortcut"],
}
]
},
@@ -831,10 +1086,10 @@ def test_command_with_aliases(self, project_dir, temp_dir):
registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir)
assert len(registered) == 2
- assert "speckit.alias.cmd" in registered
- assert "speckit.shortcut" in registered
- assert (claude_dir / "speckit.alias.cmd.md").exists()
- assert (claude_dir / "speckit.shortcut.md").exists()
+ assert "speckit.ext-alias.cmd" in registered
+ assert "speckit.ext-alias.shortcut" in registered
+ assert (claude_dir / "speckit.ext-alias.cmd.md").exists()
+ assert (claude_dir / "speckit.ext-alias.shortcut.md").exists()
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
"""Codex skill cleanup should use the same mapped names as registration."""
@@ -875,11 +1130,11 @@ def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir,
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
- skill_file = skills_dir / "speckit-test.hello" / "SKILL.md"
+ skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
- assert "name: speckit-test.hello" in content
+ assert "name: speckit-test-ext-hello" in content
assert "description: Test hello command" in content
assert "compatibility:" in content
assert "metadata:" in content
@@ -906,7 +1161,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir
"provides": {
"commands": [
{
- "name": "speckit.test.plan",
+ "name": "speckit.ext-scripted.plan",
"file": "commands/plan.md",
"description": "Scripted command",
}
@@ -944,7 +1199,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
- skill_file = skills_dir / "speckit-test.plan" / "SKILL.md"
+ skill_file = skills_dir / "speckit-ext-scripted-plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
@@ -975,9 +1230,9 @@ def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, tem
"provides": {
"commands": [
{
- "name": "speckit.alias.cmd",
+ "name": "speckit.ext-alias-skill.cmd",
"file": "commands/cmd.md",
- "aliases": ["speckit.shortcut"],
+ "aliases": ["speckit.ext-alias-skill.shortcut"],
}
]
},
@@ -994,13 +1249,13 @@ def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, tem
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
- primary = skills_dir / "speckit-alias.cmd" / "SKILL.md"
- alias = skills_dir / "speckit-shortcut" / "SKILL.md"
+ primary = skills_dir / "speckit-ext-alias-skill-cmd" / "SKILL.md"
+ alias = skills_dir / "speckit-ext-alias-skill-shortcut" / "SKILL.md"
assert primary.exists()
assert alias.exists()
- assert "name: speckit-alias.cmd" in primary.read_text()
- assert "name: speckit-shortcut" in alias.read_text()
+ assert "name: speckit-ext-alias-skill-cmd" in primary.read_text()
+ assert "name: speckit-ext-alias-skill-shortcut" in alias.read_text()
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
self, project_dir, temp_dir
@@ -1024,7 +1279,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti
"provides": {
"commands": [
{
- "name": "speckit.fallback.plan",
+ "name": "speckit.ext-script-fallback.plan",
"file": "commands/plan.md",
}
]
@@ -1056,7 +1311,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
- skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md"
+ skill_file = skills_dir / "speckit-ext-script-fallback-plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
@@ -1065,6 +1320,62 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
+ def test_codex_skill_registration_handles_non_dict_init_options(
+ self, project_dir, temp_dir
+ ):
+ """Non-dict init-options payloads should not crash skill placeholder resolution."""
+ import yaml
+
+ ext_dir = temp_dir / "ext-script-list-init"
+ ext_dir.mkdir()
+ (ext_dir / "commands").mkdir()
+
+ manifest_data = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "ext-script-list-init",
+ "name": "List init options",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.ext-script-list-init.plan",
+ "file": "commands/plan.md",
+ }
+ ]
+ },
+ }
+ with open(ext_dir / "extension.yml", "w") as f:
+ yaml.dump(manifest_data, f)
+
+ (ext_dir / "commands" / "plan.md").write_text(
+ """---
+description: "List init scripted command"
+scripts:
+ sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
+---
+
+Run {SCRIPT}
+"""
+ )
+
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text("[]")
+
+ skills_dir = project_dir / ".agents" / "skills"
+ skills_dir.mkdir(parents=True)
+
+ manifest = ExtensionManifest(ext_dir / "extension.yml")
+ registrar = CommandRegistrar()
+ registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
+
+ content = (skills_dir / "speckit-ext-script-list-init-plan" / "SKILL.md").read_text()
+ assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
+
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
self, project_dir, temp_dir, monkeypatch
):
@@ -1089,7 +1400,7 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
"provides": {
"commands": [
{
- "name": "speckit.windows.plan",
+ "name": "speckit.ext-script-windows-fallback.plan",
"file": "commands/plan.md",
}
]
@@ -1121,7 +1432,7 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
- skill_file = skills_dir / "speckit-windows.plan" / "SKILL.md"
+ skill_file = skills_dir / "speckit-ext-script-windows-fallback-plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
@@ -1143,14 +1454,14 @@ def test_register_commands_for_copilot(self, extension_dir, project_dir):
)
assert len(registered) == 1
- assert "speckit.test.hello" in registered
+ assert "speckit.test-ext.hello" in registered
# Verify command file uses .agent.md extension
- cmd_file = agents_dir / "speckit.test.hello.agent.md"
+ cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
assert cmd_file.exists()
# Verify NO plain .md file was created
- plain_md_file = agents_dir / "speckit.test.hello.md"
+ plain_md_file = agents_dir / "speckit.test-ext.hello.md"
assert not plain_md_file.exists()
content = cmd_file.read_text()
@@ -1170,12 +1481,12 @@ def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
)
# Verify companion .prompt.md file exists
- prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
+ prompt_file = project_dir / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md"
assert prompt_file.exists()
# Verify content has correct agent frontmatter
content = prompt_file.read_text()
- assert content == "---\nagent: speckit.test.hello\n---\n"
+ assert content == "---\nagent: speckit.test-ext.hello\n---\n"
def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir):
"""Test that aliases also get companion .prompt.md files for Copilot."""
@@ -1196,9 +1507,9 @@ def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir):
"provides": {
"commands": [
{
- "name": "speckit.alias-copilot.cmd",
+ "name": "speckit.ext-alias-copilot.cmd",
"file": "commands/cmd.md",
- "aliases": ["speckit.shortcut-copilot"],
+ "aliases": ["speckit.ext-alias-copilot.shortcut"],
}
]
},
@@ -1225,8 +1536,8 @@ def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir):
# Both primary and alias get companion .prompt.md
prompts_dir = project_dir / ".github" / "prompts"
- assert (prompts_dir / "speckit.alias-copilot.cmd.prompt.md").exists()
- assert (prompts_dir / "speckit.shortcut-copilot.prompt.md").exists()
+ assert (prompts_dir / "speckit.ext-alias-copilot.cmd.prompt.md").exists()
+ assert (prompts_dir / "speckit.ext-alias-copilot.shortcut.prompt.md").exists()
def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):
"""Test that non-copilot agents do NOT create .prompt.md files."""
@@ -1299,7 +1610,7 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
assert installed[0]["id"] == "test-ext"
# Verify command registered
- cmd_file = project_dir / ".claude" / "commands" / "speckit.test.hello.md"
+ cmd_file = project_dir / ".claude" / "commands" / "speckit.test-ext.hello.md"
assert cmd_file.exists()
# Verify registry has registered commands (now a dict keyed by agent)
@@ -1307,7 +1618,7 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
registered_commands = metadata["registered_commands"]
# Check that the command is registered for at least one agent
assert any(
- "speckit.test.hello" in cmds
+ "speckit.test-ext.hello" in cmds
for cmds in registered_commands.values()
)
@@ -1333,8 +1644,8 @@ def test_copilot_cleanup_removes_prompt_files(self, extension_dir, project_dir):
assert "copilot" in metadata["registered_commands"]
# Verify files exist before cleanup
- agent_file = agents_dir / "speckit.test.hello.agent.md"
- prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
+ agent_file = agents_dir / "speckit.test-ext.hello.agent.md"
+ prompt_file = project_dir / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md"
assert agent_file.exists()
assert prompt_file.exists()
@@ -2644,7 +2955,7 @@ def _create_extension_source(base_dir: Path, version: str, include_config: bool
"provides": {
"commands": [
{
- "name": "speckit.test.hello",
+ "name": "speckit.test-ext.hello",
"file": "commands/hello.md",
"description": "Test command",
}
@@ -2652,7 +2963,7 @@ def _create_extension_source(base_dir: Path, version: str, include_config: bool
},
"hooks": {
"after_tasks": {
- "command": "speckit.test.hello",
+ "command": "speckit.test-ext.hello",
"optional": True,
}
},
@@ -2681,7 +2992,7 @@ def _create_catalog_zip(zip_path: Path, version: str):
"description": "A test extension",
},
"requires": {"speckit_version": ">=0.1.0"},
- "provides": {"commands": [{"name": "speckit.test.hello", "file": "commands/hello.md"}]},
+ "provides": {"commands": [{"name": "speckit.test-ext.hello", "file": "commands/hello.md"}]},
}
with zipfile.ZipFile(zip_path, "w") as zf:
@@ -2816,11 +3127,12 @@ def test_list_shows_extension_id(self, extension_dir, project_dir):
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
+ plain = strip_ansi(result.output)
# Verify the extension ID is shown in the output
- assert "test-ext" in result.output
+ assert "test-ext" in plain
# Verify name and version are also shown
- assert "Test Extension" in result.output
- assert "1.0.0" in result.output
+ assert "Test Extension" in plain
+ assert "1.0.0" in plain
class TestExtensionPriority:
@@ -3050,7 +3362,8 @@ def test_list_shows_priority(self, extension_dir, project_dir):
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
- assert "Priority: 7" in result.output
+ plain = strip_ansi(result.output)
+ assert "Priority: 7" in plain
def test_set_priority_changes_priority(self, extension_dir, project_dir):
"""Test set-priority command changes extension priority."""
@@ -3071,7 +3384,8 @@ def test_set_priority_changes_priority(self, extension_dir, project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
- assert "priority changed: 10 β 5" in result.output
+ plain = strip_ansi(result.output)
+ assert "priority changed: 10 β 5" in plain
# Reload registry to see updated value
manager2 = ExtensionManager(project_dir)
@@ -3093,7 +3407,8 @@ def test_set_priority_same_value_no_change(self, extension_dir, project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
- assert "already has priority 5" in result.output
+ plain = strip_ansi(result.output)
+ assert "already has priority 5" in plain
def test_set_priority_invalid_value(self, extension_dir, project_dir):
"""Test set-priority rejects invalid priority values."""
@@ -3231,3 +3546,128 @@ def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir):
assert result[0][0] == "ext-with-priority"
assert result[1][0] == "legacy-ext"
assert result[2][0] == "ext-low-priority"
+
+
+class TestHookInvocationRendering:
+ """Test hook invocation formatting for different agent modes."""
+
+ def test_kimi_hooks_render_skill_invocation(self, project_dir):
+ """Kimi projects should render /skill:speckit-* invocations."""
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
+
+ hook_executor = HookExecutor(project_dir)
+ message = hook_executor.format_hook_message(
+ "before_plan",
+ [
+ {
+ "extension": "test-ext",
+ "command": "speckit.plan",
+ "optional": False,
+ }
+ ],
+ )
+
+ assert "Executing: `/skill:speckit-plan`" in message
+ assert "EXECUTE_COMMAND: speckit.plan" in message
+ assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
+
+ def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
+ """Codex projects with --ai-skills should render $speckit-* invocations."""
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True}))
+
+ hook_executor = HookExecutor(project_dir)
+ execution = hook_executor.execute_hook(
+ {
+ "extension": "test-ext",
+ "command": "speckit.tasks",
+ "optional": False,
+ }
+ )
+
+ assert execution["command"] == "speckit.tasks"
+ assert execution["invocation"] == "$speckit-tasks"
+
+ def test_non_skill_command_keeps_slash_invocation(self, project_dir):
+ """Custom hook commands should keep slash invocation style."""
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
+
+ hook_executor = HookExecutor(project_dir)
+ message = hook_executor.format_hook_message(
+ "before_tasks",
+ [
+ {
+ "extension": "test-ext",
+ "command": "pre_tasks_test",
+ "optional": False,
+ }
+ ],
+ )
+
+ assert "Executing: `/pre_tasks_test`" in message
+ assert "EXECUTE_COMMAND: pre_tasks_test" in message
+ assert "EXECUTE_COMMAND_INVOCATION: /pre_tasks_test" in message
+
+ def test_extension_command_uses_hyphenated_skill_invocation(self, project_dir):
+ """Multi-segment extension command ids should map to hyphenated skills."""
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
+
+ hook_executor = HookExecutor(project_dir)
+ message = hook_executor.format_hook_message(
+ "after_tasks",
+ [
+ {
+ "extension": "test-ext",
+ "command": "speckit.test-ext.hello",
+ "optional": False,
+ }
+ ],
+ )
+
+ assert "Executing: `/skill:speckit-test-ext-hello`" in message
+ assert "EXECUTE_COMMAND: speckit.test-ext.hello" in message
+ assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-ext-hello" in message
+
+ def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch):
+ """Init options should be loaded once per executor instance."""
+ calls = {"count": 0}
+
+ def fake_load_init_options(_project_root):
+ calls["count"] += 1
+ return {"ai": "kimi", "ai_skills": False}
+
+ monkeypatch.setattr("specify_cli.load_init_options", fake_load_init_options)
+
+ hook_executor = HookExecutor(project_dir)
+ assert hook_executor._render_hook_invocation("speckit.plan") == "/skill:speckit-plan"
+ assert hook_executor._render_hook_invocation("speckit.tasks") == "/skill:speckit-tasks"
+ assert calls["count"] == 1
+
+ def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir):
+ """Hook messages should still render actionable command placeholders."""
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
+
+ hook_executor = HookExecutor(project_dir)
+ message = hook_executor.format_hook_message(
+ "after_tasks",
+ [
+ {
+ "extension": "test-ext",
+ "command": None,
+ "optional": False,
+ }
+ ],
+ )
+
+ assert "Executing: `/`" in message
+ assert "EXECUTE_COMMAND: " in message
+ assert "EXECUTE_COMMAND_INVOCATION: /" in message
diff --git a/tests/test_presets.py b/tests/test_presets.py
index 95dca41224..cf02709b27 100644
--- a/tests/test_presets.py
+++ b/tests/test_presets.py
@@ -20,6 +20,7 @@
import yaml
+from tests.conftest import strip_ansi
from specify_cli.presets import (
PresetManifest,
PresetRegistry,
@@ -1942,10 +1943,10 @@ def test_load_returns_empty_on_invalid_json(self, project_dir):
class TestPresetSkills:
"""Tests for preset skill registration and unregistration."""
- def _write_init_options(self, project_dir, ai="claude", ai_skills=True):
+ def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
from specify_cli import save_init_options
- save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills})
+ save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script})
def _create_skill(self, skills_dir, skill_name, body="original body"):
skill_dir = skills_dir / skill_name
@@ -1995,6 +1996,26 @@ def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
content = skill_file.read_text()
assert "untouched" in content, "Skill should not be modified when ai_skills=False"
+ def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir):
+ """Corrupted init-options ai values should not crash preset skill resolution."""
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}')
+
+ manager = PresetManager(project_dir)
+
+ assert manager._get_skills_dir() is None
+
+ def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir):
+ """Corrupted non-dict init-options payloads should fail closed."""
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text("[]")
+
+ manager = PresetManager(project_dir)
+
+ assert manager._get_skills_dir() is None
+
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
"""When no init-options.json exists, preset install should not touch skills."""
skills_dir = project_dir / ".claude" / "skills"
@@ -2040,6 +2061,52 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir):
assert "preset:self-test" not in content, "Preset content should be gone"
assert "templates/commands/specify.md" in content, "Should reference core template"
+ def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
+ """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
+ self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
+ skills_dir = project_dir / ".claude" / "skills"
+ self._create_skill(skills_dir, "speckit-specify", body="old")
+ (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
+
+ core_cmds = project_dir / ".specify" / "templates" / "commands"
+ core_cmds.mkdir(parents=True, exist_ok=True)
+ (core_cmds / "specify.md").write_text(
+ "---\n"
+ "description: Core specify command\n"
+ "scripts:\n"
+ " sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
+ "---\n\n"
+ "Run:\n"
+ "{SCRIPT}\n"
+ )
+
+ manager = PresetManager(project_dir)
+ SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
+ manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
+ manager.remove("self-test")
+
+ content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
+ assert "{SCRIPT}" not in content
+ assert "{ARGS}" not in content
+ assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
+
+ def test_skill_not_overridden_when_skill_path_is_file(self, project_dir):
+ """Preset install should skip non-directory skill targets."""
+ self._write_init_options(project_dir, ai="claude")
+ skills_dir = project_dir / ".claude" / "skills"
+ skills_dir.mkdir(parents=True, exist_ok=True)
+ (skills_dir / "speckit-specify").write_text("not-a-directory")
+
+ (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
+
+ manager = PresetManager(project_dir)
+ SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
+ manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
+
+ assert (skills_dir / "speckit-specify").is_file()
+ metadata = manager.registry.get("self-test")
+ assert "speckit-specify" not in metadata.get("registered_skills", [])
+
def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir):
"""Skills should not be created when no existing skill dir is found."""
self._write_init_options(project_dir, ai="claude")
@@ -2054,6 +2121,304 @@ def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_d
metadata = manager.registry.get("self-test")
assert metadata.get("registered_skills", []) == []
+ def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir):
+ """Preset overrides for speckit.. should target speckit-- skills."""
+ self._write_init_options(project_dir, ai="codex")
+ skills_dir = project_dir / ".agents" / "skills"
+ self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched")
+ (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True)
+
+ preset_dir = temp_dir / "ext-skill-override"
+ preset_dir.mkdir()
+ (preset_dir / "commands").mkdir()
+ (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
+ "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n"
+ )
+ manifest_data = {
+ "schema_version": "1.0",
+ "preset": {
+ "id": "ext-skill-override",
+ "name": "Ext Skill Override",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "templates": [
+ {
+ "type": "command",
+ "name": "speckit.fakeext.cmd",
+ "file": "commands/speckit.fakeext.cmd.md",
+ }
+ ]
+ },
+ }
+ with open(preset_dir / "preset.yml", "w") as f:
+ yaml.dump(manifest_data, f)
+
+ manager = PresetManager(project_dir)
+ manager.install_from_directory(preset_dir, "0.1.5")
+
+ skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
+ assert skill_file.exists()
+ content = skill_file.read_text()
+ assert "preset:ext-skill-override" in content
+ assert "name: speckit-fakeext-cmd" in content
+ assert "# Speckit Fakeext Cmd Skill" in content
+
+ metadata = manager.registry.get("ext-skill-override")
+ assert "speckit-fakeext-cmd" in metadata.get("registered_skills", [])
+
+ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir):
+ """Preset removal should restore an extension-backed skill instead of deleting it."""
+ self._write_init_options(project_dir, ai="codex")
+ skills_dir = project_dir / ".agents" / "skills"
+ self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
+
+ extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
+ (extension_dir / "commands").mkdir(parents=True, exist_ok=True)
+ (extension_dir / "commands" / "cmd.md").write_text(
+ "---\n"
+ "description: Extension fakeext cmd\n"
+ "scripts:\n"
+ " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
+ "---\n\n"
+ "extension:fakeext\n"
+ "Run {SCRIPT}\n"
+ )
+ extension_manifest = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "fakeext",
+ "name": "Fake Extension",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.fakeext.cmd",
+ "file": "commands/cmd.md",
+ "description": "Fake extension command",
+ }
+ ]
+ },
+ }
+ with open(extension_dir / "extension.yml", "w") as f:
+ yaml.dump(extension_manifest, f)
+
+ preset_dir = temp_dir / "ext-skill-restore"
+ preset_dir.mkdir()
+ (preset_dir / "commands").mkdir()
+ (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
+ "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n"
+ )
+ preset_manifest = {
+ "schema_version": "1.0",
+ "preset": {
+ "id": "ext-skill-restore",
+ "name": "Ext Skill Restore",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "templates": [
+ {
+ "type": "command",
+ "name": "speckit.fakeext.cmd",
+ "file": "commands/speckit.fakeext.cmd.md",
+ }
+ ]
+ },
+ }
+ with open(preset_dir / "preset.yml", "w") as f:
+ yaml.dump(preset_manifest, f)
+
+ manager = PresetManager(project_dir)
+ manager.install_from_directory(preset_dir, "0.1.5")
+
+ skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
+ assert "preset:ext-skill-restore" in skill_file.read_text()
+
+ manager.remove("ext-skill-restore")
+
+ assert skill_file.exists()
+ content = skill_file.read_text()
+ assert "preset:ext-skill-restore" not in content
+ assert "source: extension:fakeext" in content
+ assert "extension:fakeext" in content
+ assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
+ assert "# Fakeext Cmd Skill" in content
+
+ def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir):
+ """Preset removal should not delete arbitrary directories missing SKILL.md."""
+ self._write_init_options(project_dir, ai="codex")
+ skills_dir = project_dir / ".agents" / "skills"
+ stray_skill_dir = skills_dir / "speckit-fakeext-cmd"
+ stray_skill_dir.mkdir(parents=True, exist_ok=True)
+ note_file = stray_skill_dir / "notes.txt"
+ note_file.write_text("user content", encoding="utf-8")
+
+ preset_dir = temp_dir / "ext-skill-missing-file"
+ preset_dir.mkdir()
+ (preset_dir / "commands").mkdir()
+ (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
+ "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n"
+ )
+ preset_manifest = {
+ "schema_version": "1.0",
+ "preset": {
+ "id": "ext-skill-missing-file",
+ "name": "Ext Skill Missing File",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "templates": [
+ {
+ "type": "command",
+ "name": "speckit.fakeext.cmd",
+ "file": "commands/speckit.fakeext.cmd.md",
+ }
+ ]
+ },
+ }
+ with open(preset_dir / "preset.yml", "w") as f:
+ yaml.dump(preset_manifest, f)
+
+ manager = PresetManager(project_dir)
+ installed_preset_dir = manager.presets_dir / "ext-skill-missing-file"
+ shutil.copytree(preset_dir, installed_preset_dir)
+ manager.registry.add(
+ "ext-skill-missing-file",
+ {
+ "version": "1.0.0",
+ "source": str(preset_dir),
+ "provides_templates": ["speckit.fakeext.cmd"],
+ "registered_skills": ["speckit-fakeext-cmd"],
+ "priority": 10,
+ },
+ )
+
+ manager.remove("ext-skill-missing-file")
+
+ assert stray_skill_dir.is_dir()
+ assert note_file.read_text(encoding="utf-8") == "user content"
+
+ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir):
+ """Preset overrides should still target legacy dotted Kimi skill directories."""
+ self._write_init_options(project_dir, ai="kimi")
+ skills_dir = project_dir / ".kimi" / "skills"
+ self._create_skill(skills_dir, "speckit.specify", body="untouched")
+
+ (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
+
+ manager = PresetManager(project_dir)
+ self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
+ manager.install_from_directory(self_test_dir, "0.1.5")
+
+ skill_file = skills_dir / "speckit.specify" / "SKILL.md"
+ assert skill_file.exists()
+ content = skill_file.read_text()
+ assert "preset:self-test" in content
+ assert "name: speckit.specify" in content
+
+ metadata = manager.registry.get("self-test")
+ assert "speckit.specify" in metadata.get("registered_skills", [])
+
+ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir):
+ """Kimi presets should still propagate command overrides to existing skills."""
+ self._write_init_options(project_dir, ai="kimi", ai_skills=False)
+ skills_dir = project_dir / ".kimi" / "skills"
+ self._create_skill(skills_dir, "speckit-specify", body="untouched")
+
+ (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
+
+ manager = PresetManager(project_dir)
+ self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
+ manager.install_from_directory(self_test_dir, "0.1.5")
+
+ skill_file = skills_dir / "speckit-specify" / "SKILL.md"
+ assert skill_file.exists()
+ content = skill_file.read_text()
+ assert "preset:self-test" in content
+ assert "name: speckit-specify" in content
+
+ metadata = manager.registry.get("self-test")
+ assert "speckit-specify" in metadata.get("registered_skills", [])
+
+ def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir):
+ """Kimi preset skill overrides should resolve placeholders and rewrite project paths."""
+ self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh")
+ skills_dir = project_dir / ".kimi" / "skills"
+ self._create_skill(skills_dir, "speckit-specify", body="untouched")
+ (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
+
+ preset_dir = temp_dir / "kimi-placeholder-override"
+ preset_dir.mkdir()
+ (preset_dir / "commands").mkdir()
+ (preset_dir / "commands" / "speckit.specify.md").write_text(
+ "---\n"
+ "description: Kimi placeholder override\n"
+ "scripts:\n"
+ " sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
+ "---\n\n"
+ "Execute `{SCRIPT}` for __AGENT__\n"
+ "Review templates/checklist.md and memory/constitution.md\n"
+ )
+ manifest_data = {
+ "schema_version": "1.0",
+ "preset": {
+ "id": "kimi-placeholder-override",
+ "name": "Kimi Placeholder Override",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "templates": [
+ {
+ "type": "command",
+ "name": "speckit.specify",
+ "file": "commands/speckit.specify.md",
+ }
+ ]
+ },
+ }
+ with open(preset_dir / "preset.yml", "w") as f:
+ yaml.dump(manifest_data, f)
+
+ manager = PresetManager(project_dir)
+ manager.install_from_directory(preset_dir, "0.1.5")
+
+ content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
+ assert "{SCRIPT}" not in content
+ assert "__AGENT__" not in content
+ assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
+ assert ".specify/templates/checklist.md" in content
+ assert ".specify/memory/constitution.md" in content
+ assert "for kimi" in content
+
+ def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir):
+ """Non-dict init-options payloads should not crash preset install/remove flows."""
+ init_options = project_dir / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text("[]")
+
+ skills_dir = project_dir / ".claude" / "skills"
+ self._create_skill(skills_dir, "speckit-specify", body="untouched")
+ (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
+
+ manager = PresetManager(project_dir)
+ self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
+ manager.install_from_directory(self_test_dir, "0.1.5")
+
+ content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
+ assert "untouched" in content
+
class TestPresetSetPriority:
"""Test preset set-priority CLI command."""
@@ -2077,7 +2442,8 @@ def test_set_priority_changes_priority(self, project_dir, pack_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
- assert "priority changed: 10 β 5" in result.output
+ plain = strip_ansi(result.output)
+ assert "priority changed: 10 β 5" in plain
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
@@ -2099,7 +2465,8 @@ def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
- assert "already has priority 5" in result.output
+ plain = strip_ansi(result.output)
+ assert "already has priority 5" in plain
def test_set_priority_invalid_value(self, project_dir, pack_dir):
"""Test set-priority rejects invalid priority values."""
diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py
index 0cf631e963..edc93fb39e 100644
--- a/tests/test_timestamp_branches.py
+++ b/tests/test_timestamp_branches.py
@@ -14,6 +14,7 @@
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
+CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
@@ -147,6 +148,24 @@ def test_sequential_ignores_timestamp_dirs(self, git_repo: Path):
branch = line.split(":", 1)[1].strip()
assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}"
+ def test_sequential_supports_four_digit_prefixes(self, git_repo: Path):
+ """Sequential numbering should continue past 999 without truncation."""
+ (git_repo / "specs" / "999-last-3digit").mkdir(parents=True)
+ (git_repo / "specs" / "1000-first-4digit").mkdir(parents=True)
+ result = run_script(git_repo, "--short-name", "next-feat", "Next feature")
+ assert result.returncode == 0, result.stderr
+ branch = None
+ for line in result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ branch = line.split(":", 1)[1].strip()
+ assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}"
+
+ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
+ """PowerShell scanner should parse large prefixes without [int] casts."""
+ content = CREATE_FEATURE_PS.read_text(encoding="utf-8")
+ assert "[long]::TryParse($matches[1], [ref]$num)" in content
+ assert "$num = [int]$matches[1]" not in content
+
# ββ check_feature_branch Tests βββββββββββββββββββββββββββββββββββββββββββββββ
@@ -167,11 +186,26 @@ def test_rejects_main(self):
result = source_and_call('check_feature_branch "main" "true"')
assert result.returncode != 0
+ def test_accepts_four_digit_sequential_branch(self):
+ """check_feature_branch accepts 4+ digit sequential branch."""
+ result = source_and_call('check_feature_branch "1234-feat" "true"')
+ assert result.returncode == 0
+
def test_rejects_partial_timestamp(self):
"""Test 9: check_feature_branch rejects 7-digit date."""
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
assert result.returncode != 0
+ def test_rejects_timestamp_without_slug(self):
+ """check_feature_branch rejects timestamp-like branch missing trailing slug."""
+ result = source_and_call('check_feature_branch "20260319-143022" "true"')
+ assert result.returncode != 0
+
+ def test_rejects_7digit_timestamp_without_slug(self):
+ """check_feature_branch rejects 7-digit date + 6-digit time without slug."""
+ result = source_and_call('check_feature_branch "2026031-143022" "true"')
+ assert result.returncode != 0
+
# ββ find_feature_dir_by_prefix Tests βββββββββββββββββββββββββββββββββββββββββ
@@ -195,6 +229,15 @@ def test_cross_branch_prefix(self, tmp_path: Path):
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
+ def test_four_digit_sequential_prefix(self, tmp_path: Path):
+ """find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
+ (tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
+ result = source_and_call(
+ f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
+ )
+ assert result.returncode == 0
+ assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
+
# ββ get_current_branch Tests βββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -250,3 +293,484 @@ def test_e2e_sequential(self, git_repo: Path):
assert (git_repo / "specs" / branch).is_dir()
val = source_and_call(f'check_feature_branch "{branch}" "true"')
assert val.returncode == 0
+
+
+# ββ Allow Existing Branch Tests ββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+class TestAllowExistingBranch:
+ def test_allow_existing_switches_to_branch(self, git_repo: Path):
+ """T006: Pre-create branch, verify script switches to it."""
+ subprocess.run(
+ ["git", "checkout", "-b", "004-pre-exist"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "checkout", "-"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ result = run_script(
+ git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
+ "--number", "4", "Pre-existing feature",
+ )
+ assert result.returncode == 0, result.stderr
+ current = subprocess.run(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=git_repo, capture_output=True, text=True,
+ ).stdout.strip()
+ assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
+
+ def test_allow_existing_already_on_branch(self, git_repo: Path):
+ """T007: Verify success when already on the target branch."""
+ subprocess.run(
+ ["git", "checkout", "-b", "005-already-on"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ result = run_script(
+ git_repo, "--allow-existing-branch", "--short-name", "already-on",
+ "--number", "5", "Already on branch",
+ )
+ assert result.returncode == 0, result.stderr
+
+ def test_allow_existing_creates_spec_dir(self, git_repo: Path):
+ """T008: Verify spec directory created on existing branch."""
+ subprocess.run(
+ ["git", "checkout", "-b", "006-spec-dir"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "checkout", "-"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ result = run_script(
+ git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
+ "--number", "6", "Spec dir feature",
+ )
+ assert result.returncode == 0, result.stderr
+ assert (git_repo / "specs" / "006-spec-dir").is_dir()
+ assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
+
+ def test_without_flag_still_errors(self, git_repo: Path):
+ """T009: Verify backwards compatibility (error without flag)."""
+ subprocess.run(
+ ["git", "checkout", "-b", "007-no-flag"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "checkout", "-"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ result = run_script(
+ git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
+ )
+ assert result.returncode != 0, "should fail without --allow-existing-branch"
+ assert "already exists" in result.stderr
+
+ def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
+ """T010: Pre-create spec.md with content, verify it is preserved."""
+ subprocess.run(
+ ["git", "checkout", "-b", "008-no-overwrite"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ spec_dir = git_repo / "specs" / "008-no-overwrite"
+ spec_dir.mkdir(parents=True)
+ spec_file = spec_dir / "spec.md"
+ spec_file.write_text("# My custom spec content\n")
+ subprocess.run(
+ ["git", "checkout", "-"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ result = run_script(
+ git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
+ "--number", "8", "No overwrite feature",
+ )
+ assert result.returncode == 0, result.stderr
+ assert spec_file.read_text() == "# My custom spec content\n"
+
+ def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path):
+ """T011: Verify normal creation when branch doesn't exist."""
+ result = run_script(
+ git_repo, "--allow-existing-branch", "--short-name", "new-branch",
+ "New branch feature",
+ )
+ assert result.returncode == 0, result.stderr
+ current = subprocess.run(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=git_repo, capture_output=True, text=True,
+ ).stdout.strip()
+ assert "new-branch" in current
+
+ def test_allow_existing_with_json(self, git_repo: Path):
+ """T012: Verify JSON output is correct."""
+ import json
+
+ subprocess.run(
+ ["git", "checkout", "-b", "009-json-test"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "checkout", "-"],
+ cwd=git_repo, check=True, capture_output=True,
+ )
+ result = run_script(
+ git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
+ "--number", "9", "JSON test",
+ )
+ assert result.returncode == 0, result.stderr
+ data = json.loads(result.stdout)
+ assert data["BRANCH_NAME"] == "009-json-test"
+
+ def test_allow_existing_no_git(self, no_git_dir: Path):
+ """T013: Verify flag is silently ignored in non-git repos."""
+ result = run_script(
+ no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
+ "No git feature",
+ )
+ assert result.returncode == 0, result.stderr
+
+
+class TestAllowExistingBranchPowerShell:
+ def test_powershell_supports_allow_existing_branch_flag(self):
+ """Static guard: PS script exposes and uses -AllowExistingBranch."""
+ contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
+ assert "-AllowExistingBranch" in contents
+ # Ensure the flag is referenced in script logic, not just declared
+ assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
+
+
+# ββ Dry-Run Tests ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+class TestDryRun:
+ def test_dry_run_sequential_outputs_name(self, git_repo: Path):
+ """T009: Dry-run computes correct branch name with existing specs."""
+ (git_repo / "specs" / "001-first-feat").mkdir(parents=True)
+ (git_repo / "specs" / "002-second-feat").mkdir(parents=True)
+ result = run_script(
+ git_repo, "--dry-run", "--short-name", "new-feat", "New feature"
+ )
+ assert result.returncode == 0, result.stderr
+ branch = None
+ for line in result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ branch = line.split(":", 1)[1].strip()
+ assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
+
+ def test_dry_run_no_branch_created(self, git_repo: Path):
+ """T010: Dry-run does not create a git branch."""
+ result = run_script(
+ git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
+ )
+ assert result.returncode == 0, result.stderr
+ branches = subprocess.run(
+ ["git", "branch", "--list", "*no-branch*"],
+ cwd=git_repo,
+ capture_output=True,
+ text=True,
+ )
+ assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
+ assert branches.stdout.strip() == "", "branch should not exist after dry-run"
+
+ def test_dry_run_no_spec_dir_created(self, git_repo: Path):
+ """T011: Dry-run does not create any directories (including root specs/)."""
+ specs_root = git_repo / "specs"
+ if specs_root.exists():
+ shutil.rmtree(specs_root)
+ assert not specs_root.exists(), "specs/ should not exist before dry-run"
+
+ result = run_script(
+ git_repo, "--dry-run", "--short-name", "no-dir", "No dir feature"
+ )
+ assert result.returncode == 0, result.stderr
+ assert not specs_root.exists(), "specs/ should not be created during dry-run"
+
+ def test_dry_run_empty_repo(self, git_repo: Path):
+ """T012: Dry-run returns 001 prefix when no existing specs or branches."""
+ result = run_script(
+ git_repo, "--dry-run", "--short-name", "first", "First feature"
+ )
+ assert result.returncode == 0, result.stderr
+ branch = None
+ for line in result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ branch = line.split(":", 1)[1].strip()
+ assert branch == "001-first", f"expected 001-first, got: {branch}"
+
+ def test_dry_run_with_short_name(self, git_repo: Path):
+ """T013: Dry-run with --short-name produces expected name."""
+ (git_repo / "specs" / "001-existing").mkdir(parents=True)
+ (git_repo / "specs" / "002-existing").mkdir(parents=True)
+ (git_repo / "specs" / "003-existing").mkdir(parents=True)
+ result = run_script(
+ git_repo, "--dry-run", "--short-name", "user-auth", "Add user authentication"
+ )
+ assert result.returncode == 0, result.stderr
+ branch = None
+ for line in result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ branch = line.split(":", 1)[1].strip()
+ assert branch == "004-user-auth", f"expected 004-user-auth, got: {branch}"
+
+ def test_dry_run_then_real_run_match(self, git_repo: Path):
+ """T014: Dry-run name matches subsequent real creation."""
+ (git_repo / "specs" / "001-existing").mkdir(parents=True)
+ # Dry-run first
+ dry_result = run_script(
+ git_repo, "--dry-run", "--short-name", "match-test", "Match test"
+ )
+ assert dry_result.returncode == 0, dry_result.stderr
+ dry_branch = None
+ for line in dry_result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ dry_branch = line.split(":", 1)[1].strip()
+ # Real run
+ real_result = run_script(
+ git_repo, "--short-name", "match-test", "Match test"
+ )
+ assert real_result.returncode == 0, real_result.stderr
+ real_branch = None
+ for line in real_result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ real_branch = line.split(":", 1)[1].strip()
+ assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
+
+ def test_dry_run_accounts_for_remote_branches(self, git_repo: Path):
+ """Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering."""
+ (git_repo / "specs" / "001-existing").mkdir(parents=True)
+
+ # Set up a bare remote and push (use subdirs of git_repo for isolation)
+ remote_dir = git_repo / "test-remote.git"
+ subprocess.run(
+ ["git", "init", "--bare", str(remote_dir)],
+ check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "remote", "add", "origin", str(remote_dir)],
+ check=True, cwd=git_repo, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "push", "-u", "origin", "HEAD"],
+ check=True, cwd=git_repo, capture_output=True,
+ )
+
+ # Clone into a second copy, create a higher-numbered branch, push it
+ second_clone = git_repo / "test-second-clone"
+ subprocess.run(
+ ["git", "clone", str(remote_dir), str(second_clone)],
+ check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "config", "user.email", "test@example.com"],
+ cwd=second_clone, check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "config", "user.name", "Test User"],
+ cwd=second_clone, check=True, capture_output=True,
+ )
+ # Create branch 005 on the remote (higher than local 001)
+ subprocess.run(
+ ["git", "checkout", "-b", "005-remote-only"],
+ cwd=second_clone, check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "push", "origin", "005-remote-only"],
+ cwd=second_clone, check=True, capture_output=True,
+ )
+
+ # Primary repo: dry-run should see 005 via ls-remote and return 006
+ dry_result = run_script(
+ git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
+ )
+ assert dry_result.returncode == 0, dry_result.stderr
+ dry_branch = None
+ for line in dry_result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ dry_branch = line.split(":", 1)[1].strip()
+ assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}"
+
+ def test_dry_run_json_includes_field(self, git_repo: Path):
+ """T015: JSON output includes DRY_RUN field when --dry-run is active."""
+ import json
+
+ result = run_script(
+ git_repo, "--dry-run", "--json", "--short-name", "json-test", "JSON test"
+ )
+ assert result.returncode == 0, result.stderr
+ data = json.loads(result.stdout)
+ assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}"
+ assert data["DRY_RUN"] is True
+
+ def test_dry_run_json_absent_without_flag(self, git_repo: Path):
+ """T016: Normal JSON output does NOT include DRY_RUN field."""
+ import json
+
+ result = run_script(
+ git_repo, "--json", "--short-name", "no-dry", "No dry run"
+ )
+ assert result.returncode == 0, result.stderr
+ data = json.loads(result.stdout)
+ assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
+
+ def test_dry_run_with_timestamp(self, git_repo: Path):
+ """T017: Dry-run works with --timestamp flag."""
+ result = run_script(
+ git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
+ )
+ assert result.returncode == 0, result.stderr
+ branch = None
+ for line in result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ branch = line.split(":", 1)[1].strip()
+ assert branch is not None, "no BRANCH_NAME in output"
+ assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
+ # Verify no side effects
+ branches = subprocess.run(
+ ["git", "branch", "--list", f"*ts-feat*"],
+ cwd=git_repo,
+ capture_output=True,
+ text=True,
+ )
+ assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
+ assert branches.stdout.strip() == ""
+
+ def test_dry_run_with_number(self, git_repo: Path):
+ """T018: Dry-run works with --number flag."""
+ result = run_script(
+ git_repo, "--dry-run", "--number", "42", "--short-name", "num-feat", "Number feature"
+ )
+ assert result.returncode == 0, result.stderr
+ branch = None
+ for line in result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ branch = line.split(":", 1)[1].strip()
+ assert branch == "042-num-feat", f"expected 042-num-feat, got: {branch}"
+
+ def test_dry_run_no_git(self, no_git_dir: Path):
+ """T019: Dry-run works in non-git directory."""
+ (no_git_dir / "specs" / "001-existing").mkdir(parents=True)
+ result = run_script(
+ no_git_dir, "--dry-run", "--short-name", "no-git-dry", "No git dry run"
+ )
+ assert result.returncode == 0, result.stderr
+ branch = None
+ for line in result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ branch = line.split(":", 1)[1].strip()
+ assert branch == "002-no-git-dry", f"expected 002-no-git-dry, got: {branch}"
+ # Verify no spec dir created
+ spec_dirs = [
+ d.name
+ for d in (no_git_dir / "specs").iterdir()
+ if d.is_dir() and "no-git-dry" in d.name
+ ]
+ assert len(spec_dirs) == 0
+
+
+# ββ PowerShell Dry-Run Tests βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+def _has_pwsh() -> bool:
+ """Check if pwsh is available."""
+ try:
+ subprocess.run(["pwsh", "--version"], capture_output=True, check=True)
+ return True
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ return False
+
+
+def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
+ """Run create-new-feature.ps1 from the temp repo's scripts directory."""
+ script = cwd / "scripts" / "powershell" / "create-new-feature.ps1"
+ cmd = ["pwsh", "-NoProfile", "-File", str(script), *args]
+ return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
+
+
+@pytest.fixture
+def ps_git_repo(tmp_path: Path) -> Path:
+ """Create a temp git repo with PowerShell scripts and .specify dir."""
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
+ subprocess.run(
+ ["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
+ )
+ subprocess.run(
+ ["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
+ )
+ subprocess.run(
+ ["git", "commit", "--allow-empty", "-m", "init", "-q"],
+ cwd=tmp_path,
+ check=True,
+ )
+ ps_dir = tmp_path / "scripts" / "powershell"
+ ps_dir.mkdir(parents=True)
+ shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
+ common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
+ shutil.copy(common_ps, ps_dir / "common.ps1")
+ (tmp_path / ".specify" / "templates").mkdir(parents=True)
+ return tmp_path
+
+
+@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
+class TestPowerShellDryRun:
+ def test_ps_dry_run_outputs_name(self, ps_git_repo: Path):
+ """PowerShell -DryRun computes correct branch name."""
+ (ps_git_repo / "specs" / "001-first").mkdir(parents=True)
+ result = run_ps_script(
+ ps_git_repo, "-DryRun", "-ShortName", "ps-feat", "PS feature"
+ )
+ assert result.returncode == 0, result.stderr
+ branch = None
+ for line in result.stdout.splitlines():
+ if line.startswith("BRANCH_NAME:"):
+ branch = line.split(":", 1)[1].strip()
+ assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
+
+ def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path):
+ """PowerShell -DryRun does not create a git branch."""
+ result = run_ps_script(
+ ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
+ )
+ assert result.returncode == 0, result.stderr
+ branches = subprocess.run(
+ ["git", "branch", "--list", "*no-ps-branch*"],
+ cwd=ps_git_repo,
+ capture_output=True,
+ text=True,
+ )
+ assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
+ assert branches.stdout.strip() == "", "branch should not exist after dry-run"
+
+ def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
+ """PowerShell -DryRun does not create specs/ directory."""
+ specs_root = ps_git_repo / "specs"
+ if specs_root.exists():
+ shutil.rmtree(specs_root)
+ assert not specs_root.exists()
+
+ result = run_ps_script(
+ ps_git_repo, "-DryRun", "-ShortName", "no-ps-dir", "No dir"
+ )
+ assert result.returncode == 0, result.stderr
+ assert not specs_root.exists(), "specs/ should not be created during dry-run"
+
+ def test_ps_dry_run_json_includes_field(self, ps_git_repo: Path):
+ """PowerShell -DryRun JSON output includes DRY_RUN field."""
+ import json
+
+ result = run_ps_script(
+ ps_git_repo, "-DryRun", "-Json", "-ShortName", "ps-json", "JSON test"
+ )
+ assert result.returncode == 0, result.stderr
+ data = json.loads(result.stdout)
+ assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}"
+ assert data["DRY_RUN"] is True
+
+ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path):
+ """PowerShell normal JSON output does NOT include DRY_RUN field."""
+ import json
+
+ result = run_ps_script(
+ ps_git_repo, "-Json", "-ShortName", "ps-no-dry", "No dry run"
+ )
+ assert result.returncode == 0, result.stderr
+ data = json.loads(result.stdout)
+ assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"