Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
80ac47e257 chore: bump version to 0.12.9 2026-07-09 13:29:54 +00:00
118 changed files with 721 additions and 10816 deletions

1
.github/CODEOWNERS vendored
View File

@@ -5,3 +5,4 @@
/extensions/catalog.community.json @mnriem
/integrations/catalog.community.json @mnriem
/presets/catalog.community.json @mnriem

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for contributing an extension! This template helps you submit your extension to the community catalog.
**Before submitting:**
- Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md)
- Ensure your extension has a valid `extension.yml` manifest
@@ -209,9 +209,9 @@ body:
**Tested on:**
- macOS 14.0 with Spec Kit v0.1.0
- Linux Ubuntu 22.04 with Spec Kit v0.1.0
**Test project:** [Link or description]
**Test scenarios:**
1. Installed extension
2. Configured settings
@@ -230,7 +230,7 @@ body:
```bash
# Install extension
specify extension add <extension-name> --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
# Use a command
/speckit.your-extension.command-name arg1 arg2
```

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for contributing a preset! This template helps you submit your preset to the community catalog.
**Before submitting:**
- Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)
- Ensure your preset has a valid `preset.yml` manifest

View File

@@ -19,3 +19,4 @@
- [ ] I **did** use AI assistance (describe below)
<!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): -->

View File

@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
with:
globs: |
'**/*.md'

View File

@@ -32,7 +32,7 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -74,7 +74,7 @@ jobs:
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Publish to PyPI
run: uv publish

View File

@@ -20,24 +20,24 @@ jobs:
days-before-stale: 150
# Days of inactivity before a stale issue or PR is closed (after being marked stale)
days-before-close: 30
# Stale issue settings
stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.'
stale-issue-label: 'stale'
# Stale PR settings
stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.'
stale-pr-label: 'stale'
# Exempt issues and PRs with these labels from being marked as stale
exempt-issue-labels: 'pinned,security'
exempt-pr-labels: 'pinned,security'
# Only issues or PRs with all of these labels are checked
# Leave empty to check all issues and PRs
any-of-labels: ''
# Operations per run (helps avoid rate limits)
operations-per-run: 250

View File

@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6

View File

@@ -26,4 +26,4 @@
"ignores": [
".genreleases/"
]
}
}

View File

@@ -1,12 +0,0 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-executables-have-shebangs
- id: check-yaml
exclude: \.lock\.yml$
- id: end-of-file-fixer
exclude: \.lock\.yml$
- id: trailing-whitespace
exclude: \.lock\.yml$

View File

