mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e9ac05007 | ||
|
|
a067d4c2e3 | ||
|
|
8fefd2a532 | ||
|
|
b278d66b2c | ||
|
|
709457cec2 | ||
|
|
9e259e1f8d | ||
|
|
3970855797 | ||
|
|
f612e1a30d | ||
|
|
ecb3b94b43 | ||
|
|
c5c20134df | ||
|
|
58f7a43ec3 | ||
|
|
efb04e26eb | ||
|
|
c52ea23ba2 | ||
|
|
d402a392c3 | ||
|
|
deb80956f3 | ||
|
|
4dcf2921d1 | ||
|
|
dd9c0b0500 | ||
|
|
22e76995c7 | ||
|
|
569d18a59d | ||
|
|
f10fd07481 |
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 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` |
|
||||
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
|
||||
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
|
||||
|
||||
**Example — Copilot (fully custom `setup`):**
|
||||
|
||||
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. 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)
|
||||
|
||||
@@ -391,6 +391,24 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
||||
2. Generates companion `.prompt.md` files
|
||||
3. Merges VS Code settings
|
||||
|
||||
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
|
||||
via `--integration-options="--skills"`. When enabled:
|
||||
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
|
||||
- No companion `.prompt.md` files are generated
|
||||
- No `.vscode/settings.json` merge
|
||||
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
|
||||
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
|
||||
|
||||
The two modes are mutually exclusive — a project uses one or the other:
|
||||
|
||||
```bash
|
||||
# Default mode: .agent.md agents + .prompt.md companions + settings merge
|
||||
specify init my-project --integration copilot
|
||||
|
||||
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
|
||||
specify init my-project --integration copilot --integration-options="--skills"
|
||||
```
|
||||
|
||||
### Forge Integration
|
||||
|
||||
Forge has special frontmatter and argument requirements:
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,6 +2,51 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(copilot): use --yolo to grant all permissions in non-interactive mode (#2298)
|
||||
- feat: add CITATION.cff and .zenodo.json for academic citation support (#2291)
|
||||
- Add spec-validate to community catalog (#2274)
|
||||
- feat: register Ripple in community catalog (#2272)
|
||||
- Add version-guard to community catalog (#2286)
|
||||
- Add spec-reference-loader to community catalog (#2285)
|
||||
- Add memory-loader to community catalog (#2284)
|
||||
- fix(integrations): strip UTF-8 BOM when reading agent context files (#2283)
|
||||
- Preset fiction book writing1.6 (#2270)
|
||||
- fix(integrations): migrate Antigravity (agy) layout to .agents/ and deprecate --skills (#2276)
|
||||
- chore: release 0.7.3, begin 0.7.4.dev0 development (#2263)
|
||||
|
||||
## [0.7.3] - 2026-04-17
|
||||
|
||||
### Changed
|
||||
|
||||
48
README.md
48
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)
|
||||
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:
|
||||
@@ -89,6 +93,7 @@ To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed inst
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
#### Option 2: One-time Usage
|
||||
@@ -224,6 +229,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
|
||||
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
|
||||
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
|
||||
| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
|
||||
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
@@ -236,6 +242,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
@@ -256,12 +263,14 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
|
||||
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
|
||||
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
|
||||
|
||||
@@ -269,44 +278,11 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX
|
||||
|
||||
## 🎨 Community Presets
|
||||
|
||||
> [!NOTE]
|
||||
> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
|
||||
|
||||
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json):
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
|
||||
Community-contributed presets that customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page.
|
||||
|
||||
## 🚶 Community Walkthroughs
|
||||
|
||||
> [!NOTE]
|
||||
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||
|
||||
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
|
||||
|
||||
- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.
|
||||
|
||||
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
|
||||
|
||||
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
|
||||
|
||||
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
|
||||
|
||||
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
|
||||
|
||||
- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit.
|
||||
See Spec-Driven Development in action across different scenarios with community-contributed walkthroughs; find the full list on the [Community Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) page.
|
||||
|
||||
## 🛠️ Community Friends
|
||||
|
||||
@@ -455,7 +431,7 @@ Our research and experimentation focus on:
|
||||
|
||||
- **Linux/macOS/Windows**
|
||||
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
|
||||
- [uv](https://docs.astral.sh/uv/) for package management
|
||||
- [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/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
|
||||
20
docs/community/presets.md
Normal file
20
docs/community/presets.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Community Presets
|
||||
|
||||
> [!NOTE]
|
||||
> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
|
||||
|
||||
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json):
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| 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) |
|
||||
|
||||
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).
|
||||
20
docs/community/walkthroughs.md
Normal file
20
docs/community/walkthroughs.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Community Walkthroughs
|
||||
|
||||
> [!NOTE]
|
||||
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||
|
||||
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
|
||||
|
||||
- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.
|
||||
|
||||
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
|
||||
|
||||
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
|
||||
|
||||
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
|
||||
|
||||
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
|
||||
|
||||
- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||
- [uv](https://docs.astral.sh/uv/) for package management
|
||||
- [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/)
|
||||
- [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>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For a persistent installation, `pipx` works equally well:
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
> ```
|
||||
> The project uses a standard `hatchling` build backend and has no uv-specific dependencies.
|
||||
|
||||
Or initialize in the current directory:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 .
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> You can also install the CLI persistently with `pipx`:
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git
|
||||
> ```
|
||||
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
|
||||
> ```bash
|
||||
> specify init <PROJECT_NAME>
|
||||
> specify init .
|
||||
> ```
|
||||
|
||||
Pick script type explicitly (optional):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -37,5 +37,9 @@
|
||||
# Community
|
||||
- name: Community
|
||||
items:
|
||||
- name: Presets
|
||||
href: community/presets.md
|
||||
- name: Walkthroughs
|
||||
href: community/walkthroughs.md
|
||||
- name: Friends
|
||||
href: community/friends.md
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
| What to Upgrade | Command | When to Use |
|
||||
|----------------|---------|-------------|
|
||||
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
|
||||
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
|
||||
| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### If you installed with `pipx`
|
||||
|
||||
Upgrade to a specific release:
|
||||
|
||||
```bash
|
||||
pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
### Verify the upgrade
|
||||
|
||||
```bash
|
||||
@@ -53,8 +62,8 @@ When Spec Kit releases new features (like new slash commands or updated template
|
||||
Running `specify init --here --force` will update:
|
||||
|
||||
- ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
|
||||
- ✅ **Script files** (`.specify/scripts/`)
|
||||
- ✅ **Template files** (`.specify/templates/`)
|
||||
- ✅ **Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added
|
||||
- ✅ **Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added
|
||||
- ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
|
||||
|
||||
### What stays safe?
|
||||
@@ -94,7 +103,9 @@ Template files will be merged with existing content and may overwrite existing f
|
||||
Proceed? [y/N]
|
||||
```
|
||||
|
||||
With `--force`, it skips the confirmation and proceeds immediately.
|
||||
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.
|
||||
|
||||
@@ -126,13 +137,14 @@ Or use git to restore it:
|
||||
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
|
||||
# Back up custom templates
|
||||
# Back up custom templates and scripts
|
||||
cp -r .specify/templates .specify/templates-backup
|
||||
cp -r .specify/scripts .specify/scripts-backup
|
||||
|
||||
# After upgrade, merge your changes back manually
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-21T00:00:00Z",
|
||||
"updated_at": "2026-04-23T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1198,6 +1198,38 @@
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
},
|
||||
"memory-md": {
|
||||
"name": "Memory MD",
|
||||
"id": "memory-md",
|
||||
"description": "Repository-native durable memory for Spec Kit projects",
|
||||
"author": "DyanGalih",
|
||||
"version": "0.6.2",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
|
||||
"changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
"workflow",
|
||||
"docs",
|
||||
"copilot",
|
||||
"markdown"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-23T00:00:00Z",
|
||||
"updated_at": "2026-04-23T00:00:00Z"
|
||||
},
|
||||
"memorylint": {
|
||||
"name": "MemoryLint",
|
||||
"id": "memorylint",
|
||||
@@ -1523,6 +1555,38 @@
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
},
|
||||
"red-team": {
|
||||
"name": "Red Team",
|
||||
"id": "red-team",
|
||||
"description": "Adversarial review of functional specs before /speckit.plan. Parallel adversarial lens agents catch hostile actors, silent failures, and regulatory blind spots that clarify/analyze cannot.",
|
||||
"author": "Ash Brener",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-red-team/releases/download/v1.0.2/red-team-v1.0.2.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-red-team",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-red-team",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-red-team/blob/main/README.md",
|
||||
"changelog": "https://github.com/ashbrener/spec-kit-red-team/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"adversarial-review",
|
||||
"quality-gate",
|
||||
"spec-hardening",
|
||||
"pre-plan",
|
||||
"audit"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-04-22T00:00:00Z"
|
||||
},
|
||||
"refine": {
|
||||
"name": "Spec Refine",
|
||||
"id": "refine",
|
||||
@@ -2122,6 +2186,39 @@
|
||||
"created_at": "2026-03-30T00:00:00Z",
|
||||
"updated_at": "2026-04-16T14:08:23Z"
|
||||
},
|
||||
"superpowers-bridge": {
|
||||
"name": "Superpowers Bridge",
|
||||
"id": "superpowers-bridge",
|
||||
"description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.",
|
||||
"author": "WangX0111",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/WangX0111/superspec",
|
||||
"homepage": "https://github.com/WangX0111/superspec",
|
||||
"documentation": "https://github.com/WangX0111/superspec/blob/main/README.md",
|
||||
"changelog": "https://github.com/WangX0111/superspec/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"superpowers",
|
||||
"brainstorming",
|
||||
"tdd",
|
||||
"code-review",
|
||||
"subagent",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-04-22T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
"id": "sync",
|
||||
@@ -2286,8 +2383,8 @@
|
||||
"id": "version-guard",
|
||||
"description": "Verify tech stack versions against live registries before planning and implementation",
|
||||
"author": "KevinBrown5280",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.2.0",
|
||||
"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",
|
||||
"homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard",
|
||||
"documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md",
|
||||
@@ -2297,8 +2394,8 @@
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 2
|
||||
"commands": 3,
|
||||
"hooks": 4
|
||||
},
|
||||
"tags": [
|
||||
"versioning",
|
||||
@@ -2310,7 +2407,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
"updated_at": "2026-04-22T21:10:00Z"
|
||||
},
|
||||
"whatif": {
|
||||
"name": "What-if Analysis",
|
||||
@@ -2340,6 +2437,41 @@
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
},
|
||||
"wireframe": {
|
||||
"name": "Wireframe Visual Feedback Loop",
|
||||
"id": "wireframe",
|
||||
"description": "SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement.",
|
||||
"author": "TortoiseWolfe",
|
||||
"version": "0.1.1",
|
||||
"download_url": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/archive/refs/tags/v0.1.1.zip",
|
||||
"repository": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe",
|
||||
"homepage": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe",
|
||||
"documentation": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/README.md",
|
||||
"changelog": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"wireframe",
|
||||
"visual",
|
||||
"design",
|
||||
"ui",
|
||||
"mockup",
|
||||
"svg",
|
||||
"feedback-loop",
|
||||
"sign-off"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-04-22T00:00:00Z"
|
||||
},
|
||||
"worktree": {
|
||||
"name": "Worktree Isolation",
|
||||
"id": "worktree",
|
||||
|
||||
@@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency:
|
||||
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
|
||||
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`
|
||||
|
||||
### Composition Strategies
|
||||
|
||||
Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it:
|
||||
|
||||
| Strategy | Description | Templates | Commands | Scripts |
|
||||
|----------|-------------|-----------|----------|---------|
|
||||
| `replace` (default) | Fully replaces lower-priority content | ✓ | ✓ | ✓ |
|
||||
| `prepend` | Places content before lower-priority content (separated by a blank line) | ✓ | ✓ | — |
|
||||
| `append` | Places content after lower-priority content (separated by a blank line) | ✓ | ✓ | — |
|
||||
| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | ✓ | ✓ | ✓ |
|
||||
|
||||
Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy.
|
||||
|
||||
Content resolution functions for composition:
|
||||
- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py` (templates, commands, and scripts)
|
||||
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
|
||||
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)
|
||||
|
||||
## Command Registration
|
||||
|
||||
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
|
||||
|
||||
@@ -61,7 +61,37 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:
|
||||
|
||||
| Type | `replace` | `prepend` | `append` | `wrap` |
|
||||
|------|-----------|-----------|----------|--------|
|
||||
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||
| **script** | ✓ (default) | — | — | ✓ |
|
||||
|
||||
For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -32,6 +32,15 @@ provides:
|
||||
templates:
|
||||
# CUSTOMIZE: Define your template overrides
|
||||
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
|
||||
#
|
||||
# Strategy options (optional, defaults to "replace"):
|
||||
# replace - Fully replaces the lower-priority template (default)
|
||||
# prepend - Places this content BEFORE the lower-priority template
|
||||
# append - Places this content AFTER the lower-priority template
|
||||
# wrap - Uses {CORE_TEMPLATE} placeholder (templates/commands) or
|
||||
# $CORE_SCRIPT placeholder (scripts), replaced with lower-priority content
|
||||
#
|
||||
# Note: Scripts only support "replace" and "wrap" strategies.
|
||||
- type: "template"
|
||||
name: "spec-template"
|
||||
file: "templates/spec-template.md"
|
||||
@@ -45,6 +54,26 @@ provides:
|
||||
# description: "Custom plan template"
|
||||
# replaces: "plan-template"
|
||||
|
||||
# COMPOSITION EXAMPLES:
|
||||
# The `file` field points to the content file (can differ from the
|
||||
# convention path `templates/<name>.md`). The `name` field identifies
|
||||
# which template to compose with in the priority stack.
|
||||
#
|
||||
# Append additional sections to an existing template:
|
||||
# - type: "template"
|
||||
# name: "spec-template"
|
||||
# file: "templates/spec-addendum.md"
|
||||
# description: "Add compliance section to spec template"
|
||||
# strategy: "append"
|
||||
#
|
||||
# Wrap a command with preamble/sign-off:
|
||||
# - type: "command"
|
||||
# name: "speckit.specify"
|
||||
# file: "commands/specify-wrapper.md"
|
||||
# description: "Wrap specify command with compliance checks"
|
||||
# strategy: "wrap"
|
||||
# # In the wrapper file, use {CORE_TEMPLATE} where the original content goes
|
||||
|
||||
# OVERRIDE EXTENSION TEMPLATES:
|
||||
# Presets sit above extensions in the resolution stack, so you can
|
||||
# override templates provided by any installed extension.
|
||||
|
||||
14
presets/self-test/commands/speckit.wrap-test.md
Normal file
14
presets/self-test/commands/speckit.wrap-test.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: "Self-test wrap command — pre/post around core"
|
||||
strategy: wrap
|
||||
---
|
||||
|
||||
## Preset Pre-Logic
|
||||
|
||||
preset:self-test wrap-pre
|
||||
|
||||
{CORE_TEMPLATE}
|
||||
|
||||
## Preset Post-Logic
|
||||
|
||||
preset:self-test wrap-post
|
||||
@@ -56,6 +56,11 @@ provides:
|
||||
description: "Self-test override of the specify command"
|
||||
replaces: "speckit.specify"
|
||||
|
||||
- type: "command"
|
||||
name: "speckit.wrap-test"
|
||||
file: "commands/speckit.wrap-test.md"
|
||||
description: "Self-test wrap strategy command"
|
||||
|
||||
tags:
|
||||
- "testing"
|
||||
- "self-test"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.7.4.dev0"
|
||||
version = "0.8.0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -320,8 +320,9 @@ 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)):
|
||||
print(pid)
|
||||
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
|
||||
@@ -373,3 +374,225 @@ except Exception:
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve a template name to composed content using composition strategies.
|
||||
# Reads strategy metadata from preset manifests and composes content
|
||||
# from multiple layers using prepend, append, or wrap strategies.
|
||||
#
|
||||
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
|
||||
# Returns composed content string on stdout; exit code 1 if not found.
|
||||
resolve_template_content() {
|
||||
local template_name="$1"
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
local -a layer_paths=()
|
||||
local -a layer_strategies=()
|
||||
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
if [ -f "$override" ]; then
|
||||
layer_paths+=("$override")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
local sorted_presets=""
|
||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||
import json, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
if [ -n "$sorted_presets" ]; then
|
||||
local yaml_warned=false
|
||||
while IFS= read -r preset_id; do
|
||||
# Read strategy and file path from preset manifest
|
||||
local strategy="replace"
|
||||
local manifest_file=""
|
||||
local manifest="$presets_dir/$preset_id/preset.yml"
|
||||
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
|
||||
# Requires PyYAML; falls back to replace/convention if unavailable
|
||||
local result
|
||||
local py_stderr
|
||||
py_stderr=$(mktemp)
|
||||
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
|
||||
import sys, os
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(os.environ['SPECKIT_MANIFEST']) as f:
|
||||
data = yaml.safe_load(f)
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
sys.exit(0)
|
||||
print('replace\t')
|
||||
except Exception:
|
||||
print('replace\t')
|
||||
" 2>"$py_stderr")
|
||||
local parse_status=$?
|
||||
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
|
||||
IFS=$'\t' read -r strategy manifest_file <<< "$result"
|
||||
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
|
||||
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
|
||||
yaml_warned=true
|
||||
fi
|
||||
rm -f "$py_stderr"
|
||||
fi
|
||||
# Try manifest file path first, then convention path
|
||||
local candidate=""
|
||||
if [ -n "$manifest_file" ]; then
|
||||
# Reject absolute paths and parent traversal
|
||||
case "$manifest_file" in
|
||||
/*|*../*|../*) manifest_file="" ;;
|
||||
esac
|
||||
fi
|
||||
if [ -n "$manifest_file" ]; then
|
||||
local mf="$presets_dir/$preset_id/$manifest_file"
|
||||
[ -f "$mf" ] && candidate="$mf"
|
||||
fi
|
||||
if [ -z "$candidate" ]; then
|
||||
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$cf" ] && candidate="$cf"
|
||||
fi
|
||||
if [ -n "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("$strategy")
|
||||
fi
|
||||
done <<< "$sorted_presets"
|
||||
fi
|
||||
else
|
||||
# python3 failed — fall back to unordered directory scan (replace only)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
else
|
||||
# No python3 or registry — fall back to unordered directory scan (replace only)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
case "$(basename "$ext")" in .*) continue;; esac
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
local core="$base/${template_name}.md"
|
||||
if [ -f "$core" ]; then
|
||||
layer_paths+=("$core")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
|
||||
local count=${#layer_paths[@]}
|
||||
[ "$count" -eq 0 ] && return 1
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
local has_composition=false
|
||||
for s in "${layer_strategies[@]}"; do
|
||||
[ "$s" != "replace" ] && has_composition=true && break
|
||||
done
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if [ "${layer_strategies[0]}" = "replace" ]; then
|
||||
cat "${layer_paths[0]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$has_composition" = false ]; then
|
||||
cat "${layer_paths[0]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
# to find the nearest replace layer. Only compose layers above that base.
|
||||
local base_idx=-1
|
||||
local i
|
||||
for (( i=0; i<count; i++ )); do
|
||||
if [ "${layer_strategies[$i]}" = "replace" ]; then
|
||||
base_idx=$i
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $base_idx -lt 0 ]; then
|
||||
return 1 # no base layer found
|
||||
fi
|
||||
|
||||
# Read the base content; compose layers above the base (higher priority)
|
||||
local content
|
||||
content=$(cat "${layer_paths[$base_idx]}"; printf x)
|
||||
content="${content%x}"
|
||||
|
||||
for (( i=base_idx-1; i>=0; i-- )); do
|
||||
local path="${layer_paths[$i]}"
|
||||
local strat="${layer_strategies[$i]}"
|
||||
local layer_content
|
||||
# Preserve trailing newlines
|
||||
layer_content=$(cat "$path"; printf x)
|
||||
layer_content="${layer_content%x}"
|
||||
|
||||
case "$strat" in
|
||||
replace) content="$layer_content" ;;
|
||||
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
|
||||
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
|
||||
wrap)
|
||||
case "$layer_content" in
|
||||
*'{CORE_TEMPLATE}'*) ;;
|
||||
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
|
||||
esac
|
||||
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
|
||||
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
|
||||
local after="${layer_content#*\{CORE_TEMPLATE\}}"
|
||||
layer_content="${before}${content}${after}"
|
||||
done
|
||||
content="$layer_content"
|
||||
;;
|
||||
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '%s' "$content"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,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:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
@@ -315,6 +330,7 @@ function Resolve-Template {
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
@@ -354,3 +370,206 @@ function Resolve-Template {
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolve a template name to composed content using composition strategies.
|
||||
# Reads strategy metadata from preset manifests and composes content
|
||||
# from multiple layers using prepend, append, or wrap strategies.
|
||||
function Resolve-TemplateContent {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
$layerPaths = @()
|
||||
$layerStrategies = @()
|
||||
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) {
|
||||
$layerPaths += $override
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
} catch {
|
||||
$sortedPresets = @()
|
||||
}
|
||||
}
|
||||
|
||||
if ($sortedPresets.Count -gt 0) {
|
||||
$pyCmd = Get-Python3Command
|
||||
if (-not $pyCmd) {
|
||||
# Check if any preset has strategy fields that would be ignored
|
||||
foreach ($pid in $sortedPresets) {
|
||||
$mf = Join-Path $presetsDir "$pid/preset.yml"
|
||||
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$yamlWarned = $false
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
# Read strategy and file path from preset manifest
|
||||
$strategy = 'replace'
|
||||
$manifestFilePath = ''
|
||||
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
|
||||
if ((Test-Path $manifest) -and $pyCmd) {
|
||||
try {
|
||||
# Use Python to parse YAML manifest for strategy and file path
|
||||
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
|
||||
$pyStderrFile = [System.IO.Path]::GetTempFileName()
|
||||
$stratResult = & $pyCmd[0] @pyArgs -c @"
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
data = yaml.safe_load(f)
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
sys.exit(0)
|
||||
print('replace\t')
|
||||
except Exception:
|
||||
print('replace\t')
|
||||
"@ $manifest $TemplateName 2>$pyStderrFile
|
||||
if ($stratResult) {
|
||||
$parts = $stratResult.Trim() -split "`t", 2
|
||||
$strategy = $parts[0].ToLowerInvariant()
|
||||
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
|
||||
}
|
||||
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
|
||||
Write-Warning "PyYAML not available; composition strategies may be ignored"
|
||||
$yamlWarned = $true
|
||||
}
|
||||
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
$strategy = 'replace'
|
||||
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
# Try manifest file path first, then convention path
|
||||
$candidate = $null
|
||||
if ($manifestFilePath) {
|
||||
# Reject absolute paths and parent traversal
|
||||
if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
|
||||
$manifestFilePath = ''
|
||||
}
|
||||
}
|
||||
if ($manifestFilePath) {
|
||||
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
|
||||
if (Test-Path $mf) { $candidate = $mf }
|
||||
}
|
||||
if (-not $candidate) {
|
||||
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $cf) { $candidate = $cf }
|
||||
}
|
||||
if ($candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += $strategy
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order (no registry or parse failure)
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) {
|
||||
$layerPaths += $core
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
|
||||
if ($layerPaths.Count -eq 0) { return $null }
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if ($layerStrategies[0] -eq 'replace') {
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
}
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
$hasComposition = $false
|
||||
foreach ($s in $layerStrategies) {
|
||||
if ($s -ne 'replace') { $hasComposition = $true; break }
|
||||
}
|
||||
|
||||
if (-not $hasComposition) {
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
}
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
# to find the nearest replace layer. Only compose layers above that base.
|
||||
$baseIdx = -1
|
||||
for ($i = 0; $i -lt $layerPaths.Count; $i++) {
|
||||
if ($layerStrategies[$i] -eq 'replace') {
|
||||
$baseIdx = $i
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($baseIdx -lt 0) { return $null }
|
||||
|
||||
$content = Get-Content $layerPaths[$baseIdx] -Raw
|
||||
|
||||
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
|
||||
$path = $layerPaths[$i]
|
||||
$strat = $layerStrategies[$i]
|
||||
$layerContent = Get-Content $path -Raw
|
||||
|
||||
switch ($strat) {
|
||||
'replace' { $content = $layerContent }
|
||||
'prepend' { $content = "$layerContent`n`n$content" }
|
||||
'append' { $content = "$content`n`n$layerContent" }
|
||||
'wrap' {
|
||||
if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
|
||||
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
|
||||
}
|
||||
$content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
|
||||
}
|
||||
default { throw "Unknown strategy: $strat" }
|
||||
}
|
||||
}
|
||||
|
||||
return $content
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
# "platformdirs",
|
||||
# "readchar",
|
||||
# "json5",
|
||||
# "pyyaml",
|
||||
# "packaging",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
@@ -34,8 +36,12 @@ import json
|
||||
import json5
|
||||
import stat
|
||||
import shlex
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
@@ -51,6 +57,8 @@ from typer.core import TyperGroup
|
||||
# For cross-platform keyboard input
|
||||
import readchar
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
|
||||
def _build_agent_config() -> dict[str, dict[str, Any]]:
|
||||
"""Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
|
||||
from .integrations import INTEGRATION_REGISTRY
|
||||
@@ -318,7 +326,7 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def
|
||||
|
||||
return selected_key
|
||||
|
||||
console = Console()
|
||||
console = Console(highlight=False)
|
||||
|
||||
class BannerGroup(TyperGroup):
|
||||
"""Custom group that shows banner before help."""
|
||||
@@ -714,12 +722,18 @@ def _install_shared_infra(
|
||||
project_path: Path,
|
||||
script_type: str,
|
||||
tracker: StepTracker | None = None,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""Install shared infrastructure files into *project_path*.
|
||||
|
||||
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
|
||||
bundled core_pack or source checkout. Tracks all installed files
|
||||
in ``speckit.manifest.json``.
|
||||
|
||||
When *force* is ``True``, existing files are overwritten with the
|
||||
latest bundled versions. When ``False`` (default), only missing
|
||||
files are added and existing ones are skipped.
|
||||
|
||||
Returns ``True`` on success.
|
||||
"""
|
||||
from .integrations.manifest import IntegrationManifest
|
||||
@@ -744,12 +758,11 @@ def _install_shared_infra(
|
||||
if variant_src.is_dir():
|
||||
dest_variant = dest_scripts / variant_dir
|
||||
dest_variant.mkdir(parents=True, exist_ok=True)
|
||||
# Merge without overwriting — only add files that don't exist yet
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if src_path.is_file():
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
if dst_path.exists():
|
||||
if dst_path.exists() and not force:
|
||||
skipped_files.append(str(dst_path.relative_to(project_path)))
|
||||
else:
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -770,7 +783,7 @@ def _install_shared_infra(
|
||||
for f in templates_src.iterdir():
|
||||
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
|
||||
dst = dest_templates / f.name
|
||||
if dst.exists():
|
||||
if dst.exists() and not force:
|
||||
skipped_files.append(str(dst.relative_to(project_path)))
|
||||
else:
|
||||
shutil.copy2(f, dst)
|
||||
@@ -778,10 +791,15 @@ def _install_shared_infra(
|
||||
manifest.record_existing(rel)
|
||||
|
||||
if skipped_files:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"The following shared files already exist and were not overwritten:\n%s",
|
||||
"\n".join(f" {f}" for f in skipped_files),
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
|
||||
)
|
||||
for f in skipped_files:
|
||||
console.print(f" {f}")
|
||||
console.print(
|
||||
"To refresh shared infrastructure, run "
|
||||
"[cyan]specify init --here --force[/cyan] or "
|
||||
"[cyan]specify integration upgrade --force[/cyan]."
|
||||
)
|
||||
|
||||
manifest.save()
|
||||
@@ -920,7 +938,6 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
||||
|
||||
# Constants kept for backward compatibility with presets and extensions.
|
||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
||||
NATIVE_SKILLS_AGENTS = {"codex", "kimi"}
|
||||
SKILL_DESCRIPTIONS = {
|
||||
"specify": "Create or update feature specifications from natural language descriptions.",
|
||||
"plan": "Generate technical implementation plans from feature specifications.",
|
||||
@@ -1115,7 +1132,7 @@ def init(
|
||||
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
|
||||
else:
|
||||
error_panel = Panel(
|
||||
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
|
||||
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
||||
"Please choose a different project name or remove the existing directory.\n"
|
||||
"Use [bold]--force[/bold] to merge into the existing directory.",
|
||||
title="[red]Directory Conflict[/red]",
|
||||
@@ -1251,6 +1268,12 @@ def init(
|
||||
integration_parsed_options["commands_dir"] = ai_commands_dir
|
||||
if ai_skills:
|
||||
integration_parsed_options["skills"] = True
|
||||
# Parse --integration-options and merge into parsed_options so
|
||||
# flags like --skills reach the integration's setup().
|
||||
if integration_options:
|
||||
extra = _parse_integration_options(resolved_integration, integration_options)
|
||||
if extra:
|
||||
integration_parsed_options.update(extra)
|
||||
|
||||
resolved_integration.setup(
|
||||
project_path, manifest,
|
||||
@@ -1272,7 +1295,7 @@ def init(
|
||||
|
||||
# Install shared infrastructure (scripts, templates)
|
||||
tracker.start("shared-infra")
|
||||
_install_shared_infra(project_path, selected_script, tracker=tracker)
|
||||
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force)
|
||||
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
@@ -1371,14 +1394,15 @@ def init(
|
||||
"branch_numbering": branch_numbering or "sequential",
|
||||
"context_file": resolved_integration.context_file,
|
||||
"here": here,
|
||||
"preset": preset,
|
||||
"script": selected_script,
|
||||
"speckit_version": get_speckit_version(),
|
||||
}
|
||||
# Ensure ai_skills is set for SkillsIntegration so downstream
|
||||
# tools (extensions, presets) emit SKILL.md overrides correctly.
|
||||
# Also set for integrations running in skills mode (e.g. Copilot
|
||||
# with --skills).
|
||||
from .integrations.base import SkillsIntegration as _SkillsPersist
|
||||
if isinstance(resolved_integration, _SkillsPersist):
|
||||
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
|
||||
@@ -1490,7 +1514,7 @@ def init(
|
||||
# Determine skill display mode for the next-steps panel.
|
||||
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
|
||||
from .integrations.base import SkillsIntegration as _SkillsInt
|
||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
|
||||
_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)
|
||||
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
|
||||
@@ -1498,7 +1522,8 @@ def init(
|
||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||
trae_skill_mode = selected_ai == "trae"
|
||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
||||
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:
|
||||
# Integration path installed skills; show the helpful notice
|
||||
@@ -1519,7 +1544,7 @@ def init(
|
||||
return f"/speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
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}"
|
||||
|
||||
@@ -1600,25 +1625,10 @@ def check():
|
||||
def version():
|
||||
"""Display version and system information."""
|
||||
import platform
|
||||
import importlib.metadata
|
||||
|
||||
show_banner()
|
||||
|
||||
# Get CLI version from package metadata
|
||||
cli_version = "unknown"
|
||||
try:
|
||||
cli_version = importlib.metadata.version("specify-cli")
|
||||
except Exception:
|
||||
# Fallback: try reading from pyproject.toml if running from source
|
||||
try:
|
||||
import tomllib
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
cli_version = data.get("project", {}).get("version", "unknown")
|
||||
except Exception:
|
||||
pass
|
||||
cli_version = get_speckit_version()
|
||||
|
||||
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
info_table.add_column("Key", style="cyan", justify="right")
|
||||
@@ -1641,6 +1651,163 @@ def version():
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
def _get_installed_version() -> str:
|
||||
"""Return the installed specify-cli distribution version or 'unknown'.
|
||||
|
||||
Uses importlib.metadata so the value reflects what was actually installed
|
||||
by pip/uv/pipx — not a value read from pyproject.toml. This is
|
||||
intentional for `specify self check`, which should reason about the
|
||||
installed distribution rather than a source-tree fallback. Callers must
|
||||
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
metadata_errors = [importlib.metadata.PackageNotFoundError]
|
||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||
if invalid_metadata_error is not None:
|
||||
metadata_errors.append(invalid_metadata_error)
|
||||
|
||||
try:
|
||||
return importlib.metadata.version("specify-cli")
|
||||
except tuple(metadata_errors):
|
||||
return "unknown"
|
||||
|
||||
def _normalize_tag(tag: str) -> str:
|
||||
"""Strip exactly one leading 'v' from a release tag.
|
||||
|
||||
Returns the rest of the string unchanged. This handles the common
|
||||
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
|
||||
aggressively (e.g., two leading 'v's keeps one).
|
||||
"""
|
||||
return tag[1:] if tag.startswith("v") else tag
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
|
||||
|
||||
Returns False whenever either side is 'unknown' or fails to parse; this
|
||||
keeps the comparison indeterminate (rather than crashing or falsely
|
||||
recommending a downgrade) on edge inputs.
|
||||
"""
|
||||
if latest == "unknown" or current == "unknown":
|
||||
return False
|
||||
try:
|
||||
return Version(latest) > Version(current)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
|
||||
|
||||
On success: (tag_name, None).
|
||||
On a documented network/HTTP failure (added in T029/T030): (None, category).
|
||||
On anything else — including a malformed response body — the exception
|
||||
propagates; there is no catch-all (research D-006).
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
GITHUB_API_LATEST,
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
token = None
|
||||
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
|
||||
candidate = os.environ.get(env_var)
|
||||
if candidate is not None:
|
||||
candidate = candidate.strip()
|
||||
if candidate:
|
||||
token = candidate
|
||||
break
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
raise ValueError("GitHub API response missing valid tag_name")
|
||||
return tag, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# Order matters: HTTPError is a subclass of URLError.
|
||||
if e.code == 403:
|
||||
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||
return None, f"HTTP {e.code}"
|
||||
except (urllib.error.URLError, OSError):
|
||||
return None, "offline or timeout"
|
||||
|
||||
|
||||
# ===== Self Commands =====
|
||||
self_app = typer.Typer(
|
||||
name="self",
|
||||
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
|
||||
add_completion=False,
|
||||
)
|
||||
app.add_typer(self_app, name="self")
|
||||
|
||||
@self_app.command("check")
|
||||
def self_check() -> None:
|
||||
"""Check whether a newer specify-cli release is available. Read-only.
|
||||
|
||||
This command only checks for updates; it does not modify your installation.
|
||||
The reserved (and currently non-destructive) `specify self upgrade` command
|
||||
is the name that a future release will use for actual self-upgrade — its
|
||||
behavior is not implemented in this release and is intentionally out of
|
||||
scope here. See `specify self upgrade --help` for its current status.
|
||||
"""
|
||||
|
||||
installed = _get_installed_version()
|
||||
tag, failure_reason = _fetch_latest_release_tag()
|
||||
|
||||
if tag is None:
|
||||
# Graceful-failure path (FR-008). `failure_reason` is one of the
|
||||
# enumerated strings produced by _fetch_latest_release_tag() — it
|
||||
# never contains a URL, headers, response body, or traceback.
|
||||
assert failure_reason is not None
|
||||
console.print(f"Installed: {installed}")
|
||||
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
|
||||
return
|
||||
|
||||
latest_normalized = _normalize_tag(tag)
|
||||
|
||||
if installed == "unknown":
|
||||
# FR-020: surface the latest release and the recovery action even
|
||||
# when the local distribution metadata is unavailable.
|
||||
console.print("Current version could not be determined.")
|
||||
console.print(f"Latest release: {latest_normalized}")
|
||||
console.print("\nTo reinstall:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
if _is_newer(latest_normalized, installed):
|
||||
console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}")
|
||||
console.print("\nTo upgrade:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
# Installed is parseable AND is >= latest → "up to date" (FR-006).
|
||||
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
|
||||
# returns False, and the up-to-date branch is the safer default per
|
||||
# FR-004 / test T016.
|
||||
console.print(f"[green]Up to date:[/green] {installed}")
|
||||
|
||||
|
||||
@self_app.command("upgrade")
|
||||
def self_upgrade() -> None:
|
||||
"""Reserved command surface for self-upgrade; not implemented in this release.
|
||||
|
||||
This command is a documented non-destructive stub in this release: it
|
||||
performs no outbound network request, no install-method detection, and
|
||||
invokes no installer. It prints a three-line guidance message and exits 0.
|
||||
Actual self-upgrade is planned as follow-up work.
|
||||
|
||||
Use `specify self check` today to see whether a newer release is available
|
||||
and to get a copy-pasteable reinstall command.
|
||||
"""
|
||||
console.print("specify self upgrade is not implemented yet.")
|
||||
console.print("Run 'specify self check' to see whether a newer release is available.")
|
||||
console.print("Actual self-upgrade is planned as follow-up work.")
|
||||
|
||||
|
||||
# ===== Extension Commands =====
|
||||
|
||||
@@ -2008,7 +2175,7 @@ def _update_init_options_for_integration(
|
||||
opts["context_file"] = integration.context_file
|
||||
if script_type:
|
||||
opts["script"] = script_type
|
||||
if isinstance(integration, SkillsIntegration):
|
||||
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
|
||||
opts["ai_skills"] = True
|
||||
else:
|
||||
opts.pop("ai_skills", None)
|
||||
@@ -2298,9 +2465,8 @@ def integration_upgrade(
|
||||
|
||||
selected_script = _resolve_script_type(project_root, script)
|
||||
|
||||
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||
# _install_shared_infra merges missing files without overwriting).
|
||||
_install_shared_infra(project_root, selected_script)
|
||||
# Ensure shared infrastructure is up to date; --force overwrites existing files.
|
||||
_install_shared_infra(project_root, selected_script, force=force)
|
||||
if os.name != "nt":
|
||||
ensure_executable_scripts(project_root)
|
||||
|
||||
@@ -2596,14 +2762,58 @@ def preset_resolve(
|
||||
raise typer.Exit(1)
|
||||
|
||||
resolver = PresetResolver(project_root)
|
||||
result = resolver.resolve_with_source(template_name)
|
||||
layers = resolver.collect_all_layers(template_name)
|
||||
|
||||
if result:
|
||||
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
||||
console.print(f" [dim](from: {result['source']})[/dim]")
|
||||
if layers:
|
||||
# Use the highest-priority layer for display because the final output
|
||||
# may be composed and may not map to resolve_with_source()'s single path.
|
||||
display_layer = layers[0]
|
||||
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
|
||||
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
|
||||
|
||||
has_composition = (
|
||||
layers[0]["strategy"] != "replace"
|
||||
and any(layer["strategy"] != "replace" for layer in layers)
|
||||
)
|
||||
if has_composition:
|
||||
# Verify composition is actually possible
|
||||
try:
|
||||
composed = resolver.resolve_content(template_name)
|
||||
except Exception as exc:
|
||||
composed = None
|
||||
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
|
||||
if composed is None:
|
||||
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
|
||||
else:
|
||||
console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]")
|
||||
console.print("\n [bold]Composition chain:[/bold]")
|
||||
# Compute the effective base: first replace layer scanning from
|
||||
# highest priority (matching resolve_content top-down logic).
|
||||
# Only show layers from the base upward (lower layers are ignored).
|
||||
effective_base_idx = None
|
||||
for idx, lyr in enumerate(layers):
|
||||
if lyr["strategy"] == "replace":
|
||||
effective_base_idx = idx
|
||||
break
|
||||
# Show only contributing layers (base and above)
|
||||
if effective_base_idx is not None:
|
||||
contributing = layers[:effective_base_idx + 1]
|
||||
else:
|
||||
contributing = layers
|
||||
for i, layer in enumerate(reversed(contributing)):
|
||||
strategy_label = layer["strategy"]
|
||||
if strategy_label == "replace" and i == 0:
|
||||
strategy_label = "base"
|
||||
console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}")
|
||||
else:
|
||||
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
||||
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
||||
# No layers found — fall back to resolve_with_source for non-composition cases
|
||||
result = resolver.resolve_with_source(template_name)
|
||||
if result:
|
||||
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
||||
console.print(f" [dim](from: {result['source']})[/dim]")
|
||||
else:
|
||||
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
||||
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
||||
|
||||
|
||||
@preset_app.command("info")
|
||||
|
||||
@@ -6,8 +6,9 @@ Used by both the extension system and the preset system to write
|
||||
command files into agent-specific directories in the correct format.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
import platform
|
||||
import re
|
||||
@@ -281,7 +282,8 @@ class CommandRegistrar:
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
if agent_name in {"codex", "kimi"}:
|
||||
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
@@ -399,6 +401,28 @@ class CommandRegistrar:
|
||||
|
||||
return f"speckit-{short_name}"
|
||||
|
||||
@staticmethod
|
||||
def _ensure_inside(candidate: Path, base: Path) -> None:
|
||||
"""Validate that a write target stays within the expected base directory.
|
||||
|
||||
Uses lexical normalization so traversal via ``..`` or absolute paths is
|
||||
rejected while intentionally symlinked sub-directories remain
|
||||
supported.
|
||||
|
||||
Args:
|
||||
candidate: Path that will be written.
|
||||
base: Directory the write must remain within.
|
||||
|
||||
Raises:
|
||||
ValueError: If the normalized candidate path escapes ``base``.
|
||||
"""
|
||||
normalized = Path(os.path.normpath(candidate))
|
||||
base_normalized = Path(os.path.normpath(base))
|
||||
if not normalized.is_relative_to(base_normalized):
|
||||
raise ValueError(
|
||||
f"Output path {candidate!r} escapes directory {base!r}"
|
||||
)
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -445,6 +469,15 @@ class CommandRegistrar:
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
frontmatter, body = self.parse_frontmatter(content)
|
||||
|
||||
if frontmatter.get("strategy") == "wrap":
|
||||
from .presets import _substitute_core_template
|
||||
body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self)
|
||||
frontmatter = dict(frontmatter)
|
||||
for key in ("scripts", "agent_scripts"):
|
||||
if key not in frontmatter and key in core_frontmatter:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
frontmatter.pop("strategy", None)
|
||||
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
|
||||
for key in agent_config.get("strip_frontmatter_keys", []):
|
||||
@@ -472,10 +505,12 @@ class CommandRegistrar:
|
||||
project_root,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
output = self.render_markdown_command(
|
||||
frontmatter, body, source_id, context_note
|
||||
)
|
||||
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
|
||||
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||
elif agent_config["format"] == "toml":
|
||||
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
|
||||
output = self.render_toml_command(frontmatter, body, source_id)
|
||||
elif agent_config["format"] == "yaml":
|
||||
output = self.render_yaml_command(
|
||||
@@ -485,6 +520,7 @@ class CommandRegistrar:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
self._ensure_inside(dest_file, commands_dir)
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_file.write_text(output, encoding="utf-8")
|
||||
|
||||
@@ -550,12 +586,7 @@ class CommandRegistrar:
|
||||
alias_file = (
|
||||
commands_dir / f"{alias_output_name}{agent_config['extension']}"
|
||||
)
|
||||
try:
|
||||
alias_file.resolve().relative_to(commands_dir.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Alias output path escapes commands directory: {alias_file!r}"
|
||||
)
|
||||
self._ensure_inside(alias_file, commands_dir)
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(alias_output, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
@@ -575,6 +606,7 @@ class CommandRegistrar:
|
||||
prompts_dir = project_root / ".github" / "prompts"
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
|
||||
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
|
||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
@@ -620,6 +652,49 @@ class CommandRegistrar:
|
||||
|
||||
return results
|
||||
|
||||
def register_commands_for_non_skill_agents(
|
||||
self,
|
||||
commands: List[Dict[str, Any]],
|
||||
source_id: str,
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: Optional[str] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all non-skill agents in the project.
|
||||
|
||||
Like register_commands_for_all_agents but skips skill-based agents
|
||||
(those with extension '/SKILL.md'). Used by reconciliation to avoid
|
||||
overwriting properly formatted SKILL.md files.
|
||||
|
||||
Args:
|
||||
commands: List of command info dicts
|
||||
source_id: Identifier of the source
|
||||
source_dir: Directory containing command source files
|
||||
project_root: Path to project root
|
||||
context_note: Custom context comment for markdown output
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
"""
|
||||
results = {}
|
||||
self._ensure_configs()
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
continue
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name, commands, source_id,
|
||||
source_dir, project_root,
|
||||
context_note=context_note,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
except ValueError:
|
||||
continue
|
||||
return results
|
||||
|
||||
def unregister_commands(
|
||||
self, registered_commands: Dict[str, List[str]], project_root: Path
|
||||
) -> None:
|
||||
|
||||
@@ -140,11 +140,16 @@ class ExtensionManifest:
|
||||
"""Load YAML file safely."""
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValidationError(f"Invalid YAML in {path}: {e}")
|
||||
except FileNotFoundError:
|
||||
raise ValidationError(f"Manifest not found: {path}")
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}"
|
||||
)
|
||||
return data
|
||||
|
||||
def _validate(self):
|
||||
"""Validate manifest structure and required fields."""
|
||||
|
||||
@@ -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/``
|
||||
- Installs ``.vscode/settings.json`` with prompt file recommendations
|
||||
- Context file lives at ``.github/copilot-instructions.md``
|
||||
|
||||
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
|
||||
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
|
||||
instead. The two modes are mutually exclusive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,7 +20,7 @@ import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationBase
|
||||
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
@@ -44,12 +48,40 @@ def _allow_all() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _CopilotSkillsHelper(SkillsIntegration):
|
||||
"""Internal helper used when Copilot is scaffolded in skills mode.
|
||||
|
||||
Not registered in the integration registry — only used as a delegate
|
||||
by ``CopilotIntegration`` when ``--skills`` is passed.
|
||||
"""
|
||||
|
||||
key = "copilot"
|
||||
config = {
|
||||
"name": "GitHub Copilot",
|
||||
"folder": ".github/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".github/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = ".github/copilot-instructions.md"
|
||||
|
||||
|
||||
class CopilotIntegration(IntegrationBase):
|
||||
"""Integration for GitHub Copilot (VS Code IDE + CLI).
|
||||
|
||||
The IDE integration (``requires_cli: False``) installs ``.agent.md``
|
||||
command files. Workflow dispatch additionally requires the
|
||||
``copilot`` CLI to be installed separately.
|
||||
|
||||
When ``--skills`` is passed via ``--integration-options``, commands
|
||||
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
|
||||
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
|
||||
"""
|
||||
|
||||
key = "copilot"
|
||||
@@ -68,6 +100,20 @@ class CopilotIntegration(IntegrationBase):
|
||||
}
|
||||
context_file = ".github/copilot-instructions.md"
|
||||
|
||||
# Mutable flag set by setup() — indicates the active scaffolding mode.
|
||||
_skills_mode: bool = False
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
|
||||
),
|
||||
]
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -92,7 +138,19 @@ class CopilotIntegration(IntegrationBase):
|
||||
return args
|
||||
|
||||
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 "." in stem:
|
||||
stem = stem.rsplit(".", 1)[-1]
|
||||
invocation = f"/speckit-{stem}"
|
||||
if args:
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
return args or ""
|
||||
|
||||
def dispatch_command(
|
||||
@@ -110,19 +168,37 @@ class CopilotIntegration(IntegrationBase):
|
||||
Copilot ``.agent.md`` files are agents, not skills. The CLI
|
||||
selects them with ``--agent <name>`` and the prompt is just
|
||||
the user's arguments.
|
||||
|
||||
In skills mode, the prompt includes the skill invocation
|
||||
(``/speckit-<stem>``).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
stem = command_name
|
||||
if "." in stem:
|
||||
stem = stem.rsplit(".", 1)[-1]
|
||||
agent_name = f"speckit.{stem}"
|
||||
|
||||
prompt = args or ""
|
||||
cli_args = [
|
||||
"copilot", "-p", prompt,
|
||||
"--agent", agent_name,
|
||||
]
|
||||
# Detect skills mode from project layout when not set via setup()
|
||||
skills_mode = self._skills_mode
|
||||
if not skills_mode and project_root:
|
||||
skills_dir = project_root / ".github" / "skills"
|
||||
if skills_dir.is_dir():
|
||||
skills_mode = any(
|
||||
d.is_dir() and (d / "SKILL.md").is_file()
|
||||
for d in skills_dir.glob("speckit-*")
|
||||
)
|
||||
|
||||
if skills_mode:
|
||||
prompt = f"/speckit-{stem}"
|
||||
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():
|
||||
cli_args.append("--yolo")
|
||||
if model:
|
||||
@@ -168,6 +244,59 @@ class CopilotIntegration(IntegrationBase):
|
||||
"""Copilot commands use ``.agent.md`` extension."""
|
||||
return f"speckit.{template_name}.agent.md"
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
|
||||
|
||||
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
|
||||
Copilot can associate the skill with its agent mode.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Extract skill name from frontmatter to derive the mode value
|
||||
dash_count = 0
|
||||
skill_name = ""
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1:
|
||||
if stripped.startswith("mode:"):
|
||||
return content # already present
|
||||
if stripped.startswith("name:"):
|
||||
# Parse: name: "speckit-plan" → speckit.plan
|
||||
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
# Convert speckit-plan → speckit.plan
|
||||
if val.startswith("speckit-"):
|
||||
skill_name = "speckit." + val[len("speckit-"):]
|
||||
else:
|
||||
skill_name = val
|
||||
|
||||
if not skill_name:
|
||||
return content
|
||||
|
||||
# Inject mode: before the closing --- of frontmatter
|
||||
out: list[str] = []
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2 and not injected:
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
elif line.endswith("\n"):
|
||||
eol = "\n"
|
||||
else:
|
||||
eol = ""
|
||||
out.append(f"mode: {skill_name}{eol}")
|
||||
injected = True
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -177,10 +306,24 @@ class CopilotIntegration(IntegrationBase):
|
||||
) -> list[Path]:
|
||||
"""Install copilot commands, companion prompts, and VS Code settings.
|
||||
|
||||
Uses base class primitives to: read templates, process them
|
||||
(replace placeholders, strip script blocks, rewrite paths),
|
||||
write as ``.agent.md``, then add companion prompts and VS Code settings.
|
||||
When ``parsed_options["skills"]`` is truthy, delegates to skills
|
||||
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
|
||||
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
|
||||
"""
|
||||
parsed_options = parsed_options or {}
|
||||
self._skills_mode = bool(parsed_options.get("skills"))
|
||||
if self._skills_mode:
|
||||
return self._setup_skills(project_root, manifest, parsed_options, **opts)
|
||||
return self._setup_default(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
def _setup_default(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
|
||||
project_root_resolved = project_root.resolve()
|
||||
if manifest.project_root != project_root_resolved:
|
||||
raise ValueError(
|
||||
@@ -252,6 +395,37 @@ class CopilotIntegration(IntegrationBase):
|
||||
|
||||
return created
|
||||
|
||||
def _setup_skills(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process."""
|
||||
helper = _CopilotSkillsHelper()
|
||||
created = SkillsIntegration.setup(
|
||||
helper, project_root, manifest, parsed_options, **opts
|
||||
)
|
||||
|
||||
# Post-process generated skill files with Copilot-specific frontmatter
|
||||
skills_dir = helper.skills_dest(project_root).resolve()
|
||||
for path in created:
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
updated = self.post_process_skill_content(content)
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
def _vscode_settings_path(self) -> Path | None:
|
||||
"""Return path to the bundled vscode-settings.json template."""
|
||||
tpl_dir = self.shared_templates_dir()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -173,13 +173,13 @@ class TestInitIntegrationFlag:
|
||||
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_shared_infra_skips_existing_files(self, tmp_path):
|
||||
"""Pre-existing shared files are not overwritten by _install_shared_infra."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
|
||||
"""Pre-existing shared files are not overwritten without --force."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "skip-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
# Pre-create a shared script with custom content
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
@@ -193,6 +193,97 @@ class TestInitIntegrationFlag:
|
||||
custom_template = "# user-modified spec-template\n"
|
||||
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
# User's files should be preserved (not overwritten)
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
|
||||
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
|
||||
|
||||
# Other shared files should still be installed
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
|
||||
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
|
||||
"""Pre-existing shared files ARE overwritten when force=True."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "force-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
# Pre-create a shared script with custom content
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
custom_content = "# user-modified common.sh\n"
|
||||
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
||||
|
||||
# Pre-create a shared template with custom content
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
custom_template = "# user-modified spec-template\n"
|
||||
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
# Files should be overwritten with bundled versions
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template
|
||||
|
||||
# Other shared files should also be installed
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
|
||||
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
|
||||
"""Console warning is displayed when files are skipped."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "warn-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "already exist and were not updated" in captured.out
|
||||
assert "specify init --here --force" in captured.out
|
||||
# Rich may wrap long lines; normalize whitespace for the second command
|
||||
normalized = " ".join(captured.out.split())
|
||||
assert "specify integration upgrade --force" in normalized
|
||||
|
||||
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
|
||||
"""No skip warning when force=True (all files overwritten)."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "no-warn-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "already exist and were not updated" not in captured.out
|
||||
|
||||
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
|
||||
"""E2E: specify init --here --force overwrites shared infra files."""
|
||||
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()
|
||||
try:
|
||||
os.chdir(project)
|
||||
@@ -207,14 +298,40 @@ class TestInitIntegrationFlag:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0
|
||||
# --force should overwrite the custom file
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||
|
||||
# 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 (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()
|
||||
# Warning about skipped files should appear
|
||||
assert "not updated" in result.output
|
||||
|
||||
|
||||
class TestForceExistingDirectory:
|
||||
@@ -261,7 +378,7 @@ class TestForceExistingDirectory:
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in result.output
|
||||
assert "already exists" in _normalize_cli_output(result.output)
|
||||
|
||||
|
||||
class TestGitExtensionAutoInstall:
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
@@ -275,3 +277,420 @@ class TestCopilotIntegration:
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
|
||||
class TestCopilotSkillsMode:
|
||||
"""Tests for Copilot integration in --skills mode."""
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _make_copilot(self):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
return CopilotIntegration()
|
||||
|
||||
def _setup_skills(self, copilot, tmp_path):
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
|
||||
return created, m
|
||||
|
||||
# -- Options ----------------------------------------------------------
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
copilot = get_integration("copilot")
|
||||
opts = copilot.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 1
|
||||
assert skills_opts[0].is_flag is True
|
||||
assert skills_opts[0].default is False
|
||||
|
||||
# -- Skills directory structure ---------------------------------------
|
||||
|
||||
def test_skills_creates_skill_files(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
assert len(created) > 0
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
assert f.exists()
|
||||
assert f.parent.name.startswith("speckit-")
|
||||
|
||||
def test_skills_directory_under_github_skills(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skills_dir = tmp_path / ".github" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
assert f.resolve().parent.parent == skills_dir.resolve(), (
|
||||
f"{f} is not under {skills_dir}"
|
||||
)
|
||||
|
||||
def test_skills_directory_structure(self, tmp_path):
|
||||
"""Each command produces speckit-<name>/SKILL.md."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
expected_commands = set(self._SKILL_COMMANDS)
|
||||
actual_commands = set()
|
||||
for f in skill_files:
|
||||
skill_dir_name = f.parent.name
|
||||
assert skill_dir_name.startswith("speckit-")
|
||||
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
# -- No companion files in skills mode --------------------------------
|
||||
|
||||
def test_skills_no_prompt_md_companions(self, tmp_path):
|
||||
"""Skills mode must not generate .prompt.md companion files."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
prompt_files = [f for f in created if f.name.endswith(".prompt.md")]
|
||||
assert prompt_files == []
|
||||
prompts_dir = tmp_path / ".github" / "prompts"
|
||||
if prompts_dir.exists():
|
||||
assert list(prompts_dir.iterdir()) == []
|
||||
|
||||
def test_skills_no_vscode_settings(self, tmp_path):
|
||||
"""Skills mode must not create or merge .vscode/settings.json."""
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
settings = tmp_path / ".vscode" / "settings.json"
|
||||
assert not settings.exists()
|
||||
|
||||
def test_skills_no_agent_md_files(self, tmp_path):
|
||||
"""Skills mode must not produce .agent.md files."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
agent_files = [f for f in created if f.name.endswith(".agent.md")]
|
||||
assert agent_files == []
|
||||
|
||||
# -- Frontmatter structure --------------------------------------------
|
||||
|
||||
def test_skill_frontmatter_structure(self, tmp_path):
|
||||
"""SKILL.md must have name, description, compatibility, metadata."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n"), f"{f} missing frontmatter"
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "name" in fm, f"{f} frontmatter missing 'name'"
|
||||
assert "description" in fm, f"{f} frontmatter missing 'description'"
|
||||
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
|
||||
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
|
||||
assert fm["metadata"]["author"] == "github-spec-kit"
|
||||
|
||||
# -- Copilot-specific post-processing ---------------------------------
|
||||
|
||||
def test_post_process_skill_content_injects_mode(self):
|
||||
"""post_process_skill_content() should inject mode: field."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
'name: "speckit-plan"\n'
|
||||
'description: "Plan workflow"\n'
|
||||
"---\n"
|
||||
"\nBody content\n"
|
||||
)
|
||||
updated = copilot.post_process_skill_content(content)
|
||||
assert "mode: speckit.plan" in updated
|
||||
|
||||
def test_post_process_idempotent(self):
|
||||
"""post_process_skill_content() must be idempotent."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
'name: "speckit-plan"\n'
|
||||
'description: "Plan workflow"\n'
|
||||
"---\n"
|
||||
"\nBody content\n"
|
||||
)
|
||||
first = copilot.post_process_skill_content(content)
|
||||
second = copilot.post_process_skill_content(first)
|
||||
assert first == second
|
||||
|
||||
def test_skills_have_mode_in_frontmatter(self, tmp_path):
|
||||
"""Generated SKILL.md files should have mode: field from post-processing."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "mode" in fm, f"{f} frontmatter missing 'mode'"
|
||||
# mode should be speckit.<stem>
|
||||
skill_dir_name = f.parent.name
|
||||
stem = skill_dir_name.removeprefix("speckit-")
|
||||
assert fm["mode"] == f"speckit.{stem}"
|
||||
|
||||
# -- Template processing ----------------------------------------------
|
||||
|
||||
def test_skills_templates_are_processed(self, tmp_path):
|
||||
"""Skill body must have placeholders replaced."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
|
||||
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_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}"
|
||||
)
|
||||
@@ -217,6 +217,14 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Missing required field"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_non_mapping_yaml_raises_validation_error(self, temp_dir):
|
||||
"""Manifest whose YAML root is a scalar or list raises ValidationError, not TypeError."""
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
for bad_content in ("42\n", "[]\n", "null\n"):
|
||||
manifest_path.write_text(bad_content)
|
||||
with pytest.raises(ValidationError, match="YAML mapping"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_invalid_extension_id(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with invalid extension ID format."""
|
||||
import yaml
|
||||
@@ -1361,6 +1369,79 @@ Agent __AGENT__
|
||||
assert "{ARGS}" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
|
||||
@pytest.mark.parametrize("agent_name,skills_path", [
|
||||
("codex", ".agents/skills"),
|
||||
("kimi", ".kimi/skills"),
|
||||
("claude", ".claude/skills"),
|
||||
("cursor-agent", ".cursor/skills"),
|
||||
("trae", ".trae/skills"),
|
||||
("agy", ".agents/skills"),
|
||||
])
|
||||
def test_all_skill_agents_register_commands_with_resolved_placeholders(
|
||||
self, project_dir, temp_dir, agent_name, skills_path
|
||||
):
|
||||
"""All SKILL.md agents must produce fully resolved SKILL.md files when commands are registered."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / f"ext-{agent_name}"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": f"ext-{agent_name}",
|
||||
"name": "Scripted Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": f"speckit.ext-{agent_name}.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Scripted command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "run.md").write_text(
|
||||
"---\n"
|
||||
"description: Scripted command\n"
|
||||
"scripts:\n"
|
||||
' sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"\n'
|
||||
"---\n\n"
|
||||
"Run {SCRIPT}\n"
|
||||
"Agent is __AGENT__.\n"
|
||||
)
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(f'{{"ai":"{agent_name}","script":"sh"}}')
|
||||
|
||||
skills_dir = project_dir
|
||||
for part in skills_path.split("/"):
|
||||
skills_dir = skills_dir / part
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent(agent_name, manifest, ext_dir, project_dir)
|
||||
|
||||
skill_dir_name = f"speckit-ext-{agent_name}-run"
|
||||
skill_file = skills_dir / skill_dir_name / "SKILL.md"
|
||||
assert skill_file.exists(), f"SKILL.md not created for {agent_name}"
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "{SCRIPT}" not in content, f"{{SCRIPT}} not resolved for {agent_name}"
|
||||
assert "__AGENT__" not in content, f"__AGENT__ not resolved for {agent_name}"
|
||||
assert "{ARGS}" not in content, f"{{ARGS}} not resolved for {agent_name}"
|
||||
assert '.specify/scripts/bash/setup-plan.sh' in content
|
||||
|
||||
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
|
||||
"""Codex alias skills should render their own matching `name:` frontmatter."""
|
||||
import yaml
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
204
tests/test_registrar_path_traversal.py
Normal file
204
tests/test_registrar_path_traversal.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Tests for CommandRegistrar directory traversal guards around issue #2229."""
|
||||
|
||||
import errno
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
|
||||
TRAVERSAL_PAYLOADS = [
|
||||
"../pwned",
|
||||
"../../etc/passwd",
|
||||
"subdir/../../escape",
|
||||
"/absolute/evil",
|
||||
]
|
||||
|
||||
|
||||
def _write_source(ext_dir: Path) -> Path:
|
||||
ext_dir.mkdir(parents=True, exist_ok=True)
|
||||
(ext_dir / "commands").mkdir(exist_ok=True)
|
||||
(ext_dir / "commands" / "cmd.md").write_text(
|
||||
"---\ndescription: test\n---\n\nbody\n", encoding="utf-8"
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
|
||||
def _cmd(name: str, aliases: list[str] | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"name": name,
|
||||
"file": "commands/cmd.md",
|
||||
"aliases": list(aliases or []),
|
||||
}
|
||||
|
||||
|
||||
def _project_and_source(tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
ext_dir = _write_source(tmp_path / "ext-src")
|
||||
return project, ext_dir
|
||||
|
||||
|
||||
def _assert_no_stray_files(tmp_root: Path, marker: str) -> None:
|
||||
"""Fail if a file matching ``marker`` exists outside the project tree."""
|
||||
stray = [
|
||||
p for p in tmp_root.rglob("*")
|
||||
if p.is_file() and marker in p.name and "project" not in p.parts
|
||||
]
|
||||
assert stray == [], (
|
||||
f"Traversal payload leaked files outside the project tree: {stray}"
|
||||
)
|
||||
|
||||
|
||||
class TestPrimaryCommandTraversal:
|
||||
"""Primary command names must not escape the agent's commands directory."""
|
||||
|
||||
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
|
||||
def test_gemini_rejects_traversal_in_primary_name(self, tmp_path, bad_name):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
registrar.register_commands(
|
||||
"gemini", [_cmd(bad_name)], "myext", ext_dir, project
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
|
||||
|
||||
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
|
||||
def test_copilot_rejects_traversal_in_primary_name(self, tmp_path, bad_name):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".github" / "agents").mkdir(parents=True)
|
||||
(project / ".github" / "prompts").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
registrar.register_commands(
|
||||
"copilot", [_cmd(bad_name)], "myext", ext_dir, project
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
|
||||
|
||||
|
||||
class TestAliasTraversal:
|
||||
"""Free-form aliases must not escape commands_dir (regression for b67b285)."""
|
||||
|
||||
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
|
||||
def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
registrar.register_commands(
|
||||
"gemini",
|
||||
[_cmd("speckit.myext.ok", [bad_alias])],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
|
||||
|
||||
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
|
||||
def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".github" / "agents").mkdir(parents=True)
|
||||
(project / ".github" / "prompts").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
registrar.register_commands(
|
||||
"copilot",
|
||||
[_cmd("speckit.myext.ok", [bad_alias])],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
|
||||
|
||||
|
||||
class TestCopilotPromptTraversal:
|
||||
"""`write_copilot_prompt` is a public static method — guard it directly."""
|
||||
|
||||
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
|
||||
def test_rejects_traversal_names(self, tmp_path, bad_name):
|
||||
project = tmp_path / "project"
|
||||
(project / ".github" / "prompts").mkdir(parents=True)
|
||||
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
CommandRegistrar.write_copilot_prompt(project, bad_name)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
|
||||
|
||||
|
||||
class TestSafeRegistration:
|
||||
"""Positive regression — well-formed names continue to register."""
|
||||
|
||||
def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path):
|
||||
"""Lexical check must not block legitimately symlinked sub-directories.
|
||||
|
||||
Teams sometimes symlink shared skills into their agent commands dir
|
||||
(e.g. ``.gemini/commands/shared -> /team/shared-commands``). The
|
||||
guard is purely lexical, so such a setup continues to work even though
|
||||
the resolved target lives outside commands_dir on disk.
|
||||
"""
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
commands_dir = project / ".gemini" / "commands"
|
||||
commands_dir.mkdir(parents=True)
|
||||
|
||||
external_shared = tmp_path / "external-shared"
|
||||
external_shared.mkdir()
|
||||
try:
|
||||
(commands_dir / "shared").symlink_to(
|
||||
external_shared, target_is_directory=True
|
||||
)
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EPERM, errno.EACCES}:
|
||||
pytest.skip("symlink creation is not permitted in this environment")
|
||||
raise
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"gemini",
|
||||
[_cmd("shared/hello")],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert registered == ["shared/hello"]
|
||||
assert (external_shared / "hello.toml").exists()
|
||||
|
||||
def test_safe_command_and_alias_still_register(self, tmp_path):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".claude" / "skills").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"claude",
|
||||
[_cmd("speckit.myext.hello", ["speckit.myext.hi"])],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert "speckit.myext.hello" in registered
|
||||
assert "speckit.myext.hi" in registered
|
||||
assert (
|
||||
project
|
||||
/ ".claude"
|
||||
/ "skills"
|
||||
/ "speckit-myext-hello"
|
||||
/ "SKILL.md"
|
||||
).exists()
|
||||
assert (
|
||||
project
|
||||
/ ".claude"
|
||||
/ "skills"
|
||||
/ "speckit-myext-hi"
|
||||
/ "SKILL.md"
|
||||
).exists()
|
||||
384
tests/test_upgrade.py
Normal file
384
tests/test_upgrade.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""Tests for the `specify self` sub-app (`self check` and `self upgrade`).
|
||||
|
||||
Network isolation contract (SC-004 / FR-014): every test that exercises
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock
|
||||
`urllib.request.urlopen` so no real outbound call ever reaches
|
||||
api.github.com. The `self upgrade` stub tests do not need that patch because
|
||||
the stub is contractually network-free. Run this module under `pytest-socket`
|
||||
(if installed) with `--disable-socket` as an extra safety net.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import importlib.metadata
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import (
|
||||
_get_installed_version,
|
||||
_fetch_latest_release_tag,
|
||||
_is_newer,
|
||||
_normalize_tag,
|
||||
app,
|
||||
)
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
|
||||
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
|
||||
|
||||
def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
||||
return urllib.error.HTTPError(
|
||||
url="https://api.github.com/repos/github/spec-kit/releases/latest",
|
||||
code=code,
|
||||
msg=message,
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
|
||||
|
||||
class TestSelfUpgradeStub:
|
||||
"""Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016)."""
|
||||
|
||||
def test_prints_exactly_three_lines_and_exits_zero(self):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
lines = strip_ansi(result.output).strip().splitlines()
|
||||
assert lines == [
|
||||
"specify self upgrade is not implemented yet.",
|
||||
"Run 'specify self check' to see whether a newer release is available.",
|
||||
"Actual self-upgrade is planned as follow-up work.",
|
||||
]
|
||||
|
||||
def test_stub_makes_no_network_call(self):
|
||||
# If the stub ever starts calling urllib, this patch's side_effect
|
||||
# would fire and the assertion below would fail.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=AssertionError("stub must not hit the network"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestIsNewer:
|
||||
def test_latest_strictly_greater_returns_true(self):
|
||||
assert _is_newer("0.8.0", "0.7.4") is True
|
||||
|
||||
def test_equal_versions_returns_false(self):
|
||||
assert _is_newer("0.7.4", "0.7.4") is False
|
||||
|
||||
def test_current_greater_than_latest_returns_false(self):
|
||||
assert _is_newer("0.7.0", "0.7.4") is False
|
||||
|
||||
def test_dev_build_ahead_of_release_returns_false(self):
|
||||
assert _is_newer("0.7.4", "0.7.5.dev0") is False
|
||||
|
||||
def test_invalid_version_returns_false(self):
|
||||
assert _is_newer("not-a-version", "0.7.4") is False
|
||||
|
||||
def test_local_version_containing_unknown_is_not_treated_as_sentinel(self):
|
||||
assert _is_newer("1.2.4", "1.2.3+unknown") is True
|
||||
|
||||
|
||||
class TestInstalledVersion:
|
||||
def test_invalid_metadata_error_returns_unknown(self):
|
||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||
if invalid_metadata_error is None:
|
||||
# Python versions without InvalidMetadataError: simulate with a
|
||||
# custom exception to verify the guarded except path works.
|
||||
class _FakeInvalidMetadataError(Exception):
|
||||
pass
|
||||
invalid_metadata_error = _FakeInvalidMetadataError
|
||||
# Patch the attribute onto importlib.metadata so the production
|
||||
# getattr() finds it during this test.
|
||||
with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True):
|
||||
with patch(
|
||||
"importlib.metadata.version",
|
||||
side_effect=invalid_metadata_error("bad metadata"),
|
||||
):
|
||||
assert _get_installed_version() == "unknown"
|
||||
else:
|
||||
with patch(
|
||||
"importlib.metadata.version",
|
||||
side_effect=invalid_metadata_error("bad metadata"),
|
||||
):
|
||||
assert _get_installed_version() == "unknown"
|
||||
|
||||
|
||||
class TestNormalizeTag:
|
||||
def test_strips_single_leading_v(self):
|
||||
assert _normalize_tag("v0.7.4") == "0.7.4"
|
||||
|
||||
def test_idempotent_when_no_leading_v(self):
|
||||
assert _normalize_tag("0.7.4") == "0.7.4"
|
||||
|
||||
def test_strips_exactly_one_v(self):
|
||||
assert _normalize_tag("vv0.7.4") == "v0.7.4"
|
||||
|
||||
def test_empty_string_passthrough(self):
|
||||
assert _normalize_tag("") == ""
|
||||
|
||||
|
||||
class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" in output
|
||||
assert "0.7.4" in output
|
||||
assert "0.9.0" in output
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output
|
||||
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Up to date: 0.9.0" in output
|
||||
assert "Update available" not in output
|
||||
assert "git+https://" not in output
|
||||
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" not in output
|
||||
assert "Up to date" in output
|
||||
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Current version could not be determined" in output
|
||||
assert "0.7.4" in output
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" not in output
|
||||
assert "Up to date" in output
|
||||
assert "0.7.4" in output
|
||||
|
||||
|
||||
class TestFailureCategorization:
|
||||
def test_urlerror_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("no route to host"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "offline or timeout"
|
||||
|
||||
def test_timeout_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=TimeoutError(),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "offline or timeout"
|
||||
|
||||
def test_403_maps_to_rate_limited(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=_http_error(403, "rate limited"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||
|
||||
@pytest.mark.parametrize("code", [404, 500, 502])
|
||||
def test_other_http_uses_code_string(self, code):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=_http_error(code, "oops"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == f"HTTP {code}"
|
||||
|
||||
def test_generic_exception_propagates(self):
|
||||
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
_fetch_latest_release_tag()
|
||||
|
||||
|
||||
_FAILURE_CASES = [
|
||||
("offline or timeout", urllib.error.URLError("down")),
|
||||
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
|
||||
("HTTP 500", _http_error(500)),
|
||||
]
|
||||
|
||||
|
||||
class TestUserStory2:
|
||||
@pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_prints_installed_plus_one_line_reason(
|
||||
self, expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert "Installed: 0.7.4" in output
|
||||
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
|
||||
assert "Could not check latest release: rate limited" in output
|
||||
assert "GH_TOKEN" in output
|
||||
assert "GITHUB_TOKEN" in output
|
||||
else:
|
||||
assert f"Could not check latest release: {expected_reason}" in output
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_output_contains_no_traceback_no_url(
|
||||
self, _expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = (result.output or "") + (result.stderr or "")
|
||||
combined = strip_ansi(combined)
|
||||
assert "Traceback" not in combined
|
||||
assert "https://api.github.com" not in combined
|
||||
|
||||
|
||||
def _capture_request_via_urlopen():
|
||||
captured = {}
|
||||
|
||||
def _side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
return _mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
|
||||
return captured, _side_effect
|
||||
|
||||
|
||||
class TestUserStory3:
|
||||
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||
|
||||
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
|
||||
def test_no_authorization_header_when_both_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
|
||||
@pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES)
|
||||
def test_gh_token_never_appears_in_failure_output(
|
||||
self, _reason, side_effect, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
assert SENTINEL_GH_TOKEN not in combined
|
||||
|
||||
@pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES)
|
||||
def test_github_token_never_appears_in_failure_output(
|
||||
self, _reason, side_effect, monkeypatch
|
||||
):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
assert SENTINEL_GITHUB_TOKEN not in combined
|
||||
Reference in New Issue
Block a user