Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
5b70de43dc chore: bump version to 0.7.5 2026-04-22 21:22:39 +00:00
53 changed files with 1067 additions and 4405 deletions

View File

@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- name: Set up Python
uses: actions/setup-python@v6
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6

View File

@@ -264,13 +264,13 @@ The base classes handle most work automatically. Override only when the agent de
| Override | When to use | Example |
|---|---|---|
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` |
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
**Example — Copilot (fully custom `setup`):**
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
### 7. Update Devcontainer files (Optional)
@@ -391,24 +391,6 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that:
2. Generates companion `.prompt.md` files
3. Merges VS Code settings
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
via `--integration-options="--skills"`. When enabled:
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
- No companion `.prompt.md` files are generated
- No `.vscode/settings.json` merge
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
The two modes are mutually exclusive — a project uses one or the other:
```bash
# Default mode: .agent.md agents + .prompt.md companions + settings merge
specify init my-project --integration copilot
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
specify init my-project --integration copilot --integration-options="--skills"
```
### Forge Integration
Forge has special frontmatter and argument requirements:

View File

@@ -2,34 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.8.1] - 2026-04-24
### Changed
- fix(plan): use .specify/feature.json to allow /speckit.plan on custom git branches (#2305) (#2349)
- feat(vibe): migrate to SkillsIntegration from the old prompts-based MarkdownIntegration (#2336)
- docs: move community presets table to docs site, add missing entries (#2341)
- docs(presets): add lean preset README and enrich catalog metadata (#2340)
- fix: resolve command references per integration type (dot vs hyphen) (#2354)
- Update product-forge to v1.5.1 in community catalog (#2352)
- chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#2345)
- fix: replace xargs trim with sed to handle quotes in descriptions (#2351)
- feat: register jira preset in community catalog (#2224)
- feat: Preset screenwriting (#2332)
- chore: release 0.8.0, begin 0.8.1.dev0 development (#2333)
## [0.8.0] - 2026-04-23
### Changed
- feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133)
- feat(copilot): support `--integration-options="--skills"` for skills-based scaffolding (#2324)
- docs(install): add pipx as alternative installation method (#2288)
- Add Memory MD community extension (#2327)
- Update version-guard to v1.2.0 (#2321)
- fix: `--force` now overwrites shared infra files during init and upgrade (#2320)
- chore: release 0.7.5, begin 0.7.6.dev0 development (#2322)
## [0.7.5] - 2026-04-22
### Changed

View File

@@ -62,10 +62,6 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX
# Or install latest from main (may include unreleased changes)
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
# Alternative: using pipx (also works)
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
pipx install git+https://github.com/github/spec-kit.git
```
Then verify the correct version is installed:
@@ -93,7 +89,6 @@ To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed inst
```bash
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
```
#### Option 2: One-time Usage
@@ -229,14 +224,13 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| 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) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| 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) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| 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 from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| 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) |
@@ -278,12 +272,7 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX
## 🎨 Community Presets
Community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page.
> [!NOTE]
> Community presets are third-party contributions and are not maintained by the Spec Kit team. Review them carefully before use, and see the docs page above for the full disclaimer.
To submit your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
Community-contributed presets that customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page.
## 🚶 Community Walkthroughs
@@ -436,7 +425,7 @@ Our research and experimentation focus on:
- **Linux/macOS/Windows**
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
- [uv](https://docs.astral.sh/uv/) for package management
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)

View File

@@ -11,11 +11,9 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 1 script | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |

View File

@@ -4,7 +4,7 @@
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
- [uv](https://docs.astral.sh/uv/) for package management
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)
@@ -24,13 +24,6 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJE
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
```
> [!NOTE]
> For a persistent installation, `pipx` works equally well:
> ```bash
> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
> ```
> The project uses a standard `hatchling` build backend and has no uv-specific dependencies.
Or initialize in the current directory:
```bash

View File

@@ -22,17 +22,6 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
uvx --from git+https://github.com/github/spec-kit.git specify init .
```
> [!NOTE]
> You can also install the CLI persistently with `pipx`:
> ```bash
> pipx install git+https://github.com/github/spec-kit.git
> ```
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
> ```bash
> specify init <PROJECT_NAME>
> specify init .
> ```
Pick script type explicitly (optional):
```bash

View File

@@ -9,7 +9,6 @@
| What to Upgrade | Command | When to Use |
|----------------|---------|-------------|
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
@@ -35,14 +34,6 @@ Specify the desired release tag:
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
```
### If you installed with `pipx`
Upgrade to a specific release:
```bash
pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
```
### Verify the upgrade
```bash
@@ -62,8 +53,8 @@ When Spec Kit releases new features (like new slash commands or updated template
Running `specify init --here --force` will update:
-**Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
-**Script files** (`.specify/scripts/`)**only with `--force`**; without it, only missing files are added
-**Template files** (`.specify/templates/`)**only with `--force`**; without it, only missing files are added
-**Script files** (`.specify/scripts/`)
-**Template files** (`.specify/templates/`)
-**Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
### What stays safe?
@@ -103,9 +94,7 @@ Template files will be merged with existing content and may overwrite existing f
Proceed? [y/N]
```
With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release.
Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated.
With `--force`, it skips the confirmation and proceeds immediately.
**Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten.
@@ -137,14 +126,13 @@ Or use git to restore it:
git restore .specify/memory/constitution.md
```
### 2. Custom script or template modifications
### 2. Custom template modifications
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first:
```bash
# Back up custom templates and scripts
# Back up custom templates
cp -r .specify/templates .specify/templates-backup
cp -r .specify/scripts .specify/scripts-backup
# After upgrade, merge your changes back manually
```

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-24T00:00:00Z",
"updated_at": "2026-04-22T17:54:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1198,38 +1198,6 @@
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-20T00:00:00Z"
},
"memory-md": {
"name": "Memory MD",
"id": "memory-md",
"description": "Repository-native durable memory for Spec Kit projects",
"author": "DyanGalih",
"version": "0.6.2",
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip",
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
"changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"commands": 5,
"hooks": 0
},
"tags": [
"memory",
"workflow",
"docs",
"copilot",
"markdown"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-23T00:00:00Z",
"updated_at": "2026-04-23T00:00:00Z"
},
"memorylint": {
"name": "MemoryLint",
"id": "memorylint",
@@ -1424,10 +1392,10 @@
"product-forge": {
"name": "Product Forge",
"id": "product-forge",
"description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model",
"description": "Full product lifecycle: research → product spec → SpecKit → implement → verify → test",
"author": "VaiYav",
"version": "1.5.1",
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip",
"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",
@@ -1437,21 +1405,21 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 29,
"commands": 10,
"hooks": 0
},
"tags": [
"process",
"research",
"product-spec",
"lifecycle",
"monorepo",
"v-model",
"portfolio"
"testing"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-04-24T15:52:00Z"
"updated_at": "2026-03-28T00:00:00Z"
},
"qa": {
"name": "QA Testing Extension",
@@ -2383,8 +2351,8 @@
"id": "version-guard",
"description": "Verify tech stack versions against live registries before planning and implementation",
"author": "KevinBrown5280",
"version": "1.2.0",
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.2.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/KevinBrown5280/spec-kit-version-guard",
"homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard",
"documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md",
@@ -2407,7 +2375,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-22T21:10:00Z"
"updated_at": "2026-04-22T17:54:00Z"
},
"whatif": {
"name": "What-if Analysis",
@@ -2537,4 +2505,4 @@
"updated_at": "2026-04-13T00:00:00Z"
}
}
}
}

View File

@@ -95,7 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
fi
# Trim whitespace and validate description is not empty
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1

View File

@@ -41,24 +41,6 @@ The resolution is implemented three times to ensure consistency:
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`
### Composition Strategies
Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it:
| Strategy | Description | Templates | Commands | Scripts |
|----------|-------------|-----------|----------|---------|
| `replace` (default) | Fully replaces lower-priority content | ✓ | ✓ | ✓ |
| `prepend` | Places content before lower-priority content (separated by a blank line) | ✓ | ✓ | — |
| `append` | Places content after lower-priority content (separated by a blank line) | ✓ | ✓ | — |
| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | ✓ | ✓ | ✓ |
Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy.
Content resolution functions for composition:
- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py` (templates, commands, and scripts)
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)
## Command Registration
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.

View File

