mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da4f095a49 |
29
.zenodo.json
29
.zenodo.json
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"title": "Spec Kit",
|
||||
"description": "Spec Kit is an open source toolkit for Spec-Driven Development (SDD) — a methodology that helps software teams build high-quality software faster by focusing on product scenarios and predictable outcomes. It provides the Specify CLI, slash-command templates, extensions, presets, workflows, and integrations for popular AI coding agents.",
|
||||
"creators": [
|
||||
{
|
||||
"name": "Delimarsky, Den"
|
||||
},
|
||||
{
|
||||
"name": "Riem, Manfred"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"upload_type": "software",
|
||||
"keywords": [
|
||||
"spec-driven development",
|
||||
"ai coding agents",
|
||||
"software engineering",
|
||||
"cli",
|
||||
"copilot",
|
||||
"specification"
|
||||
],
|
||||
"related_identifiers": [
|
||||
{
|
||||
"identifier": "https://github.com/github/spec-kit",
|
||||
"relation": "isSupplementTo",
|
||||
"scheme": "url"
|
||||
}
|
||||
]
|
||||
}
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -2,39 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
31
CITATION.cff
31
CITATION.cff
@@ -1,31 +0,0 @@
|
||||
cff-version: 1.2.0
|
||||
message: >-
|
||||
If you use Spec Kit in your research or reference it in a paper,
|
||||
please cite it using the metadata below.
|
||||
type: software
|
||||
title: "Spec Kit"
|
||||
abstract: >-
|
||||
Spec Kit is an open source toolkit for Spec-Driven Development (SDD) —
|
||||
a methodology that helps software teams build high-quality software faster
|
||||
by focusing on product scenarios and predictable outcomes. It provides the
|
||||
Specify CLI, slash-command templates, extensions, presets, workflows, and
|
||||
integrations for popular AI coding agents.
|
||||
authors:
|
||||
- given-names: Den
|
||||
family-names: Delimarsky
|
||||
alias: localden
|
||||
- given-names: Manfred
|
||||
family-names: Riem
|
||||
alias: mnriem
|
||||
repository-code: "https://github.com/github/spec-kit"
|
||||
url: "https://github.github.io/spec-kit/"
|
||||
license: MIT
|
||||
version: "0.7.3"
|
||||
date-released: "2026-04-17"
|
||||
keywords:
|
||||
- spec-driven development
|
||||
- ai coding agents
|
||||
- software engineering
|
||||
- cli
|
||||
- copilot
|
||||
- specification
|
||||
45
README.md
45
README.md
@@ -223,7 +223,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
|
||||
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
|
||||
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
|
||||
| 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) |
|
||||
| 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,35 +235,28 @@ 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) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
|
||||
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
|
||||
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
|
||||
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
| 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) |
|
||||
|
||||
@@ -272,11 +264,44 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX
|
||||
|
||||
## 🎨 Community Presets
|
||||
|
||||
Community-contributed presets that customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page.
|
||||
> [!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): 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 with 5 prose profiles. Supports interactive elements like brainstorming, interview, roleplay. | 21 templates, 26 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 Walkthroughs
|
||||
|
||||
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.
|
||||
> [!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.
|
||||
|
||||
## 🛠️ Community Friends
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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.
|
||||
@@ -37,9 +37,5 @@
|
||||
# Community
|
||||
- name: Community
|
||||
items:
|
||||
- name: Presets
|
||||
href: community/presets.md
|
||||
- name: Walkthroughs
|
||||
href: community/walkthroughs.md
|
||||
- name: Friends
|
||||
href: community/friends.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-22T17:54:00Z",
|
||||
"updated_at": "2026-04-17T02:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1167,37 +1167,6 @@
|
||||
"created_at": "2026-03-26T00:00:00Z",
|
||||
"updated_at": "2026-03-26T00:00:00Z"
|
||||
},
|
||||
"memory-loader": {
|
||||
"name": "Memory Loader",
|
||||
"id": "memory-loader",
|
||||
"description": "Loads .specify/memory/ files before spec-kit lifecycle commands so LLM agents have project governance context",
|
||||
"author": "KevinBrown5280",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/KevinBrown5280/spec-kit-memory-loader",
|
||||
"homepage": "https://github.com/KevinBrown5280/spec-kit-memory-loader",
|
||||
"documentation": "https://github.com/KevinBrown5280/spec-kit-memory-loader/blob/main/README.md",
|
||||
"changelog": "https://github.com/KevinBrown5280/spec-kit-memory-loader/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 7
|
||||
},
|
||||
"tags": [
|
||||
"context",
|
||||
"memory",
|
||||
"governance",
|
||||
"hooks"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
},
|
||||
"memorylint": {
|
||||
"name": "MemoryLint",
|
||||
"id": "memorylint",
|
||||
@@ -1523,38 +1492,6 @@
|
||||
"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",
|
||||
@@ -1720,38 +1657,6 @@
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"ripple": {
|
||||
"name": "Ripple",
|
||||
"id": "ripple",
|
||||
"description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection",
|
||||
"author": "chordpli",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/chordpli/spec-kit-ripple",
|
||||
"homepage": "https://github.com/chordpli/spec-kit-ripple",
|
||||
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
|
||||
"changelog": "https://github.com/chordpli/spec-kit-ripple/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"side-effects",
|
||||
"post-implementation",
|
||||
"analysis",
|
||||
"quality",
|
||||
"risk-detection"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
},
|
||||
"scope": {
|
||||
"name": "Spec Scope",
|
||||
"id": "scope",
|
||||
@@ -1891,69 +1796,6 @@
|
||||
"created_at": "2026-04-01T00:00:00Z",
|
||||
"updated_at": "2026-04-01T00:00:00Z"
|
||||
},
|
||||
"spec-reference-loader": {
|
||||
"name": "Spec Reference Loader",
|
||||
"id": "spec-reference-loader",
|
||||
"description": "Reads the ## References section from the current feature spec and loads the listed files into context",
|
||||
"author": "KevinBrown5280",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader",
|
||||
"homepage": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader",
|
||||
"documentation": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader/blob/main/README.md",
|
||||
"changelog": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 6
|
||||
},
|
||||
"tags": [
|
||||
"context",
|
||||
"references",
|
||||
"docs",
|
||||
"hooks"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
},
|
||||
"spec-validate": {
|
||||
"name": "Spec Validate",
|
||||
"id": "spec-validate",
|
||||
"description": "Comprehension validation, review gating, and approval state for spec-kit artifacts — staged-reveal quizzes, peer review SLA, and a hard gate before /speckit.implement.",
|
||||
"author": "Ahmed Eltayeb",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/aeltayeb/spec-kit-spec-validate/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/aeltayeb/spec-kit-spec-validate",
|
||||
"homepage": "https://github.com/aeltayeb/spec-kit-spec-validate",
|
||||
"documentation": "https://github.com/aeltayeb/spec-kit-spec-validate/blob/main/README.md",
|
||||
"changelog": "https://github.com/aeltayeb/spec-kit-spec-validate/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.5.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"validation",
|
||||
"review",
|
||||
"quality",
|
||||
"workflow",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-21T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
"id": "speckit-utils",
|
||||
@@ -2154,39 +1996,6 @@
|
||||
"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",
|
||||
@@ -2346,37 +2155,6 @@
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-16T00:00:00Z"
|
||||
},
|
||||
"version-guard": {
|
||||
"name": "Version Guard",
|
||||
"id": "version-guard",
|
||||
"description": "Verify tech stack versions against live registries before planning and implementation",
|
||||
"author": "KevinBrown5280",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/KevinBrown5280/spec-kit-version-guard",
|
||||
"homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard",
|
||||
"documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md",
|
||||
"changelog": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 4
|
||||
},
|
||||
"tags": [
|
||||
"versioning",
|
||||
"npm",
|
||||
"validation",
|
||||
"hooks"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-22T17:54:00Z"
|
||||
},
|
||||
"whatif": {
|
||||
"name": "What-if Analysis",
|
||||
"id": "whatif",
|
||||
@@ -2405,41 +2183,6 @@
|
||||
"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",
|
||||
@@ -2505,4 +2248,4 @@
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,5 +116,5 @@ The following enhancements are under consideration for future releases:
|
||||
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||
| **script** | ✓ (default) | — | — | ✓ |
|
||||
|
||||
For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented).
|
||||
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.
|
||||
|
||||
@@ -108,11 +108,11 @@
|
||||
"fiction-book-writing": {
|
||||
"name": "Fiction Book Writing",
|
||||
"id": "fiction-book-writing",
|
||||
"version": "1.6.0",
|
||||
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported.",
|
||||
"version": "1.5.0",
|
||||
"description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.",
|
||||
"author": "Andreas Daumann",
|
||||
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.6.0.zip",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.5.0.zip",
|
||||
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
|
||||
"license": "MIT",
|
||||
@@ -120,9 +120,8 @@
|
||||
"speckit_version": ">=0.5.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 22,
|
||||
"commands": 27,
|
||||
"scripts": 1
|
||||
"templates": 21,
|
||||
"commands": 26
|
||||
},
|
||||
"tags": [
|
||||
"writing",
|
||||
@@ -136,12 +135,11 @@
|
||||
"book",
|
||||
"brainstorming",
|
||||
"roleplay",
|
||||
"audiobook",
|
||||
"language-support"
|
||||
"audiobook"
|
||||
],
|
||||
"created_at": "2026-04-09T08:00:00Z",
|
||||
"updated_at": "2026-04-19T08:00:00Z"
|
||||
},
|
||||
"updated_at": "2026-04-16T08:00:00Z"
|
||||
},
|
||||
"multi-repo-branching": {
|
||||
"name": "Multi-Repo Branching",
|
||||
"id": "multi-repo-branching",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
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,11 +56,6 @@ 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.5"
|
||||
version = "0.7.3"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
# "platformdirs",
|
||||
# "readchar",
|
||||
# "json5",
|
||||
# "pyyaml",
|
||||
# "packaging",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
@@ -36,12 +34,8 @@ 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
|
||||
@@ -57,8 +51,6 @@ 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
|
||||
@@ -326,7 +318,7 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def
|
||||
|
||||
return selected_key
|
||||
|
||||
console = Console(highlight=False)
|
||||
console = Console()
|
||||
|
||||
class BannerGroup(TyperGroup):
|
||||
"""Custom group that shows banner before help."""
|
||||
@@ -928,6 +920,7 @@ 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.",
|
||||
@@ -1122,7 +1115,7 @@ def init(
|
||||
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
|
||||
else:
|
||||
error_panel = Panel(
|
||||
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
||||
f"Directory '[cyan]{project_name}[/cyan]' already exists\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]",
|
||||
@@ -1378,6 +1371,7 @@ 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(),
|
||||
}
|
||||
@@ -1606,10 +1600,25 @@ def check():
|
||||
def version():
|
||||
"""Display version and system information."""
|
||||
import platform
|
||||
import importlib.metadata
|
||||
|
||||
show_banner()
|
||||
|
||||
cli_version = get_speckit_version()
|
||||
# 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
|
||||
|
||||
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
info_table.add_column("Key", style="cyan", justify="right")
|
||||
@@ -1632,163 +1641,6 @@ 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 =====
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
|
||||
@@ -282,8 +281,7 @@ class CommandRegistrar:
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
if agent_name in {"codex", "kimi"}:
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
@@ -401,28 +399,6 @@ 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,
|
||||
@@ -469,15 +445,6 @@ 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", []):
|
||||
@@ -505,12 +472,10 @@ class CommandRegistrar:
|
||||
project_root,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
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)
|
||||
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(
|
||||
@@ -520,7 +485,6 @@ 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")
|
||||
|
||||
@@ -586,7 +550,12 @@ class CommandRegistrar:
|
||||
alias_file = (
|
||||
commands_dir / f"{alias_output_name}{agent_config['extension']}"
|
||||
)
|
||||
self._ensure_inside(alias_file, commands_dir)
|
||||
try:
|
||||
alias_file.resolve().relative_to(commands_dir.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Alias output path escapes commands directory: {alias_file!r}"
|
||||
)
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(alias_output, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
@@ -606,7 +575,6 @@ 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(
|
||||
|
||||
@@ -140,16 +140,11 @@ class ExtensionManifest:
|
||||
"""Load YAML file safely."""
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
return yaml.safe_load(f) or {}
|
||||
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."""
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
"""Antigravity (agy) integration — skills-based agent.
|
||||
|
||||
Antigravity uses ``.agents/skills/speckit-<name>/SKILL.md`` layout (enforced since v1.20.5).
|
||||
Antigravity uses ``.agent/skills/speckit-<name>/SKILL.md`` layout.
|
||||
Explicit command support was deprecated in version 1.20.5;
|
||||
``--skills`` defaults to ``True``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..base import SkillsIntegration
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
|
||||
class AgyIntegration(SkillsIntegration):
|
||||
@@ -21,32 +16,26 @@ class AgyIntegration(SkillsIntegration):
|
||||
key = "agy"
|
||||
config = {
|
||||
"name": "Antigravity",
|
||||
"folder": ".agents/",
|
||||
"folder": ".agent/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".agents/skills",
|
||||
"dir": ".agent/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
import click
|
||||
|
||||
click.secho(
|
||||
"Warning: The .agents/ layout requires Antigravity v1.20.5 or newer. "
|
||||
"Please ensure your agy installation is up to date.",
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Antigravity since v1.20.5)",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -482,7 +482,7 @@ class IntegrationBase(ABC):
|
||||
)
|
||||
|
||||
if ctx_path.exists():
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
start_idx = content.find(self.CONTEXT_MARKER_START)
|
||||
end_idx = content.find(
|
||||
self.CONTEXT_MARKER_END,
|
||||
@@ -547,7 +547,7 @@ class IntegrationBase(ABC):
|
||||
if not ctx_path.exists():
|
||||
return False
|
||||
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
start_idx = content.find(self.CONTEXT_MARKER_START)
|
||||
end_idx = content.find(
|
||||
self.CONTEXT_MARKER_END,
|
||||
|
||||
@@ -10,9 +10,7 @@ Copilot has several unique behaviors compared to standard markdown agents:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -20,30 +18,6 @@ from ..base import IntegrationBase
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
def _allow_all() -> bool:
|
||||
"""Return True if the Copilot CLI should run with full permissions.
|
||||
|
||||
Checks ``SPECKIT_COPILOT_ALLOW_ALL_TOOLS`` first (new canonical name).
|
||||
Falls back to the deprecated ``SPECKIT_ALLOW_ALL_TOOLS`` if set,
|
||||
emitting a deprecation warning. Default when neither is set: enabled.
|
||||
"""
|
||||
new_var = os.environ.get("SPECKIT_COPILOT_ALLOW_ALL_TOOLS")
|
||||
if new_var is not None:
|
||||
return new_var != "0"
|
||||
|
||||
old_var = os.environ.get("SPECKIT_ALLOW_ALL_TOOLS")
|
||||
if old_var is not None:
|
||||
warnings.warn(
|
||||
"SPECKIT_ALLOW_ALL_TOOLS is deprecated; "
|
||||
"use SPECKIT_COPILOT_ALLOW_ALL_TOOLS instead.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return old_var != "0"
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class CopilotIntegration(IntegrationBase):
|
||||
"""Integration for GitHub Copilot (VS Code IDE + CLI).
|
||||
|
||||
@@ -76,15 +50,13 @@ class CopilotIntegration(IntegrationBase):
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# GitHub Copilot CLI uses ``copilot -p "prompt"`` for
|
||||
# non-interactive mode. --yolo enables all permissions
|
||||
# (tools, paths, and URLs) so the agent can perform file
|
||||
# edits and shell commands without interactive prompts.
|
||||
# Controlled by SPECKIT_COPILOT_ALLOW_ALL_TOOLS env var
|
||||
# (default: enabled). The deprecated SPECKIT_ALLOW_ALL_TOOLS
|
||||
# is also honoured as a fallback.
|
||||
# non-interactive mode. --allow-all-tools is required for the
|
||||
# agent to perform file edits and shell commands. Controlled
|
||||
# by SPECKIT_ALLOW_ALL_TOOLS env var (default: enabled).
|
||||
import os
|
||||
args = ["copilot", "-p", prompt]
|
||||
if _allow_all():
|
||||
args.append("--yolo")
|
||||
if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0":
|
||||
args.append("--allow-all-tools")
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
@@ -119,12 +91,13 @@ class CopilotIntegration(IntegrationBase):
|
||||
agent_name = f"speckit.{stem}"
|
||||
|
||||
prompt = args or ""
|
||||
import os
|
||||
cli_args = [
|
||||
"copilot", "-p", prompt,
|
||||
"--agent", agent_name,
|
||||
]
|
||||
if _allow_all():
|
||||
cli_args.append("--yolo")
|
||||
if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0":
|
||||
cli_args.append("--allow-all-tools")
|
||||
if model:
|
||||
cli_args.extend(["--model", model])
|
||||
if not stream:
|
||||
|
||||
@@ -16,10 +16,7 @@ import zipfile
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, Dict, List, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .agents import CommandRegistrar
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
@@ -30,59 +27,6 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
from .extensions import ExtensionRegistry, normalize_priority
|
||||
|
||||
|
||||
def _substitute_core_template(
|
||||
body: str,
|
||||
cmd_name: str,
|
||||
project_root: "Path",
|
||||
registrar: "CommandRegistrar",
|
||||
) -> "tuple[str, dict]":
|
||||
"""Substitute {CORE_TEMPLATE} with the body of the installed core command template.
|
||||
|
||||
Args:
|
||||
body: Preset command body (may contain {CORE_TEMPLATE} placeholder).
|
||||
cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify").
|
||||
project_root: Project root path.
|
||||
registrar: CommandRegistrar instance for parse_frontmatter.
|
||||
|
||||
Returns:
|
||||
A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced
|
||||
by the core template body and core_frontmatter holds the core template's parsed
|
||||
frontmatter (so callers can inherit scripts/agent_scripts from it). Both are
|
||||
unchanged / empty when the placeholder is absent or the core template file does
|
||||
not exist.
|
||||
"""
|
||||
if "{CORE_TEMPLATE}" not in body:
|
||||
return body, {}
|
||||
|
||||
# Derive the short name (strip "speckit." prefix) used by core command templates.
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
|
||||
resolver = PresetResolver(project_root)
|
||||
# Resolution order for the core template:
|
||||
# 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4
|
||||
# name-based lookup (file named <cmd_name>.md). Checked first so that a
|
||||
# local override always wins, even for extension commands.
|
||||
# 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3
|
||||
# fallback for extension commands whose file is named differently from the
|
||||
# command name (e.g. speckit.selftest.extension → commands/selftest.md).
|
||||
# 3. resolve_core(short_name) — core template fallback using the unprefixed
|
||||
# name (e.g. specify → templates/commands/specify.md).
|
||||
# resolve_core() skips installed presets (tier 2) to prevent accidental nesting
|
||||
# where another preset's wrap output is mistaken for the real core.
|
||||
core_file = (
|
||||
resolver.resolve_core(cmd_name, "command")
|
||||
or resolver.resolve_extension_command_via_manifest(cmd_name)
|
||||
or resolver.resolve_core(short_name, "command")
|
||||
)
|
||||
if core_file is None:
|
||||
return body, {}
|
||||
|
||||
core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
|
||||
return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresetCatalogEntry:
|
||||
"""Represents a single entry in the preset catalog stack."""
|
||||
@@ -611,232 +555,6 @@ class PresetManager:
|
||||
registrar = CommandRegistrar()
|
||||
registrar.unregister_commands(registered_commands, self.project_root)
|
||||
|
||||
def _replay_wraps_for_command(self, cmd_name: str) -> None:
|
||||
"""Recompose and rewrite agent files for a wrap-strategy command.
|
||||
|
||||
Collects all installed presets that declare cmd_name in their
|
||||
wrap_commands registry field, sorts them so the highest-precedence
|
||||
preset (lowest priority number) wraps outermost, then writes the
|
||||
fully composed output to every agent directory.
|
||||
|
||||
Called after every install and remove to keep agent files correct
|
||||
regardless of installation order.
|
||||
|
||||
Args:
|
||||
cmd_name: Full command name (e.g. "speckit.specify")
|
||||
"""
|
||||
try:
|
||||
from .agents import CommandRegistrar
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
# Collect enabled presets that wrap this command, sorted ascending
|
||||
# (lowest priority number = highest precedence = outermost).
|
||||
wrap_presets = []
|
||||
for pack_id, metadata in self.registry.list_by_priority(include_disabled=False):
|
||||
if cmd_name not in metadata.get("wrap_commands", []):
|
||||
continue
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
if not pack_dir.is_dir():
|
||||
continue # corrupted state — skip
|
||||
wrap_presets.append((pack_id, pack_dir))
|
||||
|
||||
if not wrap_presets:
|
||||
return
|
||||
|
||||
# Derive short name for core resolution fallback.
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
|
||||
resolver = PresetResolver(self.project_root)
|
||||
core_file = (
|
||||
resolver.resolve_core(cmd_name, "command")
|
||||
or resolver.resolve_extension_command_via_manifest(cmd_name)
|
||||
or (
|
||||
resolver.resolve_extension_command_via_manifest(short_name)
|
||||
if short_name != cmd_name
|
||||
else None
|
||||
)
|
||||
or resolver.resolve_core(short_name, "command")
|
||||
)
|
||||
if core_file is None:
|
||||
return
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
core_frontmatter, core_body = registrar.parse_frontmatter(
|
||||
core_file.read_text(encoding="utf-8")
|
||||
)
|
||||
replay_aliases: List[str] = []
|
||||
seen_aliases: set[str] = set()
|
||||
|
||||
# Apply wraps innermost-first (reverse of ascending list).
|
||||
accumulated_body = core_body
|
||||
outermost_frontmatter = {}
|
||||
outermost_pack_id = wrap_presets[0][0] # fallback; updated per contributing preset
|
||||
for pack_id, pack_dir in reversed(wrap_presets):
|
||||
manifest_path = pack_dir / "preset.yml"
|
||||
cmd_file: Optional[Path] = None
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
manifest = PresetManifest(manifest_path)
|
||||
except (PresetValidationError, KeyError, TypeError, ValueError):
|
||||
manifest = None
|
||||
if manifest is not None:
|
||||
for template in manifest.templates:
|
||||
if template.get("type") != "command" or template.get("name") != cmd_name:
|
||||
continue
|
||||
file_rel = template.get("file")
|
||||
if isinstance(file_rel, str):
|
||||
rel_path = Path(file_rel)
|
||||
if not rel_path.is_absolute():
|
||||
try:
|
||||
preset_root = pack_dir.resolve()
|
||||
candidate = (preset_root / rel_path).resolve()
|
||||
candidate.relative_to(preset_root)
|
||||
except (OSError, ValueError):
|
||||
candidate = None
|
||||
if candidate is not None:
|
||||
cmd_file = candidate
|
||||
aliases = template.get("aliases", [])
|
||||
if not isinstance(aliases, list):
|
||||
aliases = []
|
||||
for alias in aliases:
|
||||
if isinstance(alias, str) and alias not in seen_aliases:
|
||||
replay_aliases.append(alias)
|
||||
seen_aliases.add(alias)
|
||||
break
|
||||
if cmd_file is None:
|
||||
cmd_file = pack_dir / "commands" / f"{cmd_name}.md"
|
||||
if not cmd_file.exists():
|
||||
continue
|
||||
wrap_fm, wrap_body = registrar.parse_frontmatter(
|
||||
cmd_file.read_text(encoding="utf-8")
|
||||
)
|
||||
accumulated_body = wrap_body.replace("{CORE_TEMPLATE}", accumulated_body)
|
||||
outermost_frontmatter = wrap_fm # last iteration = outermost preset
|
||||
outermost_pack_id = pack_id
|
||||
|
||||
# Build final frontmatter: outermost preset wins; fall back to core for
|
||||
# scripts/agent_scripts if the outermost preset does not define them.
|
||||
final_frontmatter = dict(outermost_frontmatter)
|
||||
final_frontmatter.pop("strategy", None)
|
||||
for key in ("scripts", "agent_scripts"):
|
||||
if key not in final_frontmatter and key in core_frontmatter:
|
||||
final_frontmatter[key] = core_frontmatter[key]
|
||||
|
||||
composed_content = (
|
||||
registrar.render_frontmatter(final_frontmatter) + "\n" + accumulated_body
|
||||
)
|
||||
|
||||
self._replay_skill_override(cmd_name, composed_content, outermost_pack_id)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
cmd_dir = tmp_path / "commands"
|
||||
cmd_dir.mkdir()
|
||||
(cmd_dir / f"{cmd_name}.md").write_text(composed_content, encoding="utf-8")
|
||||
registrar._ensure_configs()
|
||||
for agent_name, agent_config in registrar.AGENT_CONFIGS.items():
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
continue
|
||||
agent_dir = self.project_root / agent_config["dir"]
|
||||
if not agent_dir.exists():
|
||||
continue
|
||||
try:
|
||||
registrar.register_commands(
|
||||
agent_name,
|
||||
[{
|
||||
"name": cmd_name,
|
||||
"file": f"commands/{cmd_name}.md",
|
||||
"aliases": replay_aliases,
|
||||
}],
|
||||
f"preset:{outermost_pack_id}",
|
||||
tmp_path,
|
||||
self.project_root,
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
def _replay_skill_override(
|
||||
self,
|
||||
cmd_name: str,
|
||||
composed_content: str,
|
||||
outermost_pack_id: str,
|
||||
) -> None:
|
||||
"""Rewrite any active SKILL.md override for a replayed wrap command."""
|
||||
skills_dir = self._get_skills_dir()
|
||||
if not skills_dir:
|
||||
return
|
||||
|
||||
from . import SKILL_DESCRIPTIONS, load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from .integrations import get_integration
|
||||
|
||||
init_opts = load_init_options(self.project_root)
|
||||
if not isinstance(init_opts, dict):
|
||||
init_opts = {}
|
||||
selected_ai = init_opts.get("ai")
|
||||
if not isinstance(selected_ai, str):
|
||||
return
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
integration = get_integration(selected_ai)
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
create_missing_skills = bool(init_opts.get("ai_skills")) and agent_config.get("extension") != "/SKILL.md"
|
||||
|
||||
skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
||||
target_skill_names: List[str] = []
|
||||
if (skills_dir / skill_name).is_dir():
|
||||
target_skill_names.append(skill_name)
|
||||
if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir():
|
||||
target_skill_names.append(legacy_skill_name)
|
||||
if not target_skill_names and create_missing_skills:
|
||||
missing_skill_dir = skills_dir / skill_name
|
||||
if not missing_skill_dir.exists():
|
||||
target_skill_names.append(skill_name)
|
||||
if not target_skill_names:
|
||||
return
|
||||
|
||||
raw_short_name = cmd_name
|
||||
if raw_short_name.startswith("speckit."):
|
||||
raw_short_name = raw_short_name[len("speckit."):]
|
||||
short_name = raw_short_name.replace(".", "-")
|
||||
skill_title = self._skill_title_from_command(cmd_name)
|
||||
|
||||
frontmatter, body = registrar.parse_frontmatter(composed_content)
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, dict(frontmatter), body, self.project_root
|
||||
)
|
||||
|
||||
for target_skill_name in target_skill_names:
|
||||
skill_subdir = skills_dir / target_skill_name
|
||||
if skill_subdir.exists() and not skill_subdir.is_dir():
|
||||
continue
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
selected_ai,
|
||||
target_skill_name,
|
||||
enhanced_desc,
|
||||
f"preset:{outermost_pack_id}",
|
||||
)
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"{frontmatter_text}\n"
|
||||
f"---\n\n"
|
||||
f"# Speckit {skill_title} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
||||
skill_content = integration.post_process_skill_content(skill_content)
|
||||
(skill_subdir / "SKILL.md").write_text(skill_content, encoding="utf-8")
|
||||
|
||||
def _get_skills_dir(self) -> Optional[Path]:
|
||||
"""Return the active skills directory for preset skill overrides.
|
||||
|
||||
@@ -906,7 +624,7 @@ class PresetManager:
|
||||
|
||||
try:
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
except (ValidationError, TypeError, AttributeError):
|
||||
except ValidationError:
|
||||
continue
|
||||
|
||||
ext_root = ext_dir.resolve()
|
||||
@@ -1043,13 +761,6 @@ class PresetManager:
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
if frontmatter.get("strategy") == "wrap":
|
||||
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
|
||||
frontmatter = dict(frontmatter)
|
||||
for key in ("scripts", "agent_scripts"):
|
||||
if key not in frontmatter and key in core_frontmatter:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
@@ -1263,24 +974,6 @@ class PresetManager:
|
||||
# Update corresponding skills when --ai-skills was previously used
|
||||
registered_skills = self._register_skills(manifest, dest_dir)
|
||||
|
||||
# Detect wrap commands before registry.add() so a read failure doesn't
|
||||
# leave a partially-committed registry entry.
|
||||
wrap_commands = []
|
||||
try:
|
||||
from .agents import CommandRegistrar as _CR
|
||||
_registrar = _CR()
|
||||
for cmd_tmpl in manifest.templates:
|
||||
if cmd_tmpl.get("type") != "command":
|
||||
continue
|
||||
cmd_file = dest_dir / cmd_tmpl["file"]
|
||||
if not cmd_file.exists():
|
||||
continue
|
||||
cmd_fm, _ = _registrar.parse_frontmatter(cmd_file.read_text(encoding="utf-8"))
|
||||
if cmd_fm.get("strategy") == "wrap":
|
||||
wrap_commands.append(cmd_tmpl["name"])
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
self.registry.add(manifest.id, {
|
||||
"version": manifest.version,
|
||||
"source": "local",
|
||||
@@ -1289,12 +982,8 @@ class PresetManager:
|
||||
"priority": priority,
|
||||
"registered_commands": registered_commands,
|
||||
"registered_skills": registered_skills,
|
||||
"wrap_commands": wrap_commands,
|
||||
})
|
||||
|
||||
for cmd_name in wrap_commands:
|
||||
self._replay_wraps_for_command(cmd_name)
|
||||
|
||||
return manifest
|
||||
|
||||
def install_from_zip(
|
||||
@@ -1369,16 +1058,9 @@ class PresetManager:
|
||||
# Restore original skills when preset is removed
|
||||
registered_skills = metadata.get("registered_skills", []) if metadata else []
|
||||
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
||||
wrap_commands = metadata.get("wrap_commands", []) if metadata else []
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
|
||||
# _unregister_skills must run before directory deletion (reads preset files)
|
||||
if registered_skills:
|
||||
self._unregister_skills(registered_skills, pack_dir)
|
||||
# When _unregister_skills has already handled skill-agent files, strip
|
||||
# those entries from registered_commands to avoid double-deletion.
|
||||
# (When registered_skills is empty, skill-agent entries in
|
||||
# registered_commands are the only deletion path for those files.)
|
||||
try:
|
||||
from .agents import CommandRegistrar
|
||||
except ImportError:
|
||||
@@ -1390,44 +1072,14 @@ class PresetManager:
|
||||
if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md"
|
||||
}
|
||||
|
||||
# Delete the preset directory before mutating the registry so a
|
||||
# filesystem failure cannot leave files on disk without a registry entry.
|
||||
# Unregister non-skill command files from AI agents.
|
||||
if registered_commands:
|
||||
self._unregister_commands(registered_commands)
|
||||
|
||||
if pack_dir.exists():
|
||||
shutil.rmtree(pack_dir)
|
||||
|
||||
# Remove from registry before replaying so _replay_wraps_for_command sees
|
||||
# the post-removal registry state.
|
||||
self.registry.remove(pack_id)
|
||||
|
||||
# Separate wrap commands from non-wrap commands in registered_commands.
|
||||
non_wrap_commands = {
|
||||
agent_name: [c for c in cmd_names if c not in wrap_commands]
|
||||
for agent_name, cmd_names in registered_commands.items()
|
||||
}
|
||||
non_wrap_commands = {k: v for k, v in non_wrap_commands.items() if v}
|
||||
|
||||
# Unregister non-wrap command files from AI agents.
|
||||
if non_wrap_commands:
|
||||
self._unregister_commands(non_wrap_commands)
|
||||
|
||||
# For each wrapped command, either re-compose remaining wraps or delete.
|
||||
for cmd_name in wrap_commands:
|
||||
remaining = [
|
||||
pid for pid, meta in self.registry.list().items()
|
||||
if cmd_name in meta.get("wrap_commands", [])
|
||||
]
|
||||
if remaining:
|
||||
self._replay_wraps_for_command(cmd_name)
|
||||
else:
|
||||
# No wrap presets remain — delete the agent file entirely.
|
||||
wrap_agent_commands = {
|
||||
agent_name: [c for c in cmd_names if c == cmd_name]
|
||||
for agent_name, cmd_names in registered_commands.items()
|
||||
}
|
||||
wrap_agent_commands = {k: v for k, v in wrap_agent_commands.items() if v}
|
||||
if wrap_agent_commands:
|
||||
self._unregister_commands(wrap_agent_commands)
|
||||
|
||||
return True
|
||||
|
||||
def list_installed(self) -> List[Dict[str, Any]]:
|
||||
@@ -2083,7 +1735,6 @@ class PresetResolver:
|
||||
self,
|
||||
template_name: str,
|
||||
template_type: str = "template",
|
||||
skip_presets: bool = False,
|
||||
) -> Optional[Path]:
|
||||
"""Resolve a template name to its file path.
|
||||
|
||||
@@ -2092,8 +1743,6 @@ class PresetResolver:
|
||||
Args:
|
||||
template_name: Template name (e.g., "spec-template")
|
||||
template_type: Template type ("template", "command", or "script")
|
||||
skip_presets: When True, skip tier 2 (installed presets). Use
|
||||
resolve_core() as the preferred caller-facing API for this.
|
||||
|
||||
Returns:
|
||||
Path to the resolved template file, or None if not found
|
||||
@@ -2122,7 +1771,7 @@ class PresetResolver:
|
||||
return override
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority — lower number wins)
|
||||
if not skip_presets and self.presets_dir.exists():
|
||||
if self.presets_dir.exists():
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
for pack_id, _metadata in registry.list_by_priority():
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
@@ -2161,99 +1810,6 @@ class PresetResolver:
|
||||
if core.exists():
|
||||
return core
|
||||
|
||||
# Priority 5: Bundled core_pack (wheel install) or repo-root templates
|
||||
# (source-checkout / editable install). This is the canonical home for
|
||||
# speckit's built-in command/template files and must always be checked
|
||||
# so that strategy:wrap presets can locate {CORE_TEMPLATE}.
|
||||
from specify_cli import _locate_core_pack # local import to avoid cycles
|
||||
_core_pack = _locate_core_pack()
|
||||
if _core_pack is not None:
|
||||
# Wheel install path
|
||||
if template_type == "template":
|
||||
candidate = _core_pack / "templates" / f"{template_name}.md"
|
||||
elif template_type == "command":
|
||||
candidate = _core_pack / "commands" / f"{template_name}.md"
|
||||
elif template_type == "script":
|
||||
candidate = _core_pack / "scripts" / f"{template_name}{ext}"
|
||||
else:
|
||||
candidate = _core_pack / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
else:
|
||||
# Source-checkout / editable install: templates live at repo root
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
if template_type == "template":
|
||||
candidate = repo_root / "templates" / f"{template_name}.md"
|
||||
elif template_type == "command":
|
||||
candidate = repo_root / "templates" / "commands" / f"{template_name}.md"
|
||||
elif template_type == "script":
|
||||
candidate = repo_root / "scripts" / f"{template_name}{ext}"
|
||||
else:
|
||||
candidate = repo_root / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def resolve_core(
|
||||
self,
|
||||
template_name: str,
|
||||
template_type: str = "template",
|
||||
) -> Optional[Path]:
|
||||
"""Resolve while skipping installed presets (tier 2).
|
||||
|
||||
Searches tiers 1, 3, 4, and 5 (bundled core_pack / repo-root fallback).
|
||||
Use when resolving {CORE_TEMPLATE} to guarantee the result is actual
|
||||
base content, never another preset's wrap output.
|
||||
"""
|
||||
return self.resolve(template_name, template_type, skip_presets=True)
|
||||
|
||||
def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]:
|
||||
"""Resolve an extension command by consulting installed extension manifests.
|
||||
|
||||
Walks installed extension directories in priority order, loads each
|
||||
extension.yml via ExtensionManifest, and looks up the command by its
|
||||
declared name to find the actual file path. This is necessary because
|
||||
the manifest's ``provides.commands[].file`` field is authoritative and
|
||||
may differ from the command name
|
||||
(e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``).
|
||||
|
||||
Returns None if no manifest maps the given command name, so the caller
|
||||
can fall back to the name-based lookup.
|
||||
"""
|
||||
if not self.extensions_dir.exists():
|
||||
return None
|
||||
|
||||
from .extensions import ExtensionManifest, ValidationError
|
||||
|
||||
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
manifest_path = ext_dir / "extension.yml"
|
||||
if not manifest_path.is_file():
|
||||
continue
|
||||
try:
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
except (ValidationError, OSError, TypeError, AttributeError):
|
||||
continue
|
||||
for cmd_info in manifest.commands:
|
||||
if cmd_info.get("name") != cmd_name:
|
||||
continue
|
||||
file_rel = cmd_info.get("file")
|
||||
if not file_rel:
|
||||
continue
|
||||
# Mirror the containment check in ExtensionManager to guard against
|
||||
# path traversal via a malformed manifest (e.g. file: ../../AGENTS.md).
|
||||
cmd_path = Path(file_rel)
|
||||
if cmd_path.is_absolute():
|
||||
continue
|
||||
try:
|
||||
ext_root = ext_dir.resolve()
|
||||
candidate = (ext_root / cmd_path).resolve()
|
||||
candidate.relative_to(ext_root) # raises ValueError if outside
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def resolve_with_source(
|
||||
|
||||
@@ -261,7 +261,7 @@ class TestForceExistingDirectory:
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in _normalize_cli_output(result.output)
|
||||
assert "already exists" in result.output
|
||||
|
||||
|
||||
class TestGitExtensionAutoInstall:
|
||||
|
||||
@@ -5,17 +5,12 @@ from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
class TestAgyIntegration(SkillsIntegrationTests):
|
||||
KEY = "agy"
|
||||
FOLDER = ".agents/"
|
||||
FOLDER = ".agent/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
REGISTRAR_DIR = ".agent/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration(self.KEY)
|
||||
skills_opts = [o for o in i.options() if o.name == "--skills"]
|
||||
assert len(skills_opts) == 0
|
||||
|
||||
class TestAgyAutoPromote:
|
||||
"""--ai agy auto-promotes to integration path."""
|
||||
|
||||
@@ -29,17 +24,4 @@ class TestAgyAutoPromote:
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_agy_setup_warning(self, tmp_path):
|
||||
"""Agy integration should print a warning about v1.20.5 requirement during setup."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
# Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj2"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||
assert (target / ".agent" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for ClaudeIntegration."""
|
||||
|
||||
import codecs
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
@@ -75,46 +74,6 @@ class TestClaudeIntegration:
|
||||
assert "<!-- SPECKIT END -->" in content
|
||||
assert "read the current plan" in content
|
||||
|
||||
def test_upsert_context_section_strips_bom(self, tmp_path):
|
||||
"""Existing context file with UTF-8 BOM must be cleaned up on upsert."""
|
||||
integration = get_integration("claude")
|
||||
ctx_path = tmp_path / integration.context_file
|
||||
|
||||
# Write a file that starts with a UTF-8 BOM (as the old PowerShell script did)
|
||||
bom = codecs.BOM_UTF8
|
||||
ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n")
|
||||
|
||||
integration.upsert_context_section(tmp_path)
|
||||
|
||||
result = ctx_path.read_bytes()
|
||||
assert not result.startswith(bom), "BOM must be stripped after upsert"
|
||||
content = result.decode("utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "Some existing content." in content
|
||||
|
||||
def test_remove_context_section_strips_bom(self, tmp_path):
|
||||
"""remove_context_section must clean BOM from context file on Windows-authored files."""
|
||||
integration = get_integration("claude")
|
||||
ctx_path = tmp_path / integration.context_file
|
||||
|
||||
marker_content = (
|
||||
"# CLAUDE.md\n\n"
|
||||
"<!-- SPECKIT START -->\n"
|
||||
"For additional context about technologies to be used, project structure,\n"
|
||||
"shell commands, and other important information, read the current plan\n"
|
||||
"<!-- SPECKIT END -->\n"
|
||||
)
|
||||
ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8"))
|
||||
|
||||
result = integration.remove_context_section(tmp_path)
|
||||
|
||||
assert result is True
|
||||
assert ctx_path.exists(), "File should exist (non-empty content remains)"
|
||||
remaining = ctx_path.read_bytes()
|
||||
assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove"
|
||||
assert b"<!-- SPECKIT" not in remaining
|
||||
assert b"# CLAUDE.md" in remaining
|
||||
|
||||
def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -217,14 +217,6 @@ 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
|
||||
@@ -1369,79 +1361,6 @@ 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
@@ -1,204 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,371 +0,0 @@
|
||||
"""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:
|
||||
pytest.skip("InvalidMetadataError is not available on this Python version")
|
||||
with patch(
|
||||
"importlib.metadata.version",
|
||||
side_effect=invalid_metadata_error("bad metadata"),
|
||||
):
|
||||
assert _get_installed_version() == "unknown"
|
||||
|
||||
|
||||
class TestNormalizeTag:
|
||||
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
|
||||
@@ -367,49 +367,15 @@ class TestBuildExecArgs:
|
||||
assert args[2] == "do stuff"
|
||||
assert "--json" in args
|
||||
|
||||
def test_copilot_exec_args(self, monkeypatch):
|
||||
monkeypatch.delenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", raising=False)
|
||||
monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False)
|
||||
def test_copilot_exec_args(self):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
impl = CopilotIntegration()
|
||||
args = impl.build_exec_args("do stuff", model="claude-sonnet-4-20250514")
|
||||
assert args[0] == "copilot"
|
||||
assert "-p" in args
|
||||
assert "--yolo" in args
|
||||
assert "--allow-all-tools" in args
|
||||
assert "--model" in args
|
||||
|
||||
def test_copilot_new_env_var_disables_yolo(self, monkeypatch):
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False)
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
impl = CopilotIntegration()
|
||||
args = impl.build_exec_args("do stuff")
|
||||
assert "--yolo" not in args
|
||||
|
||||
def test_copilot_deprecated_env_var_still_honoured(self, monkeypatch):
|
||||
monkeypatch.delenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", raising=False)
|
||||
monkeypatch.setenv("SPECKIT_ALLOW_ALL_TOOLS", "0")
|
||||
import warnings
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
impl = CopilotIntegration()
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
args = impl.build_exec_args("do stuff")
|
||||
assert "--yolo" not in args
|
||||
assert any(
|
||||
"SPECKIT_ALLOW_ALL_TOOLS is deprecated" in str(x.message)
|
||||
and issubclass(x.category, UserWarning)
|
||||
for x in w
|
||||
)
|
||||
|
||||
def test_copilot_new_env_var_takes_precedence(self, monkeypatch):
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "1")
|
||||
monkeypatch.setenv("SPECKIT_ALLOW_ALL_TOOLS", "0")
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
impl = CopilotIntegration()
|
||||
args = impl.build_exec_args("do stuff")
|
||||
assert "--yolo" in args
|
||||
|
||||
def test_ide_only_returns_none(self):
|
||||
from specify_cli.integrations.windsurf import WindsurfIntegration
|
||||
impl = WindsurfIntegration()
|
||||
@@ -434,7 +400,6 @@ class TestCommandStep:
|
||||
"""Test the command step type."""
|
||||
|
||||
def test_execute_basic(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
@@ -448,8 +413,7 @@ class TestCommandStep:
|
||||
"command": "speckit.specify",
|
||||
"input": {"args": "{{ inputs.name }}"},
|
||||
}
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
|
||||
result = step.execute(config, ctx)
|
||||
result = step.execute(config, ctx)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert result.output["command"] == "speckit.specify"
|
||||
assert result.output["integration"] == "claude"
|
||||
@@ -510,7 +474,6 @@ class TestCommandStep:
|
||||
|
||||
def test_dispatch_not_attempted_without_cli(self):
|
||||
"""When the CLI tool is not installed, step should fail."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
@@ -525,8 +488,7 @@ class TestCommandStep:
|
||||
"command": "speckit.specify",
|
||||
"input": {"args": "{{ inputs.name }}"},
|
||||
}
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
|
||||
result = step.execute(config, ctx)
|
||||
result = step.execute(config, ctx)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert result.output["dispatched"] is False
|
||||
assert result.error is not None
|
||||
@@ -604,7 +566,6 @@ class TestPromptStep:
|
||||
"""Test the prompt step type."""
|
||||
|
||||
def test_execute_basic(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
@@ -618,8 +579,7 @@ class TestPromptStep:
|
||||
"type": "prompt",
|
||||
"prompt": "Review {{ inputs.file }} for security issues",
|
||||
}
|
||||
with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None):
|
||||
result = step.execute(config, ctx)
|
||||
result = step.execute(config, ctx)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert result.output["prompt"] == "Review auth.py for security issues"
|
||||
assert result.output["integration"] == "claude"
|
||||
@@ -1351,7 +1311,6 @@ class TestWorkflowEngine:
|
||||
engine.load_workflow("nonexistent")
|
||||
|
||||
def test_execute_simple_workflow(self, project_dir):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
@@ -1374,8 +1333,7 @@ steps:
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
|
||||
state = engine.execute(definition, {"name": "login"})
|
||||
state = engine.execute(definition, {"name": "login"})
|
||||
|
||||
assert state.status == RunStatus.FAILED
|
||||
assert "step-one" in state.step_results
|
||||
|
||||
Reference in New Issue
Block a user