Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
da4f095a49 chore: bump version to 0.7.3 2026-04-17 19:30:46 +00:00
28 changed files with 125 additions and 3208 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)",
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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