mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 14:39:00 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a63f64b69d | ||
|
|
37745ec2ee | ||
|
|
998f927576 | ||
|
|
9f14dfc6c6 | ||
|
|
8750e94d10 | ||
|
|
52c0a5f88f | ||
|
|
6413414907 | ||
|
|
7f708b9e6f | ||
|
|
13d88d22a6 | ||
|
|
6bf4ebbe33 | ||
|
|
5a52b7623e | ||
|
|
89fc554ce5 | ||
|
|
a067d4c2e3 | ||
|
|
8fefd2a532 | ||
|
|
b278d66b2c | ||
|
|
709457cec2 | ||
|
|
9e259e1f8d | ||
|
|
3970855797 | ||
|
|
f612e1a30d |
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
|
|||||||
24
AGENTS.md
24
AGENTS.md
@@ -264,13 +264,13 @@ The base classes handle most work automatically. Override only when the agent de
|
|||||||
| Override | When to use | Example |
|
| Override | When to use | Example |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
|
| `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 |
|
| `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` |
|
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
|
||||||
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
|
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
|
||||||
|
|
||||||
**Example — Copilot (fully custom `setup`):**
|
**Example — Copilot (fully custom `setup`):**
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### 7. Update Devcontainer files (Optional)
|
### 7. Update Devcontainer files (Optional)
|
||||||
|
|
||||||
@@ -391,6 +391,24 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
|||||||
2. Generates companion `.prompt.md` files
|
2. Generates companion `.prompt.md` files
|
||||||
3. Merges VS Code settings
|
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 Integration
|
||||||
|
|
||||||
Forge has special frontmatter and argument requirements:
|
Forge has special frontmatter and argument requirements:
|
||||||
|
|||||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,6 +2,51 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- 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
|
||||||
|
|
||||||
|
- fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313)
|
||||||
|
- feat(cli): add specify self check and self upgrade stub (#2316)
|
||||||
|
- Update version-guard to v1.1.0 (#2318)
|
||||||
|
- docs: move community presets from README to docs/community (#2314)
|
||||||
|
- catalog: add wireframe extension (v0.1.1) (#2262)
|
||||||
|
- Move community walkthroughs from README to docs/community (#2312)
|
||||||
|
- docs(readme): list red-team in community-extensions table (#2311)
|
||||||
|
- feat(catalog): add red-team extension to community catalog (#2306)
|
||||||
|
- Add superpowers-bridge community extension (#2309)
|
||||||
|
- feat: implement preset wrap strategy (#2189)
|
||||||
|
- fix(agents): block directory traversal in command write paths (#2229) (#2296)
|
||||||
|
- chore: release 0.7.4, begin 0.7.5.dev0 development (#2299)
|
||||||
|
|
||||||
## [0.7.4] - 2026-04-21
|
## [0.7.4] - 2026-04-21
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -62,6 +62,10 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX
|
|||||||
|
|
||||||
# Or install latest from main (may include unreleased changes)
|
# Or install latest from main (may include unreleased changes)
|
||||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
|
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:
|
Then verify the correct version is installed:
|
||||||
@@ -89,6 +93,7 @@ To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed inst
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
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
|
#### Option 2: One-time Usage
|
||||||
@@ -224,13 +229,14 @@ 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 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) |
|
| 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 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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-) |
|
| 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) |
|
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||||
| Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
| 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) |
|
||||||
| 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 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) |
|
| 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) |
|
| 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) |
|
||||||
@@ -272,7 +278,12 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX
|
|||||||
|
|
||||||
## 🎨 Community Presets
|
## 🎨 Community Presets
|
||||||
|
|
||||||
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-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 Walkthroughs
|
## 🚶 Community Walkthroughs
|
||||||
|
|
||||||
@@ -425,7 +436,7 @@ Our research and experimentation focus on:
|
|||||||
|
|
||||||
- **Linux/macOS/Windows**
|
- **Linux/macOS/Windows**
|
||||||
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
|
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
|
||||||
- [uv](https://docs.astral.sh/uv/) for package management
|
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
|
||||||
- [Python 3.11+](https://www.python.org/downloads/)
|
- [Python 3.11+](https://www.python.org/downloads/)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ 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) |
|
| 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) |
|
| 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) |
|
| 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 | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
| 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) |
|
||||||
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
- **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)
|
- 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
|
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
|
||||||
- [Python 3.11+](https://www.python.org/downloads/)
|
- [Python 3.11+](https://www.python.org/downloads/)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
@@ -24,6 +24,13 @@ 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>
|
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:
|
Or initialize in the current directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ 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 .
|
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):
|
Pick script type explicitly (optional):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
| What to Upgrade | Command | When to Use |
|
| 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** | `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 |
|
| **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 |
|
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||||
|
|
||||||
@@ -34,6 +35,14 @@ Specify the desired release tag:
|
|||||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
|
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
|
### Verify the upgrade
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -53,8 +62,8 @@ When Spec Kit releases new features (like new slash commands or updated template
|
|||||||
Running `specify init --here --force` will update:
|
Running `specify init --here --force` will update:
|
||||||
|
|
||||||
- ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
|
- ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
|
||||||
- ✅ **Script files** (`.specify/scripts/`)
|
- ✅ **Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added
|
||||||
- ✅ **Template files** (`.specify/templates/`)
|
- ✅ **Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added
|
||||||
- ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
|
- ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
|
||||||
|
|
||||||
### What stays safe?
|
### What stays safe?
|
||||||
@@ -94,7 +103,9 @@ Template files will be merged with existing content and may overwrite existing f
|
|||||||
Proceed? [y/N]
|
Proceed? [y/N]
|
||||||
```
|
```
|
||||||
|
|
||||||
With `--force`, it skips the confirmation and proceeds immediately.
|
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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -126,13 +137,14 @@ Or use git to restore it:
|
|||||||
git restore .specify/memory/constitution.md
|
git restore .specify/memory/constitution.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Custom template modifications
|
### 2. Custom script or template modifications
|
||||||
|
|
||||||
If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first:
|
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Back up custom templates
|
# Back up custom templates and scripts
|
||||||
cp -r .specify/templates .specify/templates-backup
|
cp -r .specify/templates .specify/templates-backup
|
||||||
|
cp -r .specify/scripts .specify/scripts-backup
|
||||||
|
|
||||||
# After upgrade, merge your changes back manually
|
# After upgrade, merge your changes back manually
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-04-22T17:54:00Z",
|
"updated_at": "2026-04-24T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"aide": {
|
"aide": {
|
||||||
@@ -1198,6 +1198,38 @@
|
|||||||
"created_at": "2026-04-20T00:00:00Z",
|
"created_at": "2026-04-20T00:00:00Z",
|
||||||
"updated_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": {
|
"memorylint": {
|
||||||
"name": "MemoryLint",
|
"name": "MemoryLint",
|
||||||
"id": "memorylint",
|
"id": "memorylint",
|
||||||
@@ -1392,10 +1424,10 @@
|
|||||||
"product-forge": {
|
"product-forge": {
|
||||||
"name": "Product Forge",
|
"name": "Product Forge",
|
||||||
"id": "product-forge",
|
"id": "product-forge",
|
||||||
"description": "Full product lifecycle: research → product spec → SpecKit → implement → verify → test",
|
"description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model",
|
||||||
"author": "VaiYav",
|
"author": "VaiYav",
|
||||||
"version": "1.1.1",
|
"version": "1.5.1",
|
||||||
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.1.1.zip",
|
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip",
|
||||||
"repository": "https://github.com/VaiYav/speckit-product-forge",
|
"repository": "https://github.com/VaiYav/speckit-product-forge",
|
||||||
"homepage": "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",
|
"documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md",
|
||||||
@@ -1405,21 +1437,21 @@
|
|||||||
"speckit_version": ">=0.1.0"
|
"speckit_version": ">=0.1.0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"commands": 10,
|
"commands": 29,
|
||||||
"hooks": 0
|
"hooks": 0
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
"process",
|
"process",
|
||||||
"research",
|
|
||||||
"product-spec",
|
|
||||||
"lifecycle",
|
"lifecycle",
|
||||||
"testing"
|
"monorepo",
|
||||||
|
"v-model",
|
||||||
|
"portfolio"
|
||||||
],
|
],
|
||||||
"verified": false,
|
"verified": false,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-03-28T00:00:00Z",
|
"created_at": "2026-03-28T00:00:00Z",
|
||||||
"updated_at": "2026-03-28T00:00:00Z"
|
"updated_at": "2026-04-24T15:52:00Z"
|
||||||
},
|
},
|
||||||
"qa": {
|
"qa": {
|
||||||
"name": "QA Testing Extension",
|
"name": "QA Testing Extension",
|
||||||
@@ -2351,8 +2383,8 @@
|
|||||||
"id": "version-guard",
|
"id": "version-guard",
|
||||||
"description": "Verify tech stack versions against live registries before planning and implementation",
|
"description": "Verify tech stack versions against live registries before planning and implementation",
|
||||||
"author": "KevinBrown5280",
|
"author": "KevinBrown5280",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip",
|
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.2.0.zip",
|
||||||
"repository": "https://github.com/KevinBrown5280/spec-kit-version-guard",
|
"repository": "https://github.com/KevinBrown5280/spec-kit-version-guard",
|
||||||
"homepage": "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",
|
"documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md",
|
||||||
@@ -2375,7 +2407,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-04-20T00:00:00Z",
|
"created_at": "2026-04-20T00:00:00Z",
|
||||||
"updated_at": "2026-04-22T17:54:00Z"
|
"updated_at": "2026-04-22T21:10:00Z"
|
||||||
},
|
},
|
||||||
"whatif": {
|
"whatif": {
|
||||||
"name": "What-if Analysis",
|
"name": "What-if Analysis",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Trim whitespace and validate description is not empty
|
# Trim whitespace and validate description is not empty
|
||||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency:
|
|||||||
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
|
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
|
||||||
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`
|
- **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
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -205,11 +205,21 @@ Edit `presets/catalog.community.json` and add your preset.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Submit Pull Request
|
### 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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b add-your-preset
|
git checkout -b add-your-preset
|
||||||
git add presets/catalog.community.json
|
git add presets/catalog.community.json docs/community/presets.md
|
||||||
git commit -m "Add your-preset to community catalog
|
git commit -m "Add your-preset to community catalog
|
||||||
|
|
||||||
- Preset ID: your-preset
|
- Preset ID: your-preset
|
||||||
@@ -240,6 +250,7 @@ git push origin add-your-preset
|
|||||||
- [ ] Commands register to agent directories (if applicable)
|
- [ ] Commands register to agent directories (if applicable)
|
||||||
- [ ] Commands match template sections (command + template are coherent)
|
- [ ] Commands match template sections (command + template are coherent)
|
||||||
- [ ] Added to presets/catalog.community.json
|
- [ ] Added to presets/catalog.community.json
|
||||||
|
- [ ] Added row to docs/community/presets.md table
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -61,7 +61,37 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa
|
|||||||
specify preset add pm-workflow --priority 1 # overrides everything
|
specify preset add pm-workflow --priority 1 # overrides everything
|
||||||
```
|
```
|
||||||
|
|
||||||
Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
|
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.
|
||||||
|
|
||||||
## Catalog Management
|
## Catalog Management
|
||||||
|
|
||||||
@@ -108,13 +138,5 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset
|
|||||||
|
|
||||||
The following enhancements are under consideration for future releases:
|
The following enhancements are under consideration for future releases:
|
||||||
|
|
||||||
- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:
|
- **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.
|
||||||
| 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.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-04-13T00:00:00Z",
|
"updated_at": "2026-04-15T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||||
"presets": {
|
"presets": {
|
||||||
"aide-in-place": {
|
"aide-in-place": {
|
||||||
@@ -142,6 +142,33 @@
|
|||||||
"created_at": "2026-04-09T08:00:00Z",
|
"created_at": "2026-04-09T08:00:00Z",
|
||||||
"updated_at": "2026-04-19T08: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": {
|
"multi-repo-branching": {
|
||||||
"name": "Multi-Repo Branching",
|
"name": "Multi-Repo Branching",
|
||||||
"id": "multi-repo-branching",
|
"id": "multi-repo-branching",
|
||||||
@@ -194,6 +221,44 @@
|
|||||||
"experimental"
|
"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": {
|
"toc-navigation": {
|
||||||
"name": "Table of Contents Navigation",
|
"name": "Table of Contents Navigation",
|
||||||
"id": "toc-navigation",
|
"id": "toc-navigation",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-04-10T00:00:00Z",
|
"updated_at": "2026-04-24T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
|
||||||
"presets": {
|
"presets": {
|
||||||
"lean": {
|
"lean": {
|
||||||
@@ -10,7 +10,15 @@
|
|||||||
"description": "Minimal core workflow commands - just the prompt, just the artifact",
|
"description": "Minimal core workflow commands - just the prompt, just the artifact",
|
||||||
"author": "github",
|
"author": "github",
|
||||||
"repository": "https://github.com/github/spec-kit",
|
"repository": "https://github.com/github/spec-kit",
|
||||||
|
"license": "MIT",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.6.0"
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"commands": 5,
|
||||||
|
"templates": 0
|
||||||
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
"lean",
|
"lean",
|
||||||
"minimal",
|
"minimal",
|
||||||
|
|||||||
45
presets/lean/README.md
Normal file
45
presets/lean/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 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
|
||||||
@@ -48,3 +48,4 @@ tags:
|
|||||||
- "lean"
|
- "lean"
|
||||||
- "minimal"
|
- "minimal"
|
||||||
- "workflow"
|
- "workflow"
|
||||||
|
- "core"
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ provides:
|
|||||||
templates:
|
templates:
|
||||||
# CUSTOMIZE: Define your template overrides
|
# CUSTOMIZE: Define your template overrides
|
||||||
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
|
# 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"
|
- type: "template"
|
||||||
name: "spec-template"
|
name: "spec-template"
|
||||||
file: "templates/spec-template.md"
|
file: "templates/spec-template.md"
|
||||||
@@ -45,6 +54,26 @@ provides:
|
|||||||
# description: "Custom plan template"
|
# description: "Custom plan template"
|
||||||
# replaces: "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:
|
# OVERRIDE EXTENSION TEMPLATES:
|
||||||
# Presets sit above extensions in the resolution stack, so you can
|
# Presets sit above extensions in the resolution stack, so you can
|
||||||
# override templates provided by any installed extension.
|
# override templates provided by any installed extension.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.7.5.dev0"
|
version = "0.8.1"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -153,6 +153,59 @@ check_feature_branch() {
|
|||||||
return 0
|
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
|
# 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)
|
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||||
find_feature_dir_by_prefix() {
|
find_feature_dir_by_prefix() {
|
||||||
@@ -217,16 +270,10 @@ get_feature_paths() {
|
|||||||
# Normalize relative paths to absolute under repo root
|
# Normalize relative paths to absolute under repo root
|
||||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
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
|
local _fd
|
||||||
if command -v jq >/dev/null 2>&1; then
|
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||||
_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
|
if [[ -n "$_fd" ]]; then
|
||||||
feature_dir="$_fd"
|
feature_dir="$_fd"
|
||||||
# Normalize relative paths to absolute under repo root
|
# Normalize relative paths to absolute under repo root
|
||||||
@@ -320,8 +367,9 @@ try:
|
|||||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
presets = data.get('presets', {})
|
presets = data.get('presets', {})
|
||||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
|
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||||
print(pid)
|
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||||
|
print(pid)
|
||||||
except Exception:
|
except Exception:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
" 2>/dev/null); then
|
" 2>/dev/null); then
|
||||||
@@ -373,3 +421,225 @@ except Exception:
|
|||||||
return 1
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
|||||||
eval "$_paths_output"
|
eval "$_paths_output"
|
||||||
unset _paths_output
|
unset _paths_output
|
||||||
|
|
||||||
# Check if we're on a proper feature branch (only for git repos)
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Ensure the feature directory exists
|
# Ensure the feature directory exists
|
||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|||||||
@@ -164,6 +164,74 @@ function Test-FeatureBranch {
|
|||||||
return $true
|
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).
|
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
|
||||||
function Find-FeatureDirByPrefix {
|
function Find-FeatureDirByPrefix {
|
||||||
param(
|
param(
|
||||||
@@ -287,6 +355,21 @@ 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:
|
# Resolve a template name to a file path using the priority stack:
|
||||||
# 1. .specify/templates/overrides/
|
# 1. .specify/templates/overrides/
|
||||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||||
@@ -315,6 +398,7 @@ function Resolve-Template {
|
|||||||
$presets = $registryData.presets
|
$presets = $registryData.presets
|
||||||
if ($presets) {
|
if ($presets) {
|
||||||
$sortedPresets = $presets.PSObject.Properties |
|
$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 } } |
|
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||||
ForEach-Object { $_.Name }
|
ForEach-Object { $_.Name }
|
||||||
}
|
}
|
||||||
@@ -354,3 +438,206 @@ function Resolve-Template {
|
|||||||
return $null
|
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
|
||||||
|
}
|
||||||
@@ -23,9 +23,11 @@ if ($Help) {
|
|||||||
# Get all paths and variables from common functions
|
# Get all paths and variables from common functions
|
||||||
$paths = Get-FeaturePathsEnv
|
$paths = Get-FeaturePathsEnv
|
||||||
|
|
||||||
# Check if we're on a proper feature branch (only for git repos)
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||||
exit 1
|
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ensure the feature directory exists
|
# Ensure the feature directory exists
|
||||||
|
|||||||
@@ -722,14 +722,26 @@ def _install_shared_infra(
|
|||||||
project_path: Path,
|
project_path: Path,
|
||||||
script_type: str,
|
script_type: str,
|
||||||
tracker: StepTracker | None = None,
|
tracker: StepTracker | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
invoke_separator: str = ".",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Install shared infrastructure files into *project_path*.
|
"""Install shared infrastructure files into *project_path*.
|
||||||
|
|
||||||
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
|
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
|
||||||
bundled core_pack or source checkout. Tracks all installed files
|
bundled core_pack or source checkout. Tracks all installed files
|
||||||
in ``speckit.manifest.json``.
|
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.
|
Returns ``True`` on success.
|
||||||
"""
|
"""
|
||||||
|
from .integrations.base import IntegrationBase
|
||||||
from .integrations.manifest import IntegrationManifest
|
from .integrations.manifest import IntegrationManifest
|
||||||
|
|
||||||
core = _locate_core_pack()
|
core = _locate_core_pack()
|
||||||
@@ -752,12 +764,11 @@ def _install_shared_infra(
|
|||||||
if variant_src.is_dir():
|
if variant_src.is_dir():
|
||||||
dest_variant = dest_scripts / variant_dir
|
dest_variant = dest_scripts / variant_dir
|
||||||
dest_variant.mkdir(parents=True, exist_ok=True)
|
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("*"):
|
for src_path in variant_src.rglob("*"):
|
||||||
if src_path.is_file():
|
if src_path.is_file():
|
||||||
rel_path = src_path.relative_to(variant_src)
|
rel_path = src_path.relative_to(variant_src)
|
||||||
dst_path = dest_variant / rel_path
|
dst_path = dest_variant / rel_path
|
||||||
if dst_path.exists():
|
if dst_path.exists() and not force:
|
||||||
skipped_files.append(str(dst_path.relative_to(project_path)))
|
skipped_files.append(str(dst_path.relative_to(project_path)))
|
||||||
else:
|
else:
|
||||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -778,18 +789,27 @@ def _install_shared_infra(
|
|||||||
for f in templates_src.iterdir():
|
for f in templates_src.iterdir():
|
||||||
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
|
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
|
||||||
dst = dest_templates / f.name
|
dst = dest_templates / f.name
|
||||||
if dst.exists():
|
if dst.exists() and not force:
|
||||||
skipped_files.append(str(dst.relative_to(project_path)))
|
skipped_files.append(str(dst.relative_to(project_path)))
|
||||||
else:
|
else:
|
||||||
shutil.copy2(f, dst)
|
content = f.read_text(encoding="utf-8")
|
||||||
|
content = IntegrationBase.resolve_command_refs(
|
||||||
|
content, invoke_separator
|
||||||
|
)
|
||||||
|
dst.write_text(content, encoding="utf-8")
|
||||||
rel = dst.relative_to(project_path).as_posix()
|
rel = dst.relative_to(project_path).as_posix()
|
||||||
manifest.record_existing(rel)
|
manifest.record_existing(rel)
|
||||||
|
|
||||||
if skipped_files:
|
if skipped_files:
|
||||||
import logging
|
console.print(
|
||||||
logging.getLogger(__name__).warning(
|
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
|
||||||
"The following shared files already exist and were not overwritten:\n%s",
|
)
|
||||||
"\n".join(f" {f}" for f in skipped_files),
|
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]."
|
||||||
)
|
)
|
||||||
|
|
||||||
manifest.save()
|
manifest.save()
|
||||||
@@ -1258,6 +1278,12 @@ def init(
|
|||||||
integration_parsed_options["commands_dir"] = ai_commands_dir
|
integration_parsed_options["commands_dir"] = ai_commands_dir
|
||||||
if ai_skills:
|
if ai_skills:
|
||||||
integration_parsed_options["skills"] = True
|
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(
|
resolved_integration.setup(
|
||||||
project_path, manifest,
|
project_path, manifest,
|
||||||
@@ -1279,7 +1305,7 @@ def init(
|
|||||||
|
|
||||||
# Install shared infrastructure (scripts, templates)
|
# Install shared infrastructure (scripts, templates)
|
||||||
tracker.start("shared-infra")
|
tracker.start("shared-infra")
|
||||||
_install_shared_infra(project_path, selected_script, tracker=tracker)
|
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options))
|
||||||
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
|
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
|
||||||
|
|
||||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||||
@@ -1383,8 +1409,10 @@ def init(
|
|||||||
}
|
}
|
||||||
# Ensure ai_skills is set for SkillsIntegration so downstream
|
# Ensure ai_skills is set for SkillsIntegration so downstream
|
||||||
# tools (extensions, presets) emit SKILL.md overrides correctly.
|
# 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
|
from .integrations.base import SkillsIntegration as _SkillsPersist
|
||||||
if isinstance(resolved_integration, _SkillsPersist):
|
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
|
||||||
init_opts["ai_skills"] = True
|
init_opts["ai_skills"] = True
|
||||||
save_init_options(project_path, init_opts)
|
save_init_options(project_path, init_opts)
|
||||||
|
|
||||||
@@ -1496,7 +1524,7 @@ def init(
|
|||||||
# Determine skill display mode for the next-steps panel.
|
# Determine skill display mode for the next-steps panel.
|
||||||
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
|
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
|
||||||
from .integrations.base import SkillsIntegration as _SkillsInt
|
from .integrations.base import SkillsIntegration as _SkillsInt
|
||||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
|
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
|
||||||
|
|
||||||
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
|
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)
|
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
|
||||||
@@ -1504,7 +1532,8 @@ def init(
|
|||||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||||
trae_skill_mode = selected_ai == "trae"
|
trae_skill_mode = selected_ai == "trae"
|
||||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _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
|
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
|
||||||
|
|
||||||
if codex_skill_mode and not ai_skills:
|
if codex_skill_mode and not ai_skills:
|
||||||
# Integration path installed skills; show the helpful notice
|
# Integration path installed skills; show the helpful notice
|
||||||
@@ -1525,7 +1554,7 @@ def init(
|
|||||||
return f"/speckit-{name}"
|
return f"/speckit-{name}"
|
||||||
if kimi_skill_mode:
|
if kimi_skill_mode:
|
||||||
return f"/skill:speckit-{name}"
|
return f"/skill:speckit-{name}"
|
||||||
if cursor_agent_skill_mode:
|
if cursor_agent_skill_mode or copilot_skill_mode:
|
||||||
return f"/speckit-{name}"
|
return f"/speckit-{name}"
|
||||||
return f"/speckit.{name}"
|
return f"/speckit.{name}"
|
||||||
|
|
||||||
@@ -2053,9 +2082,16 @@ def integration_install(
|
|||||||
|
|
||||||
selected_script = _resolve_script_type(project_root, script)
|
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;
|
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||||
# _install_shared_infra merges missing files without overwriting).
|
# _install_shared_infra merges missing files without overwriting).
|
||||||
_install_shared_infra(project_root, selected_script)
|
_install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options))
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
ensure_executable_scripts(project_root)
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
@@ -2063,11 +2099,6 @@ def integration_install(
|
|||||||
integration.key, project_root, version=get_speckit_version()
|
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:
|
try:
|
||||||
integration.setup(
|
integration.setup(
|
||||||
project_root, manifest,
|
project_root, manifest,
|
||||||
@@ -2156,7 +2187,7 @@ def _update_init_options_for_integration(
|
|||||||
opts["context_file"] = integration.context_file
|
opts["context_file"] = integration.context_file
|
||||||
if script_type:
|
if script_type:
|
||||||
opts["script"] = script_type
|
opts["script"] = script_type
|
||||||
if isinstance(integration, SkillsIntegration):
|
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
|
||||||
opts["ai_skills"] = True
|
opts["ai_skills"] = True
|
||||||
else:
|
else:
|
||||||
opts.pop("ai_skills", None)
|
opts.pop("ai_skills", None)
|
||||||
@@ -2337,9 +2368,16 @@ def integration_switch(
|
|||||||
opts.pop("context_file", None)
|
opts.pop("context_file", None)
|
||||||
save_init_options(project_root, opts)
|
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;
|
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||||
# _install_shared_infra merges missing files without overwriting).
|
# _install_shared_infra merges missing files without overwriting).
|
||||||
_install_shared_infra(project_root, selected_script)
|
_install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options))
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
ensure_executable_scripts(project_root)
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
@@ -2349,10 +2387,6 @@ def integration_switch(
|
|||||||
target_integration.key, project_root, version=get_speckit_version()
|
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:
|
try:
|
||||||
target_integration.setup(
|
target_integration.setup(
|
||||||
project_root, manifest,
|
project_root, manifest,
|
||||||
@@ -2446,9 +2480,15 @@ def integration_upgrade(
|
|||||||
|
|
||||||
selected_script = _resolve_script_type(project_root, script)
|
selected_script = _resolve_script_type(project_root, script)
|
||||||
|
|
||||||
# Ensure shared infrastructure is present (safe to run unconditionally;
|
# Build parsed options from --integration-options so the integration
|
||||||
# _install_shared_infra merges missing files without overwriting).
|
# can determine its effective invoke separator before shared infra
|
||||||
_install_shared_infra(project_root, selected_script)
|
# 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))
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
ensure_executable_scripts(project_root)
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
@@ -2456,10 +2496,6 @@ def integration_upgrade(
|
|||||||
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
||||||
new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
|
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:
|
try:
|
||||||
integration.setup(
|
integration.setup(
|
||||||
project_root,
|
project_root,
|
||||||
@@ -2744,14 +2780,58 @@ def preset_resolve(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
resolver = PresetResolver(project_root)
|
resolver = PresetResolver(project_root)
|
||||||
result = resolver.resolve_with_source(template_name)
|
layers = resolver.collect_all_layers(template_name)
|
||||||
|
|
||||||
if result:
|
if layers:
|
||||||
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
# Use the highest-priority layer for display because the final output
|
||||||
console.print(f" [dim](from: {result['source']})[/dim]")
|
# 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']}")
|
||||||
else:
|
else:
|
||||||
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
# No layers found — fall back to resolve_with_source for non-composition cases
|
||||||
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
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]")
|
||||||
|
|
||||||
|
|
||||||
@preset_app.command("info")
|
@preset_app.command("info")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ command files into agent-specific directories in the correct format.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
@@ -652,6 +652,49 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
return results
|
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(
|
def unregister_commands(
|
||||||
self, registered_commands: Dict[str, List[str]], project_root: Path
|
self, registered_commands: Dict[str, List[str]], project_root: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ class IntegrationBase(ABC):
|
|||||||
context_file: str | None = None
|
context_file: str | None = None
|
||||||
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
|
"""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 ------------------------------
|
# -- Markers for managed context section ------------------------------
|
||||||
|
|
||||||
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
|
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
|
||||||
@@ -96,6 +99,18 @@ class IntegrationBase(ABC):
|
|||||||
"""Return options this integration accepts. Default: none."""
|
"""Return options this integration accepts. Default: none."""
|
||||||
return []
|
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(
|
def build_exec_args(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -122,11 +137,12 @@ class IntegrationBase(ABC):
|
|||||||
agents or ``"/speckit-specify my-feature"`` for skills agents.
|
agents or ``"/speckit-specify my-feature"`` for skills agents.
|
||||||
|
|
||||||
*command_name* may be a full dotted name like
|
*command_name* may be a full dotted name like
|
||||||
``"speckit.specify"`` or a bare stem like ``"specify"``.
|
``"speckit.specify"``, an extension command like
|
||||||
|
``"speckit.git.commit"``, or a bare stem like ``"specify"``.
|
||||||
"""
|
"""
|
||||||
stem = command_name
|
stem = command_name
|
||||||
if "." in stem:
|
if stem.startswith("speckit."):
|
||||||
stem = stem.rsplit(".", 1)[-1]
|
stem = stem[len("speckit."):]
|
||||||
|
|
||||||
invocation = f"/speckit.{stem}"
|
invocation = f"/speckit.{stem}"
|
||||||
if args:
|
if args:
|
||||||
@@ -597,6 +613,24 @@ class IntegrationBase(ABC):
|
|||||||
|
|
||||||
return True
|
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
|
@staticmethod
|
||||||
def process_template(
|
def process_template(
|
||||||
content: str,
|
content: str,
|
||||||
@@ -604,6 +638,7 @@ class IntegrationBase(ABC):
|
|||||||
script_type: str,
|
script_type: str,
|
||||||
arg_placeholder: str = "$ARGUMENTS",
|
arg_placeholder: str = "$ARGUMENTS",
|
||||||
context_file: str = "",
|
context_file: str = "",
|
||||||
|
invoke_separator: str = ".",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Process a raw command template into agent-ready content.
|
"""Process a raw command template into agent-ready content.
|
||||||
|
|
||||||
@@ -615,6 +650,7 @@ class IntegrationBase(ABC):
|
|||||||
5. Replace ``__AGENT__`` with *agent_name*
|
5. Replace ``__AGENT__`` with *agent_name*
|
||||||
6. Replace ``__CONTEXT_FILE__`` with *context_file*
|
6. Replace ``__CONTEXT_FILE__`` with *context_file*
|
||||||
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
||||||
|
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||||
"""
|
"""
|
||||||
# 1. Extract script command from frontmatter
|
# 1. Extract script command from frontmatter
|
||||||
script_command = ""
|
script_command = ""
|
||||||
@@ -684,6 +720,9 @@ class IntegrationBase(ABC):
|
|||||||
|
|
||||||
content = CommandRegistrar.rewrite_project_relative_paths(content)
|
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
|
return content
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
@@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
|
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
invoke_separator = "-"
|
||||||
|
|
||||||
def build_exec_args(
|
def build_exec_args(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -1311,10 +1352,10 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||||
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
|
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
|
||||||
stem = command_name
|
stem = command_name
|
||||||
if "." in stem:
|
if stem.startswith("speckit."):
|
||||||
stem = stem.rsplit(".", 1)[-1]
|
stem = stem[len("speckit."):]
|
||||||
|
|
||||||
invocation = f"/speckit-{stem}"
|
invocation = "/speckit-" + stem.replace(".", "-")
|
||||||
if args:
|
if args:
|
||||||
invocation = f"{invocation} {args}"
|
invocation = f"{invocation} {args}"
|
||||||
return invocation
|
return invocation
|
||||||
@@ -1395,6 +1436,7 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
processed_body = self.process_template(
|
processed_body = self.process_template(
|
||||||
raw, self.key, script_type, arg_placeholder,
|
raw, self.key, script_type, arg_placeholder,
|
||||||
context_file=self.context_file or "",
|
context_file=self.context_file or "",
|
||||||
|
invoke_separator=self.invoke_separator,
|
||||||
)
|
)
|
||||||
# Strip the processed frontmatter — we rebuild it for skills.
|
# Strip the processed frontmatter — we rebuild it for skills.
|
||||||
# Preserve leading whitespace in the body to match release ZIP
|
# Preserve leading whitespace in the body to match release ZIP
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ Copilot has several unique behaviors compared to standard markdown agents:
|
|||||||
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
|
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
|
||||||
- Installs ``.vscode/settings.json`` with prompt file recommendations
|
- Installs ``.vscode/settings.json`` with prompt file recommendations
|
||||||
- Context file lives at ``.github/copilot-instructions.md``
|
- 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
|
from __future__ import annotations
|
||||||
@@ -16,7 +20,7 @@ import warnings
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..base import IntegrationBase
|
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
|
||||||
from ..manifest import IntegrationManifest
|
from ..manifest import IntegrationManifest
|
||||||
|
|
||||||
|
|
||||||
@@ -44,12 +48,40 @@ def _allow_all() -> bool:
|
|||||||
return True
|
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):
|
class CopilotIntegration(IntegrationBase):
|
||||||
"""Integration for GitHub Copilot (VS Code IDE + CLI).
|
"""Integration for GitHub Copilot (VS Code IDE + CLI).
|
||||||
|
|
||||||
The IDE integration (``requires_cli: False``) installs ``.agent.md``
|
The IDE integration (``requires_cli: False``) installs ``.agent.md``
|
||||||
command files. Workflow dispatch additionally requires the
|
command files. Workflow dispatch additionally requires the
|
||||||
``copilot`` CLI to be installed separately.
|
``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"
|
key = "copilot"
|
||||||
@@ -68,6 +100,30 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
}
|
}
|
||||||
context_file = ".github/copilot-instructions.md"
|
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(
|
def build_exec_args(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -92,7 +148,19 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
return args
|
return args
|
||||||
|
|
||||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||||
"""Copilot agents are not slash-commands — just return the args as prompt."""
|
"""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
|
||||||
return args or ""
|
return args or ""
|
||||||
|
|
||||||
def dispatch_command(
|
def dispatch_command(
|
||||||
@@ -110,19 +178,37 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
Copilot ``.agent.md`` files are agents, not skills. The CLI
|
Copilot ``.agent.md`` files are agents, not skills. The CLI
|
||||||
selects them with ``--agent <name>`` and the prompt is just
|
selects them with ``--agent <name>`` and the prompt is just
|
||||||
the user's arguments.
|
the user's arguments.
|
||||||
|
|
||||||
|
In skills mode, the prompt includes the skill invocation
|
||||||
|
(``/speckit-<stem>``).
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
stem = command_name
|
stem = command_name
|
||||||
if "." in stem:
|
if stem.startswith("speckit."):
|
||||||
stem = stem.rsplit(".", 1)[-1]
|
stem = stem[len("speckit."):]
|
||||||
agent_name = f"speckit.{stem}"
|
|
||||||
|
|
||||||
prompt = args or ""
|
# Detect skills mode from project layout when not set via setup()
|
||||||
cli_args = [
|
skills_mode = self._skills_mode
|
||||||
"copilot", "-p", prompt,
|
if not skills_mode and project_root:
|
||||||
"--agent", agent_name,
|
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])
|
||||||
if _allow_all():
|
if _allow_all():
|
||||||
cli_args.append("--yolo")
|
cli_args.append("--yolo")
|
||||||
if model:
|
if model:
|
||||||
@@ -168,6 +254,59 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
"""Copilot commands use ``.agent.md`` extension."""
|
"""Copilot commands use ``.agent.md`` extension."""
|
||||||
return f"speckit.{template_name}.agent.md"
|
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(
|
def setup(
|
||||||
self,
|
self,
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
@@ -177,10 +316,24 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
) -> list[Path]:
|
) -> list[Path]:
|
||||||
"""Install copilot commands, companion prompts, and VS Code settings.
|
"""Install copilot commands, companion prompts, and VS Code settings.
|
||||||
|
|
||||||
Uses base class primitives to: read templates, process them
|
When ``parsed_options["skills"]`` is truthy, delegates to skills
|
||||||
(replace placeholders, strip script blocks, rewrite paths),
|
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
|
||||||
write as ``.agent.md``, then add companion prompts and VS Code settings.
|
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
|
||||||
"""
|
"""
|
||||||
|
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()
|
project_root_resolved = project_root.resolve()
|
||||||
if manifest.project_root != project_root_resolved:
|
if manifest.project_root != project_root_resolved:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -252,6 +405,37 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
|
|
||||||
return created
|
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:
|
def _vscode_settings_path(self) -> Path | None:
|
||||||
"""Return path to the bundled vscode-settings.json template."""
|
"""Return path to the bundled vscode-settings.json template."""
|
||||||
tpl_dir = self.shared_templates_dir()
|
tpl_dir = self.shared_templates_dir()
|
||||||
|
|||||||
@@ -1,21 +1,133 @@
|
|||||||
"""Mistral Vibe CLI integration."""
|
"""
|
||||||
|
Mistral Vibe CLI integration — skills-based agent.
|
||||||
|
|
||||||
from ..base import MarkdownIntegration
|
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
|
||||||
|
|
||||||
|
|
||||||
class VibeIntegration(MarkdownIntegration):
|
class VibeIntegration(SkillsIntegration):
|
||||||
key = "vibe"
|
key = "vibe"
|
||||||
config = {
|
config = {
|
||||||
"name": "Mistral Vibe",
|
"name": "Mistral Vibe",
|
||||||
"folder": ".vibe/",
|
"folder": ".vibe/",
|
||||||
"commands_subdir": "prompts",
|
"commands_subdir": "skills",
|
||||||
"install_url": "https://github.com/mistralai/mistral-vibe",
|
"install_url": "https://github.com/mistralai/mistral-vibe",
|
||||||
"requires_cli": True,
|
"requires_cli": True,
|
||||||
}
|
}
|
||||||
registrar_config = {
|
registrar_config = {
|
||||||
"dir": ".vibe/prompts",
|
"dir": ".vibe/skills",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"args": "$ARGUMENTS",
|
"args": "$ARGUMENTS",
|
||||||
"extension": ".md",
|
"extension": "/SKILL.md",
|
||||||
}
|
}
|
||||||
context_file = ".vibe/agents/specify-agents.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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,13 @@
|
|||||||
**Created**: [DATE]
|
**Created**: [DATE]
|
||||||
**Feature**: [Link to spec.md or relevant documentation]
|
**Feature**: [Link to spec.md or relevant documentation]
|
||||||
|
|
||||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
============================================================================
|
============================================================================
|
||||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||||
|
|
||||||
The /speckit.checklist command MUST replace these with actual items based on:
|
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
|
||||||
- User's specific checklist request
|
- User's specific checklist request
|
||||||
- Feature requirements from spec.md
|
- Feature requirements from spec.md
|
||||||
- Technical context from plan.md
|
- Technical context from plan.md
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
|
|
||||||
## Goal
|
## 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.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_COMMAND_TASKS__` has successfully produced a complete `tasks.md`.
|
||||||
|
|
||||||
## Operating Constraints
|
## 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).
|
**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.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_COMMAND_ANALYZE__`.
|
||||||
|
|
||||||
## Execution Steps
|
## 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:
|
At end of report, output a concise Next Actions block:
|
||||||
|
|
||||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
- If CRITICAL issues exist: Recommend resolving before `__SPECKIT_COMMAND_IMPLEMENT__`
|
||||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||||
- 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'"
|
- 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'"
|
||||||
|
|
||||||
### 8. Offer Remediation
|
### 8. Offer Remediation
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Actor/timing
|
- Actor/timing
|
||||||
- Any explicit user-specified must-have items incorporated
|
- Any explicit user-specified must-have items incorporated
|
||||||
|
|
||||||
**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:
|
**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:
|
||||||
|
|
||||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||||
- Simple, memorable filenames that indicate checklist purpose
|
- Simple, memorable filenames that indicate checklist purpose
|
||||||
|
|||||||
@@ -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.
|
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.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_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.
|
||||||
|
|
||||||
Execution steps:
|
Execution steps:
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Execution steps:
|
|||||||
- `FEATURE_DIR`
|
- `FEATURE_DIR`
|
||||||
- `FEATURE_SPEC`
|
- `FEATURE_SPEC`
|
||||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
- If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_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").
|
- 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).
|
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.
|
- Path to updated spec.
|
||||||
- Sections touched (list names).
|
- 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).
|
- 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.plan` or run `/speckit.clarify` again later post-plan.
|
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
|
||||||
- Suggested next command.
|
- Suggested next command.
|
||||||
|
|
||||||
Behavior rules:
|
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 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.specify` first (do not create a new spec here).
|
- If spec file missing, instruct user to run `__SPECKIT_COMMAND_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).
|
- 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.
|
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||||
- Respect user early termination signals ("stop", "done", "proceed").
|
- Respect user early termination signals ("stop", "done", "proceed").
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Confirm the implementation follows the technical plan
|
- Confirm the implementation follows the technical plan
|
||||||
- Report final status with summary of completed work
|
- 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.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_COMMAND_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.
|
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
|
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
|
|
||||||
## Outline
|
## Outline
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Given that feature description, do this:
|
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`.
|
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.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
This allows downstream commands (`__SPECKIT_COMMAND_PLAN__`, `__SPECKIT_COMMAND_TASKS__`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||||
|
|
||||||
**IMPORTANT**:
|
**IMPORTANT**:
|
||||||
- You must only create one feature per `/speckit.specify` invocation
|
- You must only create one feature per `__SPECKIT_COMMAND_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 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
|
- 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
|
## Notes
|
||||||
|
|
||||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
|
||||||
```
|
```
|
||||||
|
|
||||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
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
|
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||||
- `SPEC_FILE` — the spec file path
|
- `SPEC_FILE` — the spec file path
|
||||||
- Checklist results summary
|
- Checklist results summary
|
||||||
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`)
|
- Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
|
||||||
|
|
||||||
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
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
|
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -39,12 +39,12 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
specs/[###-feature]/
|
specs/[###-feature]/
|
||||||
├── plan.md # This file (/speckit.plan command output)
|
├── plan.md # This file (__SPECKIT_COMMAND_PLAN__ command output)
|
||||||
├── research.md # Phase 0 output (/speckit.plan command)
|
├── research.md # Phase 0 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
├── data-model.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
├── quickstart.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
├── contracts/ # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
└── tasks.md # Phase 2 output (__SPECKIT_COMMAND_TASKS__ command - NOT created by __SPECKIT_COMMAND_PLAN__)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Source Code (repository root)
|
### Source Code (repository root)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ description: "Task list template for feature implementation"
|
|||||||
============================================================================
|
============================================================================
|
||||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||||
|
|
||||||
The /speckit.tasks command MUST replace these with actual tasks based on:
|
The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
|
||||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||||
- Feature requirements from plan.md
|
- Feature requirements from plan.md
|
||||||
- Entities from data-model.md
|
- Entities from data-model.md
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from specify_cli.integrations.base import (
|
|||||||
IntegrationBase,
|
IntegrationBase,
|
||||||
IntegrationOption,
|
IntegrationOption,
|
||||||
MarkdownIntegration,
|
MarkdownIntegration,
|
||||||
|
SkillsIntegration,
|
||||||
)
|
)
|
||||||
from specify_cli.integrations.manifest import IntegrationManifest
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||||||
from .conftest import StubIntegration
|
from .conftest import StubIntegration
|
||||||
@@ -167,3 +168,130 @@ class TestBasePrimitives:
|
|||||||
assert f.parent.name == "commands"
|
assert f.parent.name == "commands"
|
||||||
assert f.name.startswith("speckit.")
|
assert f.name.startswith("speckit.")
|
||||||
assert f.name.endswith(".md")
|
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"
|
||||||
|
|||||||
@@ -173,13 +173,13 @@ class TestInitIntegrationFlag:
|
|||||||
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
|
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
|
||||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
|
||||||
def test_shared_infra_skips_existing_files(self, tmp_path):
|
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
|
||||||
"""Pre-existing shared files are not overwritten by _install_shared_infra."""
|
"""Pre-existing shared files are not overwritten without --force."""
|
||||||
from typer.testing import CliRunner
|
from specify_cli import _install_shared_infra
|
||||||
from specify_cli import app
|
|
||||||
|
|
||||||
project = tmp_path / "skip-test"
|
project = tmp_path / "skip-test"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
|
(project / ".specify").mkdir()
|
||||||
|
|
||||||
# Pre-create a shared script with custom content
|
# Pre-create a shared script with custom content
|
||||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||||
@@ -193,6 +193,97 @@ class TestInitIntegrationFlag:
|
|||||||
custom_template = "# user-modified spec-template\n"
|
custom_template = "# user-modified spec-template\n"
|
||||||
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
(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."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
project = tmp_path / "e2e-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()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
@@ -207,14 +298,40 @@ class TestInitIntegrationFlag:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
# --force should overwrite the custom file
|
||||||
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||||
|
|
||||||
# User's files should be preserved
|
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
|
||||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
|
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
|
# Warning about skipped files should appear
|
||||||
|
assert "not updated" in result.output
|
||||||
# Other shared files should still be installed
|
|
||||||
assert (scripts_dir / "setup-plan.sh").exists()
|
|
||||||
assert (templates_dir / "plan-template.md").exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestForceExistingDirectory:
|
class TestForceExistingDirectory:
|
||||||
@@ -354,3 +471,133 @@ class TestGitExtensionAutoInstall:
|
|||||||
assert claude_skills.exists(), "Claude skills directory was not created"
|
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-")]
|
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"
|
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
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class MarkdownIntegrationTests:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
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"
|
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
|
||||||
|
|
||||||
def test_plan_references_correct_context_file(self, tmp_path):
|
def test_plan_references_correct_context_file(self, tmp_path):
|
||||||
|
|||||||
@@ -159,6 +159,22 @@ class SkillsIntegrationTests:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
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):
|
def test_skill_body_has_content(self, tmp_path):
|
||||||
"""Each SKILL.md body should contain template content after the frontmatter."""
|
"""Each SKILL.md body should contain template content after the frontmatter."""
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class TomlIntegrationTests:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
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):
|
def test_toml_has_description(self, tmp_path):
|
||||||
"""Every TOML command file should have a description key."""
|
"""Every TOML command file should have a description key."""
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ class YamlIntegrationTests:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
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):
|
def test_yaml_has_title(self, tmp_path):
|
||||||
"""Every YAML recipe should have a title field."""
|
"""Every YAML recipe should have a title field."""
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class TestClaudeIntegration:
|
|||||||
assert "{SCRIPT}" not in content
|
assert "{SCRIPT}" not in content
|
||||||
assert "{ARGS}" not in content
|
assert "{ARGS}" not in content
|
||||||
assert "__AGENT__" 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)
|
parts = content.split("---", 2)
|
||||||
parsed = yaml.safe_load(parts[1])
|
parsed = yaml.safe_load(parts[1])
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from specify_cli.integrations import get_integration
|
from specify_cli.integrations import get_integration
|
||||||
from specify_cli.integrations.manifest import IntegrationManifest
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||||||
|
|
||||||
@@ -142,6 +144,7 @@ class TestCopilotIntegration:
|
|||||||
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
|
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 "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
|
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
|
assert "\nscripts:\n" not in content
|
||||||
|
|
||||||
def test_plan_references_correct_context_file(self, tmp_path):
|
def test_plan_references_correct_context_file(self, tmp_path):
|
||||||
@@ -275,3 +278,447 @@ class TestCopilotIntegration:
|
|||||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
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}"
|
||||||
|
)
|
||||||
@@ -152,6 +152,7 @@ class TestForgeIntegration:
|
|||||||
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
|
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 "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
|
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}}
|
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
|
||||||
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
|
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
|
||||||
# Frontmatter sections should be stripped
|
# Frontmatter sections should be stripped
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class TestGenericIntegration:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
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):
|
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||||
i = get_integration("generic")
|
i = get_integration("generic")
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
"""Tests for VibeIntegration."""
|
"""Tests for VibeIntegration."""
|
||||||
|
|
||||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
import yaml
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||||||
|
|
||||||
|
from .test_integration_base_skills import SkillsIntegrationTests
|
||||||
|
|
||||||
|
|
||||||
class TestVibeIntegration(MarkdownIntegrationTests):
|
class TestVibeIntegration(SkillsIntegrationTests):
|
||||||
KEY = "vibe"
|
KEY = "vibe"
|
||||||
FOLDER = ".vibe/"
|
FOLDER = ".vibe/"
|
||||||
COMMANDS_SUBDIR = "prompts"
|
COMMANDS_SUBDIR = "skills"
|
||||||
REGISTRAR_DIR = ".vibe/prompts"
|
REGISTRAR_DIR = ".vibe/skills"
|
||||||
CONTEXT_FILE = ".vibe/agents/specify-agents.md"
|
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"
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
202
tests/test_setup_plan_feature_json.py
Normal file
202
tests/test_setup_plan_feature_json.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""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
|
||||||
@@ -1257,3 +1257,67 @@ class TestFeatureDirectoryResolution:
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
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
|
||||||
|
|||||||
@@ -100,12 +100,25 @@ class TestInstalledVersion:
|
|||||||
def test_invalid_metadata_error_returns_unknown(self):
|
def test_invalid_metadata_error_returns_unknown(self):
|
||||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||||
if invalid_metadata_error is None:
|
if invalid_metadata_error is None:
|
||||||
pytest.skip("InvalidMetadataError is not available on this Python version")
|
# Python versions without InvalidMetadataError: simulate with a
|
||||||
with patch(
|
# custom exception to verify the guarded except path works.
|
||||||
"importlib.metadata.version",
|
class _FakeInvalidMetadataError(Exception):
|
||||||
side_effect=invalid_metadata_error("bad metadata"),
|
pass
|
||||||
):
|
invalid_metadata_error = _FakeInvalidMetadataError
|
||||||
assert _get_installed_version() == "unknown"
|
# 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"
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeTag:
|
class TestNormalizeTag:
|
||||||
|
|||||||
Reference in New Issue
Block a user