@@ -2,97 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.12.15] - 2026-07-14
### Changed
- Update Autonomous Run Governance preset to v0.1.4 (#3511)
- fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
- fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)
- fix: add trailing newline to init-options.json output (#3509)
- feat(workflows): align workflow CLI with extension command surface (#3419)
- fix(extensions): stop env-var config leaking across prefix-colliding extension IDs (#3497)
- fix(integrations): escape control characters in goose recipe YAML renderer (#3384)
- [extension] Update DocGuard — CDD Enforcement extension to v0.32.0 (#3489)
- [extension] Add Multi-Repo Branch Sync extension to community catalog (#3411)
- chore: release 0.12.14, begin 0.12.15.dev0 development (#3506)
## [0.12.14] - 2026-07-13
### Changed
- [extension] Add Spec Kit Memory extension to community catalog (#3455)
- Add Test-First Governance preset to community catalog (#3504)
- Add Autonomous Run Governance preset to community catalog (#3501)
- fix(workflows): validate command step input/options are mappings (#3262)
- fix(presets): resolve() honors manifest-declared file: for installed presets (#3351)
- fix(init): don't block on confirmation for 'init --here' without a TTY (#3236)
- [extension] Add Quality Gates (Enforcement Layer) extension to community catalog (#3431)
- fix(integrations): exit cleanly on unbalanced quote in --integration-options (#3457) (#3466)
- fix(integrations): declare kiro-cli multi-install safe (#3471) (#3485)
- fix(workflows): fail fan-in step on non-list wait_for instead of crashing (#3482)
- chore: release 0.12.13, begin 0.12.14.dev0 development (#3498)
## [0.12.13] - 2026-07-13
### Changed
- fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
- Cleanup agent-file-template.md (#2579)
- fix: mark Kiro integration as multi-install safe (#3472)
- fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
- fix(templates): point constitution sync checklist at installed command files (#3418)
- feat(workflows): make shell step timeout configurable (#3327) (#3328)
- docs: clarify that release tags keep the leading v prefix (#3463)
- fix(workflows): don't crash on membership test against a non-iterable (#3448)
- fix(workflows): if-step validate accepts falsy non-list else (#3264)
- chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
## [0.12.12] - 2026-07-13
### Changed
- fix(extensions): set-priority repairs corrupted boolean priority (#3268)
- fix(presets): set-priority repairs corrupted boolean priority (#3269)
- fix(workflows): engine loop cap ignores bool max_iterations (#3270)
- docs(bundles): document --integration on 'bundle update' (#3271)
- fix(workflows): harden catalog.py against mis-shaped registry & non-string fields (#3375)
- Add Verify Review Ship extension to community catalog (#3450)
- fix(bundle): reject file:// / local download_url — catalog URLs are HTTPS-only (#3344)
- fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
- docs: document copilot skills mode (--skills) and markdown deprecation (#3313)
- chore: release 0.12.11, begin 0.12.12.dev0 development (#3460)
## [0.12.11] - 2026-07-10
### Changed
- fix(agent-context): discover nested plan.md in scoped layouts (#3024) (#3301)
- fix(auth): return no matches, not raw ValueError, for a malformed URL (#3437)
- fix(catalogs): raise catalog error, not raw ValueError, on a malformed URL (#3435)
- fix(bundler): raise BundlerError, not raw ValueError, on a malformed catalog URL (#3433)
- chore: add pre-commit config and fix trailing whitespace/end-of-file (#3430)
- Add EARS Requirements Syntax extension to community catalog (#3407)
- Add Spec Kit Figma extension to community catalog (#3408)
- fix(workflows): report validation errors instead of crashing on non-string workflow.yml scalars (#3421)
- fix(templates): remove self-referencing path in plan-template.md note (#3417)
- chore: release 0.12.10, begin 0.12.11.dev0 development (#3453)
## [0.12.10] - 2026-07-10
### Changed
- chore(deps): bump astral-sh/setup-uv from 8.2.0 to 8.3.2 (#3439)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3438)
- fix(templates): correct phase numbering in plan.md (#3416)
- fix(git-ext): honor explicit -Number 0 in PowerShell branch creation (#3412)
- docs: add 'spectatui' entry to friends.md (#3362)
- test: pin interpreter probe so py-template render test passes on Windows (#3428)
- feat(workflows): make shell step timeout configurable (#3404)
- fix: find plans in nested spec directories (#3405)
- feat(templates): add py: lines to command templates' scripts frontmatter (#3403)
- chore: release 0.12.9, begin 0.12.10.dev0 development (#3426)
## [0.12.9] - 2026-07-09
### Changed

View File

@@ -19,3 +19,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -45,7 +45,7 @@ Spec-Driven Development **flips the script** on traditional software development
### 1. Install Specify CLI
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
```bash
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z

1
docs/.gitignore vendored
View File

@@ -6,3 +6,4 @@ obj/
# Temporary files
*.tmp
*.log

View File

@@ -51,8 +51,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
@@ -84,7 +83,6 @@ The following community-contributed extensions are available in [`catalog.commun
| MemoryLint | Evidence-driven instruction drift checker: audits agent memory files for boundary, reality, conflict, and redundancy drift. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| 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) |
@@ -99,7 +97,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity). | `process` | Read+Write | [spec-gates](https://github.com/schwichtgit/spec-gates) |
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/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) |
@@ -120,8 +117,6 @@ The following community-contributed extensions are available in [`catalog.commun
| 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 Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
| Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) |
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
@@ -151,7 +146,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
| 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 Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| 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) |

View File

@@ -14,5 +14,3 @@ Community projects that extend, visualize, or build on Spec Kit:
- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates.
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.

View File

@@ -11,7 +11,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| 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) |
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 12 templates, 2 commands, 2 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| 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) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
@@ -29,7 +28,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| 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) |
| Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) |
| 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) |
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |

View File

@@ -11,8 +11,7 @@ If you want to try Spec Kit without installing it permanently, use `uvx` to run
# Create a new project (latest from main)
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
# Or target a specific release (replace vX.Y.Z with a tag from Releases;
# keep the leading v, e.g. v0.12.11 not 0.12.11)
# Or target a specific release (replace vX.Y.Z with a tag from Releases)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Initialize in the current directory

View File

@@ -7,8 +7,7 @@
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
```bash
# Install a specific stable release (recommended — replace vX.Y.Z with the
# latest tag, keeping the leading v, e.g. v0.12.11 not 0.12.11)
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
# Or install latest from main (may include unreleased changes)

View File

@@ -15,7 +15,7 @@
### Persistent Installation (Recommended)
Install once and use everywhere. Replace `vX.Y.Z` with a release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
> [!NOTE]
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).

View File

@@ -51,11 +51,10 @@ If the current directory is not yet a Spec Kit project, `install` initializes on
specify bundle update [<bundle_id>]
```
| Option | Description |
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| `--all` | Update every installed bundle |
| `--integration` | Override the integration used when refreshing components; applied only when the project's active integration can't be determined |
| `--offline` | Do not access the network |
| Option | Description |
| ------------ | ------------------------------------ |
| `--all` | Update every installed bundle |
| `--offline` | Do not access the network |
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.

View File

@@ -18,7 +18,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
@@ -219,7 +219,6 @@ Some integrations accept additional options via `--integration-options`:
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx``speckit-xxx`) |
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
Example:

View File

@@ -86,30 +86,8 @@ Lists workflows installed in the current project.
specify workflow add <source>
```
| Option | Description |
| --------------- | ------------------------------------------------------ |
| `--dev` | Install from a local workflow YAML file or directory |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
## Update Workflows
```bash
specify workflow update [workflow_id]
```
Updates one installed catalog workflow — or all of them when no ID is given — to the latest catalog version. Prompts for confirmation and keeps the installed copy if a download or validation fails.
## Enable or Disable a Workflow
```bash
specify workflow enable <workflow_id>
specify workflow disable <workflow_id>
```
Disabled workflows stay installed and listed (marked `[disabled]`) but refuse to run until re-enabled.
## Remove a Workflow
```bash
@@ -124,10 +102,9 @@ Removes an installed workflow from the project.
specify workflow search [query]
```
| Option | Description |
| ---------- | ----------------- |
| `--tag` | Filter by tag |
| `--author` | Filter by author |
| Option | Description |
| ------- | --------------- |
| `--tag` | Filter by tag |
Searches all active catalogs for workflows matching the query.

View File

@@ -15,7 +15,7 @@ The script reads the agent-context extension config at
- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`.
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (any `plan.md` under `specs/`, including nested scoped layouts such as `specs/<scope>/<feature>/plan.md`).
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected.
@@ -24,4 +24,4 @@ If `context_files` and `context_file` are empty, the command reports nothing to
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (searched recursively, so nested scoped layouts are discovered).
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.

View File

@@ -12,7 +12,7 @@
#
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
set -euo pipefail
@@ -307,28 +307,16 @@ import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
specs = root / "specs"
def _resolved_rel(p):
# Resolve symlinks before checking containment: relative_to() is lexical
# and would otherwise accept a plan reached through a specs/ symlink that
# points outside the project, emitting an in-project-looking path for an
# out-of-project file (or picking it as "most recent").
plans = sorted(
specs.glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if plans:
try:
return p.resolve().relative_to(root)
except (OSError, ValueError):
return None
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped layouts
# created via SPECIFY_FEATURE_DIRECTORY, e.g. specs/<scope>/<feature>/plan.md,
# are still discovered when feature.json is absent (#3024).
candidates = []
for p in specs.rglob("plan.md"):
rel = _resolved_rel(p)
if rel:
candidates.append((p, rel))
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
if candidates:
print(candidates[0][1].as_posix())
print(plans[0].relative_to(root).as_posix())
except ValueError:
print("")
else:
print("")
PY

View File

@@ -12,7 +12,7 @@
#
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
[CmdletBinding()]
param(
@@ -426,11 +426,9 @@ if (-not $PlanPath) {
if (-not $PlanPath) {
try {
$specsDir = Join-Path $ProjectRoot 'specs'
# Recurse (rather than the old one-level specs/*/plan.md scan) so scoped
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g.
# specs/<scope>/<feature>/plan.md, are still discovered when
# feature.json is absent (#3024).
$candidate = Get-ChildItem -Path $specsDir -Filter 'plan.md' -File -Recurse -ErrorAction SilentlyContinue |
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-07T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1106,10 +1106,10 @@
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"description": "Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js.",
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
"author": "raccioly",
"version": "0.32.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.32.0/spec-kit-docguard-v0.32.0.zip",
"version": "0.30.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1124,14 +1124,6 @@
"name": "node",
"version": ">=18.0.0",
"required": true
},
{
"name": "npx",
"required": true
},
{
"name": "specify",
"required": false
}
]
},
@@ -1153,7 +1145,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
"updated_at": "2026-07-06T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -1188,39 +1180,6 @@
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-03-13T00:00:00Z"
},
"ears": {
"name": "EARS Requirements Syntax",
"id": "ears",
"description": "Author, lint, and convert requirements using EARS (Easy Approach to Requirements Syntax) - the five industry-standard sentence patterns for unambiguous, testable requirements.",
"author": "dhruv-15-03",
"version": "1.0.0",
"download_url": "https://github.com/dhruv-15-03/spec-kit-ears/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/dhruv-15-03/spec-kit-ears",
"homepage": "https://github.com/dhruv-15-03/spec-kit-ears",
"documentation": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/README.md",
"changelog": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"ears",
"requirements",
"specification",
"quality"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"extensify": {
"name": "Extensify",
"id": "extensify",
@@ -1254,47 +1213,6 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-04-23T00:00:00Z"
},
"figma": {
"name": "Spec Kit Figma",
"id": "figma",
"description": "Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows.",
"author": "Fyloss",
"version": "1.6.0",
"download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v1.6.0.zip",
"repository": "https://github.com/Fyloss/spec-kit-figma",
"homepage": "https://github.com/Fyloss/spec-kit-figma",
"documentation": "https://github.com/Fyloss/spec-kit-figma/blob/main/docs/INSTALL.md",
"changelog": "https://github.com/Fyloss/spec-kit-figma/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "integration",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{ "name": "git", "required": true },
{ "name": "bash", "required": false },
{ "name": "curl", "required": false },
{ "name": "jq", "required": false },
{ "name": "pwsh", "required": false }
]
},
"provides": {
"commands": 5,
"hooks": 6
},
"tags": [
"figma",
"design",
"frontend",
"ui",
"design-system"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"fix-findings": {
"name": "Fix Findings",
"id": "fix-findings",
@@ -1435,57 +1353,6 @@
"created_at": "2026-05-06T00:00:00Z",
"updated_at": "2026-05-06T00:00:00Z"
},
"gates": {
"name": "Quality Gates (Enforcement Layer)",
"id": "gates",
"description": "Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity).",
"author": "schwichtgit",
"version": "0.1.0",
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.1.0/gates-0.1.0.zip",
"repository": "https://github.com/schwichtgit/spec-gates",
"homepage": "https://github.com/schwichtgit/spec-gates",
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.12.0",
"tools": [
{
"name": "jq",
"required": true
},
{
"name": "git",
"required": true
},
{
"name": "node",
"required": false
},
{
"name": "shellcheck",
"required": false
}
]
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"quality",
"enforcement",
"hooks",
"ci",
"governance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-09T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration 1",
"id": "github-issues",
@@ -2276,42 +2143,6 @@
"created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z"
},
"memory": {
"name": "Spec Kit Memory",
"id": "memory",
"description": "Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows.",
"author": "Andrey Zaytsev",
"version": "0.3.0",
"download_url": "https://github.com/zaytsevand/spec-kit-memory/archive/refs/tags/v0.3.0.zip",
"repository": "https://github.com/zaytsevand/spec-kit-memory",
"homepage": "https://github.com/zaytsevand/spec-kit-memory",
"documentation": "https://github.com/zaytsevand/spec-kit-memory/blob/main/README.md",
"changelog": "",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{ "name": "memsearch", "required": false }
]
},
"provides": {
"commands": 2,
"hooks": 3
},
"tags": [
"memory",
"recall",
"research",
"memsearch"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-10T00:00:00Z"
},
"memory-loader": {
"name": "Memory Loader",
"id": "memory-loader",
@@ -2466,48 +2297,6 @@
"created_at": "2026-05-04T02:51:52Z",
"updated_at": "2026-06-18T00:00:00Z"
},
"multi-repo-sync": {
"name": "Multi-Repo Branch Sync",
"id": "multi-repo-sync",
"description": "Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks",
"author": "Fyloss",
"version": "1.0.0",
"download_url": "https://github.com/fyloss/spec-kit-multi-repo-sync/releases/download/v1.0.0/spec-kit-multi-repo-sync.zip",
"sha256": "12a5c7392145b4424b20715aaa3d8b6a8218c143dea596873e344146c1a76ba0",
"repository": "https://github.com/fyloss/spec-kit-multi-repo-sync",
"homepage": "https://github.com/fyloss/spec-kit-multi-repo-sync",
"documentation": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/README.md",
"changelog": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{
"name": "git",
"version": ">=2.31",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 2
},
"tags": [
"git",
"branching",
"multi-repo",
"submodules",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"multi-sites": {
"name": "Multi-Sites Spec Kit",
"id": "multi-sites",
@@ -4493,40 +4282,6 @@
"created_at": "2026-03-03T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z"
},
"verify-review-ship": {
"name": "Verify Review Ship",
"id": "verify-review-ship",
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
"author": "Carlos Eduardo Gevaerd Araujo",
"version": "0.1.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-only",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"quality",
"review",
"shipping",
"workflow",
"testing"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-10T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
"id": "verify-tasks",

View File

@@ -48,4 +48,4 @@
]
}
}
}
}

View File

@@ -446,10 +446,7 @@ if ($env:GIT_BRANCH_NAME) {
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
# `[ -n "$BRANCH_NUMBER" ]` check.
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
if ($Timestamp -and $Number -ne 0) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
@@ -459,10 +456,7 @@ if ($env:GIT_BRANCH_NAME) {
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
} else {
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
# Auto-detect the next number only when -Number was not supplied; an
# explicit value (including 0) is honored, matching the bash twin's
# `[ -z "$BRANCH_NUMBER" ]` check.
if (-not $PSBoundParameters.ContainsKey('Number')) {
if ($Number -eq 0) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
} elseif ($DryRun) {

View File

@@ -48,7 +48,7 @@ cat .specify/extensions/.registry/$ARGUMENTS.json
### Step 4: Verification Report
Analyze the standard output of the three steps.
Analyze the standard output of the three steps.
Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user.
Example output format:

View File

@@ -76,3 +76,5 @@ Areas under discussion or in progress for future development:
- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md)

View File

@@ -158,7 +158,8 @@ presets/
├── plan-template.md
├── tasks-template.md
├── checklist-template.md
── constitution-template.md
── constitution-template.md
└── agent-file-template.md
```
## Module Structure

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-14T00:00:00Z",
"updated_at": "2026-06-30T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -131,35 +131,6 @@
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
},
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
"id": "autonomous-run-governance",
"version": "0.1.4",
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.4.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.1.4/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 12,
"commands": 2,
"scripts": 2
},
"tags": [
"autonomous",
"governance",
"evidence",
"permissions",
"retrospective"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-14T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
"id": "canon-core",
@@ -647,34 +618,6 @@
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z"
},
"test-first-governance": {
"name": "Test-First Governance",
"id": "test-first-governance",
"version": "1.3.0",
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
"author": "Zoltán Katona, PhD",
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
"documentation": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.12.11"
},
"provides": {
"templates": 10,
"commands": 8
},
"tags": [
"tdd",
"bdd",
"atdd",
"quality-gates",
"traceability"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"toc-navigation": {
"name": "Table of Contents Navigation",
"id": "toc-navigation",

View File

@@ -44,6 +44,12 @@ provides:
description: "Self-test constitution template"
replaces: "constitution-template"
- type: "template"
name: "agent-file-template"
file: "templates/agent-file-template.md"
description: "Self-test agent file template"
replaces: "agent-file-template"
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"

View File

@@ -0,0 +1,9 @@
# Agent File (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Agent Instructions
Follow these guidelines when working on this project.

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.15"
version = "0.12.9"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
@@ -83,3 +83,4 @@ extend-select = [
"S604", # call-with-shell-equals-true
"S605", # start-process-with-a-shell
]

View File

@@ -57,13 +57,13 @@ OPTIONS:
EXAMPLES:
# Check task prerequisites (plan.md required)
./check-prerequisites.sh --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check-prerequisites.sh --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check-prerequisites.sh --paths-only
EOF
exit 0
;;
@@ -182,13 +182,13 @@ else
# Text output
echo "FEATURE_DIR:$FEATURE_DIR"
echo "AVAILABLE_DOCS:"
# Show status of each potential document
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
if $INCLUDE_TASKS; then
check_file "$TASKS" "tasks.md"
fi

View File

@@ -94,7 +94,7 @@ fi
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
@@ -109,7 +109,7 @@ get_highest_from_specs() {
fi
done
fi
echo "$highest"
}
@@ -135,19 +135,19 @@ fi
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description="$1"
# Common stop words to filter out
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
# Convert to lowercase and split into words
local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
local meaningful_words=()
for word in $clean_name; do
# Skip empty words
[ -z "$word" ] && continue
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
@@ -160,12 +160,12 @@ generate_branch_name() {
fi
fi
done
# If we have meaningful words, use first 3-4 of them
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
@@ -221,15 +221,15 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"

View File

@@ -8,17 +8,17 @@ ARGS=()
for arg in "$@"; do
case "$arg" in
--json)
JSON_MODE=true
--json)
JSON_MODE=true
;;
--help|-h)
--help|-h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
exit 0
;;
*)
ARGS+=("$arg")
*)
ARGS+=("$arg")
;;
esac
done
@@ -77,7 +77,8 @@ if $JSON_MODE; then
fi
else
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH"
fi

View File

@@ -42,10 +42,10 @@ OPTIONS:
EXAMPLES:
# Check task prerequisites (plan.md required)
.\check-prerequisites.ps1 -Json
# Check implementation prerequisites (plan.md + tasks.md required)
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
# Get feature paths only (no validation)
.\check-prerequisites.ps1 -PathsOnly
@@ -118,35 +118,35 @@ if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
# Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
# Output results
if ($Json) {
# JSON output
[PSCustomObject]@{
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
Write-Output "AVAILABLE_DOCS:"
# Show status of each potential document
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
if ($IncludeTasks) {
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
}

View File

@@ -191,7 +191,7 @@ function Get-FeaturePathsEnv {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
exit 1
}
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
# directory basename so CURRENT_BRANCH is a usable identifier rather than

View File

@@ -63,7 +63,7 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
# Output results
if ($Json) {
$result = [PSCustomObject]@{
$result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR

View File

@@ -5,4 +5,4 @@
}
],
"settings": {}
}
}

View File

@@ -141,9 +141,8 @@ def _install_shared_infra(
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
the bundled core_pack or source checkout, where ``<variant>`` is
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
``speckit.manifest.json``.
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
Shared scripts and page templates are processed to resolve
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*

View File

@@ -24,7 +24,6 @@ GITHUB_HOSTS = frozenset({
"api.github.com",
"codeload.github.com",
})
_MAX_RELEASE_METADATA_BYTES = 5 * 1024 * 1024
def build_github_request(url: str) -> urllib.request.Request:
@@ -69,8 +68,6 @@ def resolve_github_release_asset_api_url(
open_url_fn: Callable,
timeout: int = 60,
github_hosts: tuple[str, ...] = (),
redirect_validator: Callable[[str, str], None] | None = None,
max_metadata_bytes: int = _MAX_RELEASE_METADATA_BYTES,
) -> Optional[str]:
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
@@ -94,8 +91,6 @@ def resolve_github_release_asset_api_url(
authenticated release-metadata lookup.
timeout: Per-request timeout in seconds.
github_hosts: Host patterns to treat as GitHub Enterprise Server.
redirect_validator: Optional policy applied to metadata redirects.
max_metadata_bytes: Maximum release-metadata response size.
"""
import json
import urllib.error
@@ -154,33 +149,13 @@ def resolve_github_release_asset_api_url(
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
try:
open_kwargs = {"timeout": timeout}
if redirect_validator is not None:
open_kwargs["redirect_validator"] = redirect_validator
with open_url_fn(release_url, **open_kwargs) as response:
raw_release_data = response.read(max_metadata_bytes + 1)
if len(raw_release_data) > max_metadata_bytes:
raise ValueError("GitHub release metadata exceeds size limit")
release_data = json.loads(raw_release_data)
except (
urllib.error.URLError,
json.JSONDecodeError,
TypeError,
ValueError,
):
with open_url_fn(release_url, timeout=timeout) as response:
release_data = json.loads(response.read())
except (urllib.error.URLError, json.JSONDecodeError):
return None
if not isinstance(release_data, dict):
return None
assets = release_data.get("assets", [])
if not isinstance(assets, list):
return None
for asset in assets:
if (
isinstance(asset, dict)
and asset.get("name") == asset_name
and asset.get("url")
):
for asset in release_data.get("assets", []):
if asset.get("name") == asset_name and asset.get("url"):
return str(asset["url"])
return None

View File

@@ -14,7 +14,7 @@ def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
encoding="utf-8",
)

View File

@@ -213,52 +213,6 @@ class CommandRegistrar:
".specify.specify/", ".specify/"
)
@staticmethod
def rewrite_extension_paths(
text: str, extension_id: str, extension_dir: Path
) -> str:
"""Rewrite extension-relative paths to their installed locations.
Extension command bodies reference bundled files relative to the
extension root (e.g. ``agents/control/commander.md``). After install
those files live under ``.specify/extensions/<id>/``, so bare
references would resolve against the workspace root and never be
found (#2101).
Only directories that actually exist inside *extension_dir* are
rewritten, keeping the behaviour conservative and avoiding false
positives on prose. ``commands`` (slash-command sources), ``specs``
(user project artifacts) and dot-directories are never rewritten.
"""
if not isinstance(text, str) or not text:
return text
skip = {"commands", ".git", "specs"}
try:
subdirs = [
entry.name
for entry in extension_dir.iterdir()
if entry.is_dir()
and entry.name not in skip
and not entry.name.startswith(".")
]
except OSError:
return text
for subdir in subdirs:
# Only rewrite relative references (subdir/... or ./subdir/...);
# absolute paths like /subdir/... keep their meaning. Use a
# callable replacement: subdir/extension_id come from the
# filesystem and could contain backslashes or "\1"-like
# sequences, which would corrupt a string replacement template.
replacement = f".specify/extensions/{extension_id}/{subdir}/"
text = re.sub(
r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/",
lambda m: m.group(1) + replacement,
text,
)
return text
def render_markdown_command(
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
) -> str:
@@ -685,9 +639,6 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)
if extension_id:
body = self.rewrite_extension_paths(body, extension_id, source_root)
frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id
)

View File

@@ -196,15 +196,7 @@ def find_entries_for_url(
url: str, entries: list[AuthConfigEntry]
) -> list[AuthConfigEntry]:
"""Return entries whose ``hosts`` match the hostname of *url*."""
# A malformed authority (e.g. an unterminated IPv6 bracket "https://[::1")
# makes urlparse/hostname raise ValueError. Treat that the same as a
# host-less URL: no entry can match, so return no matches rather than
# leaking a raw ValueError out of the shared HTTP client (build_request /
# open_url call this before any URL validation).
try:
hostname = (urlparse(url).hostname or "").lower()
except ValueError:
return []
hostname = (urlparse(url).hostname or "").lower()
if not hostname:
return []
return [

View File

@@ -10,8 +10,6 @@ from __future__ import annotations
import json
import os
import re
import stat
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any
@@ -89,63 +87,17 @@ def loads_json(text: str, *, origin: str = "<string>") -> Any:
def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path:
"""Atomically write pretty JSON to *path* (optionally confined to *within*)."""
"""Write *data* as pretty JSON to *path* (optionally confined to *within*)."""
path = Path(path)
if within is not None:
path = ensure_within(within, path)
fd = -1
temp_path: Path | None = None
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as handle:
with path.open("w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=False)
handle.write("\n")
try:
if path.exists():
existing = path.stat(follow_symlinks=False)
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchmod"):
os.fchmod(fd, stat.S_IMODE(existing.st_mode))
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchown"):
try:
os.fchown(fd, existing.st_uid, existing.st_gid)
except PermissionError:
pass
except OSError:
pass
staged = os.stat(temp_path, follow_symlinks=False)
opened = os.fstat(fd)
if (
not stat.S_ISREG(staged.st_mode)
or staged.st_dev != opened.st_dev
or staged.st_ino != opened.st_ino
):
raise OSError("staged JSON file changed before commit")
os.close(fd)
fd = -1
os.replace(temp_path, path)
temp_path = None
except OSError as exc:
raise BundlerError(f"Could not write {path}: {exc}") from exc
finally:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
return path

View File

@@ -68,18 +68,8 @@ def _validate_remote_url(source_id: str, url: str) -> None:
Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade
issues before any network call.
"""
# A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``)
# makes urlparse / hostname access raise ValueError. This function's
# contract is to raise BundlerError for a bad URL, so surface that as a
# clean error rather than leaking a raw ValueError to the caller.
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise BundlerError(
f"Catalog '{source_id}' URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise BundlerError(
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
@@ -89,7 +79,7 @@ def _validate_remote_url(source_id: str, url: str) -> None:
# "https://:8080" or "https://user@...", so requiring netloc would let
# those through even though they carry no host. hostname is None in those
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
if not hostname:
if not parsed.hostname:
raise BundlerError(
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
)

View File

@@ -187,41 +187,19 @@ def remove_bundle(
still_needed = components_still_needed(records, exclude_bundle_id=bundle_id)
result = InstallResult(bundle_id=bundle_id)
remove_attempted = False
try:
for component in target.contributed_components:
key = (component.kind, component.id)
if key in still_needed:
result.skipped.append(component)
continue
if installer.is_installed(project_root, component):
remove_attempted = True
installer.remove(project_root, component)
result.uninstalled.append(component)
save_records(project_root, remove_record(records, bundle_id))
except Exception as exc: # noqa: BLE001
if result.uninstalled:
detail = (
f"{len(result.uninstalled)} component(s) were already removed "
"before this failure; the bundle record was left unchanged, "
"so the project may be partially uninstalled."
)
elif remove_attempted:
detail = (
"No components were removed, but the failing component may "
"have made partial changes before raising, so the project "
"may be partially uninstalled."
)
for component in target.contributed_components:
key = (component.kind, component.id)
if key in still_needed:
result.skipped.append(component)
continue
if installer.is_installed(project_root, component):
installer.remove(project_root, component)
result.uninstalled.append(component)
else:
detail = (
"No components were removed and no removal was attempted; "
"the bundle record was left unchanged."
)
raise BundlerError(
f"Failed to remove bundle '{bundle_id}': {exc}. {detail}"
) from exc
result.skipped.append(component)
save_records(project_root, remove_record(records, bundle_id))
return result

View File

@@ -71,12 +71,8 @@ class CatalogStackBase:
"""Validate that a catalog URL uses HTTPS, except localhost HTTP."""
from urllib.parse import urlparse
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise cls._error(f"Catalog URL is malformed: {url}") from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise cls._error(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
@@ -85,7 +81,7 @@ class CatalogStackBase:
# Check hostname, not netloc: netloc is truthy for host-less URLs like
# "https://:8080" or "https://user@", so the host guarantee this error
# promises would not actually hold. hostname is None in those cases (#3209).
if not hostname:
if not parsed.hostname:
raise cls._error("Catalog URL must be a valid URL with a host.")
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:

View File

@@ -746,16 +746,11 @@ def _resolve_manifest_path(path: Path | None) -> Path:
def _download_manifest(resolved, *, offline: bool):
"""Resolve a bundle's manifest from its catalog ``download_url``.
Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost),
matching the extensions/presets/workflows catalog systems. Remote URLs are
fetched with the shared authenticated, redirect-validated HTTP client, and
only when not ``--offline``.
Local and ``file://`` sources are intentionally not resolved here: to
install a bundle from disk, pass the path positionally
(``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a
``.zip`` artifact also works), which :func:`_local_manifest_source` handles
before catalog resolution and which never touches ``download_url``.
Local/``file://`` URLs always work offline and may point at a ``.zip``
artifact, a bundle directory, or a ``bundle.yml`` (handled by
:func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with
the shared authenticated, redirect-validated HTTP client, and only when not
``--offline``.
"""
from urllib.parse import urlparse
@@ -768,35 +763,26 @@ def _download_manifest(resolved, *, offline: bool):
parsed = urlparse(url)
scheme = parsed.scheme.lower()
# ``file://`` URLs and bare filesystem paths (including Windows drive paths
# like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme)
# are not valid catalog download URLs. Catalog URLs are HTTPS-only across
# every catalog system; installing from disk is done by passing the path
# positionally, which never reaches URL resolution. Give an actionable
# error rather than accepting a scheme the rest of the codebase rejects.
# On Windows an absolute path like ``C:\bundle.yml`` parses with a
# single-letter ``scheme``; treat it as a local file, not a URL scheme.
if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url):
raise BundlerError(
f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url "
f"({url}); catalog download URLs must be HTTPS (http for localhost) — "
"a file:// URL, a local filesystem path, or a scheme-less value "
"(e.g. 'example.com/bundle.zip') is not accepted. "
"To install a bundle from disk, pass the path directly: "
"'specify bundle install <path-to-bundle.yml | bundle-dir | .zip>'."
)
local = Path(parsed.path if scheme == "file" else url)
manifest = _local_manifest_source(str(local))
if manifest is None:
raise BundlerError(f"Bundle manifest not found: {local}")
return manifest
# Validate the scheme/host *before* the offline gate so an invalid or
# non-HTTPS download_url reports the real problem in every mode, rather
# than a misleading "Network access disabled" under --offline.
# (_download_remote_manifest re-checks this, but only once network access
# is permitted.) HTTPS-only, http allowed for localhost.
_require_https(f"bundle '{resolved.entry.id}'", url)
if scheme in ("http", "https"):
if offline:
raise BundlerError(
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
return _download_remote_manifest(resolved.entry.id, url)
if offline:
raise BundlerError(
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
return _download_remote_manifest(resolved.entry.id, url)
raise BundlerError(
f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}"
)
def _require_https(label: str, url: str) -> None:

View File

@@ -220,45 +220,16 @@ def register(app: typer.Typer) -> None:
console.print(
f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)"
)
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
if force:
# Proceeding: the merge/overwrite warning is accurate here.
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
console.print(
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
)
else:
# Fold the merge risk into the confirmation prompt rather than
# printing it unconditionally first: on the EOF/no-input path
# below the command exits without changing anything, so a
# standalone "will be merged" line would mislead. Interactive
# users still see the risk as part of the question.
#
# Call typer.confirm normally so piped y/n is honored — e.g.
# `echo y | specify init --here` keeps reaching the
# non-destructive preserve-merge path.
try:
proceed = typer.confirm(
"Template files will be merged with existing content "
"and may overwrite existing files. Do you want to continue?"
)
except (typer.Abort, EOFError):
# typer.confirm raises Abort for BOTH an interactive Ctrl+C
# and an EOF on closed/empty stdin. Distinguish them: a real
# TTY cancellation is a normal exit (0, "cancelled"), while a
# missing-input EOF (non-interactive) becomes an actionable
# error pointing at --force.
if _stdin_is_interactive():
console.print("[yellow]Operation cancelled[/yellow]")
raise typer.Exit(0) from None
console.print(
"[red]Error:[/red] Current directory is not empty and no "
"confirmation input is available. Re-run with "
"[bold]--force[/bold] to merge into it."
)
raise typer.Exit(1) from None
if not proceed:
response = typer.confirm("Do you want to continue?")
if not response:
console.print("[yellow]Operation cancelled[/yellow]")
raise typer.Exit(0)
else:

View File

@@ -1078,11 +1078,6 @@ class ExtensionManager:
frontmatter = registrar._adjust_script_paths(
frontmatter, extension_id=manifest.id
)
# Mirror the register_commands() rewrite (#2101): resolve
# extension-relative subdir references (agents/, knowledge-base/,
# etc.) to their installed .specify/extensions/<id>/ location
# before the generic placeholder/path resolution below.
body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir)
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)
@@ -2737,36 +2732,6 @@ class ConfigManager:
config_file = self.extension_dir / "local-config.yml"
return self._load_yaml_config(config_file)
def _sibling_extension_ids(self) -> list[str]:
"""Return IDs of other extensions installed alongside this one.
Sourced from ``ExtensionRegistry`` (``.specify/extensions/.registry``)
rather than a directory scan: ``ExtensionManager.remove(...,
keep_config=True)`` deliberately preserves the extension directory
while dropping the registry entry, so a directory scan would treat
that config-only leftover as an installed sibling and keep silently
absorbing its ``SPECKIT_<sibling>_*`` env vars into no one. The
registry is the source of truth for "installed".
Returns an empty list if the registry is missing or corrupted
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
opens the file in text mode and only handles ``JSONDecodeError`` /
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
otherwise surface a ``UnicodeDecodeError`` here and break *every*
config read instead of degrading gracefully.
Used by ``_get_env_config`` to detect env vars whose remainder claims
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
owned by ``git-hooks`` when it is co-installed with ``git``).
"""
extensions_dir = self.project_root / ".specify" / "extensions"
try:
return list(ExtensionRegistry(extensions_dir).keys())
except (OSError, UnicodeError):
return []
def _get_env_config(self) -> Dict[str, Any]:
"""Get configuration from environment variables.
@@ -2786,70 +2751,22 @@ class ConfigManager:
ext_id_upper = self.extension_id.replace("-", "_").upper()
prefix = f"SPECKIT_{ext_id_upper}_"
# Cross-extension prefix collision: because ``_`` doubles as both the
# separator between the extension ID and the config path *and* the
# substitute for ``-`` inside an extension ID, an env var like
# ``SPECKIT_GIT_HOOKS_URL`` begins with *both* the ``SPECKIT_GIT_``
# prefix of the ``git`` extension and the ``SPECKIT_GIT_HOOKS_`` prefix
# of a co-installed ``git-hooks`` extension. It logically belongs to
# the extension whose normalized ID is the longer, more specific match
# — otherwise config intended for one extension silently surfaces
# inside another and can drive hooks that only inspect
# ``config.<field> is set``. Build the list of sibling-owned
# remainder-prefixes here so a later env var can be skipped if it
# matches one.
sibling_prefixes: list[str] = []
for sibling_id in self._sibling_extension_ids():
if sibling_id == self.extension_id:
continue
sib_upper = sibling_id.replace("-", "_").upper()
# A sibling collides only when its normalized ID *extends* our own
# (i.e. starts with ``<US>_``). ``git`` vs ``not-git`` is not a
# collision; ``git`` vs ``git-hooks`` is.
if sib_upper.startswith(ext_id_upper + "_"):
# The portion of the env-var *remainder* the sibling claims,
# including the trailing ``_`` so a shorter ID that shares a
# non-boundary prefix cannot false-positive (e.g. sibling
# ``hook`` would not eat env vars under key ``hooks``).
sibling_prefixes.append(sib_upper[len(ext_id_upper) + 1 :] + "_")
for key, value in os.environ.items():
if not key.startswith(prefix):
continue
remainder = key[len(prefix) :]
# Skip when a longer sibling ID claims this var — see the block
# above. Keeps ``SPECKIT_GIT_HOOKS_URL`` out of the ``git``
# extension's config when ``git-hooks`` is co-installed.
if any(remainder.startswith(sp) for sp in sibling_prefixes):
continue
# Remove prefix and split into parts
config_path = key[len(prefix) :].lower().split("_")
# Remove prefix and split into parts. Drop empty components from a
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
# entry under an empty key.
config_path = [p for p in remainder.lower().split("_") if p]
if not config_path:
continue
# Build nested dict. Two env vars can collide on a prefix, e.g.
# SPECKIT_X_CONNECTION=a and SPECKIT_X_CONNECTION_URL=b. Guard the
# walk so a colliding scalar is replaced by a dict (deeper/more
# specific vars win) instead of being indexed into — which raised
# TypeError ('str' object does not support item assignment) — and
# guard the leaf so a scalar processed after the nested var does
# not clobber the nested dict. Order-independent: both insertion
# orders yield {'connection': {'url': ...}}. Nested-wins mirrors
# _merge_configs' dict-preserving semantics.
# Build nested dict
current = env_config
for part in config_path[:-1]:
if not isinstance(current.get(part), dict):
if part not in current:
current[part] = {}
current = current[part]
# Set the final value, unless a nested dict already occupies it.
if not isinstance(current.get(config_path[-1]), dict):
current[config_path[-1]] = value
# Set the final value
current[config_path[-1]] = value
return env_config

View File

@@ -1566,14 +1566,7 @@ def extension_set_priority(
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
# equals 1/0 here and is never repaired.
if (
isinstance(raw_priority, int)
and not isinstance(raw_priority, bool)
and raw_priority == priority
):
if isinstance(raw_priority, int) and raw_priority == priority:
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]")
raise typer.Exit(0)

View File

@@ -190,15 +190,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
"""
import shlex
parsed: dict[str, Any] = {}
try:
tokens = shlex.split(raw_options)
except ValueError as exc:
# An unbalanced quote (e.g. --integration-options='--commands-dir "foo')
# makes shlex raise "No closing quotation". Translate it into the same
# clean exit-1 UX as every other bad-input path below rather than
# letting a raw traceback escape.
console.print(f"[red]Error:[/red] Could not parse integration options: {exc}.")
raise typer.Exit(1)
tokens = shlex.split(raw_options)
declared_options = list(integration.options())
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
allowed = ", ".join(sorted(opt.name for opt in declared_options))

View File

@@ -1122,17 +1122,6 @@ class TomlIntegration(IntegrationBase):
# YamlIntegration — YAML-format agents (Goose)
# ---------------------------------------------------------------------------
# Characters a YAML literal block scalar cannot carry: C0 controls other
# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the
# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF.
# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1
# treats it as a line break, which corrupts the block scalar's structure
# just the same, so all three are included.
_YAML_BLOCK_SCALAR_UNSAFE = re.compile(
r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]"
)
class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
@@ -1238,9 +1227,9 @@ class YamlIntegration(IntegrationBase):
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar for
normal prompt content, or an escaped quoted scalar when control
characters require it. Uses ``yaml.safe_dump()`` for the header fields.
Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
"""
header = cls._build_yaml_header(title, description)
@@ -1251,23 +1240,6 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).strip()
# YAML forbids C0 control characters (except tab and newline) and
# DEL in every scalar form, and a bare CR acts as a line break
# inside a block scalar. A literal block scalar emits such bytes
# verbatim, producing a recipe the YAML parser rejects, so fall
# back to an escaped double-quoted scalar for those bodies.
if _YAML_BLOCK_SCALAR_UNSAFE.search(body):
prompt_yaml = yaml.safe_dump(
{"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize
).strip()
lines = [
header_yaml,
prompt_yaml,
"",
f"# Source: {source_id}",
]
return "\n".join(lines) + "\n"
# Indent the body for YAML block scalar. Use an explicit indentation
# indicator ("|2") rather than a bare "|": YAML infers a plain block
# scalar's indentation from its first non-empty line, so a body whose

View File

@@ -62,4 +62,4 @@ class DevinIntegration(SkillsIntegration):
default=True,
help="Install as agent skills (default for Devin)",
),
]
]

View File

@@ -18,13 +18,13 @@ from ..manifest import IntegrationManifest
def format_forge_command_name(cmd_name: str) -> str:
"""Convert command name to Forge-compatible hyphenated format.
Forge requires command names to use hyphens instead of dots for
compatibility with ZSH and other shells. This function converts
dot-notation command names to hyphenated format.
The function is idempotent: already-formatted names are returned unchanged.
Examples:
>>> format_forge_command_name("plan")
'speckit-plan'
@@ -38,26 +38,26 @@ def format_forge_command_name(cmd_name: str) -> str:
'speckit-my-extension-example'
>>> format_forge_command_name("speckit.jira.sync-status")
'speckit-jira-sync-status'
Args:
cmd_name: Command name in dot notation (speckit.foo.bar),
cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo)
Returns:
Hyphenated command name with 'speckit-' prefix
"""
# Already in hyphenated format - return as-is (idempotent)
if cmd_name.startswith("speckit-"):
return cmd_name
# Strip 'speckit.' prefix if present
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
# Replace all dots with hyphens
short_name = short_name.replace(".", "-")
# Return with 'speckit-' prefix
return f"speckit-{short_name}"

View File

@@ -13,7 +13,6 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli"
multi_install_safe = True
config = {
"name": "Kiro CLI",
"folder": ".kiro/",
@@ -27,10 +26,3 @@ class KiroCliIntegration(MarkdownIntegration):
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
}
# Kiro CLI keeps everything under a static, isolated agent root
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
# integration writes to, so it is safe to install alongside others
# (issue #3471). The registry's multi-install-safe contract tests
# enforce that isolation for every integration setting this flag.
multi_install_safe = True

View File

@@ -778,7 +778,6 @@ class PresetManager:
matching_cmds, ext_id, ext_dir,
self.project_root,
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
extension_id=ext_id,
)
registered = True
except Exception:
@@ -1200,8 +1199,6 @@ class PresetManager:
"command_name": cmd_name,
"source_file": source_file,
"source": f"extension:{manifest.id}",
"extension_id": manifest.id,
"extension_dir": ext_root,
}
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
restore_index.setdefault(modern_skill_name, restore_info)
@@ -1466,17 +1463,6 @@ class PresetManager:
if extension_restore:
content = extension_restore["source_file"].read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content)
# Mirror the register-time rewrite (#2101): resolve
# extension-relative subdir references (agents/,
# knowledge-base/, etc.) to their installed location before
# the generic placeholder resolution below, otherwise
# restoring after a preset override removal would leave
# bare, unresolvable paths in the skill body.
body = registrar.rewrite_extension_paths(
body,
extension_restore["extension_id"],
extension_restore["extension_dir"],
)
if isinstance(selected_ai, str):
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
@@ -2588,39 +2574,6 @@ class PresetResolver:
self._manifest_cache[key] = None
return self._manifest_cache[key]
def _manifest_declared_template(
self, pack_dir: Path, template_name: str, template_type: str
) -> tuple[dict | None, Path | None]:
"""Resolve a preset's manifest-declared template entry and usable file.
Returns ``(entry, candidate)``:
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
the manifest is absent or does not list this ``(name, type)``.
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
The manifest is authoritative: when it declares a template (``entry`` is
not ``None``) but the file is unusable (``candidate`` is ``None``),
callers must NOT fall back to the convention lookup — that would mask a
typo or pick up an undeclared file. Shared by ``resolve()`` and
``collect_all_layers()`` so their manifest-first resolution cannot
silently diverge again (the divergence this fix addressed).
"""
manifest = self._get_manifest(pack_dir)
if not manifest:
return None, None
for tmpl in manifest.templates:
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
file_path = tmpl.get("file")
if file_path:
manifest_candidate = pack_dir / file_path
return tmpl, (
manifest_candidate if manifest_candidate.is_file() else None
)
return tmpl, None
return None, None
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
"""Build unified list of registered and unregistered extensions sorted by priority.
@@ -2723,27 +2676,6 @@ class PresetResolver:
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
pack_dir = self.presets_dir / pack_id
# The preset manifest is authoritative: if it declares this
# template with an explicit ``file:``, resolve to that path —
# and do NOT fall back to convention when it's missing, to
# avoid masking typos or picking up an undeclared file. Only
# when the manifest is absent or doesn't list this template do
# we use the convention-based subdir lookup. Mirrors
# collect_all_layers()/resolve_content() so resolve() and
# resolve_with_source() agree with them instead of returning
# the core template (or a stray convention file).
entry, manifest_candidate = self._manifest_declared_template(
pack_dir, template_name, template_type
)
if manifest_candidate is not None:
return manifest_candidate
if entry is not None:
# Manifest declares this template but the file is missing,
# non-file (e.g. a directory), or an empty/falsey ``file``
# value. The manifest is authoritative, so skip this pack's
# convention fallback rather than mask a typo — mirrors
# collect_all_layers().
continue
for subdir in subdirs:
if subdir:
candidate = pack_dir / subdir / f"{template_name}{ext}"
@@ -3011,22 +2943,31 @@ class PresetResolver:
pack_dir = self.presets_dir / pack_id
# Read strategy and manifest file path from preset manifest
strategy = "replace"
manifest_file_path = None
manifest_has_strategy = False
entry, manifest_candidate = self._manifest_declared_template(
pack_dir, template_name, template_type
)
if entry is not None:
strategy = entry.get("strategy", "replace")
manifest_has_strategy = "strategy" in entry
# Use the manifest's declared file when it's a usable regular file;
# only fall back to convention-based lookup when the manifest
# doesn't list this template at all, so preset.yml stays
# authoritative (a declared-but-unusable file skips convention —
# parity with resolve()).
manifest_found_entry = False
manifest = self._get_manifest(pack_dir)
if manifest:
for tmpl in manifest.templates:
if (tmpl.get("name") == template_name
and tmpl.get("type") == template_type):
strategy = tmpl.get("strategy", "replace")
manifest_has_strategy = "strategy" in tmpl
manifest_file_path = tmpl.get("file")
manifest_found_entry = True
break
# Use manifest file path if specified, otherwise convention-based
# lookup — but only when the manifest doesn't exist or doesn't
# list this template, so preset.yml stays authoritative.
candidate = None
if manifest_candidate is not None:
candidate = manifest_candidate
elif entry is None:
if manifest_file_path:
manifest_candidate = pack_dir / manifest_file_path
if manifest_candidate.exists():
candidate = manifest_candidate
# Explicit file path that doesn't exist: skip convention
# fallback to avoid masking typos or picking up unintended files.
elif not manifest_found_entry:
# Manifest doesn't list this template — check convention paths
candidate = _find_in_subdirs(pack_dir)
if candidate:
# Legacy fallback: if manifest doesn't explicitly declare a
@@ -3097,8 +3038,6 @@ class PresetResolver:
"path": candidate,
"source": source,
"strategy": "replace",
"extension_id": ext_id,
"extension_dir": ext_dir,
})
# Priority 4: Core templates (always "replace")
@@ -3218,32 +3157,10 @@ class PresetResolver:
if not layers:
return None
def _read_layer_content(layer: Dict[str, Any]) -> str:
"""Read a layer's raw text, rewriting extension-relative subdir
references (agents/, knowledge-base/, etc.) to their installed
location when the layer is extension-provided (#2101).
Extension layers are always inserted with strategy "replace"
(see collect_all_layers), so a layer only ever needs this
rewrite when it wins outright above or serves as the
composition base below — never as a mid-stack composing
(append/prepend/wrap) layer.
"""
text = layer["path"].read_text(encoding="utf-8")
extension_id = layer.get("extension_id")
extension_dir = layer.get("extension_dir")
if extension_id and extension_dir:
from ..agents import CommandRegistrar
text = CommandRegistrar.rewrite_extension_paths(
text, extension_id, extension_dir
)
return text
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if layers[0]["strategy"] == "replace":
return _read_layer_content(layers[0])
return layers[0]["path"].read_text(encoding="utf-8")
# Composition: build content bottom-up from the effective base.
# The base is the nearest replace layer scanning from highest priority
@@ -3266,7 +3183,7 @@ class PresetResolver:
# Convert to reversed_layers index
base_reversed_idx = len(layers) - 1 - base_layer_idx
content = _read_layer_content(layers[base_layer_idx])
content = layers[base_layer_idx]["path"].read_text(encoding="utf-8")
# Compose only the layers above the base (higher priority = lower index in layers,
# higher index in reversed_layers). Process bottom-up from base+1.
start_idx = base_reversed_idx + 1

View File

@@ -469,14 +469,7 @@ def preset_set_priority(
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
# equals 1/0 here and is never repaired.
if (
isinstance(raw_priority, int)
and not isinstance(raw_priority, bool)
and raw_priority == priority
):
if isinstance(raw_priority, int) and raw_priority == priority:
console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
raise typer.Exit(0)

View File

@@ -403,7 +403,7 @@ def install_shared_infra(
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
scripts_scanned = False
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
variant_dir = "bash" if script_type == "sh" else "powershell"
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -465,10 +465,6 @@ def install_shared_infra(
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Python bytecode caches are local artifacts, not
# workflow scripts — never install them.
if "__pycache__" in src_path.parts:
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,6 @@ from __future__ import annotations
import hashlib
import json
import os
import stat
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
@@ -73,180 +71,40 @@ class WorkflowRegistry:
self.registry_path = self.workflows_dir / self.REGISTRY_FILE
self.data = self._load()
def _has_symlinked_parent(self) -> bool:
"""Return True if any directory under .specify/workflows is a symlink."""
current = self.project_root
for part in (".specify", "workflows"):
current = current / part
if current.is_symlink():
return True
return False
def _load(self) -> dict[str, Any]:
"""Load registry from disk or create default."""
default_registry: dict[str, Any] = {
"schema_version": self.SCHEMA_VERSION,
"workflows": {},
}
# Defense-in-depth: refuse to read through symlinked parents or a
# symlinked registry file. Unlike StepRegistry (read-only best-effort
# elsewhere), a fabricated empty registry here is not safe: read-only
# callers (notably the bundler's remove path) query is_installed()
# before ever writing, and would otherwise conclude an installed
# workflow is absent, skip removing it, then delete the bundle
# record -- leaving the workflow untracked but still on disk. Fail
# closed here just like the unreadable-file case below.
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise OSError(
f"Refusing to read workflow registry at {self.registry_path}: "
"a parent directory or the registry file itself is a symlink"
)
if self.registry_path.exists():
try:
with open(self.registry_path, encoding="utf-8") as f:
data = json.load(f)
except OSError as exc:
# The real data may still be intact on disk. Fail closed at
# construction rather than fabricating an empty registry that
# a read-only caller could mistake for "nothing installed."
raise OSError(
f"Failed to read workflow registry at {self.registry_path}: {exc}"
) from exc
except (
json.JSONDecodeError,
ValueError,
UnicodeError,
) as exc:
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
f"{exc}"
) from exc
# Validate shape: must be a dict with a dict "workflows" field.
if not isinstance(data, dict):
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
"top-level value must be an object"
)
if not isinstance(data.get("workflows"), dict):
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
"'workflows' must be an object"
)
return data
return default_registry
return json.load(f)
except (json.JSONDecodeError, ValueError):
# Corrupted registry file — reset to default
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
def save(self) -> None:
"""Persist registry to disk atomically."""
# Refuse to write through symlinked parents (mirrors StepRegistry.save
# and the CLI-level _reject_unsafe_dir guard).
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise OSError(
"Refusing to write workflow registry through a symlinked path."
)
"""Persist registry to disk."""
self.workflows_dir.mkdir(parents=True, exist_ok=True)
# Unique, exclusive temp then replace: a failed dump cannot truncate
# the registry, a pre-created symlink cannot redirect the write, and
# concurrent CLI processes cannot collide on the same temp path.
fd, tmp = tempfile.mkstemp(
dir=str(self.registry_path.parent),
prefix=f".{self.registry_path.name}.",
suffix=".tmp",
)
try:
# Write through a duplicate so the exclusive mkstemp descriptor
# stays open for fd-based metadata updates and inode verification.
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
# mkstemp creates the temp file at 0600. A pre-existing registry
# may be shared more permissively (e.g. 0640/0644); preserve its
# mode across the replace so a save doesn't silently lock other
# project users out. A brand-new registry has no prior mode to
# preserve, so mkstemp's secure 0600 default stands. Mirrors
# _utils.py's atomic_write_json (best-effort; data safety over
# metadata preservation).
try:
if self.registry_path.exists():
existing_stat = self.registry_path.stat(
follow_symlinks=False
)
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
os, "fchmod"
):
os.fchmod(fd, stat.S_IMODE(existing_stat.st_mode))
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
os, "fchown"
):
try:
os.fchown(
fd, existing_stat.st_uid, existing_stat.st_gid
)
except PermissionError:
pass
except OSError:
pass
staged_stat = os.stat(tmp, follow_symlinks=False)
open_stat = os.fstat(fd)
if (
not stat.S_ISREG(staged_stat.st_mode)
or staged_stat.st_dev != open_stat.st_dev
or staged_stat.st_ino != open_stat.st_ino
):
raise OSError(
"Refusing to replace workflow registry: "
"staged file changed before commit"
)
os.close(fd)
fd = -1
os.replace(tmp, self.registry_path)
except BaseException:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
pass
raise
with open(self.registry_path, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
"""Add or update an installed workflow entry."""
from datetime import datetime, timezone
raw_existing = self.data["workflows"].get(workflow_id)
had_entry = workflow_id in self.data["workflows"]
# Corrupted-but-parseable registries may hold non-dict entries.
existing = raw_existing if isinstance(raw_existing, dict) else {}
existing = self.data["workflows"].get(workflow_id, {})
metadata["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat()
)
metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
self.data["workflows"][workflow_id] = metadata
try:
self.save()
except (OSError, TypeError, ValueError):
# Roll back the in-memory mutation so a later successful save
# cannot persist metadata for a write that failed.
if had_entry:
self.data["workflows"][workflow_id] = raw_existing
else:
del self.data["workflows"][workflow_id]
raise
self.save()
def remove(self, workflow_id: str) -> bool:
"""Remove an installed workflow entry. Returns True if found."""
if workflow_id in self.data["workflows"]:
removed_entry = self.data["workflows"][workflow_id]
del self.data["workflows"][workflow_id]
try:
self.save()
except (OSError, TypeError, ValueError):
# Roll back the in-memory deletion so a save failure can't
# desync this instance from the untouched file on disk,
# mirroring add()'s rollback-on-save-failure.
self.data["workflows"][workflow_id] = removed_entry
raise
self.save()
return True
return False
@@ -299,20 +157,8 @@ class WorkflowCatalog:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
# A malformed authority (e.g. an unterminated IPv6 bracket
# "https://[::1") makes urlparse / hostname access raise ValueError.
# This validator's contract is to raise WorkflowValidationError for a
# bad URL, so surface that rather than leaking a raw ValueError past the
# command handler (which only catches WorkflowValidationError). Mirrors
# specify_cli.catalogs (#3435).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise WorkflowValidationError(
f"Catalog URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
@@ -320,7 +166,7 @@ class WorkflowCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not hostname:
if not parsed.hostname:
raise WorkflowValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -486,26 +332,15 @@ class WorkflowCatalog:
from specify_cli.authentication.http import open_url as _open_url
def _validate_catalog_url(url: str) -> None:
# A malformed authority (e.g. "https://[::1") makes urlparse /
# hostname access raise ValueError; treat it as a refused fetch
# rather than leaking a raw ValueError (this also validates the
# post-redirect resp.geturl(), so a hostile redirect target cannot
# crash the fetch either).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise WorkflowCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
if not hostname:
if not parsed.hostname:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)
@@ -592,7 +427,6 @@ class WorkflowCatalog:
self,
query: str | None = None,
tag: str | None = None,
author: str | None = None,
) -> list[dict[str, Any]]:
"""Search workflows across all configured catalogs."""
merged = self._get_merged_workflows()
@@ -604,9 +438,9 @@ class WorkflowCatalog:
q = query.lower()
searchable = " ".join(
[
str(wf_data.get("name") or ""),
str(wf_data.get("description") or ""),
str(wf_data.get("id") or ""),
wf_data.get("name", ""),
wf_data.get("description", ""),
wf_data.get("id", ""),
]
).lower()
if q not in searchable:
@@ -617,10 +451,6 @@ class WorkflowCatalog:
normalized_tags = [t.lower() for t in tags if isinstance(t, str)]
if tag.lower() not in normalized_tags:
continue
if author:
wf_author = wf_data.get("author", "")
if not isinstance(wf_author, str) or wf_author.lower() != author.lower():
continue
results.append(wf_data)
return results
@@ -944,20 +774,8 @@ class StepCatalog:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
# A malformed authority (e.g. an unterminated IPv6 bracket
# "https://[::1") makes urlparse / hostname access raise ValueError.
# This validator's contract is to raise StepValidationError for a bad
# URL, so surface that rather than leaking a raw ValueError past the
# command handler (which only catches StepValidationError). Mirrors
# specify_cli.catalogs (#3435).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise StepValidationError(
f"Catalog URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
@@ -965,7 +783,7 @@ class StepCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not hostname:
if not parsed.hostname:
raise StepValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -1131,26 +949,15 @@ class StepCatalog:
from specify_cli.authentication.http import open_url as _open_url
def _validate_url(url: str) -> None:
# A malformed authority (e.g. "https://[::1") makes urlparse /
# hostname access raise ValueError; treat it as a refused fetch
# rather than leaking a raw ValueError (this also validates the
# post-redirect resp.geturl(), so a hostile redirect target cannot
# crash the fetch either).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise StepCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise StepCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
if not hostname:
if not parsed.hostname:
raise StepCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)

View File

@@ -129,50 +129,27 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
errors: list[str] = []
# -- Schema version ---------------------------------------------------
# str() so an unquoted ``schema_version: 1.0`` (YAML float) is accepted —
# rejecting it would print "Unsupported schema_version 1.0. Expected '1.0'."
if str(definition.schema_version) != "1.0":
if definition.schema_version not in ("1.0", "1"):
errors.append(
f"Unsupported schema_version {definition.schema_version!r}. "
f"Expected '1.0'."
)
# -- Top-level fields -------------------------------------------------
# YAML parses unquoted scalars like ``id: 123`` or ``version: 1.0`` as
# int/float; check types before regex/string operations so authoring
# mistakes surface as validation errors instead of tracebacks. Only
# ``None``/empty-string count as missing so falsey non-strings
# (``id: 0``, ``name: false``) still get the typed error.
if definition.id is None or definition.id == "":
if not definition.id:
errors.append("Workflow is missing 'workflow.id'.")
elif not isinstance(definition.id, str):
errors.append(
f"'workflow.id' must be a string, got "
f"{type(definition.id).__name__} ({definition.id!r})."
)
elif not _ID_PATTERN.fullmatch(definition.id):
elif not _ID_PATTERN.match(definition.id):
errors.append(
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
f"with hyphens."
)
if definition.name is None or definition.name == "":
if not definition.name:
errors.append("Workflow is missing 'workflow.name'.")
elif not isinstance(definition.name, str):
errors.append(
f"'workflow.name' must be a string, got "
f"{type(definition.name).__name__} ({definition.name!r})."
)
if definition.version is None or definition.version == "":
if not definition.version:
errors.append("Workflow is missing 'workflow.version'.")
elif not isinstance(definition.version, str):
errors.append(
f"'workflow.version' must be a string, got "
f"{type(definition.version).__name__} ({definition.version!r}) — "
f'quote it in YAML (version: "1.0.0").'
)
elif not re.fullmatch(r"\d+\.\d+\.\d+", definition.version):
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
errors.append(
f"Workflow version {definition.version!r} is not valid "
f"semantic versioning (expected X.Y.Z)."
@@ -279,15 +256,9 @@ def _validate_steps(
continue
step_id = step_config.get("id")
if step_id is None or step_id == "":
if not step_id:
errors.append("Step is missing 'id' field.")
continue
if not isinstance(step_id, str):
errors.append(
f"Step ID must be a string, got "
f"{type(step_id).__name__} ({step_id!r})."
)
continue
if ":" in step_id:
errors.append(
@@ -416,57 +387,18 @@ class RunState:
ID into a path so a malicious value cannot probe or read files
outside ``.specify/workflows/runs/<run_id>/``.
"""
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.fullmatch(run_id):
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
raise ValueError(
f"Invalid run_id {run_id!r}: must be alphanumeric with "
"hyphens/underscores only (and must start with an "
"alphanumeric character)."
)
@staticmethod
def _validate_installed_origin(
installed_workflow_id: str | None,
installed_registry_root: str | None,
) -> None:
"""Validate persisted installed-workflow ownership metadata."""
if installed_workflow_id is not None:
if not isinstance(installed_workflow_id, str):
raise ValueError(
"Invalid run state: 'installed_workflow_id' must be a "
f"string or null, got {type(installed_workflow_id).__name__}"
)
if not _ID_PATTERN.fullmatch(installed_workflow_id):
raise ValueError(
"Invalid run state: 'installed_workflow_id' must be a "
"lowercase alphanumeric workflow ID with hyphens"
)
if installed_registry_root is not None:
if not isinstance(installed_registry_root, str):
raise ValueError(
"Invalid run state: 'installed_registry_root' must be a "
f"string or null, got {type(installed_registry_root).__name__}"
)
if not installed_registry_root or not Path(
installed_registry_root
).is_absolute():
raise ValueError(
"Invalid run state: 'installed_registry_root' must be "
"an absolute path or null"
)
if installed_workflow_id is None:
raise ValueError(
"Invalid run state: 'installed_registry_root' requires "
"'installed_workflow_id'"
)
def __init__(
self,
run_id: str | None = None,
workflow_id: str = "",
project_root: Path | None = None,
installed_workflow_id: str | None = None,
installed_registry_root: str | None = None,
installed_origin_tracked: bool = True,
) -> None:
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
# string is *not* the same as "omitted" and must be validated like
@@ -478,22 +410,8 @@ class RunState:
else:
self.run_id = run_id
self._validate_run_id(self.run_id)
self._validate_installed_origin(
installed_workflow_id, installed_registry_root
)
self.workflow_id = workflow_id
self.project_root = project_root or Path(".")
# Identifies the installed workflow (if any) this run was started
# from, and the project root that owns its registry — set by
# execute() when the source was resolved to an installed ID (see
# workflow_run's ownership mapping). None for a direct/non-installed
# YAML source. ``installed_origin_tracked`` distinguishes those
# explicit None values from legacy state files that predate both
# fields, allowing the CLI to conservatively infer same-project
# registry ownership before resuming.
self.installed_workflow_id = installed_workflow_id
self.installed_registry_root = installed_registry_root
self.installed_origin_tracked = installed_origin_tracked
self.status = RunStatus.CREATED
self.current_step_index = 0
self.current_step_id: str | None = None
@@ -556,8 +474,6 @@ class RunState:
state_data = {
"run_id": self.run_id,
"workflow_id": self.workflow_id,
"installed_workflow_id": self.installed_workflow_id,
"installed_registry_root": self.installed_registry_root,
"status": self.status.value,
"current_step_index": self.current_step_index,
"current_step_id": self.current_step_id,
@@ -609,46 +525,11 @@ class RunState:
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
if not isinstance(state_data, dict):
raise ValueError("Invalid run state: expected a JSON object")
missing_fields = [
field
for field in ("run_id", "workflow_id", "status")
if field not in state_data
]
if missing_fields:
raise ValueError(
"Invalid run state: missing required field(s): "
+ ", ".join(missing_fields)
)
workflow_id = state_data["workflow_id"]
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
workflow_id
):
raise ValueError(
"Invalid run state: 'workflow_id' must be a lowercase "
"alphanumeric workflow ID with hyphens"
)
has_installed_workflow_id = "installed_workflow_id" in state_data
has_installed_registry_root = "installed_registry_root" in state_data
if has_installed_workflow_id != has_installed_registry_root:
raise ValueError(
"Invalid run state: installed workflow origin fields must "
"either both be present or both be absent"
)
installed_workflow_id = state_data.get("installed_workflow_id")
installed_registry_root = state_data.get("installed_registry_root")
state = cls(
run_id=state_data["run_id"],
workflow_id=workflow_id,
workflow_id=state_data["workflow_id"],
project_root=project_root,
installed_workflow_id=installed_workflow_id,
installed_registry_root=installed_registry_root,
installed_origin_tracked=has_installed_workflow_id,
)
state.status = RunStatus(state_data["status"])
state.current_step_index = state_data.get("current_step_index", 0)
@@ -661,16 +542,7 @@ class RunState:
if inputs_path.exists():
with open(inputs_path, encoding="utf-8") as f:
inputs_data = json.load(f)
if not isinstance(inputs_data, dict):
raise ValueError(
"Invalid run inputs: expected a JSON object"
)
inputs = inputs_data.get("inputs", {})
if not isinstance(inputs, dict):
raise ValueError(
"Invalid run inputs: 'inputs' must be a JSON object"
)
state.inputs = inputs
state.inputs = inputs_data.get("inputs", {})
return state
@@ -753,8 +625,6 @@ class WorkflowEngine:
definition: WorkflowDefinition,
inputs: dict[str, Any] | None = None,
run_id: str | None = None,
installed_workflow_id: str | None = None,
installed_registry_root: Path | None = None,
) -> RunState:
"""Execute a workflow definition.
@@ -766,12 +636,6 @@ class WorkflowEngine:
User-provided input values.
run_id:
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
installed_workflow_id, installed_registry_root:
When the run was started from an installed workflow (as opposed
to a direct/non-installed YAML source), identifies it and its
owning registry root so a later ``resume`` can re-check the
registry's current disabled state before continuing — see
``workflow_resume``.
Returns
-------
@@ -789,12 +653,6 @@ class WorkflowEngine:
run_id=effective_run_id,
workflow_id=definition.id,
project_root=self.project_root,
installed_workflow_id=installed_workflow_id,
installed_registry_root=(
str(installed_registry_root)
if installed_registry_root is not None
else None
),
)
# Persist a copy of the workflow definition so resume can
@@ -1095,16 +953,7 @@ class WorkflowEngine:
from .expressions import evaluate_condition
max_iters = step_config.get("max_iterations")
# A bool is an int in Python (isinstance(True, int) is True
# and True == 1), so a bool max_iterations would slip past
# the int check and cap the loop at range(0)==1 iteration
# instead of the default. Exclude bools, mirroring the
# while/do-while validators and the continue_on_error guard.
if (
isinstance(max_iters, bool)
or not isinstance(max_iters, int)
or max_iters < 1
):
if not isinstance(max_iters, int) or max_iters < 1:
max_iters = 10
condition = step_config.get("condition", False)
for _loop_iter in range(max_iters - 1):

View File

@@ -464,9 +464,9 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
if op == "<=":
return _safe_compare(left, right, "<=")
if op == " in ":
return _safe_membership(left, right, negate=False)
return left in right if right is not None else False
if op == " not in ":
return _safe_membership(left, right, negate=True)
return left not in right if right is not None else True
# Numeric literal
try:
@@ -511,26 +511,6 @@ def _coerce_number(value: Any) -> Any:
return value
def _safe_membership(left: Any, right: Any, *, negate: bool) -> bool:
"""Safely evaluate ``left in right`` (or ``not in``) without crashing.
``left in right`` raises ``TypeError`` whenever the operands don't support
membership testing — most commonly a non-iterable right operand (``None``,
an int, a bool), but also cases like an unhashable ``left`` against a set.
In every such case the membership relation is undefined, so treat it as
``False`` (``not in`` as ``True``) rather than leaking the error out of the
evaluator and crashing the whole workflow. Mirrors the graceful
``TypeError`` handling in ``_safe_compare`` for the ordering operators, and
generalizes the previous ``right is not None`` guard to any operand pair
that can't be membership-tested.
"""
try:
contained = left in right
except TypeError:
contained = False
return not contained if negate else contained
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Compare two values for ordering, coercing numeric strings when possible.

View File

@@ -31,20 +31,6 @@ class CommandStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
command = config.get("command", "")
input_data = config.get("input", {})
# validate() rejects a non-mapping input, but the engine does not
# auto-validate before execute(); a workflow that skipped validation can
# still reach here. Fail the step with the same contract error rather
# than silently coercing to {} and dispatching with empty args — that
# would change the command's meaning, hide the config error, and report
# COMPLETED, defeating the per-step FAILED / continue_on_error behavior.
if not isinstance(input_data, dict):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'input' must be a "
f"mapping, got {type(input_data).__name__}."
),
)
# Resolve expressions in input
resolved_input: dict[str, Any] = {}
@@ -64,18 +50,8 @@ class CommandStep(StepBase):
# Merge options (workflow defaults ← step overrides)
options = dict(context.default_options)
step_options = config.get("options", {})
# Same rationale as 'input': a malformed options fails the step rather
# than being silently ignored (which would let an invalid step run and
# apparently complete).
if not isinstance(step_options, dict):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'options' must be a "
f"mapping, got {type(step_options).__name__}."
),
)
options.update(step_options)
if step_options:
options.update(step_options)
# Attempt CLI dispatch
args_str = str(resolved_input.get("args", ""))
@@ -179,16 +155,4 @@ class CommandStep(StepBase):
errors.append(
f"Command step {config.get('id', '?')!r} is missing 'command' field."
)
# execute() iterates input.items() and options.update(step_options); a
# non-mapping here would raise at run time. Validate the shape like the
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
# crashed on.
if "input" in config and not isinstance(config["input"], dict):
errors.append(
f"Command step {config.get('id', '?')!r}: 'input' must be a mapping."
)
if "options" in config and not isinstance(config["options"], dict):
errors.append(
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
)
return errors

View File

@@ -24,24 +24,6 @@ class FanInStep(StepBase):
if not isinstance(output_config, dict):
output_config = {}
# The engine does not auto-validate step config, so an unvalidated run
# with a non-list ``wait_for`` reaches here raw. Iterating it then
# either crashes the whole run (a scalar like an int or None raises
# TypeError) or, worse, silently iterates a string's characters and
# yields a bogus join of empty results with a COMPLETED status — the
# exact "silent empty result + COMPLETED" wiring bug the engine's
# fan-in validation guards against. Fail this step loudly instead,
# mirroring the fan-out step's non-list ``items`` handling.
if not isinstance(wait_for, list):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' must be "
f"a list of step IDs, got {type(wait_for).__name__}."
),
output={"results": []},
)
# Collect results from referenced steps
results = []
for step_id in wait_for:

View File

@@ -47,8 +47,8 @@ class IfThenStep(StepBase):
errors.append(
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
)
else_branch = config.get("else")
if else_branch is not None and not isinstance(else_branch, list):
else_branch = config.get("else", [])
if else_branch and not isinstance(else_branch, list):
errors.append(
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
)

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import json
import math
import subprocess
from typing import Any
@@ -26,20 +25,7 @@ class ShellStep(StepBase):
run_cmd = str(run_cmd)
cwd = context.project_root or "."
# Per-step execution timeout in seconds; defaults to 300 for backward
# compatibility. The engine does not auto-validate step config, so
# validate here as well — a caller that skips WorkflowEngine.validate()
# must fail the step cleanly rather than crash subprocess.run() with a
# TypeError (or silently coerce ``timeout: true`` to a 1s duration,
# since bool is an int subclass).
timeout = config.get("timeout", 300)
timeout_error = self._timeout_error(config)
if timeout_error is not None:
return StepResult(
status=StepStatus.FAILED,
error=timeout_error,
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
)
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
@@ -51,7 +37,7 @@ class ShellStep(StepBase):
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
timeout=300,
)
output = {
"exit_code": proc.returncode,
@@ -88,7 +74,7 @@ class ShellStep(StepBase):
except subprocess.TimeoutExpired:
return StepResult(
status=StepStatus.FAILED,
error=f"Shell command timed out after {timeout} seconds.",
error="Shell command timed out after 300 seconds.",
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
)
except OSError as exc:
@@ -98,32 +84,6 @@ class ShellStep(StepBase):
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
)
@staticmethod
def _timeout_error(config: dict[str, Any]) -> str | None:
"""Return an error message if ``config['timeout']`` is invalid, else None.
Shared by execute() and validate() so both paths reject the same
values with the same message. An absent ``timeout`` is valid (the
default is used). bool is a subclass of int, but ``timeout: true`` is a
config error rather than a duration, so it is rejected explicitly.
Non-finite floats (YAML ``.inf``/``.nan``) pass a plain ``> 0`` check
but would raise in subprocess.run(), so they are rejected too.
"""
if "timeout" not in config:
return None
timeout = config["timeout"]
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
):
return (
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
)
return None
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "run" not in config:
@@ -146,7 +106,4 @@ class ShellStep(StepBase):
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
f"be 'json' when present, got {output_format!r}."
)
timeout_error = self._timeout_error(config)
if timeout_error is not None:
errors.append(timeout_error)
return errors

View File

@@ -26,20 +26,6 @@ class SwitchStep(StepBase):
str_value = str(value) if value is not None else ""
cases = config.get("cases", {})
if not isinstance(cases, dict):
# The engine does not auto-validate step config, so an unvalidated
# run with a non-mapping ``cases`` (a list/scalar authoring mistake)
# would otherwise raise AttributeError from ``.items()`` below and
# crash the whole run. Fail this step loudly instead, mirroring the
# fan-out step's non-list ``items`` handling.
return StepResult(
status=StepStatus.FAILED,
error=(
f"Switch step {config.get('id', '?')!r}: 'cases' must be a "
f"mapping, got {type(cases).__name__}."
),
output={"matched_case": None, "expression_value": value},
)
for case_key, case_steps in cases.items():
if str(case_key) == str_value:
return StepResult(

View File

@@ -6,16 +6,16 @@
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
<!--
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->

View File

@@ -3,7 +3,6 @@ description: Perform a non-destructive cross-artifact consistency and quality an
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
---
## User Input

View File

@@ -3,7 +3,6 @@ description: Generate a custom checklist for the current feature based on user r
scripts:
sh: scripts/bash/check-prerequisites.sh --json
ps: scripts/powershell/check-prerequisites.ps1 -Json
py: scripts/python/check_prerequisites.py --json
---
## Checklist Purpose: "Unit Tests for English"

View File

@@ -1,13 +1,12 @@
---
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
handoffs:
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
scripts:
sh: scripts/bash/check-prerequisites.sh --json --paths-only
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
py: scripts/python/check_prerequisites.py --json --paths-only
---
## User Input

View File

@@ -1,6 +1,6 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
handoffs:
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
@@ -81,7 +81,7 @@ Follow this execution flow:
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):

View File

@@ -3,7 +3,6 @@ description: Assess the current codebase against the feature's spec, plan, and t
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
---
## User Input

View File

@@ -3,7 +3,6 @@ description: Execute the implementation plan by processing and executing all tas
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
---
## User Input
@@ -43,7 +42,7 @@ You **MUST** consider the user input before proceeding (if not empty).
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
@@ -145,7 +144,7 @@ You **MUST** consider the user input before proceeding (if not empty).
6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding

View File

@@ -1,6 +1,6 @@
---
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
handoffs:
handoffs:
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into tasks
@@ -68,6 +68,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Evaluate gates (ERROR if violations unjustified)
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
- Phase 1: Generate data-model.md, contracts/, quickstart.md
- Phase 1: Update agent context by running the agent script
- Re-evaluate Constitution Check post-design
## Mandatory Post-Execution Hooks
@@ -106,7 +107,7 @@ Check if `.specify/extensions.yml` exists in the project root.
## Completion Report
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts.
Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
## Phases

View File

@@ -1,6 +1,6 @@
---
description: Create or update the feature specification from a natural language feature description.
handoffs:
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
@@ -147,20 +147,20 @@ Given that feature description, do this:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE]
**Feature**: [Link to spec.md]
## Content Quality
- [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable
@@ -169,16 +169,16 @@ Given that feature description, do this:
- [ ] Edge cases are identified
- [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified
## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
```
@@ -203,20 +203,20 @@ Given that feature description, do this:
```markdown
## Question [N]: [Topic]
**Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**:
| Option | Answer | Implications |
|--------|--------|--------------|
| A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_
```

View File

@@ -1,6 +1,6 @@
---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
handoffs:
handoffs:
- label: Analyze For Consistency
agent: speckit.analyze
prompt: Run a project analysis for consistency
@@ -51,7 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
@@ -161,7 +161,7 @@ Every task MUST strictly follow this format:
4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label
- Foundational phase: NO story label
- Foundational phase: NO story label
- User Story phases: MUST have story label
- Polish phase: NO story label
5. **Description**: Clear action with exact file path

View File

@@ -4,7 +4,6 @@ tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
---
## User Input

View File

@@ -4,7 +4,7 @@
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command; its definition describes the execution workflow.
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary

View File

@@ -11,3 +11,4 @@
".specify/scripts/powershell/": true
}
}

View File

@@ -6,7 +6,6 @@ contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error)
"""
from __future__ import annotations
import io
import json
from pathlib import Path
from unittest.mock import patch
@@ -64,42 +63,6 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch
assert "Spec Kit project" in result.output
def test_remove_reports_clean_error_when_primitive_raises_raw_exception(
project: Path,
):
"""A raw exception from a primitive installer (e.g. an OSError from an
unreadable workflow registry surfacing through _WorkflowKindManager's
fail-closed construction) must not propagate uncaught through
`specify bundle remove` -- the command only catches BundlerError, so
without a conversion at the remove_bundle boundary this would exit
with an unhandled exception and empty/raw output instead of a clean,
actionable message, and no removal side effects should occur either."""
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
from specify_cli.bundler.services.installer import install_bundle
from specify_cli.bundler.services.resolver import resolve_install_plan
from tests.bundler_helpers import FakeInstaller
manifest = BundleManifest.from_dict(valid_manifest_dict())
plan = resolve_install_plan(
manifest, speckit_version="0.11.2", active_integration="copilot"
)
install_bundle(project, plan, FakeInstaller(), manifest=manifest)
def boom(self, project_root, component):
raise OSError("workflow registry unreadable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom)
result = runner.invoke(app, ["bundle", "remove", "demo-bundle"])
assert result.exit_code != 0
assert result.output.strip() != ""
assert result.exception is None or isinstance(result.exception, SystemExit)
assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"}
def test_fail_writes_error_to_stderr_not_stdout(capsys):
"""_fail must write to stderr, not stdout: every bundle command routes errors
through it, and under --json the error would otherwise corrupt the JSON payload
@@ -212,23 +175,7 @@ def test_build_produces_artifact(project: Path):
assert len(artifacts) == 1
def _mock_manifest_download(monkeypatch, source_path: Path) -> None:
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.
Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer
point one at a local file. Patch ``_download_manifest`` to return the
manifest parsed from *source_path* (a bundle.yml or a .zip artifact),
exercising ``info``'s expansion without a network call.
"""
from specify_cli.commands.bundle import _local_manifest_source
monkeypatch.setattr(
"specify_cli.commands.bundle._download_manifest",
lambda resolved, *, offline: _local_manifest_source(str(source_path)),
)
def test_info_expands_full_component_set(project: Path, monkeypatch):
def test_info_expands_full_component_set(project: Path):
bundle_dir = project / "src-bundle"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
@@ -236,14 +183,13 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
)
catalog = project / "local-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
)
write_catalog_file(catalog, {"demo-bundle": entry})
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
@@ -261,7 +207,7 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
assert "Trust" in text.output
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
def test_info_expands_discovery_only_bundle(project: Path):
# Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them.
bundle_dir = project / "disc-bundle"
@@ -271,7 +217,7 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
)
catalog = project / "disc-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
)
write_catalog_file(catalog, {"demo-bundle": entry})
config = {
@@ -284,7 +230,6 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
@@ -292,9 +237,8 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
assert ("extensions", "ext-a") in components
def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
# A .zip artifact is extracted to read bundle.yml; info expands it. (The
# download itself is HTTPS-only now and mocked here — see contract note.)
def test_info_resolves_local_zip_download_url(project: Path):
# A local .zip artifact as download_url is extracted to read bundle.yml.
bundle_dir = project / "zip-src"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
@@ -305,15 +249,12 @@ def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
catalog = project / "zip-catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict(
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)},
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
)
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
_mock_manifest_download(monkeypatch, artifact)
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
@@ -469,16 +410,25 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
# ===== Private GitHub release asset URL resolution =====
class FakeBundleResponse(io.BytesIO):
class FakeBundleResponse:
"""Minimal context-manager response stub for open_url fakes."""
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
super().__init__(data)
self._data = data
self._url = url
def read(self) -> bytes:
return self._data
def geturl(self) -> str:
return self._url
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""

View File

@@ -638,21 +638,6 @@ class TestCreateFeatureBash:
assert result.returncode != 0
assert "requires updated Spec Kit core scripts" in result.stderr
def test_explicit_number_zero_is_honored(self, tmp_path: Path):
"""An explicit --number 0 is honored (yields 000), not treated as
'auto-detect'. Pins the canonical behavior the PowerShell twin must
mirror; the empty-string check (`[ -z "$BRANCH_NUMBER" ]`) already
distinguishes an unset flag from a supplied 0."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--number", "0", "--short-name", "zero", "Zero feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "000-zero"
assert data["FEATURE_NUM"] == "000"
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell:
@@ -957,23 +942,6 @@ class TestCreateFeaturePowerShell:
assert result.returncode != 0
assert "requires updated Spec Kit core scripts" in result.stderr
def test_explicit_number_zero_is_honored(self, tmp_path: Path):
"""An explicit -Number 0 is honored (yields 000), matching the bash twin's
--number 0. Regression guard: -Number defaults to 0, so a bare `-eq 0`
check cannot tell an unset flag from a supplied 0 and would silently
auto-detect instead. Uses PSBoundParameters.ContainsKey('Number')."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-Number", "0", "-ShortName", "zero", "Zero feature",
)
assert result.returncode == 0, result.stderr
json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
assert json_line, f"No JSON in output: {result.stdout}"
data = json.loads(json_line[-1])
assert data["BRANCH_NAME"] == "000-zero"
assert data["FEATURE_NUM"] == "000"
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────

View File

@@ -25,14 +25,6 @@ BASH = shutil.which("bash")
POWERSHELL = (
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
)
# On Windows, prefer the built-in Windows PowerShell 5.1 (.NET Framework) when a
# test needs to exercise a 5.1-specific code path; fall back to whatever
# POWERSHELL resolves to elsewhere.
WINDOWS_POWERSHELL = (
(shutil.which("powershell.exe") or shutil.which("powershell") or POWERSHELL)
if os.name == "nt"
else POWERSHELL
)
def _write_ext_config(project_root: Path, **overrides: object) -> None:
@@ -287,14 +279,12 @@ def shlex_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _run_powershell_agent_context_script(
project_root: Path, powershell: str | None = None
) -> subprocess.CompletedProcess:
def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
env = _bundled_script_env(project_root)
return subprocess.run(
[
powershell or POWERSHELL,
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
@@ -422,29 +412,6 @@ class TestBundledUpdaterPathValidation:
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@requires_bash
def test_bash_script_discovers_nested_plan(self, tmp_path):
"""Plan discovery recurses into scoped layouts (#3024)."""
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=[],
)
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
plan.parent.mkdir(parents=True)
plan.write_text("# Plan\n", encoding="utf-8")
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / "AGENTS.md").read_text(encoding="utf-8")
# The old one-level glob (specs/*/plan.md) would find nothing here, so no
# "at" line would be emitted. Normalize separators before matching: on
# MSYS bash the emitted path may be absolute with backslashes.
assert "specs/scope/001-feature/plan.md" in text.replace("\\", "/")
@requires_bash
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
@@ -517,33 +484,6 @@ class TestBundledUpdaterPathValidation:
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@pytest.mark.skipif(WINDOWS_POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_discovers_nested_plan(self, tmp_path):
"""Plan discovery recurses into scoped layouts (#3024).
The relative-path fix this covers is specific to Windows PowerShell 5.1
(.NET Framework), so prefer ``powershell.exe`` over ``pwsh`` here to
actually exercise that failure mode on Windows.
"""
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=[],
)
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
plan.parent.mkdir(parents=True)
plan.write_text("# Plan\n", encoding="utf-8")
result = _run_powershell_agent_context_script(
project, powershell=WINDOWS_POWERSHELL
)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "at specs/scope/001-feature/plan.md" in text
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
@@ -748,62 +688,6 @@ class TestExtensionSelfSeed:
_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
class TestPlanDiscovery:
"""Mtime fallback must find plans in nested spec layouts (#3024).
Repos using SPECIFY_FEATURE_DIRECTORY place plans at
``specs/<scope>/<feature>/plan.md``; a one-level ``specs/*/plan.md``
glob never matches those.
"""
@staticmethod
def _make_plans(project: Path) -> Path:
# Older flat plan plus a newer nested plan: recursive discovery
# must pick the nested one by mtime.
flat = project / "specs" / "old-feature" / "plan.md"
flat.parent.mkdir(parents=True)
flat.write_text("flat plan\n", encoding="utf-8")
os.utime(flat, (1_000_000_000, 1_000_000_000))
nested = project / "specs" / "scope" / "new-feature" / "plan.md"
nested.parent.mkdir(parents=True)
nested.write_text("nested plan\n", encoding="utf-8")
return nested
@requires_bash
def test_bash_script_finds_nested_plan(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
self._make_plans(project)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
content = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "specs/scope/new-feature/plan.md" in content
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_finds_nested_plan(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
self._make_plans(project)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
content = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "specs/scope/new-feature/plan.md" in content
class TestMdcFrontmatter:
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""

View File

@@ -6,7 +6,7 @@ hooks:
extension: "test-extension"
command: "pre_implement_test"
description: "Test before implement hook execution"
after_implement:
- id: post_test
enabled: true

View File

@@ -7,7 +7,7 @@ This directory contains a mock project to verify that LLM agents correctly ident
1. Open a chat with an LLM (like GitHub Copilot) in this project.
2. Ask it to generate tasks for the current directory:
> "Please follow `/speckit.tasks` for the `./tests/hooks` directory."
3. **Expected Behavior**:
3. **Expected Behavior**:
- Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`.
- It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`.
- It should then proceed to read the `.md` docs and produce a `tasks.md`.

View File

@@ -11,7 +11,7 @@ import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records, records_path
from specify_cli.bundler.models.records import load_records
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
from specify_cli.bundler.services.resolver import resolve_install_plan
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict
@@ -97,212 +97,6 @@ def test_remove_unknown_bundle_errors(tmp_path: Path):
remove_bundle(tmp_path, "ghost", FakeInstaller())
def test_remove_converts_raw_installer_exception_to_bundler_error(tmp_path: Path):
"""A raw exception from a primitive installer (e.g. an OSError from an
unreadable workflow registry surfacing through _WorkflowKindManager's
fail-closed construction) must not propagate uncaught out of
remove_bundle: install_bundle already converts any non-BundlerError
exception into a clean BundlerError, but remove_bundle had no such
conversion, so the CLI's `bundle remove` (which only catches
BundlerError) would let a raw exception through with no clean message
and no removal side effects should occur either."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
raise OSError("workflow registry unreadable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "is_installed", boom)
with pytest.raises(BundlerError):
remove_bundle(tmp_path, "demo-bundle", installer)
# No removal side effects: the bundle record must still be present.
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_partial_failure_message_reflects_partial_state(tmp_path: Path):
"""A failure can occur after earlier components in the same bundle have
already been removed from disk. The bundle record is left unchanged
(save_records never runs on this path), so it still claims the bundle
fully installed -- but the message must not claim "No changes were
recorded" when components were, in fact, already removed."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
real_remove = installer.remove
calls = {"n": 0}
def remove_then_fail(project_root, component):
calls["n"] += 1
if calls["n"] == 1:
return real_remove(project_root, component)
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", remove_then_fail)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no changes were recorded" not in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_bundler_error_from_installer_after_partial_removal_reports_partial_state(
tmp_path: Path,
):
"""If the primitive installer itself raises BundlerError (not a raw/
unexpected exception) after an earlier component in the same bundle was
already removed, the surfaced message must still carry the same
partial-removal detail as the generic-exception path -- a bare
``except BundlerError: raise`` would re-raise the installer's original
message verbatim with no mention that the project may now be partially
uninstalled."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
real_remove = installer.remove
calls = {"n": 0}
def remove_then_raise_bundler_error(project_root, component):
calls["n"] += 1
if calls["n"] == 1:
return real_remove(project_root, component)
raise BundlerError("kind manager refused removal")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", remove_then_raise_bundler_error)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no changes were recorded" not in message.lower()
assert "kind manager refused removal" in message
assert "partially uninstalled" in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_bundler_error_from_installer_with_zero_removed_reports_no_changes(
tmp_path: Path,
):
"""When the installer raises BundlerError before anything was actually
removed, the message should not misleadingly claim partial state."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
raise BundlerError("kind manager unavailable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "is_installed", boom)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no components were removed" in message.lower()
assert "no removal was attempted" in message.lower()
assert "partially uninstalled" not in message.lower()
assert "kind manager unavailable" in message
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_zero_completed_removals_still_cautions_about_partial_changes(
tmp_path: Path,
):
"""`result.uninstalled` only records a component after its `remove()`
call returns successfully. If the very first `remove()` call itself
raises after already deleting some files, zero completed removals are
recorded even though the project may already be partially uninstalled --
the zero-count message must not claim "No components were removed" as
an unqualified fact; it must caution that the failing component may
have made partial changes before raising."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
# Simulates a remove() that deletes some files before raising --
# from the caller's perspective this component was never recorded
# as completed, but disk state may already be partially changed.
raise OSError("disk full partway through removal")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", boom)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no components were removed" in message.lower()
assert "partial" in message.lower()
assert "partially uninstalled" in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_record_save_failure_reports_partial_state(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
record_file = records_path(tmp_path)
original_record = record_file.read_bytes()
def fail_dump(_data, handle, *_args, **_kwargs):
handle.write('{"partial":')
handle.flush()
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.bundler.lib.yamlio.json.dump",
fail_dump,
)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "disk full" in message
assert "partially uninstalled" in message.lower()
assert installer.installed == set()
assert record_file.read_bytes() == original_record
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_record_save_failure_without_remove_attempt_is_not_partial(
tmp_path: Path,
):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
installer.installed.clear()
def fail_save(*_args, **_kwargs):
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.bundler.services.installer.save_records",
fail_save,
)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no removal was attempted" in message.lower()
assert "partially uninstalled" not in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
@@ -334,7 +128,7 @@ def test_remove_counts_only_components_actually_removed(tmp_path: Path):
assert len(result.uninstalled) == 3
assert (gone.kind, gone.id) not in installer.remove_calls
assert gone not in result.skipped
assert gone in result.skipped
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()

View File

@@ -112,62 +112,3 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
assert not ExtensionManager(project).registry.is_installed("agent-context")
finally:
os.chdir(previous)
def test_download_manifest_rejects_file_url(tmp_path: Path):
"""A catalog ``file://`` download_url is rejected — catalog URLs are
HTTPS-only, matching extensions/presets/workflows. Disk installs go through
the positional path (see the local-source tests above), not download_url.
"""
from types import SimpleNamespace
from specify_cli.commands.bundle import _download_manifest
manifest_path = write_manifest(tmp_path / "my bundles")
resolved = SimpleNamespace(
entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri())
)
with pytest.raises(BundlerError, match="bundle install"):
_download_manifest(resolved, offline=True)
def test_download_manifest_rejects_bare_path(tmp_path: Path):
"""A bare filesystem path download_url is likewise rejected."""
from types import SimpleNamespace
from specify_cli.commands.bundle import _download_manifest
manifest_path = write_manifest(tmp_path / "plain")
resolved = SimpleNamespace(
entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path))
)
with pytest.raises(BundlerError, match="bundle install"):
_download_manifest(resolved, offline=True)
def test_local_install_still_resolves_via_positional_path(tmp_path: Path):
"""The supported local route — a positional path, not a download_url —
still resolves the manifest via _local_manifest_source."""
manifest_path = write_manifest(tmp_path / "my bundles")
manifest = _local_manifest_source(str(manifest_path))
assert manifest is not None
assert manifest.bundle.id == "demo-bundle"
def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
"""A non-HTTPS download_url must report the HTTPS problem, not a misleading
'Network access disabled', even under --offline (scheme is validated before
the offline gate)."""
from types import SimpleNamespace
from specify_cli.commands.bundle import _download_manifest
resolved = SimpleNamespace(
entry=SimpleNamespace(
id="demo-bundle", download_url="http://example.com/bundle.zip"
)
)
with pytest.raises(BundlerError, match="HTTPS"):
_download_manifest(resolved, offline=True)

View File

@@ -115,63 +115,6 @@ class TestInitIntegrationFlag:
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
"""`init --here` on a non-empty directory with no confirmation input (empty
stdin) must fail fast with guidance to use --force, instead of the bare
'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides
empty stdin, so typer.confirm raises Abort, which the command converts to the
actionable error."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "nonempty-here"
project.mkdir()
(project / "existing.txt").write_text("keep me", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 1, result.output
assert "--force" in result.output
# Aborted before scaffolding: the pre-existing file is untouched.
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
def test_init_here_interactive_cancel_exits_zero(self, tmp_path, monkeypatch):
"""An interactive Ctrl+C at the merge confirmation (typer.Abort on a TTY)
is a normal cancellation — exit 0, "cancelled" — NOT the missing-input
--force error, which is reserved for non-interactive EOF. Guards the
regression where Abort was caught unconditionally and every cancel became
an exit-1 --force error."""
from typer.testing import CliRunner
from specify_cli import app
import specify_cli.commands.init as init_mod
# Simulate an interactive terminal so the Abort is treated as a cancel.
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
project = tmp_path / "cancel-here"
project.mkdir()
(project / "existing.txt").write_text("keep me", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
# No input → typer.confirm raises Abort (stands in for Ctrl+C).
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "cancelled" in result.output.lower()
assert "--force" not in result.output # not the missing-input error
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
def test_integration_copilot_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
@@ -892,8 +835,7 @@ class TestInitIntegrationFlag:
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
"""E2E: confirming the merge with piped "y" (no --force) preserves
existing shared infra files (unlike --force, which overwrites them)."""
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
from typer.testing import CliRunner
from specify_cli import app

View File

@@ -201,36 +201,6 @@ class YamlIntegrationTests:
parsed = yaml.safe_load("\n".join(yaml_lines))
assert parsed["prompt"].rstrip("\n") == body
def test_yaml_prompt_with_control_characters_stays_valid(self):
"""A body containing control characters must still produce parseable YAML.
YAML forbids C0 control characters (except tab and newline), DEL,
C1 controls, lone surrogates and U+FFFE/U+FFFF in every scalar form,
and YAML 1.1 treats NEL (U+0085), LS (U+2028) and PS (U+2029) as
line breaks that corrupt a literal block scalar's structure. The
renderer falls back to an escaped double-quoted scalar for such
bodies."""
for ch in (
"\x08", "\x0c", "\x1b", "\x7f",
"\x80", "\x84", "\x85", "\x86", "\x9f",
"\u2028", "\u2029",
"\ud800", "\udfff", "\ufffe", "\uffff",
):
body = f"before{ch}after\nsecond line"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
parsed = yaml.safe_load(rendered)
assert parsed["prompt"].rstrip("\n") == body, f"char {ch!r} round-trip"
def test_yaml_prompt_with_bare_carriage_return_stays_valid(self):
"""A bare CR (not part of CRLF) must not break the generated YAML.
Inside a block scalar a lone \r acts as a line break, corrupting
the document structure."""
body = "line1\rstill line1\nline2"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
parsed = yaml.safe_load(rendered)
assert parsed["prompt"].rstrip("\n") == body
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.

View File

@@ -84,20 +84,6 @@ class TestCatalogURLValidation:
with pytest.raises(IntegrationCatalogError, match="valid URL"):
IntegrationCatalog._validate_catalog_url(url)
@pytest.mark.parametrize(
"url",
[
"https://[::1", # unclosed ipv6 bracket
"https://[not-an-ip]/c.json", # bracketed non-ip host
],
)
def test_malformed_url_rejected_cleanly(self, url):
# A malformed authority makes urlparse/hostname raise ValueError. The
# validator must turn that into its normal catalog error, not leak a
# raw ValueError to the caller.
with pytest.raises(IntegrationCatalogError, match="malformed"):
IntegrationCatalog._validate_catalog_url(url)
# ---------------------------------------------------------------------------
# IntegrationCatalog — active catalogs

View File

@@ -231,3 +231,4 @@ class TestCursorAgentCliDispatch:
argv = mock_run.call_args[0][0]
assert argv[0] == "cursor-agent"

View File

@@ -71,4 +71,4 @@ class TestDevinInitFlow:
)
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -288,13 +288,13 @@ class TestForgeCommandRegistrar:
def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
# Create a test command with dot notation name
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
@@ -304,7 +304,7 @@ class TestForgeCommandRegistrar:
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)
# Register with Forge
registrar = CommandRegistrar()
commands = [
@@ -313,7 +313,7 @@ class TestForgeCommandRegistrar:
"file": "commands/example.md"
}
]
registered = registrar.register_commands(
"forge",
commands,
@@ -321,14 +321,14 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Verify registration succeeded
assert "speckit.my-extension.example" in registered
# Check the generated file has hyphenated name in frontmatter
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
assert forge_cmd.exists()
content = forge_cmd.read_text(encoding="utf-8")
# Parse frontmatter to validate name field precisely
frontmatter, _ = registrar.parse_frontmatter(content)
@@ -339,13 +339,13 @@ class TestForgeCommandRegistrar:
def test_registrar_formats_alias_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
@@ -354,7 +354,7 @@ class TestForgeCommandRegistrar:
"Test content\n",
encoding="utf-8"
)
# Register with Forge including an alias
registrar = CommandRegistrar()
commands = [
@@ -364,7 +364,7 @@ class TestForgeCommandRegistrar:
"aliases": ["speckit.my-extension.ex"]
}
]
registrar.register_commands(
"forge",
commands,
@@ -372,11 +372,11 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Check the alias file has hyphenated name in frontmatter
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
assert alias_file.exists()
content = alias_file.read_text(encoding="utf-8")
# Parse frontmatter to validate alias name field precisely
frontmatter, _ = registrar.parse_frontmatter(content)
@@ -387,13 +387,13 @@ class TestForgeCommandRegistrar:
def test_registrar_does_not_affect_other_agents(self, tmp_path):
"""Verify format_name callback is Forge-specific and doesn't affect other agents."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
@@ -402,7 +402,7 @@ class TestForgeCommandRegistrar:
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)
# Register with Kilo Code (standard markdown agent without inject_name)
registrar = CommandRegistrar()
commands = [
@@ -411,7 +411,7 @@ class TestForgeCommandRegistrar:
"file": "commands/example.md"
}
]
registrar.register_commands(
"kilocode",
commands,
@@ -419,12 +419,12 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Kilo Code uses standard markdown format without name injection.
# The format_name callback should not be invoked for non-Forge agents.
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
assert kilocode_cmd.exists()
content = kilocode_cmd.read_text(encoding="utf-8")
# Kilo Code should NOT have a name field injected
assert "name:" not in content, (

Some files were not shown because too many files have changed in this diff Show More