@@ -205,21 +205,11 @@ Edit `presets/catalog.community.json` and add your preset.
}
```
### 3. Update Community Presets Table
Add your preset to the Community Presets table on the docs site at `docs/community/presets.md`:
```markdown
| Your Preset Name | Brief description of what your preset does | N templates, M commands[, P scripts] | — | [repo-name](https://github.com/your-org/spec-kit-preset-your-preset) |
```
Insert your row in alphabetical order by preset **name** (the first column of the table).
### 4. Submit Pull Request
### 3. Submit Pull Request
```bash
git checkout -b add-your-preset
git add presets/catalog.community.json docs/community/presets.md
git add presets/catalog.community.json
git commit -m "Add your-preset to community catalog
- Preset ID: your-preset
@@ -250,7 +240,6 @@ git push origin add-your-preset
- [ ] Commands register to agent directories (if applicable)
- [ ] Commands match template sections (command + template are coherent)
- [ ] Added to presets/catalog.community.json
- [ ] Added row to docs/community/presets.md table
```
---

View File

@@ -61,37 +61,7 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa
specify preset add pm-workflow --priority 1 # overrides everything
```
Presets **override by default**, they don't merge. If two presets both provide `spec-template` with the default `replace` strategy, the one with the lowest priority number wins entirely. However, presets can use **composition strategies** to augment rather than replace content.
### Composition Strategies
Presets can declare a `strategy` per template to control how content is combined. The `name` field identifies which template to compose with in the priority stack, while `file` points to the actual content file (which can differ from the convention path `templates/<name>.md`):
```yaml
provides:
templates:
- type: "template"
name: "spec-template"
file: "templates/spec-addendum.md"
strategy: "append" # adds content after the core template
```
| Strategy | Description |
|----------|-------------|
| `replace` (default) | Fully replaces the lower-priority template |
| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line |
| `append` | Places content **after** the resolved lower-priority template, separated by a blank line |
| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content |
**Supported combinations:**
| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |
Multiple composing presets chain recursively. For example, a security preset with `prepend` and a compliance preset with `append` will produce: security header + core content + compliance footer.
Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
## Catalog Management
@@ -138,5 +108,13 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset
The following enhancements are under consideration for future releases:
- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security").
- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts.
- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:
| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |
For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented).
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-15T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"aide-in-place": {
@@ -141,34 +141,7 @@
],
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-04-19T08:00:00Z"
},
"jira": {
"name": "Jira Issue Tracking",
"id": "jira",
"version": "1.0.0",
"description": "Overrides speckit.taskstoissues to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools.",
"author": "luno",
"repository": "https://github.com/luno/spec-kit-preset-jira",
"download_url": "https://github.com/luno/spec-kit-preset-jira/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/luno/spec-kit-preset-jira",
"documentation": "https://github.com/luno/spec-kit-preset-jira/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"templates": 0,
"commands": 1
},
"tags": [
"jira",
"atlassian",
"issue-tracking",
"preset"
],
"created_at": "2026-04-15T00:00:00Z",
"updated_at": "2026-04-15T00:00:00Z"
},
},
"multi-repo-branching": {
"name": "Multi-Repo Branching",
"id": "multi-repo-branching",
@@ -221,44 +194,6 @@
"experimental"
]
},
"screenwriting": {
"name": "Screenwriting",
"id": "screenwriting",
"version": "1.0.0",
"description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-screenwriting",
"download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-screenwriting",
"documentation": "https://github.com/adaumann/speckit-preset-screenwriting/blob/main/screenwriting/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.5.0"
},
"provides": {
"templates": 26,
"commands": 32,
"scripts": 1
},
"tags": [
"writing",
"screenplay",
"scriptwriting",
"film",
"tv",
"fountain",
"fountain-format",
"beat-sheet",
"teleplay",
"drama",
"comedy",
"storytelling",
"tutorial",
"education"
],
"created_at": "2026-04-23T08:00:00Z",
"updated_at": "2026-04-23T08:00:00Z"
},
"toc-navigation": {
"name": "Table of Contents Navigation",
"id": "toc-navigation",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-24T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"presets": {
"lean": {
@@ -10,15 +10,7 @@
"description": "Minimal core workflow commands - just the prompt, just the artifact",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"license": "MIT",
"bundled": true,
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"commands": 5,
"templates": 0
},
"tags": [
"lean",
"minimal",

View File

@@ -1,45 +0,0 @@
# Lean Workflow
A minimal preset that strips the Spec Kit workflow down to its essentials — just the prompt, just the artifact.
## When to Use
Use Lean when you want the structured specify → plan → tasks → implement pipeline without the ceremony of the full templates. Each command produces a single focused Markdown file with no boilerplate sections to fill in.
## Commands Included
| Command | Output | Description |
|---------|--------|-------------|
| `speckit.specify` | `spec.md` | Create a specification from a feature description |
| `speckit.plan` | `plan.md` | Create an implementation plan from the spec |
| `speckit.tasks` | `tasks.md` | Create dependency-ordered tasks from spec and plan |
| `speckit.implement` | *(code)* | Execute all tasks in order, marking progress |
| `speckit.constitution` | `constitution.md` | Create or update the project constitution |
## What It Replaces
Lean overrides the five core workflow commands with self-contained prompts that produce each artifact directly — no separate template files involved. The result is a shorter, more direct workflow.
## Installation
```bash
# Lean is a bundled preset — no download needed
specify preset add lean
```
## Development
```bash
# Test from local directory
specify preset add --dev ./presets/lean
# Verify commands resolve
specify preset resolve speckit.specify
# Remove when done
specify preset remove lean
```
## License
MIT

View File

@@ -48,4 +48,3 @@ tags:
- "lean"
- "minimal"
- "workflow"
- "core"

View File

@@ -32,15 +32,6 @@ provides:
templates:
# CUSTOMIZE: Define your template overrides
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
#
# Strategy options (optional, defaults to "replace"):
# replace - Fully replaces the lower-priority template (default)
# prepend - Places this content BEFORE the lower-priority template
# append - Places this content AFTER the lower-priority template
# wrap - Uses {CORE_TEMPLATE} placeholder (templates/commands) or
# $CORE_SCRIPT placeholder (scripts), replaced with lower-priority content
#
# Note: Scripts only support "replace" and "wrap" strategies.
- type: "template"
name: "spec-template"
file: "templates/spec-template.md"
@@ -54,26 +45,6 @@ provides:
# description: "Custom plan template"
# replaces: "plan-template"
# COMPOSITION EXAMPLES:
# The `file` field points to the content file (can differ from the
# convention path `templates/<name>.md`). The `name` field identifies
# which template to compose with in the priority stack.
#
# Append additional sections to an existing template:
# - type: "template"
# name: "spec-template"
# file: "templates/spec-addendum.md"
# description: "Add compliance section to spec template"
# strategy: "append"
#
# Wrap a command with preamble/sign-off:
# - type: "command"
# name: "speckit.specify"
# file: "commands/specify-wrapper.md"
# description: "Wrap specify command with compliance checks"
# strategy: "wrap"
# # In the wrapper file, use {CORE_TEMPLATE} where the original content goes
# OVERRIDE EXTENSION TEMPLATES:
# Presets sit above extensions in the resolution stack, so you can
# override templates provided by any installed extension.

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.8.1"
version = "0.7.5"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [

View File

@@ -153,59 +153,6 @@ check_feature_branch() {
return 0
}
# Safely read .specify/feature.json's "feature_directory" value.
# Prints the raw value (possibly relative) to stdout, or empty string if the file
# is missing, unparseable, or does not contain the key. Always returns 0 so callers
# under `set -e` cannot be aborted by parser failure.
# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
read_feature_json_feature_directory() {
local repo_root="$1"
local fj="$repo_root/.specify/feature.json"
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
local _fd=''
if command -v jq >/dev/null 2>&1; then
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
_fd=''
fi
elif command -v python3 >/dev/null 2>&1; then
# Use Python so pretty-printed/multi-line JSON still parses correctly.
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
_fd=''
fi
else
# Last-resort single-line grep/sed fallback. The `|| true` guards against
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
| head -n 1 \
| sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
fi
printf '%s' "$_fd"
return 0
}
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks).
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
feature_json_matches_feature_dir() {
local repo_root="$1"
local active_feature_dir="$2"
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
[[ -n "$_fd" ]] || return 1
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
[[ -d "$_fd" ]] || return 1
local norm_json norm_active
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
[[ "$norm_json" == "$norm_active" ]]
}
# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
@@ -270,10 +217,16 @@ get_feature_paths() {
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
if command -v jq >/dev/null 2>&1; then
_fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null)
elif command -v python3 >/dev/null 2>&1; then
# Fallback: use Python to parse JSON so pretty-printed/multi-line files work
_fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('feature_directory',''))" "$repo_root/.specify/feature.json" 2>/dev/null)
else
# Last resort: single-line grep fallback (won't work on multi-line JSON)
_fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
fi
if [[ -n "$_fd" ]]; then
feature_dir="$_fd"
# Normalize relative paths to absolute under repo root
@@ -367,9 +320,8 @@ try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
print(pid)
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
@@ -421,225 +373,3 @@ except Exception:
return 1
}
# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
#
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
# Returns composed content string on stdout; exit code 1 if not found.
resolve_template_content() {
local template_name="$1"
local repo_root="$2"
local base="$repo_root/.specify/templates"
# Collect all layers (highest priority first)
local -a layer_paths=()
local -a layer_strategies=()
# Priority 1: Project overrides (always "replace")
local override="$base/overrides/${template_name}.md"
if [ -f "$override" ]; then
layer_paths+=("$override")
layer_strategies+=("replace")
fi
# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
local sorted_presets=""
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
local yaml_warned=false
while IFS= read -r preset_id; do
# Read strategy and file path from preset manifest
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
# Requires PyYAML; falls back to replace/convention if unavailable
local result
local py_stderr
py_stderr=$(mktemp)
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
import sys, os
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
try:
with open(os.environ['SPECKIT_MANIFEST']) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
" 2>"$py_stderr")
local parse_status=$?
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
IFS=$'\t' read -r strategy manifest_file <<< "$result"
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
fi
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
yaml_warned=true
fi
rm -f "$py_stderr"
fi
# Try manifest file path first, then convention path
local candidate=""
if [ -n "$manifest_file" ]; then
# Reject absolute paths and parent traversal
case "$manifest_file" in
/*|*../*|../*) manifest_file="" ;;
esac
fi
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
fi
done <<< "$sorted_presets"
fi
else
# python3 failed — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
else
# No python3 or registry — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
fi
# Priority 3: Extension-provided templates (always "replace")
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
case "$(basename "$ext")" in .*) continue;; esac
local candidate="$ext/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
# Priority 4: Core templates (always "replace")
local core="$base/${template_name}.md"
if [ -f "$core" ]; then
layer_paths+=("$core")
layer_strategies+=("replace")
fi
local count=${#layer_paths[@]}
[ "$count" -eq 0 ] && return 1
# Check if any layer uses a non-replace strategy
local has_composition=false
for s in "${layer_strategies[@]}"; do
[ "$s" != "replace" ] && has_composition=true && break
done
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if [ "${layer_strategies[0]}" = "replace" ]; then
cat "${layer_paths[0]}"
return 0
fi
if [ "$has_composition" = false ]; then
cat "${layer_paths[0]}"
return 0
fi
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
local base_idx=-1
local i
for (( i=0; i<count; i++ )); do
if [ "${layer_strategies[$i]}" = "replace" ]; then
base_idx=$i
break
fi
done
if [ $base_idx -lt 0 ]; then
return 1 # no base layer found
fi
# Read the base content; compose layers above the base (higher priority)
local content
content=$(cat "${layer_paths[$base_idx]}"; printf x)
content="${content%x}"
for (( i=base_idx-1; i>=0; i-- )); do
local path="${layer_paths[$i]}"
local strat="${layer_strategies[$i]}"
local layer_content
# Preserve trailing newlines
layer_content=$(cat "$path"; printf x)
layer_content="${layer_content%x}"
case "$strat" in
replace) content="$layer_content" ;;
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
wrap)
case "$layer_content" in
*'{CORE_TEMPLATE}'*) ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
esac
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
local after="${layer_content#*\{CORE_TEMPLATE\}}"
layer_content="${before}${content}${after}"
done
content="$layer_content"
;;
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
esac
done
printf '%s' "$content"
return 0
}

View File

@@ -84,7 +84,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
fi
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1

View File

@@ -32,10 +32,8 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
eval "$_paths_output"
unset _paths_output
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
# Check if we're on a proper feature branch (only for git repos)
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# Ensure the feature directory exists
mkdir -p "$FEATURE_DIR"

View File

@@ -164,74 +164,6 @@ function Test-FeatureBranch {
return $true
}
# True when .specify/feature.json pins an existing feature directory that matches the
# active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.plan can skip git branch pattern checks).
function Test-FeatureJsonMatchesFeatureDir {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
)
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
return $false
}
try {
$raw = Get-Content -LiteralPath $featureJson -Raw
$cfg = $raw | ConvertFrom-Json
} catch {
return $false
}
$fd = $cfg.feature_directory
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
return $false
}
if (-not [System.IO.Path]::IsPathRooted($fd)) {
$fd = Join-Path $RepoRoot $fd
}
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
return $false
}
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
if ($resolvedJson) {
$normJson = $resolvedJson.Path
} else {
$normJson = [System.IO.Path]::GetFullPath($fd)
}
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
if ($resolvedActive) {
$normActive = $resolvedActive.Path
} else {
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
}
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
# absence as "we're on Windows".
if ($null -ne $IsWindows) {
$onWindows = $IsWindows
} else {
$onWindows = $true
}
if ($onWindows) {
$comparison = [System.StringComparison]::OrdinalIgnoreCase
} else {
$comparison = [System.StringComparison]::Ordinal
}
return [string]::Equals($normJson, $normActive, $comparison)
}
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
function Find-FeatureDirByPrefix {
param(
@@ -355,21 +287,6 @@ function Test-DirHasFiles {
}
}
# Find a usable Python 3 executable (python3, python, or py -3).
# Returns the command/arguments as an array, or $null if none found.
function Get-Python3Command {
if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
if (Get-Command python -ErrorAction SilentlyContinue) {
$ver = & python --version 2>&1
if ($ver -match 'Python 3') { return @('python') }
}
if (Get-Command py -ErrorAction SilentlyContinue) {
$ver = & py -3 --version 2>&1
if ($ver -match 'Python 3') { return @('py', '-3') }
}
return $null
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
@@ -398,7 +315,6 @@ function Resolve-Template {
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
ForEach-Object { $_.Name }
}
@@ -438,206 +354,3 @@ function Resolve-Template {
return $null
}
# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
function Resolve-TemplateContent {
param(
[Parameter(Mandatory=$true)][string]$TemplateName,
[Parameter(Mandatory=$true)][string]$RepoRoot
)
$base = Join-Path $RepoRoot '.specify/templates'
# Collect all layers (highest priority first)
$layerPaths = @()
$layerStrategies = @()
# Priority 1: Project overrides (always "replace")
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) {
$layerPaths += $override
$layerStrategies += 'replace'
}
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
ForEach-Object { $_.Name }
}
} catch {
$sortedPresets = @()
}
}
if ($sortedPresets.Count -gt 0) {
$pyCmd = Get-Python3Command
if (-not $pyCmd) {
# Check if any preset has strategy fields that would be ignored
foreach ($pid in $sortedPresets) {
$mf = Join-Path $presetsDir "$pid/preset.yml"
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
break
}
}
}
$yamlWarned = $false
foreach ($presetId in $sortedPresets) {
# Read strategy and file path from preset manifest
$strategy = 'replace'
$manifestFilePath = ''
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
if ((Test-Path $manifest) -and $pyCmd) {
try {
# Use Python to parse YAML manifest for strategy and file path
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
$pyStderrFile = [System.IO.Path]::GetTempFileName()
$stratResult = & $pyCmd[0] @pyArgs -c @"
import sys
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
try:
with open(sys.argv[1]) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
"@ $manifest $TemplateName 2>$pyStderrFile
if ($stratResult) {
$parts = $stratResult.Trim() -split "`t", 2
$strategy = $parts[0].ToLowerInvariant()
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
}
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
Write-Warning "PyYAML not available; composition strategies may be ignored"
$yamlWarned = $true
}
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
} catch {
$strategy = 'replace'
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
}
}
# Try manifest file path first, then convention path
$candidate = $null
if ($manifestFilePath) {
# Reject absolute paths and parent traversal
if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
$manifestFilePath = ''
}
}
if ($manifestFilePath) {
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
if (Test-Path $mf) { $candidate = $mf }
}
if (-not $candidate) {
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
}
if ($candidate) {
$layerPaths += $candidate
$layerStrategies += $strategy
}
}
} else {
# Fallback: alphabetical directory order (no registry or parse failure)
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
}
# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
# Priority 4: Core templates (always "replace")
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) {
$layerPaths += $core
$layerStrategies += 'replace'
}
if ($layerPaths.Count -eq 0) { return $null }
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if ($layerStrategies[0] -eq 'replace') {
return (Get-Content $layerPaths[0] -Raw)
}
# Check if any layer uses a non-replace strategy
$hasComposition = $false
foreach ($s in $layerStrategies) {
if ($s -ne 'replace') { $hasComposition = $true; break }
}
if (-not $hasComposition) {
return (Get-Content $layerPaths[0] -Raw)
}
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
$baseIdx = -1
for ($i = 0; $i -lt $layerPaths.Count; $i++) {
if ($layerStrategies[$i] -eq 'replace') {
$baseIdx = $i
break
}
}
if ($baseIdx -lt 0) { return $null }
$content = Get-Content $layerPaths[$baseIdx] -Raw
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
$path = $layerPaths[$i]
$strat = $layerStrategies[$i]
$layerContent = Get-Content $path -Raw
switch ($strat) {
'replace' { $content = $layerContent }
'prepend' { $content = "$layerContent`n`n$content" }
'append' { $content = "$content`n`n$layerContent" }
'wrap' {
if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
}
$content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
}
default { throw "Unknown strategy: $strat" }
}
}
return $content
}

View File

@@ -23,11 +23,9 @@ if ($Help) {
# Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv
# If feature.json pins an existing feature directory, branch naming is not required.
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
# Check if we're on a proper feature branch (only for git repos)
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
# Ensure the feature directory exists

View File

@@ -722,26 +722,14 @@ def _install_shared_infra(
project_path: Path,
script_type: str,
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
) -> 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``.
Page templates are processed to resolve ``__SPECKIT_COMMAND_<NAME>__``
placeholders using *invoke_separator* (``"."`` for markdown agents,
``"-"`` for skills agents).
When *force* is ``True``, existing files are overwritten with the
latest bundled versions. When ``False`` (default), only missing
files are added and existing ones are skipped.
Returns ``True`` on success.
"""
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
core = _locate_core_pack()
@@ -764,11 +752,12 @@ def _install_shared_infra(
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() and not force:
if dst_path.exists():
skipped_files.append(str(dst_path.relative_to(project_path)))
else:
dst_path.parent.mkdir(parents=True, exist_ok=True)
@@ -789,27 +778,18 @@ def _install_shared_infra(
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() and not force:
if dst.exists():
skipped_files.append(str(dst.relative_to(project_path)))
else:
content = f.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(
content, invoke_separator
)
dst.write_text(content, encoding="utf-8")
shutil.copy2(f, dst)
rel = dst.relative_to(project_path).as_posix()
manifest.record_existing(rel)
if skipped_files:
console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
)
for f in skipped_files:
console.print(f" {f}")
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
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()
@@ -1278,12 +1258,6 @@ def init(
integration_parsed_options["commands_dir"] = ai_commands_dir
if ai_skills:
integration_parsed_options["skills"] = True
# Parse --integration-options and merge into parsed_options so
# flags like --skills reach the integration's setup().
if integration_options:
extra = _parse_integration_options(resolved_integration, integration_options)
if extra:
integration_parsed_options.update(extra)
resolved_integration.setup(
project_path, manifest,
@@ -1305,7 +1279,7 @@ def init(
# Install shared infrastructure (scripts, templates)
tracker.start("shared-infra")
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options))
_install_shared_infra(project_path, selected_script, tracker=tracker)
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
ensure_constitution_from_template(project_path, tracker=tracker)
@@ -1409,10 +1383,8 @@ def init(
}
# Ensure ai_skills is set for SkillsIntegration so downstream
# tools (extensions, presets) emit SKILL.md overrides correctly.
# Also set for integrations running in skills mode (e.g. Copilot
# with --skills).
from .integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
if isinstance(resolved_integration, _SkillsPersist):
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
@@ -1524,7 +1496,7 @@ def init(
# Determine skill display mode for the next-steps panel.
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
from .integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
@@ -1532,8 +1504,7 @@ def init(
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
trae_skill_mode = selected_ai == "trae"
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode
if codex_skill_mode and not ai_skills:
# Integration path installed skills; show the helpful notice
@@ -1554,7 +1525,7 @@ def init(
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if cursor_agent_skill_mode or copilot_skill_mode:
if cursor_agent_skill_mode:
return f"/speckit-{name}"
return f"/speckit.{name}"
@@ -2082,16 +2053,9 @@ def integration_install(
selected_script = _resolve_script_type(project_root, script)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options))
_install_shared_infra(project_root, selected_script)
if os.name != "nt":
ensure_executable_scripts(project_root)
@@ -2099,6 +2063,11 @@ def integration_install(
integration.key, project_root, version=get_speckit_version()
)
# Build parsed options from --integration-options
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
try:
integration.setup(
project_root, manifest,
@@ -2187,7 +2156,7 @@ def _update_init_options_for_integration(
opts["context_file"] = integration.context_file
if script_type:
opts["script"] = script_type
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
if isinstance(integration, SkillsIntegration):
opts["ai_skills"] = True
else:
opts.pop("ai_skills", None)
@@ -2368,16 +2337,9 @@ def integration_switch(
opts.pop("context_file", None)
save_init_options(project_root, opts)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(target_integration, integration_options)
# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options))
_install_shared_infra(project_root, selected_script)
if os.name != "nt":
ensure_executable_scripts(project_root)
@@ -2387,6 +2349,10 @@ def integration_switch(
target_integration.key, project_root, version=get_speckit_version()
)
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(target_integration, integration_options)
try:
target_integration.setup(
project_root, manifest,
@@ -2480,15 +2446,9 @@ def integration_upgrade(
selected_script = _resolve_script_type(project_root, script)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
# Ensure shared infrastructure is up to date; --force overwrites existing files.
_install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options))
# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script)
if os.name != "nt":
ensure_executable_scripts(project_root)
@@ -2496,6 +2456,10 @@ def integration_upgrade(
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
try:
integration.setup(
project_root,
@@ -2780,58 +2744,14 @@ def preset_resolve(
raise typer.Exit(1)
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name)
result = resolver.resolve_with_source(template_name)
if layers:
# Use the highest-priority layer for display because the final output
# may be composed and may not map to resolve_with_source()'s single path.
display_layer = layers[0]
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
has_composition = (
layers[0]["strategy"] != "replace"
and any(layer["strategy"] != "replace" for layer in layers)
)
if has_composition:
# Verify composition is actually possible
try:
composed = resolver.resolve_content(template_name)
except Exception as exc:
composed = None
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
if composed is None:
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
else:
console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]")
console.print("\n [bold]Composition chain:[/bold]")
# Compute the effective base: first replace layer scanning from
# highest priority (matching resolve_content top-down logic).
# Only show layers from the base upward (lower layers are ignored).
effective_base_idx = None
for idx, lyr in enumerate(layers):
if lyr["strategy"] == "replace":
effective_base_idx = idx
break
# Show only contributing layers (base and above)
if effective_base_idx is not None:
contributing = layers[:effective_base_idx + 1]
else:
contributing = layers
for i, layer in enumerate(reversed(contributing)):
strategy_label = layer["strategy"]
if strategy_label == "replace" and i == 0:
strategy_label = "base"
console.print(f" {i + 1}. [{strategy_label}] {layer['source']}{layer['path']}")
if result:
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
console.print(f" [dim](from: {result['source']})[/dim]")
else:
# No layers found — fall back to resolve_with_source for non-composition cases
result = resolver.resolve_with_source(template_name)
if result:
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
console.print(f" [dim](from: {result['source']})[/dim]")
else:
console.print(f" [yellow]{template_name}[/yellow]: not found")
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
console.print(f" [yellow]{template_name}[/yellow]: not found")
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
@preset_app.command("info")

View File

@@ -8,7 +8,7 @@ command files into agent-specific directories in the correct format.
import os
from pathlib import Path
from typing import Dict, List, Any, Optional
from typing import Dict, List, Any
import platform
import re
@@ -652,49 +652,6 @@ class CommandRegistrar:
return results
def register_commands_for_non_skill_agents(
self,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.
Like register_commands_for_all_agents but skips skill-based agents
(those with extension '/SKILL.md'). Used by reconciliation to avoid
overwriting properly formatted SKILL.md files.
Args:
commands: List of command info dicts
source_id: Identifier of the source
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
self._ensure_configs()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
if agent_config.get("extension") == "/SKILL.md":
continue
agent_dir = project_root / agent_config["dir"]
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id,
source_dir, project_root,
context_note=context_note,
)
if registered:
results[agent_name] = registered
except ValueError:
continue
return results
def unregister_commands(
self, registered_commands: Dict[str, List[str]], project_root: Path
) -> None:

View File

@@ -84,9 +84,6 @@ class IntegrationBase(ABC):
context_file: str | None = None
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
# -- Markers for managed context section ------------------------------
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
@@ -99,18 +96,6 @@ class IntegrationBase(ABC):
"""Return options this integration accepts. Default: none."""
return []
def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
) -> str:
"""Return the invoke separator for the given options.
Subclasses whose separator depends on runtime options (e.g.
Copilot in ``--skills`` mode) should override this method.
The default implementation ignores *parsed_options* and returns
the class-level ``invoke_separator``.
"""
return self.invoke_separator
def build_exec_args(
self,
prompt: str,
@@ -137,12 +122,11 @@ class IntegrationBase(ABC):
agents or ``"/speckit-specify my-feature"`` for skills agents.
*command_name* may be a full dotted name like
``"speckit.specify"``, an extension command like
``"speckit.git.commit"``, or a bare stem like ``"specify"``.
``"speckit.specify"`` or a bare stem like ``"specify"``.
"""
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
invocation = f"/speckit.{stem}"
if args:
@@ -613,24 +597,6 @@ class IntegrationBase(ABC):
return True
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
Each placeholder encodes a command name in upper-case with
underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``,
``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses
*separator* to join the segments:
* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``
"""
return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
content,
)
@staticmethod
def process_template(
content: str,
@@ -638,7 +604,6 @@ class IntegrationBase(ABC):
script_type: str,
arg_placeholder: str = "$ARGUMENTS",
context_file: str = "",
invoke_separator: str = ".",
) -> str:
"""Process a raw command template into agent-ready content.
@@ -650,7 +615,6 @@ class IntegrationBase(ABC):
5. Replace ``__AGENT__`` with *agent_name*
6. Replace ``__CONTEXT_FILE__`` with *context_file*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
"""
# 1. Extract script command from frontmatter
script_command = ""
@@ -720,9 +684,6 @@ class IntegrationBase(ABC):
content = CommandRegistrar.rewrite_project_relative_paths(content)
# 8. Replace __SPECKIT_COMMAND_<NAME>__ with invocation strings
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
return content
def setup(
@@ -1313,8 +1274,6 @@ class SkillsIntegration(IntegrationBase):
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
"""
invoke_separator = "-"
def build_exec_args(
self,
prompt: str,
@@ -1352,10 +1311,10 @@ class SkillsIntegration(IntegrationBase):
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
invocation = "/speckit-" + stem.replace(".", "-")
invocation = f"/speckit-{stem}"
if args:
invocation = f"{invocation} {args}"
return invocation
@@ -1436,7 +1395,6 @@ class SkillsIntegration(IntegrationBase):
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.
# Preserve leading whitespace in the body to match release ZIP

View File

@@ -5,10 +5,6 @@ Copilot has several unique behaviors compared to standard markdown agents:
- 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``
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
instead. The two modes are mutually exclusive.
"""
from __future__ import annotations
@@ -20,7 +16,7 @@ import warnings
from pathlib import Path
from typing import Any
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..base import IntegrationBase
from ..manifest import IntegrationManifest
@@ -48,40 +44,12 @@ def _allow_all() -> bool:
return True
class _CopilotSkillsHelper(SkillsIntegration):
"""Internal helper used when Copilot is scaffolded in skills mode.
Not registered in the integration registry — only used as a delegate
by ``CopilotIntegration`` when ``--skills`` is passed.
"""
key = "copilot"
config = {
"name": "GitHub Copilot",
"folder": ".github/",
"commands_subdir": "skills",
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
"requires_cli": False,
}
registrar_config = {
"dir": ".github/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".github/copilot-instructions.md"
class CopilotIntegration(IntegrationBase):
"""Integration for GitHub Copilot (VS Code IDE + CLI).
The IDE integration (``requires_cli: False``) installs ``.agent.md``
command files. Workflow dispatch additionally requires the
``copilot`` CLI to be installed separately.
When ``--skills`` is passed via ``--integration-options``, commands
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
"""
key = "copilot"
@@ -100,30 +68,6 @@ class CopilotIntegration(IntegrationBase):
}
context_file = ".github/copilot-instructions.md"
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
return "-"
if self._skills_mode:
return "-"
return self.invoke_separator
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=False,
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
),
]
def build_exec_args(
self,
prompt: str,
@@ -148,19 +92,7 @@ class CopilotIntegration(IntegrationBase):
return args
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native invocation for a Copilot command.
Default mode: agents are not slash-commands — return args as prompt.
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
"""
if self._skills_mode:
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = "/speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
"""Copilot agents are not slash-commands — just return the args as prompt."""
return args or ""
def dispatch_command(
@@ -178,37 +110,19 @@ class CopilotIntegration(IntegrationBase):
Copilot ``.agent.md`` files are agents, not skills. The CLI
selects them with ``--agent <name>`` and the prompt is just
the user's arguments.
In skills mode, the prompt includes the skill invocation
(``/speckit-<stem>``).
"""
import subprocess
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
agent_name = f"speckit.{stem}"
# Detect skills mode from project layout when not set via setup()
skills_mode = self._skills_mode
if not skills_mode and project_root:
skills_dir = project_root / ".github" / "skills"
if skills_dir.is_dir():
skills_mode = any(
d.is_dir() and (d / "SKILL.md").is_file()
for d in skills_dir.glob("speckit-*")
)
if skills_mode:
prompt = "/speckit-" + stem.replace(".", "-")
if args:
prompt = f"{prompt} {args}"
else:
agent_name = f"speckit.{stem}"
prompt = args or ""
cli_args = ["copilot", "-p", prompt]
if not skills_mode:
cli_args.extend(["--agent", agent_name])
prompt = args or ""
cli_args = [
"copilot", "-p", prompt,
"--agent", agent_name,
]
if _allow_all():
cli_args.append("--yolo")
if model:
@@ -254,59 +168,6 @@ class CopilotIntegration(IntegrationBase):
"""Copilot commands use ``.agent.md`` extension."""
return f"speckit.{template_name}.agent.md"
def post_process_skill_content(self, content: str) -> str:
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
Copilot can associate the skill with its agent mode.
"""
lines = content.splitlines(keepends=True)
# Extract skill name from frontmatter to derive the mode value
dash_count = 0
skill_name = ""
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1:
if stripped.startswith("mode:"):
return content # already present
if stripped.startswith("name:"):
# Parse: name: "speckit-plan" → speckit.plan
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
# Convert speckit-plan → speckit.plan
if val.startswith("speckit-"):
skill_name = "speckit." + val[len("speckit-"):]
else:
skill_name = val
if not skill_name:
return content
# Inject mode: before the closing --- of frontmatter
out: list[str] = []
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"mode: {skill_name}{eol}")
injected = True
out.append(line)
return "".join(out)
def setup(
self,
project_root: Path,
@@ -316,24 +177,10 @@ class CopilotIntegration(IntegrationBase):
) -> list[Path]:
"""Install copilot commands, companion prompts, and VS Code settings.
When ``parsed_options["skills"]`` is truthy, delegates to skills
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
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.
"""
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
if self._skills_mode:
return self._setup_skills(project_root, manifest, parsed_options, **opts)
return self._setup_default(project_root, manifest, parsed_options, **opts)
def _setup_default(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
@@ -405,37 +252,6 @@ class CopilotIntegration(IntegrationBase):
return created
def _setup_skills(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process."""
helper = _CopilotSkillsHelper()
created = SkillsIntegration.setup(
helper, project_root, manifest, parsed_options, **opts
)
# Post-process generated skill files with Copilot-specific frontmatter
skills_dir = helper.skills_dest(project_root).resolve()
for path in created:
try:
path.resolve().relative_to(skills_dir)
except ValueError:
continue
if path.name != "SKILL.md":
continue
content = path.read_text(encoding="utf-8")
updated = self.post_process_skill_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, 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()

View File

@@ -1,133 +1,21 @@
"""
Mistral Vibe CLI integration — skills-based agent.
"""Mistral Vibe CLI integration."""
Vibe uses ``.vibe/skills/speckit-<name>/SKILL.md`` layout (enforced since v2.0.0).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
from ..base import MarkdownIntegration
class VibeIntegration(SkillsIntegration):
class VibeIntegration(MarkdownIntegration):
key = "vibe"
config = {
"name": "Mistral Vibe",
"folder": ".vibe/",
"commands_subdir": "skills",
"commands_subdir": "prompts",
"install_url": "https://github.com/mistralai/mistral-vibe",
"requires_cli": True,
}
registrar_config = {
"dir": ".vibe/skills",
"dir": ".vibe/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
"extension": ".md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills",
),
]
@staticmethod
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
"""
Insert ``key: value`` before the closing ``---`` if not already present.
Value: true by default
"""
lines = content.splitlines(keepends=True)
# Pre-scan: bail out if already present in frontmatter
dash_count = 0
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content
# Inject before the closing --- of frontmatter
out: list[str] = []
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"{key}: {value}{eol}")
injected = True
out.append(line)
return "".join(out)
def post_process_skill_content(self, content: str) -> str:
"""
Inject Vibe-specific frontmatter flags:
- user-invocable: allows the skill to be invoked by the user (not just other agents)
"""
updated = self._inject_frontmatter_flag(content, "user-invocable")
return updated
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Vibe skills then inject Vibe-specific flags"""
import click
click.secho(
"Warning: The .vibe/skills layout requires Mistral Vibe v2.0.0 or newer. "
"Please ensure your installation is up to date.",
fg="yellow",
err=True,
)
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
# Post-process generated skill files
skills_dir = self.skills_dest(project_root).resolve()
for path in created:
# Only touch SKILL.md files under the skills directory
try:
path.resolve().relative_to(skills_dir)
except ValueError:
continue
if path.name != "SKILL.md":
continue
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
updated = self.post_process_skill_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
return created
context_file = ".vibe/agents/specify-agents.md"

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,13 @@
**Created**: [DATE]
**Feature**: [Link to spec.md or relevant documentation]
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
The /speckit.checklist command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md

View File

@@ -49,13 +49,13 @@ You **MUST** consider the user input before proceeding (if not empty).
## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `__SPECKIT_COMMAND_TASKS__` has successfully produced a complete `tasks.md`.
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
## Operating Constraints
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `__SPECKIT_COMMAND_ANALYZE__`.
**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
## Execution Steps
@@ -191,9 +191,9 @@ Output a Markdown report (no file writes) with the following structure:
At end of report, output a concise Next Actions block:
- If CRITICAL issues exist: Recommend resolving before `__SPECKIT_COMMAND_IMPLEMENT__`
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
- Provide explicit command suggestions: e.g., "Run __SPECKIT_COMMAND_SPECIFY__ with refinement", "Run __SPECKIT_COMMAND_PLAN__ to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
### 8. Offer Remediation

View File

@@ -249,7 +249,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Actor/timing
- Any explicit user-specified must-have items incorporated
**Important**: Each `__SPECKIT_COMMAND_CHECKLIST__` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
- Simple, memorable filenames that indicate checklist purpose

View File

@@ -55,7 +55,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `__SPECKIT_COMMAND_PLAN__`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Execution steps:
@@ -63,7 +63,7 @@ Execution steps:
- `FEATURE_DIR`
- `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment.
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
@@ -202,13 +202,13 @@ Execution steps:
- Path to updated spec.
- Sections touched (list names).
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
- Suggested next command.
Behavior rules:
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
- If spec file missing, instruct user to run `__SPECKIT_COMMAND_SPECIFY__` first (do not create a new spec here).
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed").

View File

@@ -169,7 +169,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Confirm the implementation follows the technical plan
- Report final status with summary of completed work
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `__SPECKIT_COMMAND_TASKS__` first to regenerate the task list.
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_implement` key

View File

@@ -54,7 +54,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
The text the user typed after `__SPECKIT_COMMAND_SPECIFY__` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
Given that feature description, do this:
@@ -100,10 +100,10 @@ Given that feature description, do this:
}
```
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
This allows downstream commands (`__SPECKIT_COMMAND_PLAN__`, `__SPECKIT_COMMAND_TASKS__`, etc.) to locate the feature directory without relying on git branch name conventions.
This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
**IMPORTANT**:
- You must only create one feature per `__SPECKIT_COMMAND_SPECIFY__` invocation
- You must only create one feature per `/speckit.specify` invocation
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
- The spec directory and file are always created by this command, never by the hook
@@ -174,7 +174,7 @@ Given that feature description, do this:
## Notes
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
```
b. **Run Validation Check**: Review the spec against each checklist item:
@@ -232,7 +232,7 @@ Given that feature description, do this:
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
- `SPEC_FILE` — the spec file path
- Checklist results summary
- Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`)
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_specify` key

View File

@@ -3,7 +3,7 @@
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary
@@ -39,12 +39,12 @@
```text
specs/[###-feature]/
├── plan.md # This file (__SPECKIT_COMMAND_PLAN__ command output)
├── research.md # Phase 0 output (__SPECKIT_COMMAND_PLAN__ command)
├── data-model.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
├── quickstart.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
├── contracts/ # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
└── tasks.md # Phase 2 output (__SPECKIT_COMMAND_TASKS__ command - NOT created by __SPECKIT_COMMAND_PLAN__)
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)

View File

@@ -29,7 +29,7 @@ description: "Task list template for feature implementation"
============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
The /speckit.tasks command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md
- Entities from data-model.md

View File

@@ -6,7 +6,6 @@ from specify_cli.integrations.base import (
IntegrationBase,
IntegrationOption,
MarkdownIntegration,
SkillsIntegration,
)
from specify_cli.integrations.manifest import IntegrationManifest
from .conftest import StubIntegration
@@ -168,130 +167,3 @@ class TestBasePrimitives:
assert f.parent.name == "commands"
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")
class TestBuildCommandInvocation:
"""Tests for build_command_invocation across integration types."""
def test_base_core_command_dotted(self):
i = StubIntegration()
assert i.build_command_invocation("speckit.plan") == "/speckit.plan"
def test_base_core_command_bare(self):
i = StubIntegration()
assert i.build_command_invocation("plan") == "/speckit.plan"
def test_base_core_command_with_args(self):
i = StubIntegration()
assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature"
def test_base_extension_command(self):
i = StubIntegration()
assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit"
def test_base_extension_command_bare(self):
i = StubIntegration()
assert i.build_command_invocation("git.commit") == "/speckit.git.commit"
def test_skills_core_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"
def test_skills_extension_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
def test_skills_extension_command_with_args(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
class TestResolveCommandRefs:
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""
def test_dot_separator_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run `/speckit.plan` to plan."
def test_hyphen_separator_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run `/speckit-plan` to plan."
def test_multiple_placeholders(self):
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.specify then /speckit.plan then /speckit.tasks"
def test_extension_command_dot(self):
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run /speckit.git.commit to commit."
def test_extension_command_hyphen(self):
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run /speckit-git-commit to commit."
def test_no_placeholders_unchanged(self):
text = "No placeholders here."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_default_separator_is_dot(self):
text = "__SPECKIT_COMMAND_PLAN__"
assert IntegrationBase.resolve_command_refs(text) == "/speckit.plan"
def test_invoke_separator_class_attribute(self):
assert IntegrationBase.invoke_separator == "."
assert SkillsIntegration.invoke_separator == "-"
def test_effective_invoke_separator_default(self):
"""Base classes return invoke_separator regardless of parsed_options."""
from .conftest import StubIntegration
stub = StubIntegration()
assert stub.effective_invoke_separator() == "."
assert stub.effective_invoke_separator({"skills": True}) == "."
def test_process_template_resolves_placeholders(self):
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
result = IntegrationBase.process_template(
content, "test-agent", "sh", invoke_separator="."
)
assert "/speckit.plan" in result
assert "__SPECKIT_COMMAND_" not in result
def test_process_template_skills_separator(self):
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
result = IntegrationBase.process_template(
content, "test-agent", "sh", invoke_separator="-"
)
assert "/speckit-plan" in result
assert "__SPECKIT_COMMAND_" not in result
def test_unclosed_placeholder_unchanged(self):
text = "Run __SPECKIT_COMMAND_PLAN to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_empty_name_not_matched(self):
text = "Run __SPECKIT_COMMAND___ to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_lowercase_placeholder_not_matched(self):
text = "Run __SPECKIT_COMMAND_plan__ to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_placeholder_adjacent_to_text(self):
text = "foo__SPECKIT_COMMAND_PLAN__bar"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "foo/speckit.planbar"
def test_placeholder_with_digits(self):
text = "__SPECKIT_COMMAND_V2_PLAN__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.v2.plan"

View File

@@ -173,117 +173,26 @@ class TestInitIntegrationFlag:
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
"""Pre-existing shared files are not overwritten without --force."""
from specify_cli import _install_shared_infra
project = tmp_path / "skip-test"
project.mkdir()
(project / ".specify").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")
_install_shared_infra(project, "sh", force=False)
# User's files should be preserved (not overwritten)
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()
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
"""Pre-existing shared files ARE overwritten when force=True."""
from specify_cli import _install_shared_infra
project = tmp_path / "force-test"
project.mkdir()
(project / ".specify").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")
_install_shared_infra(project, "sh", force=True)
# Files should be overwritten with bundled versions
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 also be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra
project = tmp_path / "warn-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
captured = capsys.readouterr()
assert "already exist and were not updated" in captured.out
assert "specify init --here --force" in captured.out
# Rich may wrap long lines; normalize whitespace for the second command
normalized = " ".join(captured.out.split())
assert "specify integration upgrade --force" in normalized
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
"""No skip warning when force=True (all files overwritten)."""
from specify_cli import _install_shared_infra
project = tmp_path / "no-warn-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "already exist and were not updated" not in captured.out
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
"""E2E: specify init --here --force overwrites shared infra files."""
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 / "e2e-force"
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)
@@ -298,40 +207,14 @@ class TestInitIntegrationFlag:
os.chdir(old_cwd)
assert result.exit_code == 0
# --force should overwrite the custom file
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "e2e-no-force"
project.mkdir()
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")
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
], input="y\n", catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Without --force, custom file should be preserved
# User's files should be preserved
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
# Warning about skipped files should appear
assert "not updated" in result.output
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()
class TestForceExistingDirectory:
@@ -471,133 +354,3 @@ class TestGitExtensionAutoInstall:
assert claude_skills.exists(), "Claude skills directory was not created"
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
assert len(git_skills) > 0, "no git extension commands registered"
class TestSharedInfraCommandRefs:
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
def test_dot_separator_in_page_templates(self, tmp_path):
"""Markdown agents get /speckit.<name> in page templates."""
from specify_cli import _install_shared_infra
project = tmp_path / "dot-test"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(project, "sh", invoke_separator=".")
plan = project / ".specify" / "templates" / "plan-template.md"
assert plan.exists()
content = plan.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
assert "/speckit.plan" in content
checklist = project / ".specify" / "templates" / "checklist-template.md"
content = checklist.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit.checklist" in content
def test_hyphen_separator_in_page_templates(self, tmp_path):
"""Skills agents get /speckit-<name> in page templates."""
from specify_cli import _install_shared_infra
project = tmp_path / "hyphen-test"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(project, "sh", invoke_separator="-")
plan = project / ".specify" / "templates" / "plan-template.md"
assert plan.exists()
content = plan.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
assert "/speckit-plan" in content
assert "/speckit.plan" not in content, "dot-notation leaked into skills page template"
tasks = project / ".specify" / "templates" / "tasks-template.md"
content = tasks.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit-tasks" in content
def test_full_init_claude_resolves_page_templates(self, tmp_path):
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-claude"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "claude",
"--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}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
assert "__SPECKIT_COMMAND_" not in content
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-copilot"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "copilot",
"--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}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
assert "__SPECKIT_COMMAND_" not in content
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-copilot-skills"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "copilot",
"--integration-options", "--skills",
"--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}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
assert "__SPECKIT_COMMAND_" not in content

View File

@@ -98,7 +98,6 @@ class MarkdownIntegrationTests:
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 "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
def test_plan_references_correct_context_file(self, tmp_path):

View File

@@ -159,22 +159,6 @@ class SkillsIntegrationTests:
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 "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_command_refs_use_hyphen_separator(self, tmp_path):
"""Skills agents must resolve command refs with hyphen separator."""
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")
# Skills agents must use /speckit-<name>, not /speckit.<name>
assert "/speckit." not in content, (
f"{f.name} contains dot-notation /speckit. reference; "
f"skills agents must use /speckit-<name>"
)
def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content after the frontmatter."""

View File

@@ -106,7 +106,6 @@ class TomlIntegrationTests:
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 "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_toml_has_description(self, tmp_path):
"""Every TOML command file should have a description key."""

View File

@@ -105,7 +105,6 @@ class YamlIntegrationTests:
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 "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_yaml_has_title(self, tmp_path):
"""Every YAML recipe should have a title field."""

View File

@@ -55,8 +55,6 @@ class TestClaudeIntegration:
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert "__AGENT__" not in content
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
assert "/speckit." not in content, "skills agent must use /speckit-<name> not /speckit.<name>"
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])

View File

@@ -3,8 +3,6 @@
import json
import os
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
@@ -144,7 +142,6 @@ class TestCopilotIntegration:
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 "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content
def test_plan_references_correct_context_file(self, tmp_path):
@@ -278,447 +275,3 @@ class TestCopilotIntegration:
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
class TestCopilotSkillsMode:
"""Tests for Copilot integration in --skills mode."""
_SKILL_COMMANDS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
def _make_copilot(self):
from specify_cli.integrations.copilot import CopilotIntegration
return CopilotIntegration()
def _setup_skills(self, copilot, tmp_path):
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
return created, m
# -- Options ----------------------------------------------------------
def test_options_include_skills_flag(self):
copilot = get_integration("copilot")
opts = copilot.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
assert skills_opts[0].default is False
# -- Skills directory structure ---------------------------------------
def test_skills_creates_skill_files(self, tmp_path):
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
assert len(created) > 0
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
assert f.exists()
assert f.parent.name.startswith("speckit-")
def test_skills_directory_under_github_skills(self, tmp_path):
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skills_dir = tmp_path / ".github" / "skills"
assert skills_dir.is_dir()
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
assert f.resolve().parent.parent == skills_dir.resolve(), (
f"{f} is not under {skills_dir}"
)
def test_skills_directory_structure(self, tmp_path):
"""Each command produces speckit-<name>/SKILL.md."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
expected_commands = set(self._SKILL_COMMANDS)
actual_commands = set()
for f in skill_files:
skill_dir_name = f.parent.name
assert skill_dir_name.startswith("speckit-")
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
assert actual_commands == expected_commands
# -- No companion files in skills mode --------------------------------
def test_skills_no_prompt_md_companions(self, tmp_path):
"""Skills mode must not generate .prompt.md companion files."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
prompt_files = [f for f in created if f.name.endswith(".prompt.md")]
assert prompt_files == []
prompts_dir = tmp_path / ".github" / "prompts"
if prompts_dir.exists():
assert list(prompts_dir.iterdir()) == []
def test_skills_no_vscode_settings(self, tmp_path):
"""Skills mode must not create or merge .vscode/settings.json."""
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
settings = tmp_path / ".vscode" / "settings.json"
assert not settings.exists()
def test_skills_no_agent_md_files(self, tmp_path):
"""Skills mode must not produce .agent.md files."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
agent_files = [f for f in created if f.name.endswith(".agent.md")]
assert agent_files == []
# -- Frontmatter structure --------------------------------------------
def test_skill_frontmatter_structure(self, tmp_path):
"""SKILL.md must have name, description, compatibility, metadata."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
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"
# -- Copilot-specific post-processing ---------------------------------
def test_post_process_skill_content_injects_mode(self):
"""post_process_skill_content() should inject mode: field."""
copilot = self._make_copilot()
content = (
"---\n"
'name: "speckit-plan"\n'
'description: "Plan workflow"\n'
"---\n"
"\nBody content\n"
)
updated = copilot.post_process_skill_content(content)
assert "mode: speckit.plan" in updated
def test_post_process_idempotent(self):
"""post_process_skill_content() must be idempotent."""
copilot = self._make_copilot()
content = (
"---\n"
'name: "speckit-plan"\n'
'description: "Plan workflow"\n'
"---\n"
"\nBody content\n"
)
first = copilot.post_process_skill_content(content)
second = copilot.post_process_skill_content(first)
assert first == second
def test_skills_have_mode_in_frontmatter(self, tmp_path):
"""Generated SKILL.md files should have mode: field from post-processing."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "mode" in fm, f"{f} frontmatter missing 'mode'"
# mode should be speckit.<stem>
skill_dir_name = f.parent.name
stem = skill_dir_name.removeprefix("speckit-")
assert fm["mode"] == f"speckit.{stem}"
# -- Template processing ----------------------------------------------
def test_skills_templates_are_processed(self, tmp_path):
"""Skill body must have placeholders replaced."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
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}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_skills_command_refs_use_hyphen(self, tmp_path):
"""Copilot skills mode must use /speckit-<name> not /speckit.<name>."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "/speckit." not in content, (
f"{f.name} contains dot-notation /speckit. reference; "
f"skills mode must use /speckit-<name>"
)
def test_skills_mode_invoke_separator(self):
"""Copilot effective_invoke_separator should reflect skills mode."""
copilot = self._make_copilot()
assert copilot.effective_invoke_separator() == "."
assert copilot.effective_invoke_separator({"skills": True}) == "-"
assert copilot.effective_invoke_separator({"skills": False}) == "."
def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
body = parts[2].strip() if len(parts) >= 3 else ""
assert len(body) > 0, f"{f} has empty body"
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan skill must reference copilot's context file."""
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert copilot.context_file in content
assert "__CONTEXT_FILE__" not in content
# -- Manifest tracking ------------------------------------------------
def test_all_files_tracked_in_manifest(self, tmp_path):
copilot = self._make_copilot()
created, m = self._setup_skills(copilot, tmp_path)
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"
# -- Install/uninstall roundtrip --------------------------------------
def test_install_uninstall_roundtrip(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
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):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
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
# -- build_command_invocation -----------------------------------------
def test_build_command_invocation_skills_mode(self):
copilot = self._make_copilot()
copilot._skills_mode = True
assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan"
assert copilot.build_command_invocation("plan") == "/speckit-plan"
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
def test_build_command_invocation_skills_extension_command(self):
copilot = self._make_copilot()
copilot._skills_mode = True
assert copilot.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert copilot.build_command_invocation("git.commit") == "/speckit-git-commit"
def test_build_command_invocation_default_mode(self):
copilot = self._make_copilot()
assert copilot.build_command_invocation("plan", "my args") == "my args"
assert copilot.build_command_invocation("plan") == ""
# -- Context section ---------------------------------------------------
def test_skills_setup_upserts_context_section(self, tmp_path):
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
ctx_path = tmp_path / copilot.context_file
assert ctx_path.exists()
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
# -- CLI integration test ---------------------------------------------
def test_init_with_integration_options_skills(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' scaffolds skills."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-skills"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
skills_dir = project / ".github" / "skills"
assert skills_dir.is_dir(), "Skills directory was not created"
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
assert plan_skill.exists(), "speckit-plan/SKILL.md not found"
# Verify no default-mode artifacts
assert not (project / ".github" / "agents").exists()
assert not (project / ".github" / "prompts").exists()
assert not (project / ".vscode" / "settings.json").exists()
def test_complete_file_inventory_skills_sh(self, tmp_path):
"""Every file produced by specify init --integration copilot --integration-options='--skills' --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-skills-sh"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--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([
# Skill files
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
# Context file
".github/copilot-instructions.md",
# Integration metadata
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
# Scripts (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",
# Templates
".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",
# Bundled workflow
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
# -- Singleton leak: _skills_mode must reset --------------------------
def test_skills_mode_resets_on_default_setup(self, tmp_path):
"""setup() with skills=True then without must reset _skills_mode."""
copilot = self._make_copilot()
# First call: skills mode
(tmp_path / "proj1").mkdir()
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
assert copilot._skills_mode is True
# Second call: default mode (no skills option)
(tmp_path / "proj2").mkdir()
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
copilot.setup(tmp_path / "proj2", m2)
assert copilot._skills_mode is False
# build_command_invocation must use default (dotted) mode
assert copilot.build_command_invocation("plan", "args") == "args"
# -- Auto-detection must ignore unrelated .github/skills/ -------------
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
copilot = self._make_copilot()
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
unrelated.mkdir(parents=True)
(unrelated / "README.md").write_text("# GitHub Skills training\n")
# Should NOT detect skills mode — cli_args should contain --agent
import unittest.mock as mock
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
call_args = mock_run.call_args[0][0]
assert "--agent" in call_args, (
f"Expected --agent in cli_args but got: {call_args}"
)
assert "speckit.plan" in call_args
def test_dispatch_detects_speckit_skills_layout(self, tmp_path):
"""dispatch_command() detects speckit-*/SKILL.md as skills mode."""
copilot = self._make_copilot()
skill_dir = tmp_path / ".github" / "skills" / "speckit-plan"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n")
import unittest.mock as mock
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
call_args = mock_run.call_args[0][0]
assert "--agent" not in call_args, (
f"Skills mode should not use --agent, got: {call_args}"
)
prompt = call_args[call_args.index("-p") + 1]
assert "/speckit-plan" in prompt, (
f"Skills mode prompt should invoke /speckit-plan, got: {prompt}"
)
assert "my args" in prompt, (
f"Skills mode prompt should preserve user args, got: {prompt}"
)
# -- Next-steps display for Copilot skills mode -----------------------
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-nextsteps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Skills mode should show /speckit-plan (hyphenated)
assert "/speckit-plan" in result.output, (
f"Expected /speckit-plan in next steps but got:\n{result.output}"
)
# Must NOT show the dotted /speckit.plan form
assert "/speckit.plan" not in result.output, (
f"Should not show /speckit.plan in skills mode:\n{result.output}"
)

View File

@@ -152,7 +152,6 @@ class TestForgeIntegration:
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{cmd_file.name} has unprocessed __SPECKIT_COMMAND_*__"
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
# Frontmatter sections should be stripped

View File

@@ -101,7 +101,6 @@ class TestGenericIntegration:
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 "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration("generic")

View File

@@ -1,38 +1,11 @@
"""Tests for VibeIntegration."""
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestVibeIntegration(SkillsIntegrationTests):
class TestVibeIntegration(MarkdownIntegrationTests):
KEY = "vibe"
FOLDER = ".vibe/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".vibe/skills"
CONTEXT_FILE = "AGENTS.md"
class TestVibeUserInvocable:
def test_all_skills_have_user_invocable(self, tmp_path):
i = get_integration("vibe")
m = IntegrationManifest("vibe", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
assert skill_files
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert content.startswith("---"), (
f"{f.parent.name}/SKILL.md is missing the opening frontmatter delimiter '---'"
)
parts = content.split("---", 2)
assert len(parts) >= 3, (
f"{f.parent.name}/SKILL.md has malformed frontmatter; expected a '--- ... ---' block"
)
parsed = yaml.safe_load(parts[1])
assert parsed.get("user-invocable") is True, (
f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter"
)
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".vibe/prompts"
CONTEXT_FILE = ".vibe/agents/specify-agents.md"

File diff suppressed because it is too large Load Diff

View File

@@ -1,202 +0,0 @@
"""Tests for setup-plan bypassing branch-pattern checks when feature.json is valid."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
SETUP_PLAN_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-plan.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1"
PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
def _install_bash_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "bash"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(SETUP_PLAN_SH, d / "setup-plan.sh")
def _install_ps_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "powershell"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(SETUP_PLAN_PS, d / "setup-plan.ps1")
def _minimal_templates(repo: Path) -> None:
tdir = repo / ".specify" / "templates"
tdir.mkdir(parents=True, exist_ok=True)
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
def _clean_env() -> dict[str, str]:
"""Return a copy of the current environment with any SPECIFY_* vars removed.
setup-plan.{sh,ps1} honors SPECIFY_FEATURE, SPECIFY_FEATURE_DIRECTORY, etc.,
which would otherwise leak from a developer shell or CI runner and make these
tests flaky. Stripping them forces every case to rely purely on git branch +
.specify/feature.json state set up by the fixture.
"""
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
@pytest.fixture
def plan_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_minimal_templates(repo)
_install_bash_scripts(repo)
_install_ps_scripts(repo)
return repo
@requires_bash
def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(plan_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
encoding="utf-8",
)
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script)],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
assert (feat / "plan.md").is_file()
@requires_bash
def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script)],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Not on a feature branch" in result.stderr
@requires_bash
def test_setup_plan_numbered_branch_unchanged_without_feature_json(
plan_repo: Path,
) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
cwd=plan_repo,
check=True,
)
feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script)],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
assert (feat / "plan.md").is_file()
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: Path) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(plan_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
encoding="utf-8",
)
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script)],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
assert (feat / "plan.md").is_file()
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_plan_ps_fails_custom_branch_without_feature_json(
plan_repo: Path,
) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script)],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Not on a feature branch" in result.stderr

