Compare commits

..

12 Commits

Author SHA1 Message Date
Roland Huß
f8da535d71 feat(scripts): add --allow-existing-branch flag to create-new-feature (#1999)
* feat(scripts): add --allow-existing-branch flag to create-new-feature

Add an --allow-existing-branch / -AllowExistingBranch flag to both
bash and PowerShell create-new-feature scripts. When the target branch
already exists, the script switches to it instead of failing. The spec
directory and template are still created if missing, but existing
spec.md files are not overwritten (prevents data loss on re-runs).

The flag is opt-in, so existing behavior is completely unchanged
without it. This enables worktree-based workflows and CI/CD pipelines
that create branches externally before running speckit.specify.

Relates to #1931. Also addresses #1680, #841, #1921.

Assisted-By: 🤖 Claude Code

* fix: address PR review feedback for allow-existing-branch

- Make checkout failure fatal instead of suppressing with || true (bash)
- Check $LASTEXITCODE after git checkout in PowerShell
- Use Test-Path -PathType Leaf for spec file existence check (PS)
- Add PowerShell static assertion test for -AllowExistingBranch flag

Assisted-By: 🤖 Claude Code
2026-03-27 14:04:14 -05:00
Alexander Rampp
edaa5a7ff1 fix(scripts): add correct path for copilot-instructions.md (#1997) 2026-03-27 11:43:57 -05:00
PChemGuy
5be705e414 Update README.md (#1995)
Thank you!
2026-03-27 11:14:11 -05:00
Andrii Furmanets
796b4f47c4 fix: prevent extension command shadowing (#1994)
* fix: prevent extension command shadowing

* Validate extension command namespaces

* Reuse extension command name pattern
2026-03-27 10:55:26 -05:00
Kash
6b1f45c50c Fix Claude Code CLI detection for npm-local installs (#1978)
* Fix Claude Code CLI detection for npm-local installs

`specify check` reports "Claude Code CLI (not found)" for users who
installed Claude Code via npm-local (the default installer path, common
with nvm). The binary lives at ~/.claude/local/node_modules/.bin/claude
which was not checked. Add CLAUDE_NPM_LOCAL_PATH as a second well-known
location alongside the existing migrate-installer path.

Fixes https://github.com/github/spec-kit/issues/550

* Address Copilot review feedback

- Remove unused pytest import from test_check_tool.py
- Use tmp_path instead of hardcoded /nonexistent/claude for hermetic tests
- Simplify redundant exists() + is_file() to just is_file()

AI-assisted: Changes applied with Claude Code.

* Update tests/test_check_tool.py

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

* Update tests/test_check_tool.py

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-27 10:50:43 -05:00
AK
8778c26dcf fix(scripts): honor PowerShell agent and script filters (#1969)
Rename the Normalize-List parameter in create-release-packages.ps1 to avoid conflicting with PowerShell's automatic $input variable. This fixes Windows offline scaffolding when -Agents and -Scripts are passed.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-27 10:48:11 -05:00
Daniel Badde
41d1f4b0ac feat: add MAQA extension suite (7 extensions) to community catalog (#1981)
* feat: add MAQA extension suite to community catalog and README

Adds 7 extensions forming the MAQA (Multi-Agent & Quality Assurance)
suite to catalog.community.json in correct alphabetical order (after
'learn', before 'onboard') and to the README community extensions table:

- maqa           — coordinator/feature/QA workflow, board auto-detection
- maqa-azure-devops — Azure DevOps Boards integration
- maqa-ci           — CI/CD gate (GitHub Actions/CircleCI/GitLab/Bitbucket)
- maqa-github-projects — GitHub Projects v2 integration
- maqa-jira         — Jira integration
- maqa-linear       — Linear integration
- maqa-trello       — Trello integration

All entries placed alphabetically. maqa v0.1.3 bumped to reflect
multi-board auto-detection added in this release.

* fix: set catalog updated_at to match latest entry timestamp

Top-level updated_at was 00:00:00Z while plan-review-gate entries
had 08:22:30Z, making metadata inconsistent for freshness consumers.
Updated to 2026-03-27T08:22:30Z (>= all entry timestamps).
2026-03-27 10:45:19 -05:00
Rafael Sales
9c2481fd67 feat: add spec-kit-onboard extension to community catalog (#1991)
Adds the onboard extension (v2.1.0) — contextual onboarding and
progressive growth for developers new to spec-kit projects.

- 7 commands: start, explain, trail, quiz, badge, mentor, team
- 3 hooks: after-implement, before-implement, after-explain
- Repository: https://github.com/dmux/spec-kit-onboard
2026-03-27 09:58:43 -05:00
Ed Harrod
8520241dfe Add plan-review-gate to community catalog (#1993)
- Extension ID: plan-review-gate
- Version: 1.0.0
- Author: luno
- Catalog entries sorted alphabetically by ID
- README table row inserted alphabetically by name

Co-authored-by: Ed Harrod <your-real-email@luno.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 08:23:01 -05:00
dependabot[bot]
362868a342 chore(deps): bump actions/deploy-pages from 4 to 5 (#1990)
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:03:34 -05:00
dependabot[bot]
d7206126e0 chore(deps): bump DavidAnson/markdownlint-cli2-action from 19 to 23 (#1989)
Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 19 to 23.
- [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases)
- [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/v19...v23)

---
updated-dependencies:
- dependency-name: DavidAnson/markdownlint-cli2-action
  dependency-version: '23'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:02:40 -05:00
Manfred Riem
b22f381c0d chore: bump version to 0.4.3 (#1986)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-26 16:04:09 -05:00
19 changed files with 1081 additions and 161 deletions

View File

@@ -64,5 +64,5 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5

View File

@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v6
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@v19
uses: DavidAnson/markdownlint-cli2-action@v23
with:
globs: |
'**/*.md'

View File

@@ -497,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
}
@@ -526,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
}
@@ -536,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
}

View File

@@ -162,9 +162,18 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
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 |
|-----------|---------|----------|--------|-----|
@@ -181,6 +190,15 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| 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) |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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`.
---

View File

@@ -1,12 +1,12 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-19T12:08:20Z",
"updated_at": "2026-03-27T08:22:30Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
"name": "AI-Driven Engineering (AIDE)",
"id": "aide",
"description": "A structured 7-step workflow for building new projects from scratch with AI assistants from vision through implementation.",
"description": "A structured 7-step workflow for building new projects from scratch with AI assistants \u2014 from vision through implementation.",
"author": "mnriem",
"version": "1.0.0",
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/aide-v1.0.0/aide.zip",
@@ -170,7 +170,7 @@
"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",
"description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application \u2014 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",
@@ -379,7 +379,7 @@
"iterate": {
"name": "Iterate",
"id": "iterate",
"description": "Iterate on spec documents with a two-phase define-and-apply workflow refine specs mid-implementation and go straight back to building",
"description": "Iterate on spec documents with a two-phase define-and-apply workflow \u2014 refine specs mid-implementation and go straight back to building",
"author": "Vianca Martinez",
"version": "2.0.0",
"download_url": "https://github.com/imviancagrace/spec-kit-iterate/archive/refs/tags/v2.0.0.zip",
@@ -437,6 +437,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 \u2014 Multi-Agent & Quality Assurance",
"id": "maqa",
"description": "Coordinator \u2192 feature \u2192 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",
@@ -638,6 +959,38 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
"status": {
"name": "Project Status",
"id": "status",
"description": "Show current SDD workflow progress \u2014 active feature, artifact status, task completion, workflow phase, and extensions summary.",
"author": "KhawarHabibKhan",
"version": "1.0.0",
"download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/KhawarHabibKhan/spec-kit-status",
"homepage": "https://github.com/KhawarHabibKhan/spec-kit-status",
"documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md",
"changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"status",
"workflow",
"progress",
"feature-tracking",
"task-progress"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
"sync": {
"name": "Spec Sync",
"id": "sync",
@@ -711,38 +1064,6 @@
"created_at": "2026-03-07T00:00:00Z",
"updated_at": "2026-03-07T00:00:00Z"
},
"status": {
"name": "Project Status",
"id": "status",
"description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.",
"author": "KhawarHabibKhan",
"version": "1.0.0",
"download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/KhawarHabibKhan/spec-kit-status",
"homepage": "https://github.com/KhawarHabibKhan/spec-kit-status",
"documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md",
"changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"status",
"workflow",
"progress",
"feature-tracking",
"task-progress"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
"v-model": {
"name": "V-Model Extension Pack",
"id": "v-model",
@@ -775,37 +1096,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",

View File

@@ -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"

View File

@@ -3,6 +3,7 @@
set -e
JSON_MODE=false
ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
@@ -14,6 +15,9 @@ while [ $i -le $# ]; do
--json)
JSON_MODE=true
;;
--allow-existing-branch)
ALLOW_EXISTING=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
@@ -45,10 +49,11 @@ while [ $i -le $# ]; do
USE_TIMESTAMP=true
;;
--help|-h)
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
@@ -69,7 +74,7 @@ done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
@@ -287,12 +292,19 @@ 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
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
exit 1
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
exit 1
@@ -305,13 +317,15 @@ fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
mkdir -p "$FEATURE_DIR"
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
else
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
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

View File

@@ -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"

View File

@@ -3,6 +3,7 @@
[CmdletBinding()]
param(
[switch]$Json,
[switch]$AllowExistingBranch,
[string]$ShortName,
[Parameter()]
[long]$Number = 0,
@@ -15,10 +16,11 @@ $ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
@@ -33,7 +35,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 <name>] [-Number N] [-Timestamp] <feature description>"
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
@@ -251,12 +253,20 @@ if ($hasGit) {
# Check if branch already exists
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($Timestamp) {
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
}
exit 1
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
exit 1
@@ -269,12 +279,14 @@ if ($hasGit) {
$featureDir = Join-Path $specsDir $branchName
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
$specFile = Join-Path $featureDir 'spec.md'
if ($template -and (Test-Path $template)) {
Copy-Item $template $specFile -Force
} else {
New-Item -ItemType File -Path $specFile | Out-Null
if (-not (Test-Path -PathType Leaf $specFile)) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
if ($template -and (Test-Path $template)) {
Copy-Item $template $specFile -Force
} else {
New-Item -ItemType File -Path $specFile | Out-Null
}
}
# Set the SPECIFY_FEATURE environment variable for the current session

View File

@@ -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'

View File

@@ -345,6 +345,7 @@ AI_ASSISTANT_HELP = _build_ai_assistant_help()
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

View File

@@ -25,6 +25,49 @@ import yaml
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 @@ class ExtensionManifest:
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 @@ class ExtensionManager:
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.
@@ -861,6 +1024,9 @@ class ExtensionManager:
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():

96
tests/test_check_tool.py Normal file
View File

@@ -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

View File

@@ -18,6 +18,7 @@ from datetime import datetime, timezone
from specify_cli.extensions import (
CatalogEntry,
CORE_COMMAND_NAMES,
ExtensionManifest,
ExtensionRegistry,
ExtensionManager,
@@ -63,7 +64,7 @@ def valid_manifest_data():
"provides": {
"commands": [
{
"name": "speckit.test.hello",
"name": "speckit.test-ext.hello",
"file": "commands/hello.md",
"description": "Test command",
}
@@ -71,7 +72,7 @@ def valid_manifest_data():
},
"hooks": {
"after_tasks": {
"command": "speckit.test.hello",
"command": "speckit.test-ext.hello",
"optional": True,
"prompt": "Run test?",
}
@@ -189,7 +190,18 @@ class TestExtensionManifest:
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."""
@@ -589,6 +601,172 @@ class TestExtensionManager:
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)
@@ -852,10 +1030,10 @@ $ARGUMENTS
)
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()
@@ -885,9 +1063,9 @@ $ARGUMENTS
"provides": {
"commands": [
{
"name": "speckit.alias.cmd",
"name": "speckit.ext-alias.cmd",
"file": "commands/cmd.md",
"aliases": ["speckit.shortcut"],
"aliases": ["speckit.ext-alias.shortcut"],
}
]
},
@@ -907,10 +1085,10 @@ $ARGUMENTS
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."""
@@ -951,11 +1129,11 @@ $ARGUMENTS
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
@@ -982,7 +1160,7 @@ $ARGUMENTS
"provides": {
"commands": [
{
"name": "speckit.test.plan",
"name": "speckit.ext-scripted.plan",
"file": "commands/plan.md",
"description": "Scripted command",
}
@@ -1020,7 +1198,7 @@ Agent __AGENT__
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()
@@ -1051,9 +1229,9 @@ Agent __AGENT__
"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"],
}
]
},
@@ -1070,13 +1248,13 @@ Agent __AGENT__
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
@@ -1100,7 +1278,7 @@ Agent __AGENT__
"provides": {
"commands": [
{
"name": "speckit.fallback.plan",
"name": "speckit.ext-script-fallback.plan",
"file": "commands/plan.md",
}
]
@@ -1132,7 +1310,7 @@ Then {AGENT_SCRIPT}
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()
@@ -1163,7 +1341,7 @@ Then {AGENT_SCRIPT}
"provides": {
"commands": [
{
"name": "speckit.list.plan",
"name": "speckit.ext-script-list-init.plan",
"file": "commands/plan.md",
}
]
@@ -1194,7 +1372,7 @@ Run {SCRIPT}
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
content = (skills_dir / "speckit-list-plan" / "SKILL.md").read_text()
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(
@@ -1221,7 +1399,7 @@ Run {SCRIPT}
"provides": {
"commands": [
{
"name": "speckit.windows.plan",
"name": "speckit.ext-script-windows-fallback.plan",
"file": "commands/plan.md",
}
]
@@ -1253,7 +1431,7 @@ Then {AGENT_SCRIPT}
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()
@@ -1275,14 +1453,14 @@ Then {AGENT_SCRIPT}
)
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()
@@ -1302,12 +1480,12 @@ Then {AGENT_SCRIPT}
)
# 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."""
@@ -1328,9 +1506,9 @@ Then {AGENT_SCRIPT}
"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"],
}
]
},
@@ -1357,8 +1535,8 @@ Then {AGENT_SCRIPT}
# 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."""
@@ -1431,7 +1609,7 @@ class TestIntegration:
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)
@@ -1439,7 +1617,7 @@ class TestIntegration:
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()
)
@@ -1465,8 +1643,8 @@ class TestIntegration:
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()
@@ -2776,7 +2954,7 @@ class TestExtensionUpdateCLI:
"provides": {
"commands": [
{
"name": "speckit.test.hello",
"name": "speckit.test-ext.hello",
"file": "commands/hello.md",
"description": "Test command",
}
@@ -2784,7 +2962,7 @@ class TestExtensionUpdateCLI:
},
"hooks": {
"after_tasks": {
"command": "speckit.test.hello",
"command": "speckit.test-ext.hello",
"optional": True,
}
},
@@ -2813,7 +2991,7 @@ class TestExtensionUpdateCLI:
"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:
@@ -3442,15 +3620,15 @@ class TestHookInvocationRendering:
[
{
"extension": "test-ext",
"command": "speckit.test.hello",
"command": "speckit.test-ext.hello",
"optional": False,
}
],
)
assert "Executing: `/skill:speckit-test-hello`" in message
assert "EXECUTE_COMMAND: speckit.test.hello" in message
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-hello" in message
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."""

View File

@@ -269,3 +269,146 @@ class TestE2EFlow:
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", "")