View File

@@ -1257,67 +1257,3 @@ class TestFeatureDirectoryResolution:
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")
# ── Description Quoting Tests (issue #2339) ──────────────────────────────────
@requires_bash
class TestDescriptionQuoting:
"""Descriptions with quotes, apostrophes, and backslashes must not break the script.
Regression tests for https://github.com/github/spec-kit/issues/2339
"""
@pytest.mark.parametrize(
"description",
[
"Add user's profile page",
"Fix the \"login\" bug",
"Handle path\\with\\backslashes",
"It's a \"complex\" feature\\here",
],
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
def test_core_script_handles_special_chars(self, git_repo: Path, description: str):
"""Core create-new-feature.sh succeeds with special characters in description."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", description)
assert result.returncode == 0, (
f"Script failed for description {description!r}: {result.stderr}"
)
@pytest.mark.parametrize(
"description",
[
"Add user's profile page",
"Fix the \"login\" bug",
"Handle path\\with\\backslashes",
"It's a \"complex\" feature\\here",
],
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
"""Extension create-new-feature.sh succeeds with special characters in description."""
script = (
ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
)
result = subprocess.run(
["bash", str(script), "--dry-run", "--short-name", "feat", description],
cwd=ext_git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, (
f"Script failed for description {description!r}: {result.stderr}"
)
def test_whitespace_only_still_rejected(self, git_repo: Path):
"""Whitespace-only descriptions must still be rejected after trimming."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", " ")
assert result.returncode != 0
assert "empty" in result.stderr.lower() or "whitespace" in result.stderr.lower()
def test_plain_description_still_works(self, git_repo: Path):
"""Plain description without special characters continues to work."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
assert result.returncode == 0, result.stderr

View File

@@ -100,25 +100,12 @@ class TestInstalledVersion:
def test_invalid_metadata_error_returns_unknown(self):
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
if invalid_metadata_error is None:
# Python versions without InvalidMetadataError: simulate with a
# custom exception to verify the guarded except path works.
class _FakeInvalidMetadataError(Exception):
pass
invalid_metadata_error = _FakeInvalidMetadataError
# Patch the attribute onto importlib.metadata so the production
# getattr() finds it during this test.
with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True):
with patch(
"importlib.metadata.version",
side_effect=invalid_metadata_error("bad metadata"),
):
assert _get_installed_version() == "unknown"
else:
with patch(
"importlib.metadata.version",
side_effect=invalid_metadata_error("bad metadata"),
):
assert _get_installed_version() == "unknown"
pytest.skip("InvalidMetadataError is not available on this Python version")
with patch(
"importlib.metadata.version",
side_effect=invalid_metadata_error("bad metadata"),
):
assert _get_installed_version() == "unknown"
class TestNormalizeTag: