Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
c38a0d96fa Initial plan 2026-06-08 13:10:00 +00:00
104 changed files with 2890 additions and 10309 deletions

View File

@@ -70,8 +70,6 @@ Use the existing entries as the format template. Required fields:
"documentation": "<documentation>",
"changelog": "<changelog>",
"license": "<license>",
"category": "<category>",
"effect": "<effect>",
"requires": {
"speckit_version": "<speckit_version>"
},
@@ -89,9 +87,6 @@ Use the existing entries as the format template. Required fields:
}
```
**Category** — free-form string; common values: `docs`, `code`, `process`, `integration`, `visibility`
**Effect** — one of: `read-only`, `read-write`
If the extension has optional tool dependencies, add a `"tools"` array inside `"requires"`:
```json
@@ -118,8 +113,8 @@ Determine the category and effect from the extension's behavior:
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
```
**Category**free-form; common values: `docs`, `code`, `process`, `integration`, `visibility`
**Effect** write canonical values `read-only` or `read-write` in `extension.yml` and `catalog.community.json`; use `Read-only`/`Read+Write` only for the docs table display
**Category**one of: `docs`, `code`, `process`, `integration`, `visibility`
**Effect**`Read-only` (produces reports only) or `Read+Write` (modifies project files)
### 6. Commit, push, and open PR

9
.gitignore vendored
View File

@@ -50,12 +50,3 @@ docs/dev
.specify/extensions/.cache/
.specify/extensions/.backup/
.specify/extensions/*/local-config.yml
# The following directories/file are intentionally ignored so that they are not accidentally
# committed to the repository. They contain the scaffolding `specify init --integration copilot`
# does and they are meant for dogfooding Spec Kit during its own feature development.
.github/agents/
.github/prompts/
.github/copilot-instructions.md
.specify/
specs/

View File

@@ -2,110 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.11.1] - 2026-06-17
### Changed
- chore: ignore Copilot dogfooding scaffolding in .gitignore (#3019)
- docs: clarify Taskify specify command (#3016)
- docs: document evolving specs in existing projects (#2902)
- feat(workflows): opt-in output_format: json exposes parsed shell stdout as output.data (#2963)
- fix: non-zero exit code when a workflow run ends failed or aborted (#2959)
- fix(skills): preserve non-ASCII characters in skill frontmatter (#2917)
- fix: prevent extension self-install from deleting source dir (#2990) (#2991)
- fix: disable Rich Live transient mode on Windows to prevent PS 5.1 hang (#2938)
- Update a11y-governance preset to v0.4.0 (#2981)
- chore: release 0.11.0, begin 0.11.1.dev0 development (#3012)
## [0.11.0] - 2026-06-16
### Changed
- Add workflow step catalog — community-installable step types (#2394)
- feat(dev): add integration scaffolder (#2685)
- Add Command Density preset to community catalog (#3006)
- fix(tests): don't run PowerShell tests via WSL-interop powershell.exe (#2971)
- Add Zed integration (#2780)
- Update architecture-governance preset to v0.5.0 (#2929)
- Update Superpowers Implementation Bridge extension to v1.1.0 (#3011)
- Update isaqb-architecture-governance preset to v0.2.0 (#2984)
- Update security-governance preset to v0.6.0 (#2932)
- chore: update CITATION.cff to v0.10.2 (2026-06-11) (#2966)
- chore: release 0.10.4, begin 0.10.5.dev0 development (#3010)
## [0.10.4] - 2026-06-16
### Changed
- fix: fail loudly when a fan-out 'items' expression does not resolve to a list (#2957)
- refactor: move preset command handlers to presets/_commands.py (PR-6/8) (#2826)
- Update agent-parity-governance preset to v0.3.0 (#2982)
- Update cross-platform-governance preset to v0.2.0 (#2983)
- Add Data Model Diagram extension to community catalog (#2922)
- Add Spec Kit TLDR extension to community catalog (#3007)
- docs: add guide for handling complex features (#3004)
- Add Loop Engineering extension to community catalog (#3002)
- Update MemoryLint extension to v1.5.1 (#3000)
- chore: release 0.10.3, begin 0.10.4.dev0 development (#2999)
## [0.10.3] - 2026-06-16
### Changed
- Update Superpowers Bridge extension to v1.6.0 (#2998)
- Add Improve Extension to community catalog (#2997)
- Update Product Forge extension to v1.7.0 (#2996)
- Update Linear Integration extension to v0.5.0 (#2995)
- Update Superpowers Implementation Bridge extension to v1.0.3 (#2993)
- Update Ralph community extension to v1.1.1 (#2861)
- Update Linear Integration extension to v0.4.0 (#2942)
- Update DocGuard — CDD Enforcement to v0.26.0 (#2941)
- Add SpecKit Companion extension to community catalog (#2937)
- chore: release 0.10.2, begin 0.10.3.dev0 development (#2936)
## [0.10.2] - 2026-06-11
### Changed
- Add Research Harness extension to community catalog (#2935)
- Add Coding Standards Drift Control extension to community catalog (#2934)
- Add Spec Trace extension to community catalog (#2527)
- fix(extensions): preserve argument-hint in extension Claude SKILL.md (#2916)
- fix(presets): harden preset URL installs against unsafe redirects (#2911)
- fix: skip recovered files during refresh_managed overwrite check (#2918) (#2919)
- Update multi-model-review extension to v0.1.1 (#2900)
- feat: add category and effect as first-class fields in extension schema (#2899)
- chore(catalog): add Jira Integration (Sync Engine) extension (#2895)
- chore: release 0.10.1, begin 0.10.2.dev0 development (#2910)
## [0.10.1] - 2026-06-09
### Changed
- Update DocGuard — CDD Enforcement extension to v0.25.1 (#2909)
- Update a11y-governance preset to v0.3.0 (#2867)
- docs: document spec persistence models (#2856)
- chore(catalog): bump Linear Integration to v0.3.0 (repo renamed to spec-kit-linear-sync) (#2893)
- chore: update DocGuard extension to v0.25.0 (#2707)
- chore: remove unused open_github_url/_StripAuthOnRedirect from _github_http.py (#2883)
- fix(catalogs): validate extension and preset catalog payload shape (#2621)
- feat(integration): add status reporting (#2674)
- chore: release 0.10.0, begin 0.10.1.dev0 development (#2904)
## [0.10.0] - 2026-06-09
### Changed
- feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
- [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
- test(workflows): cover executable override fallback preflight (#2843)
- Add GitHub Copilot CLI guidance to readme (#2891)
- Update Security Review extension to v1.5.3 (#2898)
- Update Architecture Guard extension to v1.8.17 (#2897)
- feat(extensions): per-event hook lists with priority ordering (#2798)
- feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
- chore: release 0.9.5, begin 0.9.6.dev0 development (#2875)
## [0.9.5] - 2026-06-05
### Changed

View File

@@ -20,8 +20,8 @@ authors:
repository-code: "https://github.com/github/spec-kit"
url: "https://github.github.io/spec-kit/"
license: MIT
version: "0.10.2"
date-released: "2026-06-11"
version: "0.7.3"
date-released: "2026-04-17"
keywords:
- spec-driven development
- ai coding agents

View File

@@ -79,7 +79,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio
### 3. Establish project principles
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
@@ -254,12 +254,6 @@ Spec-Driven Development is a structured process that emphasizes:
| **Creative Exploration** | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul> |
| **Iterative Enhancement** ("Brownfield") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul> |
For existing projects, keep Spec Kit tooling updates separate from feature
artifact evolution: refresh managed project files when upgrading, and update
`specs/` artifacts when intended behavior changes. The
[Evolving Specs guide](./docs/guides/evolving-specs.md) describes the
recommended brownfield loop.
## 🎯 Experimental Goals
Our research and experimentation focus on:

View File

@@ -7,7 +7,7 @@
The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):
**Categories** (common values, but any string is allowed):
**Categories:**
- `docs` — reads, validates, or generates spec artifacts
- `code` — reviews, validates, or modifies source code
@@ -15,13 +15,10 @@ The following community-contributed extensions are available in [`catalog.commun
- `integration` — syncs with external platforms
- `visibility` — reports on project health or progress
**Effect** (canonical `extension.yml`/catalog values):
**Effect:**
- `read-only` — produces reports without modifying files (displayed as `Read-only` in the table)
- `read-write` — modifies files, creates artifacts, or updates specs (displayed as `Read+Write` in the table)
> [!TIP]
> Extension authors can declare `category` and `effect` in their `extension.yml` under the `extension:` block. These fields are also available in `catalog.community.json` for tooling and the CLI (`specify extension info`).
- `Read-only` — produces reports without modifying files
- `Read+Write` — modifies files, creates artifacts, or updates specs
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
@@ -44,27 +41,22 @@ The following community-contributed extensions are available in [`catalog.commun
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
| 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 | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `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) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear](https://github.com/ashbrener/spec-kit-linear) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
@@ -76,7 +68,7 @@ The following community-contributed extensions are available in [`catalog.commun
| MDE | Minimal model-driven engineering workflow with setup, next, and status commands | `process` | Read+Write | [spec-kit-mde](https://github.com/AI-MDE/spec-kit-mde) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| 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) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| 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-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) |
@@ -87,7 +79,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
| Product Forge | Full product-lifecycle orchestrator for Spec Kit: research → product-spec → plan → tasks → implement → verify → test → release-readiness, across express/lite/standard/v-model modes with human-in-the-loop gates. | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Product Forge | Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
| 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) |
@@ -96,7 +88,6 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
| Research Harness | State-externalizing research harness: budgeted exploration, evidence curation, and claim verification for spec-driven development | `process` | Read+Write | [spec-kit-harness](https://github.com/formin/spec-kit-harness) |
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
| Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) |
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
@@ -111,21 +102,18 @@ 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 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) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
| Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |

View File

@@ -7,24 +7,23 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| 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) |
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| Agent Parity Governance | Keeps shared AI-agent instructions aligned and adds agent-neutral Spec Kit model-routing guidance across project-defined agent guidance surfaces | 9 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) |
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-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) |
| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 25 templates, 33 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| Security Governance | Adds secure development governance: memory-safe-language preference, language-specific secure-coding profiles, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/AI-SBOM, VEX/SLSA, OpenSSF Scorecard, G7/BSI AI-SBOM target evidence, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| 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) |
| 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) |

View File

@@ -1,83 +0,0 @@
# Handling Complex Features
Large or complex features often run smoothly through `/speckit.specify`,
`/speckit.plan`, and `/speckit.tasks`, then degrade during implementation. In
the middle of a long `/speckit.implement` run, agents can start to lose track of
the plan, ignore tasks, or hallucinate — usually right before or after context
compaction is triggered.
The underlying cause is context window exhaustion. When a single
implementation run tries to hold the entire feature in context, the model
degrades as the window fills. The fix is to scope each run so it stays well
within context limits.
The `/speckit.implement` command accepts free-form user input that the agent
must consider before proceeding. This means you can scope each run without any
tooling changes.
## Option 1: Limit How Many Tasks Run Per Invocation
Instead of letting `/speckit.implement` run through every task at once, tell it
to stop early:
```text
/speckit.implement only execute tasks T001-T010, then stop and report progress
```
or scope by phase:
```text
/speckit.implement only execute the Setup phase, then stop
```
Because completed tasks are marked `[X]` in `tasks.md`, the next
`/speckit.implement` invocation picks up where you left off. This keeps each run
well within context limits.
## Option 2: Instruct the Agent to Use Sub-Agents
If your coding agent supports sub-agents (for example, GitHub Copilot CLI or the
GitHub Copilot extension for VS Code), you can instruct `/speckit.implement` to
delegate individual tasks:
```text
/speckit.implement delegate each parallel [P] task to a sub-agent
```
Each sub-agent gets a focused context — one task plus the relevant plan
excerpts — rather than the full feature context, so compaction never triggers
in the main session.
## Option 3: Combine Both
For very large features, combine scoping and delegation:
```text
/speckit.implement execute only the Core phase, delegate [P] tasks to sub-agents
```
## Option 4: Decompose the Feature Into Smaller Specs
When even a single phase overwhelms the context, break the feature into
independently specified sub-features. Each sub-feature gets its own
`spec.md`, `plan.md`, and `tasks.md`, and runs through its own
specify/plan/tasks/implement cycle.
This is the "spec of specs" approach: the first iteration breaks a massive
feature into smaller, self-contained specs that can each be implemented without
overwhelming the model. It adds the most overhead, so reserve it for features
that are too large to handle any other way.
## Which Approach to Choose
| Approach | Best for |
| --- | --- |
| Limit to N tasks or a phase | Any agent; simplest; no sub-agent support needed |
| Sub-agent delegation | Agents that support sub-agents; maximizes parallelism |
| Combine scoping + delegation | Large features on sub-agent-capable agents; balances both |
| Decompose into smaller specs | When even a single phase overwhelms the context |
For most cases, limiting task scope per run is the simplest fix. Reach for
sub-agent delegation when your agent supports it and you want parallelism, and
decompose into smaller specs only when a single phase is still too large to
handle in one run.

View File

@@ -11,12 +11,6 @@ Spec-Driven Development is a structured process that emphasizes:
- **Multi-step refinement** rather than one-shot code generation from prompts
- **Heavy reliance** on advanced AI model capabilities for specification interpretation
Spec Kit does not prescribe how teams preserve or mutate `spec.md`, `plan.md`,
and `tasks.md` after requirements change. See
[Spec Persistence Models](spec-persistence.md) for the concepts and
[Evolving Specs in Existing Projects](../guides/evolving-specs.md) for the
existing-project evolution workflows.
## Development Phases
| Phase | Focus | Key Activities |

View File

@@ -1,107 +0,0 @@
# Spec Persistence Models
Spec Kit intentionally leaves teams in control of what happens to `spec.md`,
`plan.md`, and `tasks.md` after requirements change. The toolkit gives you a
repeatable workflow, but it does not force one artifact maintenance strategy.
This page names three common models so teams can make that choice explicit.
None is the default, and none is required by Spec Kit.
## Two Separate Questions
Spec-driven development has a temporal question: how long should the
specification matter? One
[overview of SDD tooling](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)
frames that lifecycle in three levels:
- **Spec-first**: write a spec before coding, then allow it to be discarded.
- **Spec-anchored**: keep the spec after implementation and use it for future
changes.
- **Spec-as-source**: treat the spec as the only human-edited source and
regenerate implementation artifacts from it.
Spec Kit also exposes a second question: what happens to the artifact set when
requirements change? The models below describe that mutation strategy.
## Flow-Back Spec
Use flow-back when `spec.md`, `plan.md`, `tasks.md`, and the implementation are
all allowed to inform each other.
In this model, edits can begin in any artifact. A developer might update
`tasks.md` during implementation, revise `plan.md` after a technical discovery,
or adjust `spec.md` after a product clarification. The team then reconciles the
artifact set manually so the final project history still makes sense.
Flow-back works well when:
- the team is small enough to notice and reconcile drift quickly
- implementation discoveries are expected to reshape the original plan
- speed matters more than preserving each intermediate decision as immutable
history
The main risk is silent divergence. If the team changes lower-level artifacts
without reflecting the decision back into `spec.md`, future contributors may
not know which artifact to trust.
## Flow-Forward Spec
Use flow-forward when each feature directory should remain a historical record.
In this model, completed artifacts are treated as immutable. When requirements
change, the team creates a new feature directory instead of mutating the
existing `spec.md`, `plan.md`, or `tasks.md`. The older directory remains useful
for audit, comparison, or explaining how the project reached its current state.
Flow-forward works well when:
- auditability and traceability matter
- features are well-scoped and rarely revisited in place
- the team wants a clear sequence of requirement changes over time
The main tradeoff is duplication. Related decisions can be spread across
multiple feature directories, so teams need naming, linking, or review habits
that make the lineage easy to follow.
## Living Spec
Use living spec when `spec.md` is the contract and the other artifacts are
derived from it.
In this model, teams update `spec.md` first and then regenerate or revise
`plan.md` and `tasks.md` from that source. The plan and task list are still
valuable, but they are treated as disposable derivations rather than permanent
sources of truth.
Living spec works well when:
- the product contract is stable enough to own the workflow
- the team is comfortable regenerating derived artifacts after spec changes
- consistency between requirements and implementation matters more than keeping
every intermediate plan intact
The main risk is losing useful implementation rationale if derived artifacts are
discarded without preserving important decisions elsewhere.
## Choosing a Model
The model is a team convention, not a CLI setting. A project can even use
different models in different areas, as long as contributors know which one
applies.
| Model | Mutation rule | Best fit | Watch out for |
|---|---|---|---|
| Flow-back spec | Edit any artifact, then reconcile | Fast iteration and close collaboration | Silent drift between artifacts |
| Flow-forward spec | Create a new feature directory for new requirements | Audit trails and historical clarity | Duplicate or fragmented context |
| Living spec | Edit `spec.md`; regenerate derived artifacts | Spec as contract | Lost rationale in regenerated files |
If your team has not chosen a model yet, start by answering two questions:
1. Should completed feature directories be historical records or editable work
areas?
2. Is `spec.md` the single source of truth, or are `plan.md` and `tasks.md`
allowed to become co-equal sources?
Once those answers are clear, document the convention in your project
constitution or team onboarding notes so future contributors know how to handle
changes.

View File

@@ -7,7 +7,6 @@
"toc.yml",
"community/*.md",
"concepts/*.md",
"guides/*.md",
"reference/*.md",
"install/*.md"
]
@@ -79,3 +78,4 @@
}
}
}

View File

@@ -1,90 +0,0 @@
# Evolving Specs in Existing Projects
Existing projects need two separate maintenance loops:
- **Spec Kit project-file updates** refresh managed commands, scripts,
templates, and shared memory files.
- **Feature artifact evolution** keeps repository-specific `specs/` artifacts
aligned with the code and product behavior you intend to ship.
Use the [upgrade workflow](../upgrade.md) when you need newer Spec Kit project
files. Use one of the artifact persistence models below when requirements or
implementation insights change an existing project.
For the conceptual model definitions, see
[Spec Persistence Models](../concepts/spec-persistence.md).
## Flow-Forward Spec
Use flow-forward when each feature directory should remain a historical record.
When you add another feature or make a substantial follow-up change, create a
new feature spec through your installed `/speckit.specify` command and continue
through the standard flow:
1. Run `/speckit.specify` to create a new feature directory under `specs/`.
2. Run `/speckit.plan` to define the implementation approach.
3. Run `/speckit.tasks` to derive the work breakdown.
4. Run `/speckit.implement` and review the resulting code and artifact diffs.
The previous feature directory remains intact for audit, comparison, or
explaining how the project reached its current state. Use clear feature names or
cross-links when a new directory supersedes or extends earlier work.
## Living Spec
Use living spec when `spec.md` is the contract and `plan.md` and `tasks.md` are
derived from it.
When intended behavior changes, revise the existing `spec.md` first. Then
regenerate or manually revise downstream artifacts so they match the updated
spec:
1. Start from a clean working tree or a dedicated branch so every generated
change is reviewable.
2. Update `spec.md` with `/speckit.clarify` or an explicit edit.
3. Rerun `/speckit.plan` or revise `plan.md` so the technical approach matches
the revised spec.
4. Rerun `/speckit.tasks` or revise `tasks.md` so implementation work matches
the revised plan.
5. Run `/speckit.analyze` before implementation resumes to catch gaps between
the spec, plan, and tasks.
6. Run `/speckit.implement`, then review the code and artifact diffs together.
Preserve important implementation rationale before replacing derived artifacts.
If a plan or task list contains decisions that still matter, carry them forward
explicitly.
## Flow-Back Spec
Use flow-back when implementation discoveries are allowed to reshape the
artifact set.
In this model, the first useful edit can happen wherever the insight lands:
`spec.md`, `plan.md`, `tasks.md`, or the implementation. After the change, bring
the artifact set back into alignment:
1. Capture the discovery in the artifact closest to the work.
2. Decide whether it changes intended behavior, implementation strategy, task
breakdown, or only code.
3. Update any other artifacts that now disagree with the accepted direction.
4. Run `/speckit.analyze` to check for gaps across `spec.md`, `plan.md`, and
`tasks.md`.
5. Continue implementation only after the artifact set describes the behavior
and approach you want future contributors to trust.
Flow-back is flexible, but it requires discipline. Do not leave a lower-level
change in `tasks.md` or code if `spec.md` still says something different and the
spec is meant to remain trustworthy.
## Before Updating Spec Kit Project Files
Before refreshing Spec Kit project files with the terminal command
`specify init --here --force --integration <your-agent>`, protect any
project-specific material that lives outside `specs/`, especially
`.specify/memory/constitution.md` and customized files under
`.specify/templates/` or `.specify/scripts/`. Use `<your-agent>` for the AI
coding agent integration used by the target project.
Your `specs/` directory is not part of the template package, but shared project
files can be overwritten by a forced refresh.

View File

@@ -4,7 +4,7 @@
**Define what to build before building it — with any AI coding agent.**
Spec Kit is a toolkit for [Spec-Driven Development](concepts/sdd.md) (SDD), a methodology that puts specifications at the center of AI-assisted software development. Instead of jumping straight to code, you describe _what_ to build, refine it through structured phases, and let your AI coding agent implement it.
Spec Kit is a toolkit for [Spec-Driven Development](concepts/sdd.md) (SDD), a methodology that puts specifications at the center of AI-assisted software development. Instead of jumping straight to code, you describe *what* to build, refine it through structured phases, and let your AI coding agent implement it.
<a href="installation.md" class="btn btn-primary btn-lg">Install Spec Kit</a>&nbsp;
<a href="quickstart.md" class="btn btn-outline-primary btn-lg">Quick Start</a>
@@ -31,7 +31,7 @@ Define what to build before building it. Rich templates, quality checklists, and
### Use any coding agent
<span class="pillar-stat">30+ integrations</span> — Copilot, Gemini, Codex, Windsurf, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
<span class="pillar-stat">30 integrations</span> — Copilot, Gemini, Codex, Windsurf, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
Run `specify init` with your agent of choice and Spec Kit sets up the right command files, context rules, and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool.
@@ -90,7 +90,7 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
<span class="stat-label">Contributors</span>
</div>
<div class="stat-item">
<span class="stat-number">30+</span>
<span class="stat-number">30</span>
<span class="stat-label">Integrations</span>
</div>
<div class="stat-item">

View File

@@ -6,7 +6,7 @@
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
- [Git](https://git-scm.com/downloads)
## Installation

View File

@@ -98,41 +98,15 @@ ls -l scripts | grep .sh
On Windows you will instead use the `.ps1` scripts (no chmod needed).
## 6. Scaffold a Built-In Integration
## 6. Run Lint / Basic Checks (Add Your Own)
Use the integration scaffold command to create the initial Python package and
test skeleton for a new built-in integration:
```bash
specify integration scaffold my-agent --type markdown
specify integration scaffold my-agent --type toml
specify integration scaffold my-agent --type yaml
specify integration scaffold my-agent --type skills
```
Hyphenated keys are converted to Python-safe package names, for example
`my-agent` creates `src/specify_cli/integrations/my_agent/` and
`tests/integrations/test_integration_my_agent.py`.
The scaffold does not register the integration automatically. Review the
generated metadata, then add the import and `_register()` call in
`src/specify_cli/integrations/__init__.py`.
## 7. Run Lint / Basic Checks
CI enforces `ruff check src/` (see `.github/workflows/test.yml`), so run it locally before pushing:
```bash
uvx ruff check src/
```
You can also quickly sanity check importability:
Currently no enforced lint config is bundled, but you can quickly sanity check importability:
```bash
python -c "import specify_cli; print('Import OK')"
```
## 8. Build a Wheel Locally (Optional)
## 7. Build a Wheel Locally (Optional)
Validate packaging before publishing:
@@ -143,7 +117,7 @@ ls dist/
Install the built artifact into a fresh throwaway environment if needed.
## 9. Using a Temporary Workspace
## 8. Using a Temporary Workspace
When testing `init --here` in a dirty directory, create a temp workspace:
@@ -154,7 +128,7 @@ python -m src.specify_cli init --here --integration claude --ignore-agent-tools
Or copy only the modified CLI portion if you want a lighter sandbox.
## 10. Debug Network / TLS Issues
## 9. Debug Network / TLS Issues
> **Deprecated:** The `--skip-tls` flag is a no-op and has no effect.
> It was previously used to bypass TLS validation during local testing.
@@ -163,7 +137,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox.
>
> For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`.
## 11. Rapid Edit Loop Summary
## 10. Rapid Edit Loop Summary
| Action | Command |
|--------|---------|
@@ -174,7 +148,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox.
| Git branch uvx | `uvx --from git+URL@branch specify ...` |
| Build wheel | `uv build` |
## 12. Cleaning Up
## 11. Cleaning Up
Remove build artifacts / virtual env quickly:
@@ -182,17 +156,17 @@ Remove build artifacts / virtual env quickly:
rm -rf .venv dist build *.egg-info
```
## 13. Common Issues
## 12. Common Issues
| Symptom | Fix |
|---------|-----|
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
| Git commands unavailable | Install the git extension with `specify extension add git` |
| Git step skipped | You passed `--no-git` or Git not installed |
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
## 14. Next Steps
## 13. Next Steps
- Update docs and run through Quick Start using your modified CLI
- Open a PR when satisfied

View File

@@ -127,7 +127,7 @@ Initialize the project's constitution to set ground rules:
### Step 2: Define Requirements with `/speckit.specify`
```text
/speckit.specify Develop Taskify, a team productivity platform. It should allow users to create projects, add team members,
Develop Taskify, a team productivity platform. It should allow users to create projects, add team members,
assign tasks, comment and move tasks between boards in Kanban style. In this initial phase for this feature,
let's call it "Create Taskify," let's have multiple users but the users will be declared ahead of time, predefined.
I want five users in two different categories, one product manager and four engineers. Let's create three

View File

@@ -15,13 +15,16 @@ specify init [<project_name>]
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--here` | Initialize in the current directory instead of creating a new one |
| `--force` | Force merge/overwrite when initializing in an existing directory |
| `--no-git` | Skip git repository initialization |
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
| `--preset <id>` | Install a preset during initialization |
| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` |
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
> [!NOTE]
> Git repository initialization and branching are managed by the **git extension**, which is not installed by default. Run `specify extension add git` after init to enable git workflows.
> The git extension is currently enabled by default during `specify init`.
> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`.
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
@@ -42,8 +45,14 @@ specify init --here --force --integration copilot
# Use PowerShell scripts (Windows/cross-platform)
specify init my-project --integration copilot --script ps
# Skip git initialization
specify init my-project --integration copilot --no-git
# Install a preset during initialization
specify init my-project --integration copilot --preset compliance
# Use timestamp-based branch numbering (useful for distributed teams)
specify init my-project --integration copilot --branch-numbering timestamp
```
### Environment Variables
@@ -58,7 +67,7 @@ specify init my-project --integration copilot --preset compliance
specify check
```
Checks that CLI-based AI coding agents are available on your system. IDE-based agents are skipped since they don't require a CLI tool.
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool.
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.

View File

@@ -38,7 +38,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |
| [Windsurf](https://windsurf.com/) | `windsurf` | |
| [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-<command>` |
| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir <path>"` for AI coding agents not listed above |
## List Available Integrations
@@ -127,27 +126,6 @@ specify integration upgrade [<key>]
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
## Report Integration Status
```bash
specify integration status
specify integration status --json
```
Reports the current project's integration status without changing files. The
status report includes the default integration, installed integrations,
multi-install safety, missing managed files, modified managed files, invalid
manifest paths, shared Spec Kit infrastructure health, unchecked manifests, and
the target integration for default-sensitive shared templates. The JSON form is
intended for CI and coding agents that need stable machine-readable status data;
it also reports the raw recorded integrations and the integration manifests that
were checked when state repair heuristics differ from the recorded file.
The command exits 0 when the report status is `ok` or `warning`; it exits 1
only when the report status is `error`. In JSON output, `multi_install_safe`
is `null` when no installed integration set can be evaluated, such as when the
integration state is missing, unreadable, lacks a valid recorded integration
list, or records no installed integrations.
## Integration-Specific Options
Some integrations accept additional options via `--integration-options`:

View File

@@ -41,18 +41,12 @@
items:
- name: What is SDD?
href: concepts/sdd.md
- name: Spec Persistence Models
href: concepts/spec-persistence.md
- name: Handling Complex Features
href: concepts/complex-features.md
# Development workflows
- name: Development
items:
- name: Local Development
href: local-development.md
- name: Evolving Specs
href: guides/evolving-specs.md
# Community
- name: Community

View File

@@ -257,38 +257,70 @@ rm speckit.old-command-name.md
# Restart your IDE
```
### Scenario 4: "I don't want the git extension"
### Scenario 4: "I'm working on a project without Git"
The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
If you initialized your project with `--no-git`, you can still upgrade:
```bash
# Manually back up files you customized
cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
cp .specify/memory/constitution.md /tmp/constitution-backup.md
# Run upgrade
specify init --here --force --integration copilot
specify init --here --force --integration copilot --no-git
# Restore customizations
mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
mv /tmp/constitution-backup.md .specify/memory/constitution.md
```
If you later decide you want the git extension's commands and hooks, install it explicitly:
The `--no-git` flag skips git initialization but doesn't affect file updates.
---
## Using `--no-git` Flag
The `--no-git` flag tells Spec Kit to **skip git repository initialization**. This is useful when:
- You manage version control differently (Mercurial, SVN, etc.)
- Your project is part of a larger monorepo with existing git setup
- You're experimenting and don't want version control yet
**During initial setup:**
```bash
specify extension add git
specify init my-project --integration copilot --no-git
```
Projects that do not use Git can still work with Spec Kit by setting `SPECIFY_FEATURE_DIRECTORY` to the feature directory path before planning commands:
**During upgrade:**
```bash
specify init --here --force --integration copilot --no-git
```
### What `--no-git` does NOT do
❌ Does NOT prevent file updates
❌ Does NOT skip slash command installation
❌ Does NOT affect template merging
It **only** skips running `git init` and creating the initial commit.
### Working without Git
If you use `--no-git`, you'll need to manage feature directories manually:
**Set the `SPECIFY_FEATURE` environment variable** before using planning commands:
```bash
# Bash/Zsh
export SPECIFY_FEATURE_DIRECTORY="specs/001-my-feature"
export SPECIFY_FEATURE="001-my-feature"
# PowerShell
$env:SPECIFY_FEATURE_DIRECTORY = "specs/001-my-feature"
$env:SPECIFY_FEATURE = "001-my-feature"
```
Alternatively, run the `/speckit.specify` command which creates `.specify/feature.json` automatically.
This tells Spec Kit which feature directory to use when creating specs, plans, and tasks.
**Why this matters:** Without git, Spec Kit can't detect your current branch name to determine the active feature. The environment variable provides that context manually.
---

File diff suppressed because it is too large Load Diff

View File

@@ -94,7 +94,7 @@ When Git is not installed or the directory is not a Git repository:
The extension bundles cross-platform scripts:
- `scripts/bash/create-new-feature-branch.sh` — Bash implementation (branch creation only)
- `scripts/bash/create-new-feature.sh` — Bash implementation
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
- `scripts/powershell/create-new-feature-branch.ps1` — PowerShell implementation (branch creation only)
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

View File

@@ -31,9 +31,8 @@ If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variabl
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `feature_numbering` value (inherit from core)
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
4. Default to `sequential` if none of the above exist
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
@@ -44,10 +43,10 @@ Generate a concise short name (2-4 words) for the branch:
Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env bash
# Git extension: create-new-feature-branch.sh
# Creates a git feature branch only. The feature directory and spec file
# are created by the core create-new-feature.sh script.
# Git extension: create-new-feature.sh
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
# Sources common.sh from the project's installed scripts, falling back to
# git-common.sh for minimal git helpers.

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env pwsh
# Git extension: create-new-feature-branch.ps1
# Creates a git feature branch only. The feature directory and spec file
# are created by the core create-new-feature.ps1 script.
# Git extension: create-new-feature.ps1
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
# Sources common.ps1 from the project's installed scripts, falling back to
# git-common.ps1 for minimal git helpers.
[CmdletBinding()]
@@ -20,7 +19,7 @@ param(
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
@@ -38,7 +37,7 @@ if ($Help) {
}
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}

View File

@@ -13,14 +13,6 @@ extension:
# CUSTOMIZE: Brief description (under 200 characters)
description: "Brief description of what your extension does"
# CUSTOMIZE: Extension category — describes what the extension operates on
# Common values: docs, code, process, integration, visibility
# category: "process"
# CUSTOMIZE: Extension effect — whether it modifies project files
# One of: read-only | read-write
# effect: "read-write"
# CUSTOMIZE: Your name or organization name
author: "Your Name"

View File

@@ -1,16 +1,16 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-16T00:00:00Z",
"updated_at": "2026-06-03T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
"name": "A11Y Governance",
"id": "a11y-governance",
"version": "0.4.0",
"description": "Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence.",
"version": "0.2.0",
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
"license": "MIT",
@@ -18,7 +18,7 @@
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 10,
"templates": 9,
"commands": 3
},
"tags": [
@@ -26,23 +26,19 @@
"accessibility",
"bilingual",
"wcag",
"wcag-2-2",
"cefr-b2",
"inclusion",
"include-everyone",
"didactic-comments"
"inclusion"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-04-27T00:00:00Z"
},
"agent-parity-governance": {
"name": "Agent Parity Governance",
"id": "agent-parity-governance",
"version": "0.3.0",
"description": "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.",
"version": "0.2.0",
"description": "Keeps shared AI-agent guidance aligned and adds agent-neutral Spec Kit model-routing guidance across declared agent instruction surfaces.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.3.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
"license": "MIT",
@@ -50,7 +46,7 @@
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 6,
"templates": 9,
"commands": 3
},
"tags": [
@@ -63,7 +59,7 @@
"multi-agent"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-05-31T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",
@@ -96,11 +92,11 @@
"architecture-governance": {
"name": "Architecture Governance",
"id": "architecture-governance",
"version": "0.5.0",
"description": "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.",
"version": "0.2.0",
"description": "Adds secure architecture governance, threat modeling, STRIDE/CAPEC, Zero Trust, S-ADRs, and OWASP SAMM to Spec Kit.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
"license": "MIT",
@@ -108,7 +104,7 @@
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 13,
"templates": 11,
"commands": 3
},
"tags": [
@@ -116,20 +112,10 @@
"governance",
"threat-modeling",
"stride",
"capec",
"arc42",
"adr",
"zero-trust",
"samm",
"isaqb",
"cloud",
"sovereignty",
"c3a",
"c5",
"assurance"
"zero-trust"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-04-27T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
@@ -182,42 +168,14 @@
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"command-density": {
"name": "Command Density",
"id": "command-density",
"version": "1.0.0",
"description": "Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure.",
"author": "Maksim Kudriavtsev",
"repository": "https://github.com/Xopoko/spec-kit-preset-command-density",
"download_url": "https://github.com/Xopoko/spec-kit-preset-command-density/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/Xopoko/spec-kit-preset-command-density",
"documentation": "https://github.com/Xopoko/spec-kit-preset-command-density/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.10.3"
},
"provides": {
"templates": 0,
"commands": 9
},
"tags": [
"commands",
"tokens",
"compact",
"workflow",
"prompt-density"
],
"created_at": "2026-06-16T00:00:00Z",
"updated_at": "2026-06-16T00:00:00Z"
},
"cross-platform-governance": {
"name": "Cross-Platform Governance",
"id": "cross-platform-governance",
"version": "0.2.0",
"description": "Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit.",
"version": "0.1.0",
"description": "Adds Bash and PowerShell parity, dry-run/WhatIf parity, man-page expectations, and Verb-Noun Cmdlet discipline.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
"license": "MIT",
@@ -230,18 +188,13 @@
},
"tags": [
"cross-platform",
"governance",
"bash",
"powershell",
"man-page",
"cmdlet",
"verb-noun",
"windows",
"macos",
"linux"
"cmdlet"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-04-27T00:00:00Z"
},
"explicit-task-dependencies": {
"name": "Explicit Task Dependencies",
@@ -271,11 +224,11 @@
"fiction-book-writing": {
"name": "Fiction Book Writing",
"id": "fiction-book-writing",
"version": "1.9.0",
"description": "Spec-Driven Development for novel and long-form fiction. 34 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, illustrations, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
"version": "1.8.1",
"description": "Spec-Driven Development for novel and long-form fiction. 33 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.9.0.zip",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.8.1.zip",
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
"license": "MIT",
@@ -283,8 +236,8 @@
"speckit_version": ">=0.5.0"
},
"provides": {
"templates": 26,
"commands": 34,
"templates": 25,
"commands": 33,
"scripts": 2
},
"tags": [
@@ -303,7 +256,7 @@
"language-support"
],
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-06-02T08:00:00Z"
"updated_at": "2026-05-24T08:00:00Z"
},
"game-narrative-writing": {
"name": "Game Narrative Writing",
@@ -345,11 +298,11 @@
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
"version": "0.2.0",
"description": "Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt.",
"version": "0.1.0",
"description": "Adds general iSAQB/CPSA-F and arc42 architecture governance, including views, quality scenarios, ADRs, risks, and technical debt.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
"license": "MIT",
@@ -364,15 +317,11 @@
"architecture",
"governance",
"isaqb",
"cpsa-f",
"arc42",
"adr",
"quality-attributes",
"architecture-views",
"technical-debt"
"adr"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-04-27T00:00:00Z"
},
"jira": {
"name": "Jira Issue Tracking",
@@ -525,11 +474,11 @@
"security-governance": {
"name": "Security Governance",
"id": "security-governance",
"version": "0.6.0",
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA to Spec Kit.",
"version": "0.4.0",
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, ASVS verification, SBOM/AI-SBOM supply-chain transparency, and EU Cyber Resilience Act awareness.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.4.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
"license": "MIT",
@@ -537,7 +486,7 @@
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 14,
"templates": 12,
"commands": 3
},
"tags": [
@@ -562,15 +511,10 @@
"typescript",
"g7",
"bsi",
"cra",
"cyber-resilience-act",
"nis2",
"ai-act",
"dora",
"regulatory"
"cra"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-05-26T00:00:00Z"
},
"spec2cloud": {
"name": "Spec2Cloud",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.11.1"
version = "0.9.6.dev0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [

View File

@@ -111,6 +111,9 @@ if $PATHS_ONLY; then
exit 0
fi
# Validate branch name
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# Validate required directories and files
if [[ ! -d "$FEATURE_DIR" ]]; then
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2

View File

@@ -24,8 +24,8 @@ find_specify_root() {
return 1
}
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
get_repo_root() {
# First, look for .specify directory (spec-kit's own marker)
local specify_root
@@ -34,24 +34,123 @@ get_repo_root() {
return
fi
# Final fallback to script location
# Fallback to git if no .specify found
if git rev-parse --show-toplevel >/dev/null 2>&1; then
git rev-parse --show-toplevel
return
fi
# Final fallback to script location for non-git repos
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
(cd "$script_dir/../../.." && pwd)
}
# Get current feature name from explicit state only.
# Returns the feature identifier or empty string if none is set.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
# Get current branch, with fallback for non-git repositories
get_current_branch() {
# First check if SPECIFY_FEATURE environment variable is set
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
echo "$SPECIFY_FEATURE"
return
fi
# No explicit feature set — caller must handle this via feature.json
# in get_feature_paths(). Return empty to signal "unknown".
echo ""
# Then check git if available at the spec-kit root (not parent)
local repo_root=$(get_repo_root)
if has_git; then
git -C "$repo_root" rev-parse --abbrev-ref HEAD
return
fi
# For non-git repos, try to find the latest feature directory
local specs_dir="$repo_root/specs"
if [[ -d "$specs_dir" ]]; then
local latest_feature=""
local highest=0
local latest_timestamp=""
for dir in "$specs_dir"/*; do
if [[ -d "$dir" ]]; then
local dirname=$(basename "$dir")
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
# Timestamp-based branch: compare lexicographically
local ts="${BASH_REMATCH[1]}"
if [[ "$ts" > "$latest_timestamp" ]]; then
latest_timestamp="$ts"
latest_feature=$dirname
fi
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
local number=${BASH_REMATCH[1]}
number=$((10#$number))
if [[ "$number" -gt "$highest" ]]; then
highest=$number
# Only update if no timestamp branch found yet
if [[ -z "$latest_timestamp" ]]; then
latest_feature=$dirname
fi
fi
fi
fi
done
if [[ -n "$latest_feature" ]]; then
echo "$latest_feature"
return
fi
fi
echo "main" # Final fallback
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
has_git() {
# First check if git command is available (before calling get_repo_root which may use git)
command -v git >/dev/null 2>&1 || return 1
local repo_root=$(get_repo_root)
# Check if .git exists (directory or file for worktrees/submodules)
[ -e "$repo_root/.git" ] || return 1
# Verify it's actually a valid git work tree
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
if [[ "$has_git_repo" != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
return 0
fi
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
return 0
}
# Safely read .specify/feature.json's "feature_directory" value.
@@ -86,66 +185,105 @@ read_feature_json_feature_directory() {
return 0
}
# Persist a feature_directory value to .specify/feature.json.
# Writes only when the file is missing or the value differs from what's stored.
# Accepts the raw (possibly relative) path — callers should pass the original
# user-supplied value, not the normalized absolute path.
_persist_feature_json() {
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
feature_json_matches_feature_dir() {
local repo_root="$1"
local feature_dir_value="$2"
local fj="$repo_root/.specify/feature.json"
local active_feature_dir="$2"
# Strip repo_root prefix if the value is absolute and under repo_root
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
feature_dir_value="${feature_dir_value#"$repo_root/"}"
fi
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
# Read current value (if any) and skip write when unchanged
local current_val
current_val=$(read_feature_json_feature_directory "$repo_root")
if [[ "$current_val" == "$feature_dir_value" ]]; then
return 0
fi
[[ -n "$_fd" ]] || return 1
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
[[ -d "$_fd" ]] || return 1
# Ensure .specify/ directory exists
mkdir -p "$repo_root/.specify"
local norm_json norm_active
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
# Write feature.json — prefer jq for safe JSON, fall back to printf
if command -v jq >/dev/null 2>&1; then
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
[[ "$norm_json" == "$norm_active" ]]
}
# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
local repo_root="$1"
local branch_name
branch_name=$(spec_kit_effective_branch_name "$2")
local specs_dir="$repo_root/specs"
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
local prefix=""
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
prefix="${BASH_REMATCH[1]}"
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
prefix="${BASH_REMATCH[1]}"
else
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
# If branch doesn't have a recognized prefix, fall back to exact match
echo "$specs_dir/$branch_name"
return
fi
# Search for directories in specs/ that start with this prefix
local matches=()
if [[ -d "$specs_dir" ]]; then
for dir in "$specs_dir"/"$prefix"-*; do
if [[ -d "$dir" ]]; then
matches+=("$(basename "$dir")")
fi
done
fi
# Handle results
if [[ ${#matches[@]} -eq 0 ]]; then
# No match found - return the branch name path (will fail later with clear error)
echo "$specs_dir/$branch_name"
elif [[ ${#matches[@]} -eq 1 ]]; then
# Exactly one match - perfect!
echo "$specs_dir/${matches[0]}"
else
# Multiple matches - this shouldn't happen with proper naming convention
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
echo "Please ensure only one spec directory exists per prefix." >&2
return 1
fi
}
get_feature_paths() {
local repo_root=$(get_repo_root)
local current_branch=$(get_current_branch)
local has_git_repo="false"
if has_git; then
has_git_repo="true"
fi
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Error — no feature context available
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
# 3. Branch-name-based prefix lookup (legacy fallback)
local feature_dir
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
# Persist to feature.json so future sessions without the env var still work
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
if [[ -n "$_fd" ]]; then
feature_dir="$_fd"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
else
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi
else
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi
@@ -153,6 +291,7 @@ get_feature_paths() {
# via crafted branch names or paths containing special characters
printf 'REPO_ROOT=%q\n' "$repo_root"
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
printf 'HAS_GIT=%q\n' "$has_git_repo"
printf 'FEATURE_DIR=%q\n' "$feature_dir"
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"

View File

@@ -57,9 +57,9 @@ while [ $i -le $# ]; do
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --dry-run Compute feature name and paths without creating directories or files"
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
@@ -113,18 +113,94 @@ get_highest_from_specs() {
echo "$highest"
}
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
_extract_highest_number() {
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
echo "$highest"
}
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
done
echo "$highest"
}
# Function to check existing branches (local and remote) and return next available number.
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
if [ "$skip_fetch" = true ]; then
# Side-effect-free: query remotes via ls-remote
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
# Get highest number from ALL specs (not just matching short name)
local highest_spec=$(get_highest_from_specs "$specs_dir")
# Take the maximum of both
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
# Return next number
echo $((max_num + 1))
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Resolve repository root using common.sh functions which prioritize .specify
# Resolve repository root using common.sh functions which prioritize .specify over git
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
REPO_ROOT=$(get_repo_root)
# Check if git is available at this repo root (not a parent)
if has_git; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
@@ -200,10 +276,23 @@ if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
# Determine branch number from existing feature directories
# Determine branch number
if [ -z "$BRANCH_NUMBER" ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
# Dry-run without git: local spec dirs only
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
# Fall back to local directory check
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
@@ -237,13 +326,43 @@ FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ "$DRY_RUN" != true ]; then
if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then
if [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name."
else
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number."
if [ "$HAS_GIT" = true ]; then
branch_create_error=""
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
# Check if branch already exists
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
# If we're already on the branch, continue without another checkout.
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
# Otherwise switch to the existing branch instead of failing.
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
if [ -n "$switch_branch_error" ]; then
>&2 printf '%s\n' "$switch_branch_error"
fi
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
exit 1
fi
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
if [ -n "$branch_create_error" ]; then
>&2 printf '%s\n' "$branch_create_error"
else
>&2 echo "Please check your git configuration and try again."
fi
exit 1
fi
fi
exit 1
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
mkdir -p "$FEATURE_DIR"
@@ -258,12 +377,8 @@ if [ "$DRY_RUN" != true ]; then
fi
fi
# Persist to .specify/feature.json so downstream commands can find the feature
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
# Inform the user how to set feature state in their own shell
# Inform the user how to persist the feature variable in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
fi
if $JSON_MODE; then
@@ -294,6 +409,5 @@ else
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
fi
fi

View File

@@ -32,6 +32,11 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
eval "$_paths_output"
unset _paths_output
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
# Ensure the feature directory exists
mkdir -p "$FEATURE_DIR"
@@ -70,15 +75,17 @@ if $JSON_MODE; then
--arg impl_plan "$IMPL_PLAN" \
--arg specs_dir "$FEATURE_DIR" \
--arg branch "$CURRENT_BRANCH" \
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
--arg has_git "$HAS_GIT" \
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
else
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
fi
else
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH"
echo "HAS_GIT: $HAS_GIT"
fi

View File

@@ -27,7 +27,12 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
eval "$_paths_output"
unset _paths_output
# Validate required files
# Validate branch
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2

View File

@@ -81,6 +81,11 @@ if ($PathsOnly) {
exit 0
}
# Validate branch name
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
exit 1
}
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"

View File

@@ -24,8 +24,8 @@ function Find-SpecifyRoot {
}
}
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
function Get-RepoRoot {
# First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot
@@ -33,81 +33,263 @@ function Get-RepoRoot {
return $specifyRoot
}
# Final fallback to script location
# Fallback to git if no .specify found
try {
$result = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# Final fallback to script location for non-git repos
# Use -LiteralPath to handle paths with wildcard characters
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
}
function Get-CurrentBranch {
# Return feature name from explicit state only.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
# First check if SPECIFY_FEATURE environment variable is set
if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE
}
# No explicit feature set - return empty to signal "unknown".
return ""
}
# Persist a feature_directory value to .specify/feature.json.
# Writes only when the file is missing or the value differs from what's stored.
function Save-FeatureJson {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$FeatureDirectory
)
# Strip repo root prefix if the value is absolute and under repo root.
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
if ($onWin) {
$cmp = [System.StringComparison]::OrdinalIgnoreCase
} else {
$cmp = [System.StringComparison]::Ordinal
}
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
}
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
# Read current value and skip write when unchanged
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
# Then check git if available at the spec-kit root (not parent)
$repoRoot = Get-RepoRoot
if (Test-HasGit) {
try {
$raw = Get-Content -LiteralPath $fjPath -Raw
$cfg = $raw | ConvertFrom-Json
if ($cfg.feature_directory -eq $FeatureDirectory) {
return
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# File is corrupt or unreadable - overwrite it
# Git command failed
}
}
# Ensure .specify/ directory exists
$specifyDir = Join-Path $RepoRoot '.specify'
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
# For non-git repos, try to find the latest feature directory
$specsDir = Join-Path $repoRoot "specs"
if (Test-Path $specsDir) {
$latestFeature = ""
$highest = 0
$latestTimestamp = ""
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{8}-\d{6})-') {
# Timestamp-based branch: compare lexicographically
$ts = $matches[1]
if ($ts -gt $latestTimestamp) {
$latestTimestamp = $ts
$latestFeature = $_.Name
}
} elseif ($_.Name -match '^(\d{3,})-') {
$num = [long]$matches[1]
if ($num -gt $highest) {
$highest = $num
# Only update if no timestamp branch found yet
if (-not $latestTimestamp) {
$latestFeature = $_.Name
}
}
}
}
if ($latestFeature) {
return $latestFeature
}
}
# Final fallback
return "main"
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
function Test-HasGit {
# First check if git command is available (before calling Get-RepoRoot which may use git)
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
return $false
}
$repoRoot = Get-RepoRoot
# Check if .git exists (directory or file for worktrees/submodules)
# Use -LiteralPath to handle paths with wildcard characters
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
return $false
}
# Verify it's actually a valid git work tree
try {
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
function Test-FeatureBranch {
param(
[string]$Branch,
[bool]$HasGit = $true
)
# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
# Write feature.json
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true
}
# True when .specify/feature.json pins an existing feature directory that matches the
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
function Test-FeatureJsonMatchesFeatureDir {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
)
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
return $false
}
try {
$raw = Get-Content -LiteralPath $featureJson -Raw
$cfg = $raw | ConvertFrom-Json
} catch {
return $false
}
$fd = $cfg.feature_directory
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
return $false
}
if (-not [System.IO.Path]::IsPathRooted($fd)) {
$fd = Join-Path $RepoRoot $fd
}
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
return $false
}
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
if ($resolvedJson) {
$normJson = $resolvedJson.Path
} else {
$normJson = [System.IO.Path]::GetFullPath($fd)
}
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
if ($resolvedActive) {
$normActive = $resolvedActive.Path
} else {
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
}
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
# absence as "we're on Windows".
if ($null -ne $IsWindows) {
$onWindows = $IsWindows
} else {
$onWindows = $true
}
if ($onWindows) {
$comparison = [System.StringComparison]::OrdinalIgnoreCase
} else {
$comparison = [System.StringComparison]::Ordinal
}
return [string]::Equals($normJson, $normActive, $comparison)
}
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
function Find-FeatureDirByPrefix {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$Branch
)
$specsDir = Join-Path $RepoRoot 'specs'
$branchName = Get-SpecKitEffectiveBranchName $Branch
$prefix = $null
if ($branchName -match '^(\d{8}-\d{6})-') {
$prefix = $Matches[1]
} elseif ($branchName -match '^(\d{3,})-') {
$prefix = $Matches[1]
} else {
return (Join-Path $specsDir $branchName)
}
$dirMatches = @()
if (Test-Path -LiteralPath $specsDir -PathType Container) {
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
}
if ($dirMatches.Count -eq 0) {
return (Join-Path $specsDir $branchName)
}
if ($dirMatches.Count -eq 1) {
return $dirMatches[0].FullName
}
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
return $null
}
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
function Get-FeatureDirFromBranchPrefixOrExit {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$CurrentBranch
)
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
if ($null -eq $resolved) {
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
exit 1
}
return $resolved
}
function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch
$hasGit = Test-HasGit
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Error - no feature context available
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
$featureJson = Join-Path $repoRoot '.specify/feature.json'
if ($env:SPECIFY_FEATURE_DIRECTORY) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
@@ -115,8 +297,6 @@ function Get-FeaturePathsEnv {
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
# Persist to feature.json so future sessions without the env var still work
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
} elseif (Test-Path $featureJson) {
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
try {
@@ -132,17 +312,16 @@ function Get-FeaturePathsEnv {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
exit 1
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
exit 1
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
}
[PSCustomObject]@{
REPO_ROOT = $repoRoot
CURRENT_BRANCH = $currentBranch
HAS_GIT = $hasGit
FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md'

View File

@@ -21,9 +21,9 @@ if ($Help) {
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
@@ -67,17 +67,111 @@ function Get-HighestNumberFromSpecs {
return $highest
}
# Extract the highest sequential feature number from a list of branch/ref names.
# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
function Get-HighestNumberFromNames {
param([string[]]$Names)
[long]$highest = 0
foreach ($name in $Names) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0 -and $branches) {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
}
return 0
}
function Get-HighestNumberFromRemoteRefs {
[long]$highest = 0
try {
$remotes = git remote 2>$null
if ($remotes) {
foreach ($remote in $remotes) {
$env:GIT_TERMINAL_PROMPT = '0'
$refs = git ls-remote --heads $remote 2>$null
$env:GIT_TERMINAL_PROMPT = $null
if ($LASTEXITCODE -eq 0 -and $refs) {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
}
} catch {
Write-Verbose "Could not query remote refs: $_"
}
return $highest
}
# Return next available branch number. When SkipFetch is true, queries remotes
# via ls-remote (read-only) instead of fetching.
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch
)
if ($SkipFetch) {
# Side-effect-free: query remotes via ls-remote
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
try {
git fetch --all --prune 2>$null | Out-Null
} catch {
# Ignore fetch errors
}
$highestBranch = Get-HighestNumberFromBranches
}
# Get highest number from ALL specs (not just matching short name)
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
# Take the maximum of both
$maxNum = [Math]::Max($highestBranch, $highestSpec)
# Return next number
return $maxNum + 1
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
# Load common functions (includes Get-RepoRoot and Resolve-Template)
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
. "$PSScriptRoot/common.ps1"
# Use common.ps1 functions which prioritize .specify
# Use common.ps1 functions which prioritize .specify over git
$repoRoot = Get-RepoRoot
# Check if git is available at this repo root (not a parent)
$hasGit = Test-HasGit
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
@@ -150,9 +244,21 @@ if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
# Determine branch number from existing feature directories
# Determine branch number
if ($Number -eq 0) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
if ($DryRun -and $hasGit) {
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
# Dry-run without git: local spec dirs only
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
# Fall back to local directory check
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
@@ -185,13 +291,58 @@ $featureDir = Join-Path $specsDir $branchName
$specFile = Join-Path $featureDir 'spec.md'
if (-not $DryRun) {
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
if ($Timestamp) {
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
} else {
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
if ($hasGit) {
$branchCreated = $false
$branchCreateError = ''
try {
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
$branchCreateError = $_.Exception.Message
}
exit 1
if (-not $branchCreated) {
$currentBranch = ''
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
# Check if branch already exists
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
# If we're already on the branch, continue without another checkout.
if ($currentBranch -eq $branchName) {
# Already on the target branch -- nothing to do
} else {
# Otherwise switch to the existing branch instead of failing.
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
if ($switchBranchError) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
} else {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
}
exit 1
}
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
exit 1
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
exit 1
}
} else {
if ($branchCreateError) {
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
}
exit 1
}
}
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
}
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
@@ -208,12 +359,8 @@ if (-not $DryRun) {
}
}
# Persist to .specify/feature.json so downstream commands can find the feature
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
# Set environment variables for the current session
# Set the SPECIFY_FEATURE environment variable for the current session
$env:SPECIFY_FEATURE = $branchName
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
}
if ($Json) {
@@ -221,6 +368,7 @@ if ($Json) {
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
@@ -230,8 +378,8 @@ if ($Json) {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE set to: $branchName"
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}
}

View File

@@ -23,6 +23,13 @@ if ($Help) {
# Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv
# If feature.json pins an existing feature directory, branch naming is not required.
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
}
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
@@ -54,6 +61,7 @@ if ($Json) {
IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR
BRANCH = $paths.CURRENT_BRANCH
HAS_GIT = $paths.HAS_GIT
}
$result | ConvertTo-Json -Compress
} else {
@@ -61,4 +69,5 @@ if ($Json) {
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
}

View File

@@ -16,9 +16,16 @@ if ($Help) {
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
# Get feature paths and validate branch
$paths = Get-FeaturePathsEnv
# If feature.json pins an existing feature directory, branch naming is not required.
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ layer, not out of it, to avoid circular imports.
"""
from __future__ import annotations
import sys
from collections.abc import Callable
import readchar
@@ -193,8 +192,7 @@ def select_with_arrows(
def run_selection_loop():
nonlocal selected_key, selected_index
_transient = sys.platform != "win32"
with Live(create_selection_panel(), console=console, transient=_transient, auto_refresh=False) as live:
with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live:
while True:
try:
key = get_key()

View File

@@ -1,11 +1,9 @@
"""Shared GitHub HTTP request helpers.
"""Shared GitHub-authenticated HTTP helpers.
Provides ``build_github_request()`` for attaching GITHUB_TOKEN / GH_TOKEN
credentials to requests targeting GitHub-hosted domains, and
``resolve_github_release_asset_api_url()`` — used by extensions, presets,
and workflow URL resolution — to translate browser release-download URLs
into GitHub REST API asset URLs. Authenticated downloads themselves go
through the config-driven helpers in :mod:`specify_cli.authentication.http`.
Used by both ExtensionCatalog and PresetCatalog to attach
GITHUB_TOKEN / GH_TOKEN credentials to requests targeting
GitHub-hosted domains, while preventing token leakage to
third-party hosts on redirects.
"""
import os
@@ -56,6 +54,28 @@ def build_github_request(url: str) -> urllib.request.Request:
return urllib.request.Request(url, headers=headers)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Redirect handler that drops the Authorization header when leaving GitHub.
Prevents token leakage to CDNs or other third-party hosts that GitHub
may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com).
Auth is preserved as long as the redirect target remains within GITHUB_HOSTS.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
original_auth = req.get_header("Authorization")
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
hostname = (urlparse(newurl).hostname or "").lower()
if hostname in GITHUB_HOSTS:
if original_auth:
new_req.add_unredirected_header("Authorization", original_auth)
else:
new_req.headers.pop("Authorization", None)
new_req.unredirected_hdrs.pop("Authorization", None)
return new_req
def resolve_github_release_asset_api_url(
download_url: str,
open_url_fn: Callable,
@@ -127,3 +147,20 @@ def resolve_github_release_asset_api_url(
return str(asset["url"])
return None
def open_github_url(url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
When the request carries an Authorization header, a custom redirect
handler drops that header if the redirect target is not a GitHub-owned
domain, preventing token leakage to CDNs or other third-party hosts
that GitHub may redirect to (e.g. S3 for release asset downloads).
"""
req = build_github_request(url)
if not req.get_header("Authorization"):
return urllib.request.urlopen(req, timeout=timeout)
opener = urllib.request.build_opener(_StripAuthOnRedirect)
return opener.open(req, timeout=timeout)

View File

@@ -1,45 +0,0 @@
"""Agent invocation-style constants and helpers.
Agents that scaffold skills (``speckit-<name>/SKILL.md``) use different
slash-command invocation formats depending on the agent. This module
centralises the mapping so that ``HookExecutor._render_hook_invocation``
and ``specify init``'s next-steps output stay consistent.
"""
from __future__ import annotations
# Agents that always render /speckit-<name>, regardless of ai_skills.
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "trae", "zed"})
# Agents that render /speckit-<name> only when ai_skills is enabled.
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
{
"agy",
"claude",
"copilot",
"cursor-agent",
"hermes",
"lingma",
"rovodev",
"vibe",
}
)
def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``/speckit-<name>`` invocations.
The decision is based on the agent sets defined in this module:
* Agents in `ALWAYS_SLASH_AGENTS` always use slash invocations.
* Agents in `CONDITIONAL_SLASH_AGENTS` only use them when
*ai_skills_enabled* is ``True``.
* All other agents return ``False``.
"""
if selected_ai is None:
return False
if not isinstance(selected_ai, str):
return False
return selected_ai in ALWAYS_SLASH_AGENTS or (
selected_ai in CONDITIONAL_SLASH_AGENTS and ai_skills_enabled
)

View File

@@ -8,7 +8,6 @@ import shutil
import stat
import subprocess
import tempfile
import yaml
from pathlib import Path
from typing import Any
from ._console import console
@@ -17,16 +16,6 @@ CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
def dump_frontmatter(data: dict[str, Any]) -> str:
"""Serialize skill/command frontmatter to a YAML string.
Centralizes the dump options used for SKILL.md frontmatter: ``allow_unicode``
preserves Unicode descriptions and ``sort_keys=False`` keeps key order, so no
call site can silently drop either.
"""
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
"""Run a shell command and optionally capture output."""
try:
@@ -88,6 +77,51 @@ def check_tool(tool: str, tracker=None) -> bool:
return found
def is_git_repo(path: Path | None = None) -> bool:
"""Check if the specified path is inside a git repository."""
if path is None:
path = Path.cwd()
if not path.is_dir():
return False
try:
subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
check=True,
capture_output=True,
cwd=path,
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, str | None]:
"""Initialize a git repository in the specified path."""
try:
original_cwd = Path.cwd()
os.chdir(project_path)
if not quiet:
console.print("[cyan]Initializing git repository...[/cyan]")
subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
if not quiet:
console.print("[green]✓[/green] Git repository initialized")
return True, None
except subprocess.CalledProcessError as e:
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
if e.stderr:
error_msg += f"\nError: {e.stderr.strip()}"
elif e.stdout:
error_msg += f"\nOutput: {e.stdout.strip()}"
if not quiet:
console.print(f"[red]Error initializing git repository:[/red] {e}")
return False, error_msg
finally:
os.chdir(original_cwd)
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
"""Handle merging or copying of .vscode/settings.json files.

View File

@@ -14,7 +14,6 @@ from __future__ import annotations
import urllib.error
import urllib.request
from fnmatch import fnmatch
from typing import Callable
from urllib.parse import urlparse
from . import get_provider
@@ -57,36 +56,22 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
RedirectValidator = Callable[[str, str], None]
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
def __init__(
self,
hosts: tuple[str, ...],
redirect_validator: RedirectValidator | None = None,
) -> None:
def __init__(self, hosts: tuple[str, ...]) -> None:
super().__init__()
self._hosts = hosts
self._redirect_validator = redirect_validator
def redirect_request(self, req, fp, code, msg, headers, newurl):
if self._redirect_validator is not None:
self._redirect_validator(req.full_url, newurl)
original_auth = (
req.get_header("Authorization")
or req.unredirected_hdrs.get("Authorization")
)
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
old_scheme = urlparse(req.full_url).scheme
new_parsed = urlparse(newurl)
hostname = (new_parsed.hostname or "").lower()
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
hostname = (urlparse(newurl).hostname or "").lower()
if _hostname_in_hosts(hostname, self._hosts):
if original_auth:
new_req.add_unredirected_header("Authorization", original_auth)
else:
@@ -118,12 +103,7 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
return urllib.request.Request(url, headers=headers)
def open_url(
url: str,
timeout: int = 10,
extra_headers: dict[str, str] | None = None,
redirect_validator: RedirectValidator | None = None,
):
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None):
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
1. Find ``auth.json`` entries whose hosts match the URL.
@@ -133,8 +113,6 @@ def open_url(
5. Non-auth errors (404, 500, network) raise immediately.
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
before following each redirect and may raise to reject the redirect.
"""
entries = find_entries_for_url(url, _load_config())
@@ -157,7 +135,7 @@ def open_url(
continue
req = _make_req(provider.auth_headers(token, entry.auth))
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator))
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
try:
return opener.open(req, timeout=timeout)
except urllib.error.HTTPError as exc:
@@ -168,7 +146,4 @@ def open_url(
# No entry worked (or none matched) — unauthenticated fallback
req = _make_req({})
if redirect_validator is not None:
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
return opener.open(req, timeout=timeout)
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310

View File

@@ -1,5 +1,4 @@
"""specify init command."""
from __future__ import annotations
import os
@@ -24,7 +23,7 @@ from .._assets import (
get_speckit_version,
)
from .._console import StepTracker, console, select_with_arrows, show_banner
from .._utils import check_tool
from .._utils import check_tool, init_git_repo, is_git_repo
def _stdin_is_interactive() -> bool:
@@ -36,9 +35,7 @@ def ensure_constitution_from_template(
) -> None:
"""Copy constitution template to memory if it doesn't exist."""
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
template_constitution = (
project_path / ".specify" / "templates" / "constitution-template.md"
)
template_constitution = project_path / ".specify" / "templates" / "constitution-template.md"
if memory_constitution.exists():
if tracker:
@@ -65,75 +62,26 @@ def ensure_constitution_from_template(
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", str(e))
else:
console.print(
f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]"
)
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
def register(app: typer.Typer) -> None:
@app.command()
def init(
project_name: str = typer.Argument(
None,
help="Name for your new project directory (optional if using --here, or use '.' for current directory)",
),
script_type: str = typer.Option(
None, "--script", help="Script type to use: sh or ps"
),
ignore_agent_tools: bool = typer.Option(
False,
"--ignore-agent-tools",
help="Skip checks for coding agent tools like Claude Code",
),
here: bool = typer.Option(
False,
"--here",
help="Initialize project in the current directory instead of creating a new one",
),
force: bool = typer.Option(
False,
"--force",
help="Force merge/overwrite when using --here (skip confirmation)",
),
skip_tls: bool = typer.Option(
False,
"--skip-tls",
help="Deprecated (no-op). Previously: skip SSL/TLS verification.",
hidden=True,
),
debug: bool = typer.Option(
False,
"--debug",
help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.",
hidden=True,
),
github_token: str = typer.Option(
None,
"--github-token",
help="Deprecated (no-op). Previously: GitHub token for API requests.",
hidden=True,
),
offline: bool = typer.Option(
False,
"--offline",
help="Deprecated (no-op). All scaffolding now uses bundled assets.",
hidden=True,
),
preset: str = typer.Option(
None,
"--preset",
help="Install a preset during initialization (by preset ID)",
),
integration: str = typer.Option(
None,
"--integration",
help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations.",
),
integration_options: str = typer.Option(
None,
"--integration-options",
help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")',
),
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True),
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):
"""
Initialize a new Specify project.
@@ -143,16 +91,18 @@ def register(app: typer.Typer) -> None:
match the installed CLI version.
This command will:
1. Check that required tools are installed
1. Check that required tools are installed (git is optional)
2. Let you choose your coding agent integration, or default to Copilot
in non-interactive sessions
3. Install bundled Spec Kit templates, scripts, workflow, and shared
project infrastructure
4. Set up coding agent integration commands and optional presets
4. Initialize a fresh git repository (if not --no-git and no existing repo)
5. Set up coding agent integration commands and optional presets
Examples:
specify init my-project
specify init my-project --integration claude
specify init my-project --integration copilot --no-git
specify init --ignore-agent-tools my-project
specify init . --integration claude # Initialize in current directory
specify init . # Initialize in current directory (interactive integration selection)
@@ -175,18 +125,15 @@ def register(app: typer.Typer) -> None:
ensure_executable_scripts,
save_init_options,
)
from ..integration_runtime import (
with_integration_setting as _with_integration_setting,
)
from ..integrations._commands import (
_parse_integration_options,
_write_integration_json,
)
from ..integration_runtime import with_integration_setting as _with_integration_setting
show_banner()
from ..integrations import INTEGRATION_REGISTRY, get_integration
if integration:
resolved_integration = get_integration(integration)
if not resolved_integration:
@@ -195,20 +142,28 @@ def register(app: typer.Typer) -> None:
console.print(f"[yellow]Available integrations:[/yellow] {available}")
raise typer.Exit(1)
if no_git:
console.print(
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
"[yellow]The git extension will no longer be enabled by default "
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
)
if project_name == ".":
here = True
project_name = None
if here and project_name:
console.print(
"[red]Error:[/red] Cannot specify both project name and --here flag"
)
console.print("[red]Error:[/red] Cannot specify both project name and --here flag")
raise typer.Exit(1)
if not here and not project_name:
console.print(
"[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag"
)
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
raise typer.Exit(1)
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
raise typer.Exit(1)
dir_existed_before = False
@@ -219,16 +174,10 @@ def register(app: typer.Typer) -> None:
existing_items = list(project_path.iterdir())
if existing_items:
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]"
)
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:
console.print(
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
)
console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]")
else:
response = typer.confirm("Do you want to continue?")
if not response:
@@ -239,22 +188,14 @@ def register(app: typer.Typer) -> None:
dir_existed_before = project_path.exists()
if project_path.exists():
if not project_path.is_dir():
console.print(
f"[red]Error:[/red] '{project_name}' exists but is not a directory."
)
console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.")
raise typer.Exit(1)
existing_items = list(project_path.iterdir())
if force:
if existing_items:
console.print(
f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)"
)
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
console.print(
f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]"
)
console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)")
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
else:
error_panel = Panel(
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
@@ -262,7 +203,7 @@ def register(app: typer.Typer) -> None:
"Use [bold]--force[/bold] to merge into the existing directory.",
title="[red]Directory Conflict[/red]",
border_style="red",
padding=(1, 2),
padding=(1, 2)
)
console.print()
console.print(error_panel)
@@ -270,9 +211,7 @@ def register(app: typer.Typer) -> None:
if integration:
if integration not in AGENT_CONFIG:
console.print(
f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
)
console.print(f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
raise typer.Exit(1)
selected_ai = integration
elif not _stdin_is_interactive():
@@ -296,12 +235,8 @@ def register(app: typer.Typer) -> None:
raise typer.Exit(1)
if selected_ai == "generic" and not integration_options:
console.print(
"[red]Error:[/red] --integration generic requires --integration-options with --commands-dir"
)
console.print(
'[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]'
)
console.print("[red]Error:[/red] --integration generic requires --integration-options with --commands-dir")
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
raise typer.Exit(1)
current_dir = Path.cwd()
@@ -316,9 +251,13 @@ def register(app: typer.Typer) -> None:
if not here:
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
console.print(
Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))
)
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
should_init_git = False
if not no_git:
should_init_git = check_tool("git")
if not should_init_git:
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
if not ignore_agent_tools:
agent_config = AGENT_CONFIG.get(selected_ai)
@@ -332,7 +271,7 @@ def register(app: typer.Typer) -> None:
"Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check",
title="[red]Agent Detection Error[/red]",
border_style="red",
padding=(1, 2),
padding=(1, 2)
)
console.print()
console.print(error_panel)
@@ -340,20 +279,14 @@ def register(app: typer.Typer) -> None:
if script_type:
if script_type not in SCRIPT_TYPE_CHOICES:
console.print(
f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
)
console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}")
raise typer.Exit(1)
selected_script = script_type
else:
default_script = "ps" if os.name == "nt" else "sh"
if _stdin_is_interactive():
selected_script = select_with_arrows(
SCRIPT_TYPE_CHOICES,
"Choose script type (or press Enter)",
default_script,
)
selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script)
else:
selected_script = default_script
@@ -375,41 +308,32 @@ def register(app: typer.Typer) -> None:
for key, label in [
("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"),
("git", "Install git extension"),
("workflow", "Install bundled workflow"),
("agent-context", "Install agent-context extension"),
("final", "Finalize"),
]:
tracker.add(key, label)
# Disable transient mode on Windows: PowerShell 5.1's legacy console
# hangs when Rich tries to restore cursor state via VT escape sequences.
_transient = sys.platform != "win32"
git_default_notice = False
with Live(
tracker.render(), console=console, refresh_per_second=8, transient=_transient
) as live:
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
tracker.attach_refresh(lambda: live.update(tracker.render()))
try:
from ..integrations.manifest import IntegrationManifest
tracker.start("integration")
manifest = IntegrationManifest(
resolved_integration.key,
project_path,
version=get_speckit_version(),
resolved_integration.key, project_path, version=get_speckit_version()
)
integration_parsed_options: dict[str, Any] = {}
if integration_options:
extra = _parse_integration_options(
resolved_integration, integration_options
)
extra = _parse_integration_options(resolved_integration, integration_options)
if extra:
integration_parsed_options.update(extra)
resolved_integration.setup(
project_path,
manifest,
project_path, manifest,
parsed_options=integration_parsed_options or None,
script_type=selected_script,
raw_options=integration_options,
@@ -431,10 +355,7 @@ def register(app: typer.Typer) -> None:
integration_settings,
)
tracker.complete(
"integration",
resolved_integration.config.get("name", resolved_integration.key),
)
tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key))
tracker.start("shared-infra")
_install_shared_infra_or_exit(
@@ -442,68 +363,101 @@ def register(app: typer.Typer) -> None:
selected_script,
tracker=tracker,
force=force,
invoke_separator=resolved_integration.effective_invoke_separator(
integration_parsed_options
),
)
tracker.complete(
"shared-infra", f"scripts ({selected_script}) + templates"
invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options),
)
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
ensure_constitution_from_template(project_path, tracker=tracker)
if not no_git:
tracker.start("git")
git_messages = []
git_has_error = False
if is_git_repo(project_path):
git_messages.append("existing repo detected")
elif should_init_git:
success, error_msg = init_git_repo(project_path, quiet=True)
if success:
git_messages.append("initialized")
else:
git_has_error = True
if error_msg:
sanitized = error_msg.replace('\n', ' ').strip()
git_messages.append(f"init failed: {sanitized[:120]}")
else:
git_messages.append("init failed")
else:
git_messages.append("git not available")
try:
from ..extensions import ExtensionManager
bundled_path = _locate_bundled_extension("git")
if bundled_path:
manager = ExtensionManager(project_path)
if manager.registry.is_installed("git"):
git_messages.append("extension already installed")
else:
manager.install_from_directory(
bundled_path, get_speckit_version()
)
git_default_notice = True
git_messages.append("extension installed")
else:
git_has_error = True
git_messages.append("bundled extension not found")
except Exception as ext_err:
git_has_error = True
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
git_messages.append(
f"extension install failed: {sanitized_ext[:120]}"
)
summary = "; ".join(git_messages)
if git_has_error:
tracker.error("git", summary)
else:
tracker.complete("git", summary)
else:
tracker.skip("git", "--no-git flag")
try:
bundled_wf = _locate_bundled_workflow("speckit")
if bundled_wf:
from ..workflows.catalog import WorkflowRegistry
from ..workflows.engine import WorkflowDefinition
wf_registry = WorkflowRegistry(project_path)
if wf_registry.is_installed("speckit"):
tracker.complete("workflow", "already installed")
else:
import shutil as _shutil
dest_wf = (
project_path / ".specify" / "workflows" / "speckit"
)
dest_wf = project_path / ".specify" / "workflows" / "speckit"
dest_wf.mkdir(parents=True, exist_ok=True)
_shutil.copy2(
bundled_wf / "workflow.yml",
dest_wf / "workflow.yml",
)
definition = WorkflowDefinition.from_yaml(
dest_wf / "workflow.yml"
)
wf_registry.add(
"speckit",
{
"name": definition.name,
"version": definition.version,
"description": definition.description,
"source": "bundled",
},
)
definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml")
wf_registry.add("speckit", {
"name": definition.name,
"version": definition.version,
"description": definition.description,
"source": "bundled",
})
tracker.complete("workflow", "speckit installed")
else:
tracker.skip("workflow", "bundled workflow not found")
except Exception as wf_err:
sanitized_wf = str(wf_err).replace("\n", " ").strip()
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
init_opts = {
"ai": selected_ai,
"integration": resolved_integration.key,
"branch_numbering": branch_numbering or "sequential",
"here": here,
"script": selected_script,
"feature_numbering": "sequential",
"speckit_version": get_speckit_version(),
}
from ..integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist) or getattr(
resolved_integration, "_skills_mode", False
):
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
@@ -512,7 +466,6 @@ def register(app: typer.Typer) -> None:
# registration can read ai_skills + integration key.
try:
from ..extensions import ExtensionManager as _ExtMgr
bundled_ac = _locate_bundled_extension("agent-context")
if bundled_ac:
ac_mgr = _ExtMgr(project_path)
@@ -525,14 +478,13 @@ def register(app: typer.Typer) -> None:
tracker.complete("agent-context", "extension installed")
else:
from ..extensions import REINSTALL_COMMAND as _ac_reinstall
tracker.error(
"agent-context",
f"bundled extension not found — installation may be "
f"incomplete. Run: {_ac_reinstall}",
)
except Exception as ac_err:
sanitized_ac = str(ac_err).replace("\n", " ").strip()
sanitized_ac = str(ac_err).replace('\n', ' ').strip()
tracker.error(
"agent-context",
f"extension install failed: {sanitized_ac[:120]}",
@@ -552,34 +504,24 @@ def register(app: typer.Typer) -> None:
if preset:
try:
from ..presets import PresetCatalog, PresetError, PresetManager
from ..presets import PresetManager, PresetCatalog, PresetError
preset_manager = PresetManager(project_path)
speckit_ver = get_speckit_version()
local_path = Path(preset).resolve()
if local_path.is_dir() and (local_path / "preset.yml").exists():
preset_manager.install_from_directory(
local_path, speckit_ver
)
preset_manager.install_from_directory(local_path, speckit_ver)
else:
bundled_path = _locate_bundled_preset(preset)
if bundled_path:
preset_manager.install_from_directory(
bundled_path, speckit_ver
)
preset_manager.install_from_directory(bundled_path, speckit_ver)
else:
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(
f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping."
)
elif pack_info.get("bundled") and not pack_info.get(
"download_url"
):
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
elif pack_info.get("bundled") and not pack_info.get("download_url"):
from ..extensions import REINSTALL_COMMAND
console.print(
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
f"but could not be found in the installed package."
@@ -587,16 +529,12 @@ def register(app: typer.Typer) -> None:
console.print(
"This usually means the spec-kit installation is incomplete or corrupted."
)
console.print(
f"Try reinstalling: {REINSTALL_COMMAND}"
)
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
else:
zip_path = None
try:
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(
zip_path, speckit_ver
)
preset_manager.install_from_zip(zip_path, speckit_ver)
except PresetError as preset_err:
_print_cli_warning(
"install",
@@ -625,13 +563,7 @@ def register(app: typer.Typer) -> None:
raise
except Exception as e:
tracker.error("final", str(e))
console.print(
Panel(
f"Initialization failed: {e}",
title="Failure",
border_style="red",
)
)
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
if debug:
_env_pairs = [
("Python", sys.version.split()[0]),
@@ -639,155 +571,99 @@ def register(app: typer.Typer) -> None:
("CWD", str(Path.cwd())),
]
_label_width = max(len(k) for k, _ in _env_pairs)
env_lines = [
f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]"
for k, v in _env_pairs
]
console.print(
Panel(
"\n".join(env_lines),
title="Debug Environment",
border_style="magenta",
)
)
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
if not here and project_path.exists() and not dir_existed_before:
shutil.rmtree(project_path)
raise typer.Exit(1)
finally:
pass
if _transient:
console.print(tracker.render())
console.print(tracker.render())
console.print("\n[bold green]Project ready.[/bold green]")
agent_config = AGENT_CONFIG.get(selected_ai)
if agent_config:
agent_folder = agent_config["folder"] or integration_parsed_options.get(
"commands_dir"
)
agent_folder = agent_config["folder"] or integration_parsed_options.get("commands_dir")
if agent_folder:
security_notice = Panel(
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
title="[yellow]Agent Folder Security[/yellow]",
border_style="yellow",
padding=(1, 2),
padding=(1, 2)
)
console.print()
console.print(security_notice)
if git_default_notice:
default_change_notice = Panel(
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
"Use [bold]specify extension add git[/bold] after init when needed.",
title="[yellow]Notice: Git Default Changing[/yellow]",
border_style="yellow",
padding=(1, 2),
)
console.print()
console.print(default_change_notice)
steps_lines = []
if not here:
steps_lines.append(
f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]"
)
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
step_num = 2
else:
steps_lines.append("1. You're already in the project directory!")
step_num = 2
from ..integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(
resolved_integration, _SkillsInt
) or getattr(resolved_integration, "_skills_mode", False)
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
claude_skill_mode = selected_ai == "claude" and _is_skills_integration
kimi_skill_mode = selected_ai == "kimi"
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
trae_skill_mode = selected_ai == "trae"
cursor_agent_skill_mode = (
selected_ai == "cursor-agent" and _is_skills_integration
)
cursor_agent_skill_mode = selected_ai == "cursor-agent" and _is_skills_integration
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
devin_skill_mode = selected_ai == "devin"
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
cline_skill_mode = selected_ai == "cline"
native_skill_mode = (
codex_skill_mode
or claude_skill_mode
or kimi_skill_mode
or agy_skill_mode
or trae_skill_mode
or cursor_agent_skill_mode
or copilot_skill_mode
or devin_skill_mode
or zed_skill_mode
)
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
if codex_skill_mode:
steps_lines.append(
f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
)
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
step_num += 1
if claude_skill_mode:
steps_lines.append(
f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]"
)
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
step_num += 1
if cursor_agent_skill_mode:
steps_lines.append(
f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]"
)
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
step_num += 1
if devin_skill_mode:
steps_lines.append(
f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]"
)
step_num += 1
if zed_skill_mode:
steps_lines.append(
f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
)
steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]")
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"
from .._invocation_style import is_slash_skills_agent as _is_slash_skills_agent
# `_is_skills_integration` means the integration is installed in
# skills mode, which is the semantic equivalent of `ai_skills_enabled`
# used by `is_slash_skills_agent()`.
_ai_skills_enabled = _is_skills_integration
def _display_cmd(name: str) -> str:
if codex_skill_mode:
if codex_skill_mode or agy_skill_mode or trae_skill_mode:
return f"$speckit-{name}"
if claude_skill_mode:
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if (
_is_slash_skills_agent(selected_ai, _ai_skills_enabled)
or cline_skill_mode
):
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or cline_skill_mode:
return f"/speckit-{name}"
return f"/speckit.{name}"
steps_lines.append(
f"{step_num}. Start using {usage_label} with your coding agent:"
)
steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:")
steps_lines.append(
f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles"
)
steps_lines.append(
f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification"
)
steps_lines.append(
f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan"
)
steps_lines.append(
f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks"
)
steps_lines.append(
f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation"
)
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
steps_panel = Panel(
"\n".join(steps_lines),
title="Next Steps",
border_style="cyan",
padding=(1, 2),
)
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1, 2))
console.print()
console.print(steps_panel)
@@ -801,16 +677,9 @@ def register(app: typer.Typer) -> None:
"",
f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])",
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
]
enhancements_title = (
"Enhancement Skills" if native_skill_mode else "Enhancement Commands"
)
enhancements_panel = Panel(
"\n".join(enhancement_lines),
title=enhancements_title,
border_style="cyan",
padding=(1, 2),
)
enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1, 2))
console.print()
console.print(enhancements_panel)

File diff suppressed because it is too large Load Diff

View File

@@ -1,287 +0,0 @@
"""Developer helpers for scaffolding built-in integrations."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class IntegrationScaffoldResult:
"""Files and next steps produced by an integration scaffold run."""
key: str
package_name: str
class_name: str
integration_file: Path
test_file: Path
next_steps: tuple[str, ...]
@dataclass(frozen=True)
class _IntegrationTemplate:
base_class: str
commands_subdir: str
registrar_format: str
args: str
extension: str
_KEY_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
_TEMPLATES = {
"markdown": _IntegrationTemplate(
base_class="MarkdownIntegration",
commands_subdir="commands",
registrar_format="markdown",
args="$ARGUMENTS",
extension=".md",
),
"toml": _IntegrationTemplate(
base_class="TomlIntegration",
commands_subdir="commands",
registrar_format="toml",
args="{{args}}",
extension=".toml",
),
"yaml": _IntegrationTemplate(
base_class="YamlIntegration",
commands_subdir="recipes",
registrar_format="yaml",
args="{{args}}",
extension=".yaml",
),
"skills": _IntegrationTemplate(
base_class="SkillsIntegration",
commands_subdir="skills",
registrar_format="markdown",
args="$ARGUMENTS",
extension="/SKILL.md",
),
}
def supported_integration_scaffold_types() -> tuple[str, ...]:
"""Return supported scaffold template names."""
return tuple(sorted(_TEMPLATES))
def _clean_key(key: str) -> str:
clean = key.strip()
if not _KEY_RE.fullmatch(clean):
raise ValueError(
"Integration key must be lowercase kebab-case, for example 'my-agent'."
)
return clean
def _package_name(key: str) -> str:
return key.replace("-", "_")
def _class_name(key: str) -> str:
return "".join(part.capitalize() for part in key.split("-")) + "Integration"
def _display_name(key: str) -> str:
return " ".join(part.capitalize() for part in key.split("-"))
def _integration_content(
*,
key: str,
class_name: str,
integration_type: str,
) -> str:
template = _TEMPLATES[integration_type]
display_name = _display_name(key)
folder = f".{key}/"
commands_dir = f"{folder}{template.commands_subdir}"
return f'''"""{display_name} integration."""
from ..base import {template.base_class}
class {class_name}({template.base_class}):
key = "{key}"
config = {{
"name": "{display_name}",
"folder": "{folder}",
"commands_subdir": "{template.commands_subdir}",
"install_url": None,
"requires_cli": False,
}}
registrar_config = {{
"dir": "{commands_dir}",
"format": "{template.registrar_format}",
"args": "{template.args}",
"extension": "{template.extension}",
}}
context_file = "AGENTS.md"
# Default to False so the generated boilerplate passes the registry
# contract out of the box: multi-install-safe integrations must each have a
# distinct context_file, and the placeholder above ("AGENTS.md") collides
# with the existing codex integration. Opt in once you pick a unique one.
multi_install_safe = False
'''
def _test_content(
*,
key: str,
class_name: str,
integration_type: str,
) -> str:
template = _TEMPLATES[integration_type]
display_name = _display_name(key)
package_name = _package_name(key)
commands_dir = f".{key}/{template.commands_subdir}"
return f'''"""Tests for the {key} integration."""
from specify_cli.integrations.{package_name} import {class_name}
from specify_cli.integrations.base import {template.base_class}
def test_metadata():
integration = {class_name}()
assert isinstance(integration, {template.base_class})
assert integration.key == "{key}"
assert integration.config["name"] == "{display_name}"
assert integration.config["folder"] == ".{key}/"
assert integration.config["commands_subdir"] == "{template.commands_subdir}"
assert integration.config["requires_cli"] is False
assert integration.registrar_config["dir"] == "{commands_dir}"
assert integration.registrar_config["format"] == "{template.registrar_format}"
assert integration.registrar_config["args"] == "{template.args}"
assert integration.registrar_config["extension"] == "{template.extension}"
assert integration.context_file == "AGENTS.md"
assert integration.multi_install_safe is False
'''
def _is_spec_kit_repo_root(project_root: Path) -> bool:
"""Return True when `project_root` looks like the Spec Kit repository root."""
return all(
(
(project_root / "pyproject.toml").is_file(),
(project_root / "src" / "specify_cli" / "__init__.py").is_file(),
(project_root / "src" / "specify_cli" / "integrations").is_dir(),
(
project_root / "src" / "specify_cli" / "integrations" / "__init__.py"
).is_file(),
(project_root / "tests" / "integrations").is_dir(),
)
)
def _assert_safe_scaffold_target(project_root: Path, target: Path) -> None:
"""Refuse to scaffold through a symlinked path that could escape the repo.
Walks each component of *target* under *project_root* and rejects any
existing symlinked directory (or symlinked target), then confirms the
write destination still resolves inside the repository root. Mirrors the
symlink-aware guarding used for integration manifests.
"""
try:
rel = target.relative_to(project_root)
except ValueError:
raise ValueError(
f"Refusing to scaffold outside the repository root: {target}"
) from None
current = project_root
for part in rel.parts:
current = current / part
if current.is_symlink():
label = current.relative_to(project_root).as_posix()
raise ValueError(f"Refusing to scaffold through symlinked path: {label}")
root_resolved = project_root.resolve()
try:
target.parent.resolve().relative_to(root_resolved)
except (OSError, ValueError):
raise ValueError(
f"Refusing to scaffold outside the repository root: {target}"
) from None
def scaffold_integration(
project_root: Path,
key: str,
integration_type: str,
) -> IntegrationScaffoldResult:
"""Create a minimal built-in integration package and test skeleton."""
clean_key = _clean_key(key)
normalized_type = integration_type.strip().lower()
if normalized_type not in _TEMPLATES:
supported = ", ".join(supported_integration_scaffold_types())
raise ValueError(
f"Unsupported integration type '{normalized_type}'. Use one of: {supported}."
)
integrations_root = project_root / "src" / "specify_cli" / "integrations"
tests_root = project_root / "tests" / "integrations"
if not _is_spec_kit_repo_root(project_root):
raise ValueError("Run this command from the Spec Kit repository root.")
package_name = _package_name(clean_key)
class_name = _class_name(clean_key)
integration_dir = integrations_root / package_name
integration_file = integration_dir / "__init__.py"
test_file = tests_root / f"test_integration_{package_name}.py"
for target in (integration_file, test_file):
_assert_safe_scaffold_target(project_root, target)
existing = [path for path in (integration_file, test_file) if path.exists()]
if existing:
labels = ", ".join(path.relative_to(project_root).as_posix() for path in existing)
raise FileExistsError(f"Refusing to overwrite existing scaffold file(s): {labels}")
created_integration_dir = not integration_dir.exists()
try:
integration_dir.mkdir(exist_ok=True)
integration_file.write_text(
_integration_content(
key=clean_key,
class_name=class_name,
integration_type=normalized_type,
),
encoding="utf-8",
)
test_file.write_text(
_test_content(
key=clean_key,
class_name=class_name,
integration_type=normalized_type,
),
encoding="utf-8",
)
except OSError:
for path in (test_file, integration_file):
try:
if path.is_file() or path.is_symlink():
path.unlink()
except OSError:
pass
if created_integration_dir:
try:
integration_dir.rmdir()
except OSError:
pass
raise
next_steps = (
f"Register {class_name} in src/specify_cli/integrations/__init__.py.",
"Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.",
f"Run pytest tests/integrations/test_integration_{package_name}.py -v.",
)
return IntegrationScaffoldResult(
key=clean_key,
package_name=package_name,
class_name=class_name,
integration_file=integration_file,
test_file=test_file,
next_steps=next_steps,
)

View File

@@ -25,14 +25,17 @@ class IntegrationReadError:
schema: int | None = None
def _read_integration_json_data(
def try_read_integration_json(
project_root: Path,
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
"""Read raw integration state without normalizing or raising.
"""Parse ``.specify/integration.json`` without raising.
Returns ``(data, None)`` when the JSON object is readable and supported,
``(None, None)`` when the file is absent, and ``(None, error)`` for parse,
schema, encoding, or filesystem failures.
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
file does not exist, or ``(None, error)`` for any parse / validation
failure. This is the single low-level reader; both the CLI's loud
``_read_integration_json`` and the workflow engine's silent
``_load_project_integration`` consume it so the schema guard and parse
logic cannot drift between them.
"""
path = project_root / INTEGRATION_JSON
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
@@ -67,41 +70,9 @@ def _read_integration_json_data(
and schema > INTEGRATION_STATE_SCHEMA
):
return None, IntegrationReadError(kind="schema_too_new", schema=schema)
return data, None
def try_read_integration_json(
project_root: Path,
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
"""Parse ``.specify/integration.json`` without raising.
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
file does not exist, or ``(None, error)`` for any parse / validation
failure. This helper delegates file I/O and raw JSON validation to
``_read_integration_json_data`` so callers that need raw state can share
the same low-level reader instead of duplicating parse logic.
"""
data, error = _read_integration_json_data(project_root)
if data is None:
return None, error
return normalize_integration_state(data), None
def try_read_integration_json_with_raw(
project_root: Path,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, IntegrationReadError | None]:
"""Parse ``integration.json`` and return normalized plus raw state.
Returns ``(normalized_state, raw_state, None)`` when the file is readable,
``(None, None, None)`` when it is absent, and ``(None, None, error)`` for
parse, schema, encoding, or filesystem failures.
"""
data, error = _read_integration_json_data(project_root)
if data is None:
return None, None, error
return normalize_integration_state(data), data, None
def clean_integration_key(key: Any) -> str | None:
"""Return a stripped integration key, or None for empty/non-string values."""
if not isinstance(key, str) or not key.strip():

View File

@@ -1,663 +0,0 @@
"""Read-only status reporting for project integration state."""
from __future__ import annotations
import hashlib
import re
import stat
from pathlib import Path
from typing import Any
from .integration_state import (
INTEGRATION_JSON,
INTEGRATION_STATE_SCHEMA,
IntegrationReadError,
default_integration_key,
installed_integration_keys,
try_read_integration_json_with_raw,
)
from .integrations import INTEGRATION_REGISTRY
from .integrations.manifest import IntegrationManifest
_MANIFEST_READ_ERRORS = (ValueError, OSError)
_MANIFEST_KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_WINDOWS_RESERVED_MANIFEST_BASENAMES = {
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}
_SHARED_MANIFEST_KEY = "speckit"
def _finding(
severity: str,
code: str,
message: str,
*,
integration: str | None = None,
path: str | None = None,
suggestion: str | None = None,
) -> dict[str, str]:
item = {
"severity": severity,
"code": code,
"message": message,
}
if integration:
item["integration"] = integration
if path:
item["path"] = path
if suggestion:
item["suggestion"] = suggestion
return item
def _status(findings: list[dict[str, str]]) -> str:
if any(item["severity"] == "error" for item in findings):
return "error"
if findings:
return "warning"
return "ok"
def _with_error_detail(message: str, error: IntegrationReadError) -> str:
if error.detail:
return f"{message} Detail: {error.detail}"
return message
def _integration_state_error_message(error: IntegrationReadError) -> str:
if error.kind == "decode":
return _with_error_detail(
f"{INTEGRATION_JSON} contains invalid JSON or is not valid UTF-8.",
error,
)
if error.kind == "os":
return _with_error_detail(f"Could not read {INTEGRATION_JSON}.", error)
if error.kind == "not_object":
return f"{INTEGRATION_JSON} must contain a JSON object, got {error.detail}."
if error.kind == "schema_too_new":
return (
f"{INTEGRATION_JSON} uses integration state schema {error.schema}, "
f"which is newer than this CLI supports; supported schema: {INTEGRATION_STATE_SCHEMA}."
)
return f"Could not inspect {INTEGRATION_JSON}."
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def _strip_extended_length_prefix(path: Path) -> Path:
"""Drop the Windows ``\\\\?\\`` extended-length prefix for path comparison.
``os.readlink`` and ``Path.resolve`` can return extended-length paths on
Windows (e.g. ``\\\\?\\C:\\proj``). Comparing such a path against a plain
``C:\\proj`` root via :meth:`Path.relative_to` would spuriously fail, so we
normalise both sides through this helper before containment checks.
"""
raw = str(path)
if raw.startswith("\\\\?\\UNC\\"):
return Path("\\\\" + raw[len("\\\\?\\UNC\\"):])
if raw.startswith("\\\\?\\"):
return Path(raw[len("\\\\?\\"):])
return path
def _is_within_project(project_root_resolved: Path, candidate: Path) -> bool:
"""Return ``True`` when *candidate* stays within *project_root_resolved*.
Both paths are stripped of any Windows extended-length prefix first so that
a target produced by ``os.readlink`` (which may be ``\\\\?\\``-prefixed) is
still recognised as living inside an unprefixed project root.
"""
try:
_strip_extended_length_prefix(candidate).relative_to(
_strip_extended_length_prefix(project_root_resolved)
)
except ValueError:
return False
return True
def _safe_manifest_file(
project_root: Path,
project_root_resolved: Path,
rel: str,
*,
project_root_is_resolved: bool = True,
) -> Path | None:
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
return None
candidate = project_root / rel_path
if not project_root_is_resolved:
walk = project_root
for part in rel_path.parts[:-1]:
walk = walk / part
try:
if walk.is_symlink():
return None
except OSError:
return None
try:
candidate_parent = (
candidate.parent.resolve(strict=False)
if project_root_is_resolved
else candidate.parent.absolute()
)
except (OSError, RuntimeError):
return None
if not _is_within_project(project_root_resolved, candidate_parent):
return None
return candidate
def _tracked_symlink_manifest_status(
path: Path,
project_root_resolved: Path,
*,
project_root_is_resolved: bool = True,
) -> str:
"""Classify a tracked symlink without following it outside the project.
Manifests store content hashes for regular files, so an existing in-project
symlink is still reported as modified. Escaping targets are invalid, and
dangling in-project targets are missing.
"""
try:
target = path.readlink()
except OSError:
return "modified"
target_path = target if target.is_absolute() else path.parent / target
try:
contained_parent = (
target_path.parent.resolve(strict=False)
if project_root_is_resolved
else target_path.parent.absolute()
)
except (OSError, RuntimeError):
return "invalid"
if not _is_within_project(project_root_resolved, contained_parent):
return "invalid"
try:
target_path.lstat()
except FileNotFoundError:
return "missing"
except OSError:
return "modified"
return "modified"
def _resolve_project_root_for_status(
project_root: Path,
findings: list[dict[str, str]],
) -> tuple[Path, bool]:
try:
return project_root.resolve(), True
except (OSError, RuntimeError) as exc:
findings.append(
_finding(
"warning",
"project-root-unresolved",
f"Could not fully resolve project root: {exc}",
suggestion="Check project path permissions and symlinks before relying on manifest path checks.",
)
)
return project_root.absolute(), False
def _is_safe_manifest_key(key: str) -> bool:
if key in {"", ".", ".."}:
return False
if key.endswith("."):
return False
if _MANIFEST_KEY_RE.fullmatch(key) is None:
return False
if key.split(".", 1)[0].upper() in _WINDOWS_RESERVED_MANIFEST_BASENAMES:
return False
if "/" in key or "\\" in key:
return False
key_path = Path(key)
return not key_path.is_absolute() and key_path.name == key
def _manifest_file_status(
manifest: IntegrationManifest,
project_root_resolved: Path,
*,
project_root_is_resolved: bool = True,
) -> tuple[list[str], list[str], list[str], list[str]]:
missing: list[str] = []
modified: list[str] = []
invalid: list[str] = []
valid: list[str] = []
for rel, expected_hash in manifest.files.items():
path = _safe_manifest_file(
manifest.project_root,
project_root_resolved,
rel,
project_root_is_resolved=project_root_is_resolved,
)
if path is None:
invalid.append(rel)
continue
try:
path_stat = path.lstat()
except FileNotFoundError:
valid.append(rel)
missing.append(rel)
continue
except OSError:
valid.append(rel)
modified.append(rel)
continue
is_symlink = stat.S_ISLNK(path_stat.st_mode)
if not is_symlink:
try:
is_symlink = path.is_symlink()
except OSError:
is_symlink = False
if is_symlink:
symlink_status = _tracked_symlink_manifest_status(
path,
project_root_resolved,
project_root_is_resolved=project_root_is_resolved,
)
if symlink_status == "invalid":
invalid.append(rel)
continue
valid.append(rel)
if symlink_status == "missing":
missing.append(rel)
continue
modified.append(rel)
continue
valid.append(rel)
if not stat.S_ISREG(path_stat.st_mode):
modified.append(rel)
continue
try:
if _sha256_file(path) != expected_hash:
modified.append(rel)
except OSError:
modified.append(rel)
return missing, modified, invalid, valid
def _default_not_installed_from_raw_state(raw_state: dict[str, Any]) -> str | None:
if not isinstance(raw_state.get("installed_integrations"), list):
return None
raw_default = default_integration_key(raw_state)
raw_installed = installed_integration_keys(raw_state)
if raw_default and raw_default not in raw_installed:
return raw_default
return None
def _manifest_summary(
manifest_path: Path,
project_root: Path,
*,
readable: bool,
tracked_files: int = 0,
missing_files: list[str] | None = None,
modified_files: list[str] | None = None,
invalid_files: list[str] | None = None,
) -> dict[str, Any]:
return {
"manifest": manifest_path.relative_to(project_root).as_posix(),
"readable": readable,
"tracked_files": tracked_files,
"missing_files": missing_files or [],
"modified_files": modified_files or [],
"invalid_files": invalid_files or [],
}
def _manifest_owner(key: str) -> str:
if key == _SHARED_MANIFEST_KEY:
return "shared Spec Kit infrastructure"
return f"integration '{key}'"
def _manifest_suggestion(key: str, default_key: str | None) -> str:
if key == _SHARED_MANIFEST_KEY:
if default_key and default_key in INTEGRATION_REGISTRY:
return f"Run `specify integration upgrade {default_key}` to regenerate shared managed files."
return (
"Run `specify init --here --force --integration <key>` to regenerate "
"shared managed files."
)
if key not in INTEGRATION_REGISTRY:
return (
"Upgrade Spec Kit, reinstall with a supported CLI version, "
f"or remove the stale integration entry from {INTEGRATION_JSON}."
)
return f"Run `specify integration upgrade {key}` or reinstall the integration."
def build_integration_status_report(project_root: Path) -> dict[str, Any]:
"""Return a machine-readable integration status report for *project_root*."""
findings: list[dict[str, str]] = []
project_root_resolved, project_root_is_resolved = _resolve_project_root_for_status(
project_root,
findings,
)
state, raw_state, error = try_read_integration_json_with_raw(project_root)
if error is not None:
findings.append(
_finding(
"error",
"integration-state-unreadable",
_integration_state_error_message(error),
path=INTEGRATION_JSON,
suggestion=f"Fix or delete {INTEGRATION_JSON}, then retry.",
)
)
return _build_report(None, [], findings, {}, None)
if state is None:
findings.append(
_finding(
"error",
"integration-state-missing",
f"{INTEGRATION_JSON} is missing.",
path=INTEGRATION_JSON,
suggestion="Run `specify integration install <key>` to install an integration.",
)
)
return _build_report(None, [], findings, {}, None)
assert raw_state is not None
raw_default_key = default_integration_key(raw_state)
raw_installed_value = raw_state.get("installed_integrations")
raw_installed_is_list = isinstance(raw_installed_value, list)
raw_installed_keys = (
installed_integration_keys(raw_state)
if raw_installed_is_list
else []
)
default_key = raw_default_key or default_integration_key(state)
installed_keys = installed_integration_keys(state)
raw_default_not_installed = _default_not_installed_from_raw_state(raw_state)
if raw_installed_is_list and raw_default_not_installed and raw_installed_keys:
check_installed_keys = raw_installed_keys
else:
check_installed_keys = installed_keys
recorded_installed_keys = raw_installed_keys
if "installed_integrations" in raw_state and not raw_installed_is_list:
findings.append(
_finding(
"warning",
"installed-integrations-invalid",
(
"installed_integrations must be a list, "
f"got {type(raw_installed_value).__name__}."
),
path=INTEGRATION_JSON,
suggestion=f"Fix {INTEGRATION_JSON}, then retry.",
)
)
if not installed_keys:
findings.append(
_finding(
"warning",
"no-installed-integrations",
"No installed integrations are recorded.",
suggestion="Run `specify integration install <key>` to install one.",
)
)
if raw_installed_keys and raw_default_key is None:
default_key = None
findings.append(
_finding(
"error",
"default-integration-missing",
"No default integration is recorded.",
suggestion="Run `specify integration use <key>` after choosing an installed integration.",
)
)
if raw_default_not_installed:
findings.append(
_finding(
"error",
"default-integration-not-installed",
(
f"Default integration '{raw_default_not_installed}' is not listed "
"in installed_integrations."
),
integration=raw_default_not_installed,
suggestion="Run `specify integration use <key>` for an installed integration, or reinstall the default integration.",
)
)
known_installed = [key for key in check_installed_keys if key in INTEGRATION_REGISTRY]
unknown_installed: list[str] = []
for key in check_installed_keys:
if key not in INTEGRATION_REGISTRY:
unknown_installed.append(key)
findings.append(
_finding(
"error",
"unknown-integration",
f"Integration '{key}' is installed but is not known to this CLI.",
integration=key,
suggestion=(
"Upgrade Spec Kit, reinstall with a supported CLI version, "
f"or remove the stale integration entry from {INTEGRATION_JSON}."
),
)
)
unsafe = [
key for key in known_installed
if not getattr(INTEGRATION_REGISTRY[key], "multi_install_safe", False)
]
if len(check_installed_keys) > 1:
unsafe.extend(unknown_installed)
if len(check_installed_keys) > 1 and unsafe:
findings.append(
_finding(
"error",
"unsafe-multi-install",
(
"Installed integrations are not all declared multi-install safe: "
+ ", ".join(sorted(unsafe))
),
suggestion=(
"Use `specify integration use <key>` to change defaults, "
"or `specify integration switch <key>` only when replacing integrations."
),
)
)
manifest_files_by_path: dict[str, list[str]] = {}
manifest_summaries: dict[str, dict[str, Any]] = {}
attempted_manifest_keys: list[str] = []
manifest_keys = list(check_installed_keys)
if _SHARED_MANIFEST_KEY not in manifest_keys:
manifest_keys.append(_SHARED_MANIFEST_KEY)
for key in manifest_keys:
owner = _manifest_owner(key)
if not _is_safe_manifest_key(key):
findings.append(
_finding(
"error",
"integration-key-invalid",
f"Integration key {key!r} cannot be used as a manifest filename.",
integration=key,
path=INTEGRATION_JSON,
suggestion=f"Fix {INTEGRATION_JSON}, then reinstall the integration.",
)
)
continue
attempted_manifest_keys.append(key)
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
try:
manifest = IntegrationManifest.load(
key,
project_root_resolved,
resolve_project_root=False,
)
except FileNotFoundError:
findings.append(
_finding(
"error",
"manifest-missing",
f"Manifest for {owner} is missing.",
integration=key,
path=manifest_path.relative_to(project_root).as_posix(),
suggestion=_manifest_suggestion(key, default_key),
)
)
manifest_summaries[key] = _manifest_summary(
manifest_path,
project_root,
readable=False,
)
continue
except _MANIFEST_READ_ERRORS as exc:
manifest_summaries[key] = _manifest_summary(
manifest_path,
project_root,
readable=False,
)
findings.append(
_finding(
"error",
"manifest-unreadable",
f"Manifest for {owner} is unreadable: {exc}",
integration=key,
path=manifest_path.relative_to(project_root).as_posix(),
suggestion=_manifest_suggestion(key, default_key),
)
)
continue
missing, modified, invalid, valid_files = _manifest_file_status(
manifest,
project_root_resolved,
project_root_is_resolved=project_root_is_resolved,
)
manifest_summaries[key] = _manifest_summary(
manifest_path,
project_root,
readable=True,
tracked_files=len(manifest.files),
missing_files=missing,
modified_files=modified,
invalid_files=invalid,
)
for rel in valid_files:
manifest_files_by_path.setdefault(rel, []).append(key)
if invalid:
findings.append(
_finding(
"error",
"manifest-paths-invalid",
f"{len(invalid)} unsafe manifest path(s) are recorded for {owner}.",
integration=key,
path=manifest_path.relative_to(project_root).as_posix(),
suggestion=_manifest_suggestion(key, default_key),
)
)
if missing:
findings.append(
_finding(
"error",
"managed-files-missing",
f"{len(missing)} managed file(s) are missing for {owner}.",
integration=key,
suggestion=_manifest_suggestion(key, default_key),
)
)
if modified:
findings.append(
_finding(
"warning",
"managed-files-modified",
f"{len(modified)} managed file(s) were modified for {owner}.",
integration=key,
suggestion="Review the changes before running `specify integration upgrade --force`.",
)
)
for rel, keys in sorted(manifest_files_by_path.items()):
if len(keys) > 1:
findings.append(
_finding(
"warning",
"managed-file-collision",
f"Managed file '{rel}' is tracked by multiple integrations: {', '.join(sorted(keys))}.",
path=rel,
suggestion="Review the manifests before uninstalling or upgrading these integrations.",
)
)
if not raw_installed_is_list or not raw_installed_keys:
multi_install_safe = None
else:
multi_install_safe = not (len(check_installed_keys) > 1 and unsafe)
return _build_report(
default_key,
installed_keys,
findings,
manifest_summaries,
multi_install_safe,
manifest_checked_keys=attempted_manifest_keys,
recorded_installed_keys=recorded_installed_keys,
)
def _build_report(
default_key: str | None,
installed_keys: list[str],
findings: list[dict[str, str]],
manifests: dict[str, dict[str, Any]],
multi_install_safe: bool | None,
*,
manifest_checked_keys: list[str] | None = None,
recorded_installed_keys: list[str] | None = None,
) -> dict[str, Any]:
missing_count = sum(len(item.get("missing_files", [])) for item in manifests.values())
modified_count = sum(len(item.get("modified_files", [])) for item in manifests.values())
invalid_count = sum(len(item.get("invalid_files", [])) for item in manifests.values())
unchecked_count = sum(1 for item in manifests.values() if not item.get("readable", True))
return {
"status": _status(findings),
"default_integration": default_key,
"installed_integrations": installed_keys,
"recorded_installed_integrations": (
installed_keys if recorded_installed_keys is None else recorded_installed_keys
),
"manifest_checked_integrations": (
installed_keys if manifest_checked_keys is None else manifest_checked_keys
),
"multi_install_safe": multi_install_safe,
"shared_templates_target_alignment": default_key,
"missing_managed_files": missing_count,
"modified_managed_files": modified_count,
"invalid_manifest_paths": invalid_count,
"unchecked_manifests": unchecked_count,
"manifests": manifests,
"findings": findings,
}

View File

@@ -80,7 +80,6 @@ def _register_builtins() -> None:
from .trae import TraeIntegration
from .vibe import VibeIntegration
from .windsurf import WindsurfIntegration
from .zed import ZedIntegration
# -- Registration (alphabetical) --------------------------------------
_register(AgyIntegration())
@@ -116,7 +115,6 @@ def _register_builtins() -> None:
_register(TraeIntegration())
_register(VibeIntegration())
_register(WindsurfIntegration())
_register(ZedIntegration())
_register_builtins()

View File

@@ -31,5 +31,4 @@ def register(app: typer.Typer) -> None:
from . import _install_commands # noqa: F401 — registers handlers via decorators
from . import _migrate_commands # noqa: F401
from . import _query_commands # noqa: F401
from . import _scaffold_commands # noqa: F401
app.add_typer(integration_app, name="integration")

View File

@@ -1,12 +1,10 @@
"""specify integration list/status/use/search/info + catalog list/add/remove command handlers."""
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
from __future__ import annotations
import json
import os
from typing import Any, Optional
from typing import Optional
import typer
from rich.markup import escape as _rich_escape
from rich.table import Table
from .._console import console
@@ -122,86 +120,6 @@ def integration_list(
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
def _print_integration_status_report(report: dict[str, Any]) -> None:
status = report["status"]
status_label = {
"ok": "[green]OK[/green]",
"warning": "[yellow]WARNING[/yellow]",
"error": "[red]ERROR[/red]",
}.get(str(status), str(status).upper())
installed = report.get("installed_integrations") or []
installed_display = ", ".join(_rich_escape(str(item)) for item in installed)
console.print(f"Integration status: {status_label}")
console.print(
f"Default integration: {_rich_escape(str(report.get('default_integration') or 'none'))}"
)
console.print(f"Installed integrations: {installed_display if installed else 'none'}")
multi_install_safe = report.get("multi_install_safe")
if multi_install_safe is None:
multi_install_safe_display = "unknown"
else:
multi_install_safe_display = "yes" if multi_install_safe else "no"
console.print(f"Multi-install safe: {multi_install_safe_display}")
console.print(
f"Shared templates target alignment: "
f"{_rich_escape(str(report.get('shared_templates_target_alignment') or 'none'))}"
)
console.print(f"Modified managed files: {report.get('modified_managed_files', 0)}")
console.print(f"Missing managed files: {report.get('missing_managed_files', 0)}")
console.print(f"Invalid manifest paths: {report.get('invalid_manifest_paths', 0)}")
console.print(f"Unchecked manifests: {report.get('unchecked_manifests', 0)}")
findings = report.get("findings") or []
if not findings:
return
console.print()
console.print("[bold]Findings:[/bold]")
for item in findings:
severity = item.get("severity", "")
severity_label = {
"error": "[red]error[/red]",
"warning": "[yellow]warning[/yellow]",
}.get(severity, severity)
prefix = f"- {severity_label} {_rich_escape(str(item.get('code', '')))}"
if item.get("integration"):
prefix += f" ({_rich_escape(str(item['integration']))})"
console.print(
f"{prefix}: {_rich_escape(str(item.get('message', '')))}",
soft_wrap=True,
)
if item.get("suggestion"):
console.print(
f" Suggestion: {_rich_escape(str(item['suggestion']))}",
soft_wrap=True,
)
@integration_app.command("status")
def integration_status(
json_output: bool = typer.Option(
False,
"--json",
help="Emit machine-readable integration status.",
),
):
"""Report the current project's integration status without changing files."""
from .. import _require_specify_project
from ..integration_status import build_integration_status_report
project_root = _require_specify_project()
report = build_integration_status_report(project_root)
if json_output:
typer.echo(json.dumps(report, indent=2))
else:
_print_integration_status_report(report)
if report["status"] == "error":
raise typer.Exit(1)
@integration_app.command("use")
def integration_use(
key: str = typer.Argument(help="Installed integration key to make the default"),

View File

@@ -1,52 +0,0 @@
"""specify integration scaffold command handler."""
from __future__ import annotations
from enum import Enum
from pathlib import Path
import typer
from .._console import console
from ..integration_scaffold import supported_integration_scaffold_types
from ._commands import integration_app
INTEGRATION_SCAFFOLD_TYPES = supported_integration_scaffold_types()
_IntegrationScaffoldType = Enum(
"_IntegrationScaffoldType",
{name: name for name in INTEGRATION_SCAFFOLD_TYPES},
type=str,
)
@integration_app.command("scaffold")
def integration_scaffold(
key: str = typer.Argument(help="Integration key in lowercase kebab-case, e.g. my-agent"),
integration_type: _IntegrationScaffoldType = typer.Option(
_IntegrationScaffoldType.markdown,
"--type",
case_sensitive=False,
help=f"Scaffold type: {', '.join(INTEGRATION_SCAFFOLD_TYPES)}",
),
):
"""Create a minimal built-in integration package and test skeleton."""
from ..integration_scaffold import scaffold_integration
project_root = Path.cwd()
try:
result = scaffold_integration(project_root, key, integration_type.value)
except (OSError, ValueError) as exc:
# OSError covers filesystem failures during mkdir()/write_text()
# (permission denied, read-only checkout, a path component that is a
# file, ...) as well as FileExistsError; surface them as a clean CLI
# error instead of a traceback.
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print(f"[green]Created integration scaffold:[/green] {result.key}")
console.print(f" {result.integration_file.relative_to(project_root).as_posix()}")
console.print(f" {result.test_file.relative_to(project_root).as_posix()}")
console.print()
console.print("[bold]Next steps:[/bold]")
for index, step in enumerate(result.next_steps, start=1):
console.print(f"{index}. {step}")

View File

@@ -5,9 +5,10 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from ..base import SkillsIntegration
from ..manifest import IntegrationManifest
from ..._utils import dump_frontmatter
# Mapping of command template stem → argument-hint text shown inline
# when a user invokes the slash command in Claude Code.
@@ -102,7 +103,7 @@ class ClaudeIntegration(SkillsIntegration):
skill_frontmatter = self._build_skill_fm(
skill_name, description, f"templates/commands/{template_name}.md"
)
frontmatter_text = dump_frontmatter(skill_frontmatter)
frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip()
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:

View File

@@ -108,23 +108,11 @@ class IntegrationManifest:
key: Integration identifier (e.g. ``"copilot"``).
project_root: Absolute path to the project directory.
version: CLI version string recorded in the manifest.
resolve_project_root: Resolve ``project_root`` before using it.
"""
def __init__(
self,
key: str,
project_root: Path,
version: str = "",
*,
resolve_project_root: bool = True,
) -> None:
def __init__(self, key: str, project_root: Path, version: str = "") -> None:
self.key = key
self.project_root = (
project_root.resolve()
if resolve_project_root
else project_root.absolute()
)
self.project_root = project_root.resolve()
self.version = version
self._files: dict[str, str] = {} # rel_path → sha256 hex
self._recovered_files: set[str] = set()
@@ -399,18 +387,12 @@ class IntegrationManifest:
return path
@classmethod
def load(
cls,
key: str,
project_root: Path,
*,
resolve_project_root: bool = True,
) -> IntegrationManifest:
def load(cls, key: str, project_root: Path) -> IntegrationManifest:
"""Load an existing manifest from disk.
Raises ``FileNotFoundError`` if the manifest does not exist.
"""
inst = cls(key, project_root, resolve_project_root=resolve_project_root)
inst = cls(key, project_root)
path = inst.manifest_path
try:
data = json.loads(path.read_text(encoding="utf-8"))

View File

@@ -1,34 +0,0 @@
"""Zed editor integration — skills-based agent.
Zed uses the ``.agents/skills/speckit-<name>/SKILL.md`` layout so Spec Kit
commands are exposed as project-local skills that can be invoked from Zed's
slash-command menu.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class ZedIntegration(SkillsIntegration):
"""Integration for Zed editor skills."""
key = "zed"
config = {
"name": "Zed",
"folder": ".agents/",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".agents/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return []

View File

@@ -19,7 +19,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Optional, Dict, List, Any
if TYPE_CHECKING:
from ..agents import CommandRegistrar
from .agents import CommandRegistrar
from datetime import datetime, timezone
import re
@@ -27,10 +27,9 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .._init_options import is_ai_skills_enabled
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .integrations.base import IntegrationBase
from ._init_options import is_ai_skills_enabled
def _substitute_core_template(
@@ -677,7 +676,7 @@ class PresetManager:
commands_to_register.append(cmd)
try:
from ..agents import CommandRegistrar
from .agents import CommandRegistrar
except ImportError:
return {}
@@ -693,7 +692,7 @@ class PresetManager:
registered_commands: Dict mapping agent names to command name lists
"""
try:
from ..agents import CommandRegistrar
from .agents import CommandRegistrar
except ImportError:
return
@@ -716,7 +715,7 @@ class PresetManager:
return
try:
from ..agents import CommandRegistrar
from .agents import CommandRegistrar
except ImportError:
return
@@ -768,7 +767,7 @@ class PresetManager:
ext_manifest_path = ext_dir / "extension.yml"
if ext_manifest_path.exists():
try:
from ..extensions import ExtensionManifest
from .extensions import ExtensionManifest
ext_manifest = ExtensionManifest(ext_manifest_path)
# Filter to only the command being reconciled
matching_cmds = [
@@ -892,7 +891,7 @@ class PresetManager:
# Load aliases from extension manifest when the winning layer is an extension
if source_id and not source_id.startswith("preset:"):
try:
from ..extensions import ExtensionManifest
from .extensions import ExtensionManifest
for ext_dir in (self.project_root / ".specify" / "extensions").iterdir():
if not ext_dir.is_dir():
continue
@@ -1043,8 +1042,8 @@ class PresetManager:
skill_subdir.mkdir(parents=True, exist_ok=True)
skill_file = skill_subdir / "SKILL.md"
try:
from ..agents import CommandRegistrar
from .. import SKILL_DESCRIPTIONS, load_init_options
from .agents import CommandRegistrar
from . import SKILL_DESCRIPTIONS, load_init_options
registrar = CommandRegistrar()
content = top_layer["path"].read_text(encoding="utf-8")
fm, body = registrar.parse_frontmatter(content)
@@ -1069,14 +1068,14 @@ class PresetManager:
skill_name, desc,
f"override:{cmd_name}",
)
fm_text = dump_frontmatter(fm_data)
fm_text = yaml.safe_dump(fm_data, sort_keys=False).strip()
skill_title = self._skill_title_from_command(cmd_name)
skill_content = (
f"---\n{fm_text}\n---\n\n"
f"# Speckit {skill_title} Skill\n\n{body}\n"
)
# Apply integration post-processing (e.g. Claude flags)
from ..integrations import get_integration
from .integrations import get_integration
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
if integration is not None and hasattr(integration, "post_process_skill_content"):
skill_content = integration.post_process_skill_content(skill_content)
@@ -1111,7 +1110,7 @@ class PresetManager:
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
"""
from .. import resolve_active_skills_dir, _print_cli_warning
from . import resolve_active_skills_dir, _print_cli_warning
try:
return resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
@@ -1159,7 +1158,7 @@ class PresetManager:
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
"""Index extension-backed skill restore data by skill directory name."""
from ..extensions import ExtensionManifest, ValidationError
from .extensions import ExtensionManifest, ValidationError
resolver = PresetResolver(self.project_root)
extensions_dir = self.project_root / ".specify" / "extensions"
@@ -1254,9 +1253,9 @@ class PresetManager:
if not skills_dir:
return []
from .. import SKILL_DESCRIPTIONS, load_init_options
from ..agents import CommandRegistrar
from ..integrations import get_integration
from . import SKILL_DESCRIPTIONS, load_init_options
from .agents import CommandRegistrar
from .integrations import get_integration
init_opts = load_init_options(self.project_root)
if not isinstance(init_opts, dict):
@@ -1346,7 +1345,7 @@ class PresetManager:
enhanced_desc,
f"preset:{manifest.id}",
)
frontmatter_text = dump_frontmatter(frontmatter_data)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_content = (
f"---\n"
f"{frontmatter_text}\n"
@@ -1383,9 +1382,9 @@ class PresetManager:
if not skills_dir:
return
from .. import SKILL_DESCRIPTIONS, load_init_options
from ..agents import CommandRegistrar
from ..integrations import get_integration
from . import SKILL_DESCRIPTIONS, load_init_options
from .agents import CommandRegistrar
from .integrations import get_integration
# Locate core command templates from the project's installed templates
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
@@ -1442,7 +1441,7 @@ class PresetManager:
enhanced_desc,
f"templates/commands/{short_name}.md",
)
frontmatter_text = dump_frontmatter(frontmatter_data)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_title = self._skill_title_from_command(short_name)
skill_content = (
f"---\n"
@@ -1479,7 +1478,7 @@ class PresetManager:
frontmatter.get("description", f"Extension command: {command_name}"),
extension_restore["source"],
)
frontmatter_text = dump_frontmatter(frontmatter_data)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_content = (
f"---\n"
f"{frontmatter_text}\n"
@@ -1713,7 +1712,7 @@ class PresetManager:
if registered_skills:
self._unregister_skills(registered_skills, pack_dir)
try:
from ..agents import CommandRegistrar
from .agents import CommandRegistrar
except ImportError:
CommandRegistrar = None
if CommandRegistrar is not None:
@@ -1893,48 +1892,6 @@ class PresetCatalog:
download_url, self._open_url, timeout=timeout
)
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
"""Validate a parsed preset-catalog payload's shape.
Applied to both network-fetched and cache-loaded payloads so a
once-poisoned cache (older spec-kit version, manual edit, upstream
served a bad payload before the network-side guards were added)
cannot re-crash ``_get_merged_packs`` on subsequent calls.
Checking only key presence would let a payload like
``{"presets": []}`` or ``{"presets": null}`` slip through here and
then crash with ``AttributeError: 'list' object has no attribute
'items'`` deep inside ``_get_merged_packs``. The sibling
integration catalog reader already guards both the root object and
the nested mapping (see ``integrations/catalog.py``); the preset
catalog must stay consistent so a malformed payload surfaces as
the user-facing ``Invalid preset catalog format`` error instead of
a raw Python traceback.
Args:
catalog_data: Parsed JSON payload from the catalog source.
url: Source URL used in the error message so the user can
tell which catalog in a multi-catalog stack is malformed.
Raises:
PresetError: If the payload's shape is invalid.
"""
if not isinstance(catalog_data, dict):
raise PresetError(
f"Invalid preset catalog format from {url}: "
"expected a JSON object"
)
if (
"schema_version" not in catalog_data
or "presets" not in catalog_data
):
raise PresetError(f"Invalid preset catalog format from {url}")
if not isinstance(catalog_data.get("presets"), dict):
raise PresetError(
f"Invalid preset catalog format from {url}: "
"'presets' must be a JSON object"
)
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
@@ -2096,7 +2053,7 @@ class PresetCatalog:
if not cache_file.exists() or not metadata_file.exists():
return False
try:
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
metadata = json.loads(metadata_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
@@ -2104,23 +2061,7 @@ class PresetCatalog:
datetime.now(timezone.utc) - cached_at
).total_seconds()
return age_seconds < self.CACHE_DURATION
except (
json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
KeyError,
TypeError,
AttributeError,
):
# Cache validity is best-effort: invalid/missing fields, an
# unreadable metadata file (permissions / disk), a wrongly
# encoded one (written by a tool using the system locale
# codec), or a metadata payload that parses to a non-mapping
# like ``[]`` or ``"oops"`` (so ``metadata.get(...)`` raises
# ``AttributeError``) all degrade to "cache invalid" so the
# caller falls through to a network refetch instead of
# crashing.
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
return False
def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
@@ -2138,55 +2079,29 @@ class PresetCatalog:
"""
cache_file, metadata_file = self._get_cache_paths(entry.url)
# Use cache if valid. A previously-cached payload must clear the
# same shape checks as a freshly-fetched one — otherwise a once-
# poisoned cache would re-crash on every invocation despite the
# cache being "valid" by age. If validation fails on the cached
# read, fall through to the network fetch path so the cache gets
# refreshed.
if not force_refresh and self._is_url_cache_valid(entry.url):
try:
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
self._validate_catalog_payload(cached_data, entry.url)
return cached_data
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
# Cache is best-effort: a JSON-decode failure, an OS-level
# read failure (permissions / disk / handle limit), or a
# text-encoding failure on a cache file written by an
# older client all fall through to the network fetch path.
# Only the network failure is surfaced to the caller.
return json.loads(cache_file.read_text())
except json.JSONDecodeError:
pass
try:
with self._open_url(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read())
self._validate_catalog_payload(catalog_data, entry.url)
if (
"schema_version" not in catalog_data
or "presets" not in catalog_data
):
raise PresetError("Invalid preset catalog format")
# Both files are written explicitly as UTF-8 to match the
# ``read_text(encoding="utf-8")`` on the read side and the
# ``integrations/catalog.py`` precedent. Without this,
# platforms whose default encoding isn't UTF-8 would write
# locale-encoded bytes the read path can't decode, forcing an
# unnecessary refetch on every invocation. The write itself
# is best-effort like the read side: an unwritable cache dir
# (read-only checkout, permissions) must not be re-raised as
# a ``PresetError`` for a payload that was already fetched
# and validated.
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(
json.dumps(catalog_data, indent=2), encoding="utf-8"
)
metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
}
metadata_file.write_text(
json.dumps(metadata, indent=2), encoding="utf-8"
)
except OSError:
pass # Cache is best-effort; proceed with fetched data
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(catalog_data, indent=2))
metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
}
metadata_file.write_text(json.dumps(metadata, indent=2))
return catalog_data
@@ -2212,17 +2127,6 @@ class PresetCatalog:
try:
data = self._fetch_single_catalog(entry, force_refresh)
for pack_id, pack_data in data.get("presets", {}).items():
# Per-entry guard: ``_fetch_single_catalog`` already
# validates that ``data["presets"]`` is a mapping, but it
# does not (and should not) validate every entry shape
# there — one malformed entry shouldn't poison an
# otherwise valid catalog. Skip non-mapping entries here
# so a payload like ``{"presets": {"foo": [], "bar":
# {...}}}`` still merges the valid entries without
# crashing on ``**pack_data``. Mirrors
# ``integrations/catalog.py:245``.
if not isinstance(pack_data, dict):
continue
pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed}
merged[pack_id] = pack_data_with_catalog
except PresetError:
@@ -2233,12 +2137,6 @@ class PresetCatalog:
def is_cache_valid(self) -> bool:
"""Check if cached catalog is still valid.
Returns ``False`` for any read/decoding failure on the metadata
file (missing fields, malformed JSON, permissions / disk errors,
wrong text encoding) so callers fall through to a network refetch
instead of crashing. Treating cache validity as best-effort
matches the contract used by ``_is_url_cache_valid`` above.
Returns:
True if cache exists and is within cache duration
"""
@@ -2246,9 +2144,7 @@ class PresetCatalog:
return False
try:
metadata = json.loads(
self.cache_metadata_file.read_text(encoding="utf-8")
)
metadata = json.loads(self.cache_metadata_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
@@ -2256,20 +2152,7 @@ class PresetCatalog:
datetime.now(timezone.utc) - cached_at
).total_seconds()
return age_seconds < self.CACHE_DURATION
except (
json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
KeyError,
TypeError,
AttributeError,
):
# ``AttributeError`` covers the case where the metadata file
# parses to a non-mapping (``[]``, ``"oops"``, ``42``) so
# ``metadata.get(...)`` would otherwise crash. All decode /
# shape failures degrade to "cache invalid" so the caller
# falls through to a network refetch.
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
return False
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
@@ -2286,61 +2169,35 @@ class PresetCatalog:
"""
catalog_url = self.get_catalog_url()
# Match the ``_fetch_single_catalog`` cache contract: a poisoned
# or unreadable cache silently falls through to a network refetch
# rather than crashing the caller. ``_validate_catalog_payload``
# is reused here so a cache written by an older client
# (pre-validation) is rejected and refreshed instead of returning
# the stale malformed payload.
if not force_refresh and self.is_cache_valid():
try:
metadata = json.loads(
self.cache_metadata_file.read_text(encoding="utf-8")
)
metadata = json.loads(self.cache_metadata_file.read_text())
if metadata.get("catalog_url") == catalog_url:
cached_data = json.loads(
self.cache_file.read_text(encoding="utf-8")
)
self._validate_catalog_payload(cached_data, catalog_url)
return cached_data
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
# Cache is corrupt, unreadable, or fails the shape check;
# fall through to network fetch.
return json.loads(self.cache_file.read_text())
except (json.JSONDecodeError, OSError):
# Cache is corrupt or unreadable; fall through to network fetch
pass
try:
with self._open_url(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read())
# Validate catalog structure. Reuses the same helper as
# ``_fetch_single_catalog`` so all three branches (root type,
# missing keys, nested-mapping type) stay consistent.
self._validate_catalog_payload(catalog_data, catalog_url)
if (
"schema_version" not in catalog_data
or "presets" not in catalog_data
):
raise PresetError("Invalid preset catalog format")
# Save to cache. Explicit UTF-8 on both writes mirrors the
# ``read_text(encoding="utf-8")`` on the read side and the
# ``integrations/catalog.py`` precedent — otherwise platforms
# whose default encoding isn't UTF-8 would write
# locale-encoded bytes the read path can't decode, forcing an
# unnecessary refetch on every invocation. Like the read
# side, the write is best-effort: an unwritable cache dir
# must not be re-raised as a ``PresetError`` for a payload
# that was already fetched and validated.
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file.write_text(
json.dumps(catalog_data, indent=2), encoding="utf-8"
)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog_url,
}
self.cache_metadata_file.write_text(
json.dumps(metadata, indent=2), encoding="utf-8"
)
except OSError:
pass # Cache is best-effort; proceed with fetched data
metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog_url,
}
self.cache_metadata_file.write_text(
json.dumps(metadata, indent=2)
)
return catalog_data
@@ -2451,7 +2308,7 @@ class PresetCatalog:
# Bundled presets without a download URL must be installed locally
if pack_info.get("bundled") and not pack_info.get("download_url"):
from ..extensions import REINSTALL_COMMAND
from .extensions import REINSTALL_COMMAND
raise PresetError(
f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
@@ -2770,7 +2627,7 @@ class PresetResolver:
if not self.extensions_dir.exists():
return None
from ..extensions import ExtensionManifest, ValidationError
from .extensions import ExtensionManifest, ValidationError
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
ext_dir = self.extensions_dir / ext_id
@@ -2996,7 +2853,7 @@ class PresetResolver:
ext_manifest_path = ext_dir / "extension.yml"
if ext_manifest_path.exists():
try:
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
from .extensions import ExtensionManifest, ValidationError as ExtValidationError
ext_manifest = ExtensionManifest(ext_manifest_path)
for cmd in ext_manifest.commands:
if cmd.get("name") == template_name:
@@ -3277,7 +3134,7 @@ class PresetResolver:
if top_fm:
top_frontmatter_text = (
"---\n"
+ dump_frontmatter(top_fm)
+ yaml.safe_dump(top_fm, sort_keys=False).strip()
+ "\n---"
)
else:

View File

@@ -1,711 +0,0 @@
"""specify preset * command handlers — app objects and register() entry point.
Moved out of __init__.py (PR-6/8). Handlers reference helpers that remain in
the package root (`_require_specify_project`, `get_speckit_version`,
`_locate_bundled_preset`, `_display_project_path`) via lazy `from .. import`
calls inside each function so test monkeypatching of `specify_cli.<helper>`
keeps working.
"""
from __future__ import annotations
import os
from pathlib import Path
import typer
import yaml
from .._console import console
preset_app = typer.Typer(
name="preset",
help="Manage spec-kit presets",
add_completion=False,
)
preset_catalog_app = typer.Typer(
name="catalog",
help="Manage preset catalogs",
add_completion=False,
)
preset_app.add_typer(preset_catalog_app, name="catalog")
# ===== Preset Commands =====
@preset_app.command("list")
def preset_list():
"""List installed presets."""
from .. import _require_specify_project
from . import PresetManager
project_root = _require_specify_project()
manager = PresetManager(project_root)
installed = manager.list_installed()
if not installed:
console.print("[yellow]No presets installed.[/yellow]")
console.print("\nInstall a preset with:")
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
return
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
for pack in installed:
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
pri = pack.get('priority', 10)
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']}{status} — priority {pri}")
console.print(f" {pack['description']}")
if pack.get("tags"):
tags_str = ", ".join(pack["tags"])
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
@preset_app.command("add")
def preset_add(
preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install a preset."""
from .. import _locate_bundled_preset, _require_specify_project, get_speckit_version
from . import (
PresetManager,
PresetCatalog,
PresetError,
PresetValidationError,
PresetCompatibilityError,
)
project_root = _require_specify_project()
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = PresetManager(project_root)
speckit_version = get_speckit_version()
try:
if dev:
dev_path = Path(dev).resolve()
if not dev_path.exists():
console.print(f"[red]Error:[/red] Directory not found: {dev}")
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...")
manifest = manager.install_from_directory(dev_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
elif from_url:
# Validate URL scheme before downloading
from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse
_parsed = _urlparse(from_url)
def _is_allowed_download_url(parsed_url):
host = parsed_url.hostname
if not host:
return False
is_loopback = host == "localhost"
if not is_loopback:
try:
is_loopback = ip_address(host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
def _validate_download_redirect(old_url, new_url):
if not _is_allowed_download_url(_urlparse(new_url)):
import urllib.error
raise urllib.error.URLError(
"redirect target must use HTTPS with a hostname, "
"or HTTP for localhost/loopback"
)
if not _is_allowed_download_url(_parsed):
console.print(
"[red]Error:[/red] URL must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
)
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
import urllib.error
import tempfile
import shutil
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
try:
from specify_cli.authentication.http import open_url as _open_url
from specify_cli._github_http import resolve_github_release_asset_api_url
_preset_extra_headers = None
_resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url)
if _resolved_from_url:
from_url = _resolved_from_url
_preset_extra_headers = {"Accept": "application/octet-stream"}
with _open_url(
from_url,
timeout=60,
extra_headers=_preset_extra_headers,
redirect_validator=_validate_download_redirect,
) as response:
final_url = response.geturl() if hasattr(response, "geturl") else from_url
if not _is_allowed_download_url(_urlparse(final_url)):
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
)
raise typer.Exit(1)
with zip_path.open("wb") as output:
try:
shutil.copyfileobj(response, output)
except TypeError:
output.write(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {e}")
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
elif preset_id:
# Try bundled preset first, then catalog
bundled_path = _locate_bundled_preset(preset_id)
if bundled_path:
console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...")
manifest = manager.install_from_directory(bundled_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
else:
catalog = PresetCatalog(project_root)
pack_info = catalog.get_pack_info(preset_id)
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog")
raise typer.Exit(1)
# Bundled presets should have been caught above; if we reach
# here the bundled files are missing from the installation.
if pack_info.get("bundled") and not pack_info.get("download_url"):
from ..extensions import REINSTALL_COMMAND
console.print(
f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"\nThis usually means the spec-kit installation is incomplete or corrupted."
)
console.print("Try reinstalling spec-kit:")
console.print(f" {REINSTALL_COMMAND}")
raise typer.Exit(1)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
raise typer.Exit(1)
console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
try:
zip_path = catalog.download_pack(preset_id)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
finally:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
else:
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)
except PresetCompatibilityError as e:
console.print(f"[red]Compatibility Error:[/red] {e}")
raise typer.Exit(1)
except PresetValidationError as e:
console.print(f"[red]Validation Error:[/red] {e}")
raise typer.Exit(1)
except PresetError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@preset_app.command("remove")
def preset_remove(
preset_id: str = typer.Argument(..., help="Preset ID to remove"),
):
"""Remove an installed preset."""
from .. import _require_specify_project
from . import PresetManager
project_root = _require_specify_project()
manager = PresetManager(project_root)
if not manager.registry.is_installed(preset_id):
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
raise typer.Exit(1)
if manager.remove(preset_id):
console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully")
else:
console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'")
raise typer.Exit(1)
@preset_app.command("search")
def preset_search(
query: str = typer.Argument(None, help="Search query"),
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
author: str = typer.Option(None, "--author", help="Filter by author"),
):
"""Search for presets in the catalog."""
from .. import _require_specify_project
from . import PresetCatalog, PresetError
project_root = _require_specify_project()
catalog = PresetCatalog(project_root)
try:
results = catalog.search(query=query, tag=tag, author=author)
except PresetError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
if not results:
console.print("[yellow]No presets found matching your criteria.[/yellow]")
return
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
for pack in results:
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
console.print(f" {pack.get('description', '')}")
if pack.get("tags"):
tags_str = ", ".join(pack["tags"])
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print()
@preset_app.command("resolve")
def preset_resolve(
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
):
"""Show which template will be resolved for a given name."""
from .. import _require_specify_project
from . import PresetResolver
project_root = _require_specify_project()
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name)
if layers:
# Use the highest-priority layer for display because the final output
# may be composed and may not map to resolve_with_source()'s single path.
display_layer = layers[0]
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
has_composition = (
layers[0]["strategy"] != "replace"
and any(layer["strategy"] != "replace" for layer in layers)
)
if has_composition:
# Verify composition is actually possible
try:
composed = resolver.resolve_content(template_name)
except Exception as exc:
composed = None
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
if composed is None:
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
else:
console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]")
console.print("\n [bold]Composition chain:[/bold]")
# Compute the effective base: first replace layer scanning from
# highest priority (matching resolve_content top-down logic).
# Only show layers from the base upward (lower layers are ignored).
effective_base_idx = None
for idx, lyr in enumerate(layers):
if lyr["strategy"] == "replace":
effective_base_idx = idx
break
# Show only contributing layers (base and above)
if effective_base_idx is not None:
contributing = layers[:effective_base_idx + 1]
else:
contributing = layers
for i, layer in enumerate(reversed(contributing)):
strategy_label = layer["strategy"]
if strategy_label == "replace" and i == 0:
strategy_label = "base"
console.print(f" {i + 1}. [{strategy_label}] {layer['source']}{layer['path']}")
else:
# No layers found — fall back to resolve_with_source for non-composition cases
result = resolver.resolve_with_source(template_name)
if result:
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
console.print(f" [dim](from: {result['source']})[/dim]")
else:
console.print(f" [yellow]{template_name}[/yellow]: not found")
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
@preset_app.command("info")
def preset_info(
preset_id: str = typer.Argument(..., help="Preset ID to get info about"),
):
"""Show detailed information about a preset."""
from .. import _require_specify_project
from ..extensions import normalize_priority
from . import PresetCatalog, PresetManager, PresetError
project_root = _require_specify_project()
# Check if installed locally first
manager = PresetManager(project_root)
local_pack = manager.get_pack(preset_id)
if local_pack:
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
console.print(f" ID: {local_pack.id}")
console.print(f" Version: {local_pack.version}")
console.print(f" Description: {local_pack.description}")
if local_pack.author:
console.print(f" Author: {local_pack.author}")
if local_pack.tags:
console.print(f" Tags: {', '.join(local_pack.tags)}")
console.print(f" Templates: {len(local_pack.templates)}")
for tmpl in local_pack.templates:
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
repo = local_pack.data.get("preset", {}).get("repository")
if repo:
console.print(f" Repository: {repo}")
license_val = local_pack.data.get("preset", {}).get("license")
if license_val:
console.print(f" License: {license_val}")
console.print("\n [green]Status: installed[/green]")
# Get priority from registry
pack_metadata = manager.registry.get(preset_id)
priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
console.print(f" [dim]Priority:[/dim] {priority}")
console.print()
return
# Fall back to catalog
catalog = PresetCatalog(project_root)
try:
pack_info = catalog.get_pack_info(preset_id)
except PresetError:
pack_info = None
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)")
raise typer.Exit(1)
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n")
console.print(f" ID: {pack_info['id']}")
console.print(f" Version: {pack_info.get('version', '?')}")
console.print(f" Description: {pack_info.get('description', '')}")
if pack_info.get("author"):
console.print(f" Author: {pack_info['author']}")
if pack_info.get("tags"):
console.print(f" Tags: {', '.join(pack_info['tags'])}")
if pack_info.get("repository"):
console.print(f" Repository: {pack_info['repository']}")
if pack_info.get("license"):
console.print(f" License: {pack_info['license']}")
console.print("\n [yellow]Status: not installed[/yellow]")
console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]")
console.print()
@preset_app.command("set-priority")
def preset_set_priority(
preset_id: str = typer.Argument(help="Preset ID"),
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
):
"""Set the resolution priority of an installed preset."""
from .. import _require_specify_project
from . import PresetManager
project_root = _require_specify_project()
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = PresetManager(project_root)
# Check if preset is installed
if not manager.registry.is_installed(preset_id):
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
raise typer.Exit(1)
# Get current metadata
metadata = manager.registry.get(preset_id)
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
from ..extensions import normalize_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)
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)
old_priority = normalize_priority(raw_priority)
# Update priority
manager.registry.update(preset_id, {"priority": priority})
console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority}{priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
@preset_app.command("enable")
def preset_enable(
preset_id: str = typer.Argument(help="Preset ID to enable"),
):
"""Enable a disabled preset."""
from .. import _require_specify_project
from . import PresetManager
project_root = _require_specify_project()
manager = PresetManager(project_root)
# Check if preset is installed
if not manager.registry.is_installed(preset_id):
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
raise typer.Exit(1)
# Get current metadata
metadata = manager.registry.get(preset_id)
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
if metadata.get("enabled", True):
console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]")
raise typer.Exit(0)
# Enable the preset
manager.registry.update(preset_id, {"enabled": True})
console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
console.print("\nTemplates from this preset will now be included in resolution.")
console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]")
@preset_app.command("disable")
def preset_disable(
preset_id: str = typer.Argument(help="Preset ID to disable"),
):
"""Disable a preset without removing it."""
from .. import _require_specify_project
from . import PresetManager
project_root = _require_specify_project()
manager = PresetManager(project_root)
# Check if preset is installed
if not manager.registry.is_installed(preset_id):
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
raise typer.Exit(1)
# Get current metadata
metadata = manager.registry.get(preset_id)
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
if not metadata.get("enabled", True):
console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]")
raise typer.Exit(0)
# Disable the preset
manager.registry.update(preset_id, {"enabled": False})
console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
console.print("\nTemplates from this preset will be skipped during resolution.")
console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]")
console.print(f"To re-enable: specify preset enable {preset_id}")
# ===== Preset Catalog Commands =====
@preset_catalog_app.command("list")
def preset_catalog_list():
"""List all active preset catalogs."""
from .. import _display_project_path, _require_specify_project
from . import PresetCatalog, PresetValidationError
project_root = _require_specify_project()
catalog = PresetCatalog(project_root)
try:
active_catalogs = catalog.get_active_catalogs()
except PresetValidationError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
for entry in active_catalogs:
install_str = (
"[green]install allowed[/green]"
if entry.install_allowed
else "[yellow]discovery only[/yellow]"
)
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
if entry.description:
console.print(f" {entry.description}")
console.print(f" URL: {entry.url}")
console.print(f" Install: {install_str}")
console.print()
config_path = project_root / ".specify" / "preset-catalogs.yml"
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]")
else:
try:
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
except PresetValidationError:
proj_loaded = False
if proj_loaded:
console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]")
else:
try:
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
except PresetValidationError:
user_loaded = False
if user_loaded:
console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
else:
console.print("[dim]Using built-in default catalog stack.[/dim]")
console.print(
"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
)
@preset_catalog_app.command("add")
def preset_catalog_add(
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
name: str = typer.Option(..., "--name", help="Catalog name"),
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
install_allowed: bool = typer.Option(
False, "--install-allowed/--no-install-allowed",
help="Allow presets from this catalog to be installed",
),
description: str = typer.Option("", "--description", help="Description of the catalog"),
):
"""Add a catalog to .specify/preset-catalogs.yml."""
from .. import _display_project_path, _require_specify_project
from . import PresetCatalog, PresetValidationError
project_root = _require_specify_project()
specify_dir = project_root / ".specify"
# Validate URL
tmp_catalog = PresetCatalog(project_root)
try:
tmp_catalog._validate_catalog_url(url)
except PresetValidationError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
config_path = specify_dir / "preset-catalogs.yml"
# Load existing config
if config_path.exists():
try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception as e:
config_label = _display_project_path(project_root, config_path)
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
raise typer.Exit(1)
else:
config = {}
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
# Check for duplicate name
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
raise typer.Exit(1)
catalogs.append({
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
})
config["catalogs"] = catalogs
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
install_label = "install allowed" if install_allowed else "discovery only"
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
console.print(f" URL: {url}")
console.print(f" Priority: {priority}")
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
@preset_catalog_app.command("remove")
def preset_catalog_remove(
name: str = typer.Argument(help="Catalog name to remove"),
):
"""Remove a catalog from .specify/preset-catalogs.yml."""
from .. import _require_specify_project
project_root = _require_specify_project()
specify_dir = project_root / ".specify"
config_path = specify_dir / "preset-catalogs.yml"
if not config_path.exists():
console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.")
raise typer.Exit(1)
try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception:
console.print("[red]Error:[/red] Failed to read preset catalog config.")
raise typer.Exit(1)
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
original_count = len(catalogs)
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
raise typer.Exit(1)
config["catalogs"] = catalogs
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
console.print(f"[green]✓[/green] Removed catalog '{name}'")
if not catalogs:
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
def register(app: typer.Typer) -> None:
"""Attach the preset command group to the root Typer app."""
app.add_typer(preset_app, name="preset")

View File

@@ -313,8 +313,6 @@ def install_shared_infra(
expected = prior_hashes.get(rel)
if not expected or not dst.is_file() or dst.is_symlink():
return False
if manifest.is_recovered(rel):
return False
try:
return _sha256(dst) == expected
except OSError:

View File

@@ -7,12 +7,10 @@ Provides:
- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances.
- ``WorkflowEngine`` — orchestrator that loads, validates, and executes
workflow YAML definitions.
- ``load_custom_steps`` — loads community-installed step types into STEP_REGISTRY.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -68,134 +66,3 @@ def _register_builtin_steps() -> None:
_register_builtin_steps()
def load_custom_steps(project_root: Path) -> list[str]:
"""Load community-installed custom step types into STEP_REGISTRY.
Scans ``.specify/workflows/steps/`` for installed step packages.
Each valid package must contain ``step.yml`` (with a ``step.type_key``
field) and ``__init__.py`` (a ``StepBase`` subclass).
Returns a list of type_keys that were successfully loaded.
Silently skips packages that fail to import or validate.
"""
import hashlib as _hashlib
import importlib.util as _importlib_util
import re as _re
import sys as _sys
steps_dir = Path(project_root) / ".specify" / "workflows" / "steps"
# Defense-in-depth: refuse to execute step code from a symlinked
# parent directory under .specify/workflows/steps, which could redirect
# the import outside the project root and bypass the install-time
# symlink guard. Check symlinks *before* is_dir() since the latter
# follows symlinks and would stat an external target.
_current = Path(project_root)
for _part in (".specify", "workflows", "steps"):
_current = _current / _part
if _current.is_symlink():
return []
if not steps_dir.is_dir():
return []
loaded: list[str] = []
for step_dir in steps_dir.iterdir():
# Check symlinks before is_dir() since the latter follows symlinks
# and would stat an external target through a symlinked directory.
if step_dir.is_symlink():
continue
if not step_dir.is_dir():
continue
step_yml = step_dir / "step.yml"
init_py = step_dir / "__init__.py"
if step_yml.is_symlink() or init_py.is_symlink():
continue
if not step_yml.is_file() or not init_py.is_file():
continue
try:
import yaml as _yaml
meta = _yaml.safe_load(step_yml.read_text(encoding="utf-8")) or {}
step_meta = meta.get("step", {})
type_key = step_meta.get("type_key", "")
if not type_key:
continue
# Skip if already registered (e.g. built-in or previously loaded)
if type_key in STEP_REGISTRY:
continue
# Sanitize type_key so the synthetic module name is a valid identifier
# (e.g. "test-custom" → "_speckit_custom_step_test_custom_<hash>").
# The 8-char SHA-256 hash of the original type_key makes the name
# collision-resistant when different type_keys produce the same
# sanitized form (e.g. "a-b" and "a_b" both sanitize to "a_b" but
# have different hashes).
safe_key = _re.sub(r"[^A-Za-z0-9_]", "_", type_key)
key_hash = _hashlib.sha256(type_key.encode()).hexdigest()[:8]
module_name = f"_speckit_custom_step_{safe_key}_{key_hash}"
# Treat the step directory as a proper package so that relative
# imports inside the step (e.g. ``from .helpers import …``) work.
spec = _importlib_util.spec_from_file_location(
module_name,
init_py,
submodule_search_locations=[str(step_dir)],
)
if spec is None or spec.loader is None:
continue
module = _importlib_util.module_from_spec(spec)
module.__package__ = module_name
# Register before exec so relative imports resolve correctly.
_sys.modules[module_name] = module
registered = False
try:
spec.loader.exec_module(module) # type: ignore[union-attr]
# Find the StepBase subclass in the module
from .base import StepBase as _StepBase
step_class = None
for attr_name in dir(module):
attr = getattr(module, attr_name)
try:
if (
isinstance(attr, type)
and issubclass(attr, _StepBase)
and attr is not _StepBase
and getattr(attr, "type_key", "") == type_key
):
step_class = attr
break
except TypeError:
continue
if step_class is None:
continue
_register_step(step_class())
loaded.append(type_key)
registered = True
finally:
# If the step wasn't successfully registered (failed import,
# no matching StepBase subclass, or registration error), remove
# the synthetic module — and any submodules loaded via relative
# imports (e.g. ``from .helpers import …``) — from sys.modules so
# a broken/skipped step package leaves no lingering import state
# behind.
if not registered:
_sys.modules.pop(module_name, None)
submodule_prefix = module_name + "."
for _mod_key in [
k for k in _sys.modules if k.startswith(submodule_prefix)
]:
_sys.modules.pop(_mod_key, None)
except Exception: # noqa: BLE001
# Silently skip broken step packages at load time
continue
return loaded

View File

@@ -1,10 +1,9 @@
"""Workflow catalog — discovery, install, and management of workflows and step types.
"""Workflow catalog — discovery, install, and management of workflows.
Mirrors the existing extension/preset catalog pattern with:
- Multi-catalog stack (env var → project → user → built-in)
- SHA256-hashed per-URL caching with 1-hour TTL
- Workflow registry for installed workflow tracking
- Step registry for installed custom step type tracking
- Search across all configured catalog sources
"""
@@ -166,7 +165,7 @@ class WorkflowCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.hostname:
if not parsed.netloc:
raise WorkflowValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -182,11 +181,6 @@ class WorkflowCatalog:
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise WorkflowValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
if not isinstance(data, dict):
raise WorkflowValidationError(
f"Invalid catalog config: expected a mapping, "
f"got {type(data).__name__}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
@@ -308,9 +302,9 @@ class WorkflowCatalog:
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
fetched_at = float(meta.get("fetched_at", 0))
fetched_at = meta.get("fetched_at", 0)
return (time.time() - fetched_at) < self.CACHE_DURATION
except (json.JSONDecodeError, OSError, TypeError, ValueError):
except (json.JSONDecodeError, OSError):
return False
def _fetch_single_catalog(
@@ -324,7 +318,6 @@ class WorkflowCatalog:
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
# Ignore invalid/unreadable cache and fall back to fetching from source.
pass
# Fetch from URL — validate scheme before opening and after redirects
@@ -340,10 +333,6 @@ class WorkflowCatalog:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
if not parsed.hostname:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)
_validate_catalog_url(entry.url)
@@ -358,7 +347,6 @@ class WorkflowCatalog:
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, ValueError, OSError):
# Stale-cache read failed; let the original fetch error propagate.
pass
raise WorkflowCatalogError(
f"Failed to fetch catalog from {entry.url}: {exc}"
@@ -370,14 +358,11 @@ class WorkflowCatalog:
)
# Write cache
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
with open(meta_file, "w", encoding="utf-8") as f:
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
except OSError:
pass # Proceed without caching if disk write fails
self.cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
with open(meta_file, "w", encoding="utf-8") as f:
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
return data
@@ -483,14 +468,7 @@ class WorkflowCatalog:
data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise WorkflowValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
if raw is None:
raw = {"catalogs": []}
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise WorkflowValidationError(
"Catalog config file is corrupted (expected a mapping)."
@@ -509,21 +487,9 @@ class WorkflowCatalog:
f"Catalog URL already configured: {url}"
)
# Derive priority from the highest existing priority + 1.
# Coerce existing priorities to int with a safe fallback so a user-edited
# workflow-catalogs.yml with a non-integer priority (e.g. "1") doesn't blow up.
def _coerce_priority(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
# Derive priority from the highest existing priority + 1
max_priority = max(
(
_coerce_priority(cat.get("priority", 0))
for cat in catalogs
if isinstance(cat, dict)
),
(cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)),
default=0,
)
catalogs.append(
@@ -537,14 +503,9 @@ class WorkflowCatalog:
)
data["catalogs"] = catalogs
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
except OSError as exc:
raise WorkflowValidationError(
f"Failed to write catalog config {config_path}: {exc}"
) from exc
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
def remove_catalog(self, index: int) -> str:
"""Remove a catalog source by index (0-based). Returns the removed name."""
@@ -552,12 +513,7 @@ class WorkflowCatalog:
if not config_path.exists():
raise WorkflowValidationError("No catalog config file found.")
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise WorkflowValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise WorkflowValidationError(
"Catalog config file is corrupted (expected a mapping)."
@@ -576,623 +532,8 @@ class WorkflowCatalog:
removed = catalogs.pop(index)
data["catalogs"] = catalogs
try:
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
except OSError as exc:
raise WorkflowValidationError(
f"Failed to write catalog config {config_path}: {exc}"
) from exc
if isinstance(removed, dict):
return removed.get("name", f"catalog-{index + 1}")
return f"catalog-{index + 1}"
# ---------------------------------------------------------------------------
# Step catalog errors
# ---------------------------------------------------------------------------
class StepCatalogError(Exception):
"""Base error for step catalog operations."""
class StepValidationError(StepCatalogError):
"""Validation error for step catalog config or step data."""
# ---------------------------------------------------------------------------
# StepCatalogEntry
# ---------------------------------------------------------------------------
@dataclass
class StepCatalogEntry:
"""Represents a single step catalog source in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
description: str = ""
# ---------------------------------------------------------------------------
# StepRegistry
# ---------------------------------------------------------------------------
class StepRegistry:
"""Manages the registry of installed custom step types.
Tracks installed step types and their metadata in
``.specify/workflows/steps/step-registry.json``.
"""
REGISTRY_FILE = "step-registry.json"
SCHEMA_VERSION = "1.0"
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.steps_dir = project_root / ".specify" / "workflows" / "steps"
self.registry_path = self.steps_dir / self.REGISTRY_FILE
self.data = self._load()
def _has_symlinked_parent(self) -> bool:
"""Return True if any directory under .specify/workflows/steps is a symlink."""
current = self.project_root
for part in (".specify", "workflows", "steps"):
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, "steps": {}}
# Defense-in-depth: refuse to read the registry if any parent directory
# under .specify/workflows/steps is a symlink, which could redirect the
# read outside the project root.
if self._has_symlinked_parent():
return default_registry
# Defense-in-depth: also refuse to read a symlinked registry file,
# which could redirect the read outside the project root.
if self.registry_path.is_symlink():
return default_registry
if self.registry_path.exists():
try:
with open(self.registry_path, encoding="utf-8") as f:
data = json.load(f)
# Validate shape: must be a dict with a dict "steps" field
if not isinstance(data, dict):
return default_registry
if not isinstance(data.get("steps"), dict):
data["steps"] = {}
return data
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
return default_registry
return default_registry
def save(self) -> None:
"""Persist registry to disk.
Raises ``StepValidationError`` with a clear message on filesystem
errors (read-only fs, permission denied, ...) so callers can surface
a clean error to the user rather than an unhandled ``OSError``.
"""
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise StepValidationError(
"Refusing to write step registry through a symlinked path."
)
try:
self.steps_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
except OSError as exc:
raise StepValidationError(
f"Failed to write step registry at {self.registry_path}: {exc}"
) from exc
def add(self, step_id: str, metadata: dict[str, Any]) -> None:
"""Add or update an installed step entry."""
import copy
from datetime import datetime, timezone
existing = self.data["steps"].get(step_id, {})
metadata_to_store = copy.deepcopy(metadata)
metadata_to_store["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat()
)
metadata_to_store["updated_at"] = datetime.now(timezone.utc).isoformat()
self.data["steps"][step_id] = metadata_to_store
self.save()
def remove(self, step_id: str) -> bool:
"""Remove an installed step entry. Returns True if found."""
if step_id in self.data["steps"]:
del self.data["steps"][step_id]
self.save()
return True
return False
def get(self, step_id: str) -> dict[str, Any] | None:
"""Get metadata for an installed step."""
return self.data["steps"].get(step_id)
def list(self) -> dict[str, dict[str, Any]]:
"""Return all installed steps."""
return dict(self.data["steps"])
def is_installed(self, step_id: str) -> bool:
"""Check if a step is installed."""
return step_id in self.data["steps"]
# ---------------------------------------------------------------------------
# StepCatalog
# ---------------------------------------------------------------------------
class StepCatalog:
"""Manages step catalog fetching, caching, and searching.
Resolution order for catalog sources:
1. ``SPECKIT_STEP_CATALOG_URL`` env var (overrides all)
2. Project-level ``.specify/step-catalogs.yml``
3. User-level ``~/.specify/step-catalogs.yml``
4. Built-in defaults (official + community)
"""
DEFAULT_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
"workflows/step-catalog.json"
)
COMMUNITY_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
"workflows/step-catalog.community.json"
)
CACHE_DURATION = 3600 # 1 hour
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.steps_dir = project_root / ".specify" / "workflows" / "steps"
self.cache_dir = self.steps_dir / ".cache"
def _is_cache_path_safe(self) -> bool:
"""Return False if any component of the cache path is a symlink."""
current = self.project_root
for part in (".specify", "workflows", "steps", ".cache"):
current = current / part
if current.is_symlink():
return False
return True
# -- Catalog resolution -----------------------------------------------
def _validate_catalog_url(self, url: str) -> None:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
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 StepValidationError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.hostname:
raise StepValidationError(
"Catalog URL must be a valid URL with a host."
)
def _load_catalog_config(
self, config_path: Path
) -> list[StepCatalogEntry] | None:
"""Load catalog stack configuration from a YAML file."""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise StepValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
if not isinstance(data, dict):
raise StepValidationError(
f"Invalid catalog config: expected a mapping, "
f"got {type(data).__name__}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
return None
if not isinstance(catalogs_data, list):
raise StepValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
entries: list[StepCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise StepValidationError(
f"Invalid catalog entry at index {idx}: "
f"expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise StepValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in (
"true",
"yes",
"1",
)
else:
install_allowed = bool(raw_install)
entries.append(
StepCatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
)
)
entries.sort(key=lambda e: e.priority)
if not entries:
raise StepValidationError(
f"Catalog config {config_path} contains {len(catalogs_data)} "
f"entries but none have valid URLs."
)
return entries
def get_active_catalogs(self) -> list[StepCatalogEntry]:
"""Get the ordered list of active step catalogs."""
# 1. Environment variable override
env_url = os.environ.get("SPECKIT_STEP_CATALOG_URL", "").strip()
if env_url:
self._validate_catalog_url(env_url)
return [
StepCatalogEntry(
url=env_url,
name="env-override",
priority=1,
install_allowed=True,
description="From SPECKIT_STEP_CATALOG_URL",
)
]
# 2. Project-level config
project_config = self.project_root / ".specify" / "step-catalogs.yml"
project_entries = self._load_catalog_config(project_config)
if project_entries is not None:
return project_entries
# 3. User-level config
home = Path.home()
user_config = home / ".specify" / "step-catalogs.yml"
user_entries = self._load_catalog_config(user_config)
if user_entries is not None:
return user_entries
# 4. Built-in defaults
return [
StepCatalogEntry(
url=self.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
description="Official step types",
),
StepCatalogEntry(
url=self.COMMUNITY_CATALOG_URL,
name="community",
priority=2,
install_allowed=False,
description="Community-contributed step types (discovery only)",
),
]
# -- Caching ----------------------------------------------------------
def _get_cache_paths(self, url: str) -> tuple[Path, Path]:
"""Get cache file paths for a URL (hash-based)."""
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
cache_file = self.cache_dir / f"step-catalog-{url_hash}.json"
meta_file = self.cache_dir / f"step-catalog-{url_hash}-meta.json"
return cache_file, meta_file
def _is_url_cache_valid(self, url: str) -> bool:
"""Check if cached data for a URL is still fresh."""
_, meta_file = self._get_cache_paths(url)
if not meta_file.exists():
return False
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
fetched_at = float(meta.get("fetched_at", 0))
return (time.time() - fetched_at) < self.CACHE_DURATION
except (json.JSONDecodeError, OSError, TypeError, ValueError):
return False
def _fetch_single_catalog(
self, entry: StepCatalogEntry, force_refresh: bool = False
) -> dict[str, Any]:
"""Fetch a single catalog, using cache when possible."""
cache_safe = self._is_cache_path_safe()
cache_file, meta_file = self._get_cache_paths(entry.url)
if cache_safe and not force_refresh and self._is_url_cache_valid(entry.url):
try:
with open(cache_file, encoding="utf-8") as f:
cached = json.load(f)
if isinstance(cached, dict):
return cached
except (json.JSONDecodeError, OSError):
# Ignore invalid/unreadable cache and fall back to fetching from source.
pass
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
def _validate_url(url: str) -> None:
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 parsed.hostname:
raise StepCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)
_validate_url(entry.url)
try:
with _open_url(entry.url, timeout=30) as resp:
_validate_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:
if cache_safe and cache_file.exists():
try:
with open(cache_file, encoding="utf-8") as f:
cached = json.load(f)
if isinstance(cached, dict):
return cached
except (json.JSONDecodeError, ValueError, OSError):
# Stale-cache read failed; let the original fetch error propagate.
pass
raise StepCatalogError(
f"Failed to fetch catalog from {entry.url}: {exc}"
) from exc
if not isinstance(data, dict):
raise StepCatalogError(
f"Catalog from {entry.url} is not a valid JSON object."
)
if cache_safe:
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
with open(meta_file, "w", encoding="utf-8") as f:
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
except OSError:
pass # Proceed without caching if disk write fails
return data
def _get_merged_steps(
self, force_refresh: bool = False
) -> dict[str, dict[str, Any]]:
"""Merge steps from all active catalogs (lower priority number wins)."""
catalogs = self.get_active_catalogs()
merged: dict[str, dict[str, Any]] = {}
fetch_errors = 0
for entry in reversed(catalogs):
try:
data = self._fetch_single_catalog(entry, force_refresh)
except StepCatalogError:
fetch_errors += 1
continue
steps = data.get("steps", {})
if isinstance(steps, dict):
for step_id, step_data in steps.items():
if not isinstance(step_data, dict):
continue
step_data["_catalog_name"] = entry.name
step_data["_install_allowed"] = entry.install_allowed
merged[step_id] = step_data
elif isinstance(steps, list):
for step_data in steps:
if not isinstance(step_data, dict):
continue
raw_step_id = step_data.get("id")
if raw_step_id is None:
continue
step_id = str(raw_step_id).strip()
if step_id:
step_data["id"] = step_id
step_data["_catalog_name"] = entry.name
step_data["_install_allowed"] = entry.install_allowed
merged[step_id] = step_data
if fetch_errors == len(catalogs) and catalogs:
raise StepCatalogError("All configured step catalogs failed to fetch.")
return merged
# -- Public API -------------------------------------------------------
def search(
self,
query: str | None = None,
) -> list[dict[str, Any]]:
"""Search step types across all configured catalogs."""
merged = self._get_merged_steps()
results: list[dict[str, Any]] = []
for step_id, step_data in merged.items():
step_data.setdefault("id", step_id)
if query:
q = query.lower()
searchable = " ".join(
[
str(step_data.get("name") or ""),
str(step_data.get("description") or ""),
str(step_data.get("id") or ""),
]
).lower()
if q not in searchable:
continue
results.append(step_data)
return results
def get_step_info(self, step_id: str) -> dict[str, Any] | None:
"""Get details for a specific step from the catalog."""
merged = self._get_merged_steps()
step = merged.get(step_id)
if step:
step.setdefault("id", step_id)
return step
def get_catalog_configs(self) -> list[dict[str, Any]]:
"""Return current catalog configuration as a list of dicts."""
entries = self.get_active_catalogs()
return [
{
"name": e.name,
"url": e.url,
"priority": e.priority,
"install_allowed": e.install_allowed,
"description": e.description,
}
for e in entries
]
def add_catalog(self, url: str, name: str | None = None) -> None:
"""Add a catalog source to the project-level config."""
self._validate_catalog_url(url)
config_path = self.project_root / ".specify" / "step-catalogs.yml"
data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise StepValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
if not isinstance(raw, dict):
raise StepValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
data = raw
catalogs = data.get("catalogs", [])
if not isinstance(catalogs, list):
raise StepValidationError(
"Catalog config 'catalogs' must be a list."
)
for cat in catalogs:
if isinstance(cat, dict) and cat.get("url") == url:
raise StepValidationError(
f"Catalog URL already configured: {url}"
)
# Coerce existing priorities to int with a safe fallback so a user-edited
# step-catalogs.yml with a non-integer priority (e.g. "1") doesn't blow up.
def _coerce_priority(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
max_priority = max(
(
_coerce_priority(cat.get("priority", 0))
for cat in catalogs
if isinstance(cat, dict)
),
default=0,
)
catalogs.append(
{
"name": name or f"catalog-{len(catalogs) + 1}",
"url": url,
"priority": max_priority + 1,
"install_allowed": True,
"description": "",
}
)
data["catalogs"] = catalogs
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(
data, f, default_flow_style=False, sort_keys=False, allow_unicode=True
)
except OSError as exc:
raise StepValidationError(
f"Failed to write catalog config {config_path}: {exc}"
) from exc
def remove_catalog(self, index: int) -> str:
"""Remove a catalog source by index (0-based). Returns the removed name."""
config_path = self.project_root / ".specify" / "step-catalogs.yml"
if not config_path.exists():
raise StepValidationError("No step catalog config file found.")
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise StepValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
if not isinstance(data, dict):
raise StepValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
catalogs = data.get("catalogs", [])
if not isinstance(catalogs, list):
raise StepValidationError(
"Catalog config 'catalogs' must be a list."
)
if index < 0 or index >= len(catalogs):
raise StepValidationError(
f"Catalog index {index} out of range (0-{len(catalogs) - 1})."
)
removed = catalogs.pop(index)
data["catalogs"] = catalogs
try:
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(
data, f, default_flow_style=False, sort_keys=False, allow_unicode=True
)
except OSError as exc:
raise StepValidationError(
f"Failed to write catalog config {config_path}: {exc}"
) from exc
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
if isinstance(removed, dict):
return removed.get("name", f"catalog-{index + 1}")

View File

@@ -22,28 +22,12 @@ class FanOutStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
items_expr = config.get("items", "[]")
items = evaluate_expression(items_expr, context)
if not isinstance(items, list):
items = []
max_concurrency = config.get("max_concurrency", 1)
step_template = config.get("step", {})
if not isinstance(items, list):
# A non-list here is a wiring error (the expression did not
# resolve to a collection); silently fanning out over zero
# items hides it. An explicit empty list remains valid input.
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-out step {config.get('id', '?')!r}: 'items' must "
f"resolve to a list, got {type(items).__name__} from "
f"{items_expr!r}."
),
output={
"items": [],
"max_concurrency": max_concurrency,
"step_template": step_template,
"item_count": 0,
},
)
return StepResult(
status=StepStatus.COMPLETED,
output={

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import subprocess
from typing import Any
@@ -50,23 +49,6 @@ class ShellStep(StepBase):
error=f"Shell command exited with code {proc.returncode}.",
output=output,
)
if config.get("output_format") == "json":
# Opt-in structured output: expose the parsed stdout under
# ``output.data`` so later steps can consume typed values
# (e.g. a fan-out's ``items:``). A parse failure fails the
# step — declaring ``output_format: json`` is a contract.
try:
output["data"] = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
return StepResult(
status=StepStatus.FAILED,
error=(
f"Shell step {config.get('id', '?')!r} declared "
f"output_format: json but stdout is not valid "
f"JSON: {exc}"
),
output=output,
)
return StepResult(
status=StepStatus.COMPLETED,
output=output,
@@ -90,10 +72,4 @@ class ShellStep(StepBase):
errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
)
output_format = config.get("output_format")
if output_format is not None and output_format != "json":
errors.append(
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
f"be 'json' when present, got {output_format!r}."
)
return errors

View File

@@ -83,12 +83,11 @@ Given that feature description, do this:
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
2. Otherwise, auto-generate it under `specs/`:
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
- Check `.specify/init-options.json` for `branch_numbering`
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
**Create the directory and spec file**:
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`

View File

@@ -3,7 +3,7 @@ Tests for the bundled git extension (extensions/git/).
Validates:
- extension.yml manifest
- Bash scripts (create-new-feature-branch.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
- PowerShell scripts (where pwsh is available)
- Config reading from git-config.yml
- Extension install via ExtensionManager
@@ -193,11 +193,11 @@ class TestGitExtensionInstall:
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
ext_installed = tmp_path / ".specify" / "extensions" / "git"
assert (ext_installed / "scripts" / "bash" / "create-new-feature-branch.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
assert (ext_installed / "scripts" / "powershell" / "create-new-feature-branch.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
@@ -270,16 +270,16 @@ class TestInitializeRepoPowerShell:
assert result.returncode == 0
# ── create-new-feature-branch.sh Tests ──────────────────────────────────────────────
# ── create-new-feature.sh Tests ──────────────────────────────────────────────
@requires_bash
class TestCreateFeatureBash:
def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature-branch.sh creates sequential branch."""
"""Extension create-new-feature.sh creates sequential branch."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature-branch.sh", project,
"create-new-feature.sh", project,
"--json", "--short-name", "user-auth", "Add user authentication",
)
assert result.returncode == 0, result.stderr
@@ -288,10 +288,10 @@ class TestCreateFeatureBash:
assert data["FEATURE_NUM"] == "001"
def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature-branch.sh creates timestamp branch."""
"""Extension create-new-feature.sh creates timestamp branch."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature-branch.sh", project,
"create-new-feature.sh", project,
"--json", "--timestamp", "--short-name", "feat", "Feature",
)
assert result.returncode == 0, result.stderr
@@ -305,7 +305,7 @@ class TestCreateFeatureBash:
(project / "specs" / "002-second").mkdir(parents=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"create-new-feature.sh", project,
"--json", "--short-name", "third", "Third feature",
)
assert result.returncode == 0, result.stderr
@@ -313,10 +313,10 @@ class TestCreateFeatureBash:
assert data["FEATURE_NUM"] == "003"
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature-branch.sh works without git (outputs branch name, skips branch creation)."""
"""create-new-feature.sh works without git (outputs branch name, skips branch creation)."""
project = _setup_project(tmp_path, git=False)
result = _run_bash(
"create-new-feature-branch.sh", project,
"create-new-feature.sh", project,
"--json", "--short-name", "no-git", "No git feature",
)
assert result.returncode == 0, result.stderr
@@ -329,7 +329,7 @@ class TestCreateFeatureBash:
"""--dry-run computes branch name without creating anything."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature-branch.sh", project,
"create-new-feature.sh", project,
"--json", "--dry-run", "--short-name", "dry", "Dry run test",
)
assert result.returncode == 0, result.stderr
@@ -341,10 +341,10 @@ class TestCreateFeatureBash:
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell:
def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature-branch.ps1 creates sequential branch."""
"""Extension create-new-feature.ps1 creates sequential branch."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"create-new-feature.ps1", project,
"-Json", "-ShortName", "user-auth", "Add user authentication",
)
assert result.returncode == 0, result.stderr
@@ -352,10 +352,10 @@ class TestCreateFeaturePowerShell:
assert data["BRANCH_NAME"] == "001-user-auth"
def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature-branch.ps1 creates timestamp branch."""
"""Extension create-new-feature.ps1 creates timestamp branch."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"create-new-feature.ps1", project,
"-Json", "-Timestamp", "-ShortName", "feat", "Feature",
)
assert result.returncode == 0, result.stderr
@@ -363,10 +363,10 @@ class TestCreateFeaturePowerShell:
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature-branch.ps1 works without git."""
"""create-new-feature.ps1 works without git."""
project = _setup_project(tmp_path, git=False)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"create-new-feature.ps1", project,
"-Json", "-ShortName", "no-git", "No git feature",
)
assert result.returncode == 0, result.stderr

View File

@@ -63,7 +63,7 @@ class TestInitIntegrationFlag:
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -111,7 +111,7 @@ class TestInitIntegrationFlag:
runner = CliRunner()
project = tmp_path / "noninteractive"
result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--ignore-agent-tools",
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
assert result.exit_code == 0, result.output
@@ -131,7 +131,7 @@ class TestInitIntegrationFlag:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -160,6 +160,7 @@ class TestInitIntegrationFlag:
"copilot",
"--script",
"sh",
"--no-git",
"--preset",
"lean",
],
@@ -191,7 +192,7 @@ class TestInitIntegrationFlag:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--ignore-agent-tools",
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -632,6 +633,7 @@ class TestInitIntegrationFlag:
"init", "--here", "--force",
"--integration", "copilot",
"--script", "sh",
"--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -661,6 +663,7 @@ class TestInitIntegrationFlag:
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
], input="y\n", catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -689,7 +692,7 @@ class TestForceExistingDirectory:
runner = CliRunner()
result = runner.invoke(app, [
"init", str(target), "--integration", "copilot", "--force",
"--script", "sh",
"--no-git", "--script", "sh",
], catch_exceptions=False)
assert result.exit_code == 0, f"init --force failed: {result.output}"
@@ -712,22 +715,22 @@ class TestForceExistingDirectory:
runner = CliRunner()
result = runner.invoke(app, [
"init", str(target), "--integration", "copilot",
"--script", "sh",
"--no-git", "--script", "sh",
], catch_exceptions=False)
assert result.exit_code == 1
assert "already exists" in _normalize_cli_output(result.output)
class TestGitExtensionOptIn:
"""Tests verifying that the git extension is opt-in (not auto-installed) during specify init."""
class TestGitExtensionAutoInstall:
"""Tests for auto-installation of the git extension during specify init."""
def test_git_extension_not_auto_installed(self, tmp_path):
"""Git extension is NOT installed automatically during init."""
def test_git_extension_auto_installed(self, tmp_path):
"""Without --no-git, the git extension is installed during init."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-opt-in"
project = tmp_path / "git-auto"
project.mkdir()
old_cwd = os.getcwd()
try:
@@ -742,16 +745,30 @@ class TestGitExtensionOptIn:
assert result.exit_code == 0, f"init failed: {result.output}"
# Git extension directory should NOT be present after init
ext_dir = project / ".specify" / "extensions" / "git"
assert not ext_dir.exists(), "git extension should not be auto-installed"
# Check that the tracker didn't report a git error
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
def test_no_git_flag_is_rejected(self, tmp_path):
"""--no-git flag has been removed; passing it should fail."""
# Git extension files should be installed
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not installed"
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
# Hooks should be registered
extensions_yml = project / ".specify" / "extensions.yml"
assert extensions_yml.exists(), "extensions.yml not created"
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
assert "hooks" in hooks_data
assert "before_specify" in hooks_data["hooks"]
assert "before_constitution" in hooks_data["hooks"]
def test_no_git_skips_extension(self, tmp_path):
"""With --no-git, the git extension is NOT installed."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "no-git-rejected"
project = tmp_path / "no-git"
project.mkdir()
old_cwd = os.getcwd()
try:
@@ -760,19 +777,75 @@ class TestGitExtensionOptIn:
result = runner.invoke(app, [
"init", "--here", "--integration", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
])
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0, "--no-git should be rejected as an unknown option"
assert "No such option" in result.output or "no such option" in result.output.lower()
assert result.exit_code == 0, f"init failed: {result.output}"
def test_git_extension_commands_not_registered_by_default(self, tmp_path):
"""Git extension commands are NOT registered with the agent during default init."""
# Git extension should NOT be installed
ext_dir = project / ".specify" / "extensions" / "git"
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
def test_no_git_emits_deprecation_warning(self, tmp_path):
"""Using --no-git emits a visible deprecation warning."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-cmds-absent"
project = tmp_path / "no-git-warn"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "--no-git" in normalized_output
assert "deprecated" in normalized_output
assert "0.10.0" in normalized_output
assert "specify extension" in normalized_output
assert "will be removed" in normalized_output
assert "git extension will no longer be enabled by default" in normalized_output
def test_default_git_auto_enable_emits_notice(self, tmp_path):
"""Default git auto-enable emits notice about the v0.10.0 opt-in change."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-default-notice"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "claude", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
# Check for key message components (notice may have box-drawing chars)
assert "git extension is currently enabled by default" in normalized_output
assert "v0.10.0" in normalized_output
assert "explicit opt-in" in normalized_output
assert "specify extension add git" in normalized_output
def test_git_extension_commands_registered(self, tmp_path):
"""Git extension commands are registered with the agent during init."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-cmds"
project.mkdir()
old_cwd = os.getcwd()
try:
@@ -787,11 +860,11 @@ class TestGitExtensionOptIn:
assert result.exit_code == 0, f"init failed: {result.output}"
# Git extension skill commands should NOT be present
# Git extension commands should be registered with the agent
claude_skills = project / ".claude" / "skills"
assert claude_skills.exists(), "Claude skills directory was not created"
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
assert len(git_skills) == 0, "git extension commands should not be registered by default"
assert len(git_skills) > 0, "no git extension commands registered"
class TestSharedInfraCommandRefs:
@@ -910,6 +983,7 @@ class TestSharedInfraCommandRefs:
"init", str(project),
"--integration", "claude",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
@@ -940,6 +1014,7 @@ class TestSharedInfraCommandRefs:
"init", str(project),
"--integration", "copilot",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
@@ -971,6 +1046,7 @@ class TestSharedInfraCommandRefs:
"--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:

View File

@@ -39,7 +39,7 @@ class TestAgyInitFlow:
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
@@ -52,7 +52,7 @@ class TestAgyInitFlow:
# Click >= 8.2 separates stdout and stderr natively
runner = CliRunner()
target = tmp_path / "test-proj2"
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
assert result.exit_code == 0
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr

View File

@@ -192,7 +192,7 @@ class MarkdownIntegrationTests:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
@@ -213,7 +213,7 @@ class MarkdownIntegrationTests:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
@@ -238,7 +238,7 @@ class MarkdownIntegrationTests:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -321,13 +321,13 @@ class MarkdownIntegrationTests:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
for p in project.rglob("*") if p.is_file())
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
@@ -346,13 +346,13 @@ class MarkdownIntegrationTests:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps",
"--ignore-agent-tools",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
for p in project.rglob("*") if p.is_file())
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"

View File

@@ -325,7 +325,7 @@ class SkillsIntegrationTests:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
@@ -346,7 +346,7 @@ class SkillsIntegrationTests:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
@@ -369,7 +369,7 @@ class SkillsIntegrationTests:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -471,15 +471,15 @@ class SkillsIntegrationTests:
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
"init", "--here", "--integration", self.KEY,
"--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
@@ -498,15 +498,15 @@ class SkillsIntegrationTests:
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps",
"--ignore-agent-tools",
"init", "--here", "--integration", self.KEY,
"--script", "ps", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("ps")
assert actual == expected, (

View File

@@ -409,6 +409,7 @@ class TomlIntegrationTests:
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -439,6 +440,7 @@ class TomlIntegrationTests:
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -467,7 +469,7 @@ class TomlIntegrationTests:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -578,6 +580,7 @@ class TomlIntegrationTests:
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -586,7 +589,7 @@ class TomlIntegrationTests:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
@@ -613,6 +616,7 @@ class TomlIntegrationTests:
self.KEY,
"--script",
"ps",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -621,7 +625,7 @@ class TomlIntegrationTests:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("ps")
assert actual == expected, (

View File

@@ -288,6 +288,7 @@ class YamlIntegrationTests:
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -318,6 +319,7 @@ class YamlIntegrationTests:
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -346,7 +348,7 @@ class YamlIntegrationTests:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -457,6 +459,7 @@ class YamlIntegrationTests:
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -465,7 +468,7 @@ class YamlIntegrationTests:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
@@ -492,6 +495,7 @@ class YamlIntegrationTests:
self.KEY,
"--script",
"ps",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -500,7 +504,7 @@ class YamlIntegrationTests:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("ps")
assert actual == expected, (

View File

@@ -458,6 +458,7 @@ class TestIntegrationListCatalog:
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
@@ -555,6 +556,7 @@ class TestIntegrationUpgrade:
"init", "--here",
"--integration", integration,
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:

View File

@@ -66,16 +66,6 @@ class TestClaudeIntegration:
assert parsed["disable-model-invocation"] is False
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
def test_render_skill_unicode(self):
"""Test rendering a skill preserves non-ASCII characters."""
integration = get_integration("claude")
rendered = integration._render_skill(
"constitution",
{"description": "Prüfe Konformität der Implementierung"},
"Body",
)
assert "Prüfe Konformität" in rendered
def test_setup_upserts_context_section(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
@@ -147,6 +137,7 @@ class TestClaudeIntegration:
"claude",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -184,6 +175,7 @@ class TestClaudeIntegration:
"claude",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -216,6 +208,7 @@ class TestClaudeIntegration:
"--here",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
@@ -250,7 +243,7 @@ class TestClaudeIntegration:
result = runner.invoke(
app,
["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
["init", str(target), "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
)
assert result.exit_code == 0

View File

@@ -139,6 +139,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,

View File

@@ -24,7 +24,7 @@ class TestCodexInitFlow:
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--ignore-agent-tools", "--script", "sh"])
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -186,12 +186,12 @@ class TestCopilotIntegration:
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
expected = sorted([
".github/agents/speckit.agent-context.update.agent.md",
".github/agents/speckit.analyze.agent.md",
@@ -256,12 +256,12 @@ class TestCopilotIntegration:
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "ps",
"init", "--here", "--integration", "copilot", "--script", "ps", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
expected = sorted([
".github/agents/speckit.agent-context.update.agent.md",
".github/agents/speckit.analyze.agent.md",
@@ -622,7 +622,7 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -648,12 +648,12 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
expected = sorted([
# Skill files (core + extension-installed agent-context command)
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
@@ -775,6 +775,7 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)

View File

@@ -102,7 +102,7 @@ class TestCursorAgentInitFlow:
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--ignore-agent-tools", "--script", "sh"])
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -68,7 +68,7 @@ class TestDevinInitFlow:
target = tmp_path / "test-proj"
result = runner.invoke(
app,
["init", str(target), "--integration", "devin", "--ignore-agent-tools", "--script", "sh"],
["init", str(target), "--integration", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
)
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"

View File

@@ -251,6 +251,7 @@ class TestGenericIntegration:
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "test-generic"), "--integration", "generic",
"--script", "sh", "--no-git",
])
# Generic requires --commands-dir via --integration-options
assert result.exit_code != 0
@@ -269,7 +270,7 @@ class TestGenericIntegration:
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands",
"--script", "sh",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -291,14 +292,14 @@ class TestGenericIntegration:
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands",
"--script", "sh",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
for p in project.rglob("*") if p.is_file()
)
expected = sorted([
"AGENTS.md",
@@ -355,14 +356,14 @@ class TestGenericIntegration:
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands",
"--script", "ps",
"--script", "ps", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
for p in project.rglob("*") if p.is_file()
)
expected = sorted([
"AGENTS.md",

View File

@@ -232,7 +232,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY,
"--script", "sh", "--ignore-agent-tools",
"--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -270,7 +270,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY,
"--script", "ps", "--ignore-agent-tools",
"--script", "ps", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -342,6 +342,7 @@ class TestHermesInitFlow:
result = runner.invoke(app, [
"init", str(target),
"--integration", "hermes",
"--no-git",
"--ignore-agent-tools",
"--script", "sh",
])

View File

@@ -137,7 +137,7 @@ class TestKimiNextSteps:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "kimi",
"init", "--here", "--integration", "kimi", "--no-git",
"--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False)
finally:

View File

@@ -140,7 +140,7 @@ class TestKiroIntegration:
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "kiro-cli",
"--ignore-agent-tools", "--script", "sh",
"--ignore-agent-tools", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)

View File

@@ -25,7 +25,8 @@ def _run_init(project, *flags: str) -> Result:
os.chdir(project)
return CliRunner().invoke(
app,
["init", "--here", *flags, "--script", "sh", "--ignore-agent-tools"],
["init", "--here", *flags, "--script", "sh",
"--no-git", "--ignore-agent-tools"],
catch_exceptions=False,
)
finally:

View File

@@ -1,238 +0,0 @@
"""Tests for integration scaffolding commands."""
from pathlib import Path
import pytest
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.integration_scaffold import scaffold_integration
from tests.conftest import strip_ansi
runner = CliRunner()
def _repo_root(tmp_path: Path) -> Path:
root = tmp_path / "spec-kit"
(root / "src" / "specify_cli" / "integrations").mkdir(parents=True)
(root / "tests" / "integrations").mkdir(parents=True)
(root / "pyproject.toml").write_text("[project]\nname = \"specify-cli\"\n", encoding="utf-8")
(root / "src" / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
(root / "src" / "specify_cli" / "integrations" / "__init__.py").write_text(
"",
encoding="utf-8",
)
return root
def test_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
monkeypatch.chdir(root)
result = runner.invoke(app, [
"integration", "scaffold", "my-agent",
"--type", "markdown",
], catch_exceptions=False)
output = strip_ansi(result.output)
integration_file = root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
assert result.exit_code == 0
assert integration_file.exists()
assert test_file.exists()
assert "Created integration scaffold: my-agent" in output
assert "Register MyAgentIntegration" in output
content = integration_file.read_text(encoding="utf-8")
assert "class MyAgentIntegration(MarkdownIntegration):" in content
assert 'key = "my-agent"' in content
assert '"folder": ".my-agent/"' in content
assert '"extension": ".md"' in content
assert "multi_install_safe = False" in content
test_content = test_file.read_text(encoding="utf-8")
assert "from specify_cli.integrations.my_agent import MyAgentIntegration" in test_content
assert 'assert integration.registrar_config["dir"] == ".my-agent/commands"' in test_content
assert "assert integration.multi_install_safe is False" in test_content
@pytest.mark.parametrize(
("integration_type", "base_class", "commands_subdir", "args", "extension"),
[
("markdown", "MarkdownIntegration", "commands", "$ARGUMENTS", ".md"),
("toml", "TomlIntegration", "commands", "{{args}}", ".toml"),
("yaml", "YamlIntegration", "recipes", "{{args}}", ".yaml"),
("skills", "SkillsIntegration", "skills", "$ARGUMENTS", "/SKILL.md"),
],
)
def test_scaffold_type_templates(
tmp_path,
integration_type,
base_class,
commands_subdir,
args,
extension,
):
root = _repo_root(tmp_path)
result = scaffold_integration(root, f"{integration_type}-agent", integration_type)
content = result.integration_file.read_text(encoding="utf-8")
assert f"class {result.class_name}({base_class}):" in content
assert f'"commands_subdir": "{commands_subdir}"' in content
assert f'"args": "{args}"' in content
assert f'"extension": "{extension}"' in content
assert "multi_install_safe = False" in content
def test_integration_scaffold_rejects_unknown_type_before_scaffolding(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
monkeypatch.chdir(root)
result = runner.invoke(app, [
"integration", "scaffold", "my-agent",
"--type", "xml",
])
output = strip_ansi(result.output)
assert result.exit_code == 2
assert "Invalid value for '--type'" in output
assert not (root / "src" / "specify_cli" / "integrations" / "my_agent").exists()
def test_integration_scaffold_reports_filesystem_errors_cleanly(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
monkeypatch.chdir(root)
import specify_cli.integration_scaffold as scaffold_module
def boom(*args, **kwargs):
raise PermissionError("Permission denied: read-only checkout")
monkeypatch.setattr(scaffold_module, "scaffold_integration", boom)
result = runner.invoke(app, [
"integration", "scaffold", "my-agent",
"--type", "markdown",
], catch_exceptions=False)
output = strip_ansi(result.output)
assert result.exit_code == 1
assert "Error:" in output
assert "Permission denied" in output
def test_scaffold_refuses_invalid_key(tmp_path):
root = _repo_root(tmp_path)
with pytest.raises(ValueError, match="lowercase kebab-case"):
scaffold_integration(root, "Bad_Key", "markdown")
def test_scaffold_refuses_unknown_type(tmp_path):
root = _repo_root(tmp_path)
with pytest.raises(ValueError, match="Unsupported integration type 'xml'"):
scaffold_integration(root, "my-agent", " XML ")
def test_scaffold_refuses_overwrite(tmp_path):
root = _repo_root(tmp_path)
scaffold_integration(root, "my-agent", "markdown")
with pytest.raises(FileExistsError, match="Refusing to overwrite"):
scaffold_integration(root, "my-agent", "markdown")
def test_scaffold_rolls_back_partial_files_on_write_failure(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
integration_dir = root / "src" / "specify_cli" / "integrations" / "my_agent"
integration_file = integration_dir / "__init__.py"
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
original_write_text = Path.write_text
def fail_test_write(path, *args, **kwargs):
if path == test_file:
raise PermissionError("simulated test file write failure")
return original_write_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", fail_test_write)
with pytest.raises(PermissionError, match="simulated test file write failure"):
scaffold_integration(root, "my-agent", "markdown")
assert not integration_file.exists()
assert not integration_dir.exists()
assert not test_file.exists()
def test_scaffold_creates_only_leaf_integration_directory(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
original_mkdir = Path.mkdir
mkdir_calls = []
def record_mkdir(path, *args, **kwargs):
mkdir_calls.append((path, args, kwargs))
return original_mkdir(path, *args, **kwargs)
monkeypatch.setattr(Path, "mkdir", record_mkdir)
scaffold_integration(root, "my-agent", "markdown")
assert any(
path == root / "src" / "specify_cli" / "integrations" / "my_agent"
for path, _args, _kwargs in mkdir_calls
)
assert all(not kwargs.get("parents", False) for _path, _args, kwargs in mkdir_calls)
def test_scaffold_requires_repo_root(tmp_path):
with pytest.raises(ValueError, match="Spec Kit repository root"):
scaffold_integration(tmp_path, "my-agent", "markdown")
def test_scaffold_requires_integration_registry_file(tmp_path):
root = _repo_root(tmp_path)
(root / "src" / "specify_cli" / "integrations" / "__init__.py").unlink()
with pytest.raises(ValueError, match="Spec Kit repository root"):
scaffold_integration(root, "my-agent", "markdown")
def test_scaffold_refuses_symlinked_target_directory(tmp_path):
root = _repo_root(tmp_path)
# `outside` carries its own __init__.py so the repo-root heuristic still
# passes through the symlink, isolating the symlink guard under test.
outside = tmp_path / "outside"
outside.mkdir()
(outside / "__init__.py").write_text("", encoding="utf-8")
integrations = root / "src" / "specify_cli" / "integrations"
(integrations / "__init__.py").unlink()
integrations.rmdir()
try:
integrations.symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
with pytest.raises(ValueError, match="symlinked path"):
scaffold_integration(root, "my-agent", "markdown")
assert not (outside / "my_agent").exists()
def test_integration_scaffold_accepts_uppercase_type(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
monkeypatch.chdir(root)
result = runner.invoke(app, [
"integration", "scaffold", "my-agent",
"--type", "YAML",
], catch_exceptions=False)
assert result.exit_code == 0, strip_ansi(result.output)
content = (
root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
).read_text(encoding="utf-8")
assert "class MyAgentIntegration(YamlIntegration):" in content

View File

@@ -2,10 +2,7 @@
import json
import os
import shutil
from pathlib import Path
import pytest
from typer.testing import CliRunner
from specify_cli import app
@@ -26,6 +23,7 @@ def _init_project(tmp_path, integration="copilot"):
"init", "--here",
"--integration", integration,
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
@@ -50,32 +48,6 @@ def _write_invalid_manifest(project, key):
return manifest
def _copy_project_template(tmp_path, template):
project = tmp_path / "proj"
shutil.copytree(template, project)
return project
@pytest.fixture(scope="module")
def status_copilot_template(tmp_path_factory):
return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot")
@pytest.fixture(scope="module")
def status_claude_template(tmp_path_factory):
return _init_project(tmp_path_factory.mktemp("status-claude"), "claude")
@pytest.fixture
def copilot_project(tmp_path, status_copilot_template):
return _copy_project_template(tmp_path, status_copilot_template)
@pytest.fixture
def claude_project(tmp_path, status_claude_template):
return _copy_project_template(tmp_path, status_claude_template)
def _integration_list_row_cells(output: str, key: str) -> list[str]:
plain = strip_ansi(output)
row = next(line for line in plain.splitlines() if line.startswith(f"{key}"))
@@ -120,7 +92,6 @@ class TestIntegrationList:
# Should show multiple integrations
assert "claude" in result.output
assert "gemini" in result.output
assert "zed" in result.output
def test_list_shows_multi_install_safe_status(self, tmp_path):
project = _init_project(tmp_path, "claude")
@@ -156,823 +127,6 @@ class TestIntegrationList:
assert "only supports schema 1" in normalized
# ── status ───────────────────────────────────────────────────────────
class TestIntegrationStatus:
def test_status_requires_speckit_project(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["integration", "status"])
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_status_reports_healthy_project(self, copilot_project):
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code == 0
assert "Integration status: OK" in result.output
assert "Default integration: copilot" in result.output
assert "Installed integrations: copilot" in result.output
assert "Shared templates target alignment: copilot" in result.output
assert "Modified managed files: 0" in result.output
assert "Missing managed files: 0" in result.output
def test_status_json_reports_healthy_project(self, copilot_project):
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "ok"
assert payload["default_integration"] == "copilot"
assert payload["installed_integrations"] == ["copilot"]
assert payload["recorded_installed_integrations"] == ["copilot"]
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
assert payload["multi_install_safe"] is True
assert payload["shared_templates_target_alignment"] == "copilot"
assert "shared_templates_aligned_to" not in payload
assert payload["findings"] == []
def test_status_reports_invalid_integration_json(self, copilot_project):
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "integration-state-unreadable" in result.output
assert "invalid JSON" in result.output
assert "Detail:" in result.output
assert "Multi-install safe: unknown" in result.output
assert "Traceback" not in result.output
def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable(
self,
copilot_project,
):
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == []
assert payload["findings"][0]["code"] == "integration-state-unreadable"
assert "Detail:" in payload["findings"][0]["message"]
def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["integration_state_schema"] = 99
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["findings"][0]["code"] == "integration-state-unreadable"
assert "schema 99" in payload["findings"][0]["message"]
assert "supported schema: 1" in payload["findings"][0]["message"]
def test_status_reports_missing_integration_json(self, copilot_project):
(copilot_project / ".specify" / "integration.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "integration-state-missing" in result.output
assert ".specify/integration.json is missing" in result.output
assert "Multi-install safe: unknown" in result.output
def test_status_json_reports_unknown_multi_install_safety_when_state_missing(
self,
copilot_project,
):
(copilot_project / ".specify" / "integration.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == []
assert payload["findings"][0]["code"] == "integration-state-missing"
def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state_path.write_text(
json.dumps({
"version": "test",
"integration_state_schema": 1,
"installed_integrations": [],
}),
encoding="utf-8",
)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == []
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["findings"][0]["code"] == "no-installed-integrations"
assert "speckit" in payload["manifests"]
assert payload["manifests"]["speckit"]["readable"] is True
def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state_path.write_text(
json.dumps({
"version": "test",
"integration_state_schema": 1,
"installed_integrations": [],
}),
encoding="utf-8",
)
(copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["unchecked_manifests"] == 1
assert any(
item["code"] == "no-installed-integrations"
for item in payload["findings"]
)
assert any(
item["code"] == "manifest-missing"
and item["integration"] == "speckit"
for item in payload["findings"]
)
def test_status_json_reports_missing_default_integration_as_error(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state.pop("default_integration", None)
state.pop("integration", None)
state["installed_integrations"] = ["claude"]
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["default_integration"] is None
assert any(
item["code"] == "default-integration-missing"
for item in payload["findings"]
)
def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state.pop("default_integration", None)
state.pop("integration", None)
state["installed_integrations"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == []
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["multi_install_safe"] is None
assert [item["code"] for item in payload["findings"]] == [
"installed-integrations-invalid",
"no-installed-integrations",
]
def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["default_integration"] = "copilot"
state["integration"] = "copilot"
state["installed_integrations"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == ["copilot"]
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
assert payload["multi_install_safe"] is None
assert [item["code"] for item in payload["findings"]] == [
"installed-integrations-invalid",
]
def test_status_reports_default_integration_not_installed(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["default_integration"] = "codex"
state["integration"] = "codex"
state["installed_integrations"] = ["claude"]
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["default_integration"] == "codex"
assert payload["installed_integrations"] == ["codex", "claude"]
assert payload["recorded_installed_integrations"] == ["claude"]
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
assert any(
item["code"] == "default-integration-not-installed"
and "Default integration 'codex' is not listed" in item["message"]
for item in payload["findings"]
)
assert "codex" not in payload["manifests"]
assert not any(
item["code"] == "manifest-missing" and item.get("integration") == "codex"
for item in payload["findings"]
)
def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = []
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["installed_integrations"] == ["claude"]
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
assert payload["multi_install_safe"] is None
assert payload["manifests"]["claude"]["readable"] is True
assert any(
item["code"] == "default-integration-not-installed"
for item in payload["findings"]
)
def test_status_reports_missing_manifest(self, copilot_project):
(copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "manifest-missing" in result.output
assert "Manifest for integration 'copilot' is missing" in result.output
def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project):
_write_invalid_manifest(copilot_project, "copilot")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["unchecked_manifests"] == 1
assert payload["manifests"]["copilot"]["readable"] is False
assert payload["manifests"]["copilot"]["missing_files"] == []
assert payload["manifests"]["copilot"]["modified_files"] == []
def test_status_reports_modified_managed_files_without_failing(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
(copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code == 0
assert "Integration status: WARNING" in result.output
assert "managed-files-modified" in result.output
assert "Modified managed files: 1" in result.output
def test_status_reports_missing_managed_files(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
(copilot_project / first_rel).unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "managed-files-missing" in result.output
assert "Missing managed files: 1" in result.output
def test_status_reports_missing_shared_managed_files(self, copilot_project):
shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh"
assert shared_file.exists()
shared_file.unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "managed-files-missing" in result.output
assert "shared Spec Kit infrastructure" in result.output
assert "Missing managed files: 1" in result.output
def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
def fail_exists(self):
raise AssertionError(f"Path.exists() should not be used for {self}")
monkeypatch.setattr(Path, "exists", fail_exists)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project.resolve(),
)
assert missing == []
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch):
def fail_exists(self):
raise AssertionError(f"Path.exists() should not be used for {self}")
monkeypatch.setattr(Path, "exists", fail_exists)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "ok"
assert payload["manifests"]["copilot"]["readable"] is True
def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch):
original_resolve = Path.resolve
failed = {"done": False}
def fail_first_project_root_resolve(self, *args, **kwargs):
if self == copilot_project and not failed["done"]:
failed["done"] = True
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
def test_status_loads_manifests_when_project_root_resolution_keeps_failing(
self,
copilot_project,
monkeypatch,
):
original_resolve = Path.resolve
def fail_project_root_resolve(self, *args, **kwargs):
if self == copilot_project:
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_project_root_resolve)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["manifests"]["copilot"]["readable"] is True
assert payload["manifests"]["speckit"]["readable"] is True
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
real_project = tmp_path / "real-project"
real_project.mkdir()
tracked = real_project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
symlinked_project = tmp_path / "symlinked-project"
try:
symlinked_project.symlink_to(real_project, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
manifest = IntegrationManifest("test", real_project, version="test")
manifest.record_existing("tracked.md")
manifest.project_root = symlinked_project.absolute()
missing, modified, invalid, valid = _manifest_file_status(
manifest,
symlinked_project.absolute(),
project_root_is_resolved=False,
)
assert missing == []
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
project_root_resolved = project.resolve()
original_resolve = Path.resolve
def fail_project_parent_resolve(self, *args, **kwargs):
if self == project:
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project_root_resolved,
)
assert missing == []
assert modified == []
assert invalid == ["tracked.md"]
assert valid == []
def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch):
from specify_cli import integration_status as status_module
def fail_load(key, project_root, **kwargs):
raise RuntimeError(f"unexpected manifest loader bug for {key}")
monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load)
with pytest.raises(RuntimeError, match="unexpected manifest loader bug"):
status_module.build_integration_status_report(copilot_project)
def test_status_treats_dangling_symlink_as_missing(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
target = copilot_project / first_rel
target.unlink()
try:
target.symlink_to(copilot_project / "missing-target")
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert first_rel in payload["manifests"]["copilot"]["missing_files"]
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
regular_stat = tracked.lstat()
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
tracked.unlink()
try:
tracked.symlink_to(project / "missing-target")
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
original_lstat = Path.lstat
original_is_symlink = Path.is_symlink
def windows_style_lstat(self):
if self == tracked:
return regular_stat
return original_lstat(self)
def windows_style_is_symlink(self):
if self == tracked:
return True
return original_is_symlink(self)
monkeypatch.setattr(Path, "lstat", windows_style_lstat)
monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project.resolve(),
)
assert missing == ["tracked.md"]
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_strip_extended_length_prefix_normalizes_windows_paths(self):
from specify_cli.integration_status import _strip_extended_length_prefix
# Build the prefixed strings explicitly so the test is meaningful on
# every platform (POSIX won't parse backslash separators, but the
# helper operates on the string form). Compare Path objects rather than
# their str() form: on Windows pathlib renders a UNC root with a
# trailing separator (``\\server\share\``), so an exact string match is
# brittle, whereas Path equality captures the intended semantics on
# both POSIX and Windows.
bs = "\\"
assert _strip_extended_length_prefix(
Path(f"{bs}{bs}?{bs}C:{bs}proj")
) == Path(f"C:{bs}proj")
assert _strip_extended_length_prefix(
Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share")
) == Path(f"{bs}{bs}server{bs}share")
# Paths without the prefix are returned unchanged.
assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path")
def test_is_within_project_tolerates_extended_length_prefix(self):
from specify_cli.integration_status import _is_within_project
# A readlink result on POSIX never carries the prefix, so an in-project
# child is contained and an outside path is not. The Windows
# prefix-stripping branch is exercised by the dangling-symlink tests on
# Windows CI; here we lock in the cross-platform containment contract.
root = Path("/tmp/project").resolve()
assert _is_within_project(root, root / "child")
assert not _is_within_project(root, Path("/tmp/other").resolve())
def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project):
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("outside project\n", encoding="utf-8")
link = copilot_project / "outside-link"
try:
link.symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"]["outside-link/secret.txt"] = "wrong"
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["invalid_manifest_paths"] == 1
assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"]
assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"]
def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch):
outside = tmp_path / "outside"
outside.mkdir()
outside_file = outside / "secret.txt"
outside_file.write_text("outside project\n", encoding="utf-8")
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
tracked_path = copilot_project / first_rel
tracked_path.unlink()
try:
tracked_path.symlink_to(outside_file)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
original_stat = Path.stat
def fail_tracked_symlink_stat(self, *args, **kwargs):
follows_symlinks = kwargs.get("follow_symlinks", True)
if self == tracked_path and follows_symlinks:
raise AssertionError("Path.stat() should not follow tracked symlinks")
return original_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["invalid_manifest_paths"] == 1
assert first_rel in payload["manifests"]["copilot"]["invalid_files"]
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
def test_status_reports_unsafe_multi_install_combination(self, copilot_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["copilot", "claude"]
state["default_integration"] = "copilot"
state["integration"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
IntegrationManifest("claude", copilot_project, version="test").save()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "unsafe-multi-install" in result.output
assert "Multi-install safe: no" in result.output
assert "specify integration switch <key>" in result.output
def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["claude", "mystery"]
state["default_integration"] = "claude"
state["integration"] = "claude"
state_path.write_text(json.dumps(state), encoding="utf-8")
IntegrationManifest("mystery", claude_project, version="test").save()
result = _run_in_project(claude_project, ["integration", "status"])
assert result.exit_code != 0
assert "unknown-integration" in result.output
assert "unsafe-multi-install" in result.output
assert "remove the stale integration entry" in result.output
assert "Multi-install safe: no" in result.output
def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["mystery"]
state["default_integration"] = "mystery"
state["integration"] = "mystery"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
manifest_finding = next(
item for item in payload["findings"]
if item["code"] == "manifest-missing" and item["integration"] == "mystery"
)
assert "remove the stale integration entry" in manifest_finding["suggestion"]
assert "integration upgrade mystery" not in manifest_finding["suggestion"]
def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "../../../escape"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
outside_manifest = tmp_path / "escape.manifest.json"
outside_manifest.write_text(
json.dumps({"integration": unsafe_key, "files": {}}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert unsafe_key not in payload["manifests"]
assert payload["manifest_checked_integrations"] == ["speckit"]
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_rejects_filename_invalid_integration_keys(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "bad:key"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_rejects_windows_reserved_integration_keys(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "CON"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_reports_managed_file_collisions(self, claude_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["claude", "codex"]
state["default_integration"] = "claude"
state["integration"] = "claude"
state_path.write_text(json.dumps(state), encoding="utf-8")
claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json"
tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"]
shared_rel = next(iter(tracked_files))
codex_manifest = IntegrationManifest("codex", claude_project, version="test")
codex_manifest.record_existing(shared_rel)
codex_manifest.save()
result = _run_in_project(claude_project, ["integration", "status"])
assert result.exit_code == 0
assert "managed-file-collision" in result.output
assert "Integration status: WARNING" in result.output
def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
(project / ".specify" / "integration.json").write_text(
json.dumps({
"integration": "[red]x[/red]",
"installed_integrations": ["[red]x[/red]"],
}),
encoding="utf-8",
)
monkeypatch.chdir(project)
result = runner.invoke(app, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["default_integration"] == "[red]x[/red]"
assert payload["installed_integrations"] == ["[red]x[/red]"]
def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
(project / ".specify" / "integration.json").write_text(
json.dumps({
"integration": "[red]x[/red]",
"installed_integrations": ["[red]x[/red]"],
}),
encoding="utf-8",
)
monkeypatch.chdir(project)
result = runner.invoke(app, ["integration", "status"])
assert result.exit_code != 0
assert "Default integration: [red]x[/red]" in result.output
assert "Installed integrations: [red]x[/red]" in result.output
# ── install ──────────────────────────────────────────────────────────
@@ -1807,7 +961,7 @@ class TestIntegrationSwitch:
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
"""Switching refreshes managed shared scripts to the target command style."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
assert shared_script.exists()
shared_content = shared_script.read_text(encoding="utf-8")
assert "/speckit-plan" in shared_content
@@ -1833,7 +987,7 @@ class TestIntegrationSwitch:
import hashlib
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
# Simulate a stale vendored script: write truncated content as bytes
@@ -1845,7 +999,7 @@ class TestIntegrationSwitch:
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
hashlib.sha256(stale_bytes).hexdigest()
)
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
@@ -1894,7 +1048,7 @@ class TestIntegrationSwitch:
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
rendered_bytes = shared_script.read_bytes()
@@ -1919,45 +1073,6 @@ class TestIntegrationSwitch:
assert "/speckit.plan" in updated
assert "/speckit-plan" not in updated
def test_switch_preserves_recovered_files(self, tmp_path):
"""Regression for #2918: files marked recovered in the manifest are not overwritten.
When a file already exists on disk before init and is recorded with
``recovered=True``, ``integration use``/``switch`` must not treat it as
managed even when the on-disk hash matches the manifest hash.
"""
import hashlib
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert shared_script.is_file()
# Simulate a team-customized file that was recorded as recovered:
# write custom content, then update the manifest to record its hash
# with the recovered flag set.
custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
shared_script.write_bytes(custom_bytes)
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
rel = ".specify/scripts/bash/setup-tasks.sh"
manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
manifest_data.setdefault("recovered_files", []).append(rel)
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Recovered file must NOT be overwritten — team content preserved.
assert shared_script.read_bytes() == custom_bytes
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.

View File

@@ -1,164 +0,0 @@
"""Tests for ZedIntegration."""
import json
import pytest
from specify_cli.integrations import get_integration
from .test_integration_base_skills import SkillsIntegrationTests
class TestZedIntegration(SkillsIntegrationTests):
KEY = "zed"
FOLDER = ".agents/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
CONTEXT_FILE = "AGENTS.md"
def test_options_include_skills_flag(self):
"""Not applicable to Zed — Zed is always skills-based with no --skills flag."""
pytest.skip("Zed is always skills-based and does not expose a --skills option")
def test_options_do_not_include_skills_flag(self):
"""Zed is always skills-based; no --skills option is exposed."""
i = get_integration(self.KEY)
assert i is not None
opts = i.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 0, (
"Zed is always skills-based and should not expose a --skills option"
)
def test_requires_cli_is_false(self):
"""Zed is IDE-based; requires_cli must remain False."""
i = get_integration(self.KEY)
assert i is not None
assert i.config is not None
assert i.config["requires_cli"] is False
class TestZedHookInvocations:
"""Zed hook messages should reference slash-invokable skills."""
def test_hooks_render_skill_invocation(self, tmp_path):
"""Zed is always skills-based: renders /speckit-plan even with ai_skills=False."""
from specify_cli.extensions import HookExecutor
project = tmp_path / "zed-hooks"
project.mkdir()
init_options = project / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "zed", "ai_skills": False}))
hook_executor = HookExecutor(project)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
def test_init_persists_ai_skills_for_zed(self, tmp_path, monkeypatch):
"""specify init --integration zed must persist ai_skills: true,
so HookExecutor renders slash-skill invocations without manual
init-options manipulation."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.extensions import HookExecutor
project = tmp_path / "zed-init-test"
project.mkdir()
monkeypatch.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"zed",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert result.exit_code == 0, f"init failed: {result.output}"
opts_path = project / ".specify" / "init-options.json"
assert opts_path.exists()
opts = json.loads(opts_path.read_text(encoding="utf-8"))
assert opts.get("ai") == "zed"
assert opts.get("ai_skills") is True, (
f"init must persist ai_skills=true for Zed, got: {opts.get('ai_skills')}"
)
hook_executor = HookExecutor(project)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "Executing: `/speckit-plan`" in message, (
"Hook rendering must produce /speckit-plan for Zed without hint injection"
)
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
class TestSlashSkillsSets:
"""Parameterized coverage for ALWAYS_SLASH_AGENTS / CONDITIONAL_SLASH_AGENTS."""
@staticmethod
def _render_invocation(project_path, ai: str, ai_skills: bool) -> str:
"""Return the rendered invocation for ``speckit.plan`` via HookExecutor."""
from specify_cli.extensions import HookExecutor
init_options = project_path / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": ai, "ai_skills": ai_skills}))
hook_executor = HookExecutor(project_path)
result = hook_executor.execute_hook(
{"extension": "test-ext", "command": "speckit.plan", "optional": False}
)
return result.get("invocation", "")
@pytest.mark.parametrize(
("ai", "ai_skills", "expected"),
[
# ALWAYS_SLASH_AGENTS — unconditional on ai_skills
("devin", True, "/speckit-plan"),
("devin", False, "/speckit-plan"),
("trae", True, "/speckit-plan"),
("trae", False, "/speckit-plan"),
("zed", True, "/speckit-plan"),
("zed", False, "/speckit-plan"),
# CONDITIONAL_SLASH_AGENTS — only when ai_skills is enabled
("agy", True, "/speckit-plan"),
("agy", False, "/speckit.plan"),
("claude", True, "/speckit-plan"),
("claude", False, "/speckit.plan"),
("copilot", True, "/speckit-plan"),
("copilot", False, "/speckit.plan"),
("cursor-agent", True, "/speckit-plan"),
("cursor-agent", False, "/speckit.plan"),
],
)
def test_hook_invocation_format(self, tmp_path, ai, ai_skills, expected):
result = self._render_invocation(tmp_path, ai, ai_skills)
assert result == expected, (
f"{ai} (ai_skills={ai_skills}): expected {expected!r}, got {result!r}"
)

View File

@@ -27,7 +27,7 @@ ALL_INTEGRATION_KEYS = [
# Stage 4 — TOML integrations
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations
"codex", "kimi", "agy", "zed", "generic",
"codex", "kimi", "agy", "generic",
]
@@ -254,6 +254,7 @@ class TestMultiInstallSafeContracts:
initial,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,

View File

@@ -793,35 +793,6 @@ class TestRedirectStripping:
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None
def test_https_to_http_same_host_redirect_strips_auth(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://github.com/org/repo")
assert new_req is not None
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None
def test_redirect_validator_can_reject_before_following_redirect(self):
import urllib.error
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
def reject_http(old_url, new_url):
if new_url.startswith("http://"):
raise urllib.error.URLError("scheme downgrade")
handler = _StripAuthOnRedirect(("github.com",), reject_http)
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
with pytest.raises(urllib.error.URLError, match="scheme downgrade"):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://github.com/org/repo")
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
"""Auth survives a multi-hop redirect chain within allowed hosts."""
from specify_cli.authentication.http import _StripAuthOnRedirect

View File

@@ -1,24 +1,74 @@
"""
Unit tests verifying --branch-numbering removal (v0.10.0).
Unit tests for branch numbering options (sequential vs timestamp).
Branch numbering is now managed entirely by the git extension's config.
The --branch-numbering flag was removed from `specify init`.
Tests cover:
- Persisting branch_numbering in init-options.json
- Default value when branch_numbering is None
- Validation of branch_numbering values
"""
import json
from pathlib import Path
from specify_cli import save_init_options
class TestBranchNumberingFlagRemoved:
"""--branch-numbering flag was removed in v0.10.0."""
def test_branch_numbering_flag_is_rejected(self, tmp_path: Path):
class TestSaveBranchNumbering:
"""Tests for save_init_options with branch_numbering."""
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
opts = {"branch_numbering": "timestamp", "ai": "claude"}
save_init_options(tmp_path, opts)
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "timestamp"
def test_save_branch_numbering_sequential(self, tmp_path: Path):
opts = {"branch_numbering": "sequential", "ai": "claude"}
save_init_options(tmp_path, opts)
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "sequential"
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
project_dir = tmp_path / "proj"
runner = CliRunner()
result = runner.invoke(app, ["init", str(project_dir), "--integration", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "sequential"
class TestBranchNumberingValidation:
"""Tests for branch_numbering CLI validation via CliRunner."""
def test_invalid_branch_numbering_rejected(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "proj"), "--integration", "claude",
"--branch-numbering", "sequential", "--ignore-agent-tools",
])
assert result.exit_code != 0, "--branch-numbering should be rejected"
assert "No such option" in result.output or "no such option" in result.output.lower()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
assert result.exit_code == 1
assert "Invalid --branch-numbering" in result.output
def test_valid_branch_numbering_sequential(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")
def test_valid_branch_numbering_timestamp(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")

View File

@@ -17,7 +17,7 @@ COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
def _install_bash_scripts(repo: Path) -> None:
@@ -34,15 +34,6 @@ def _install_ps_scripts(repo: Path) -> None:
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
@@ -78,10 +69,7 @@ def prereq_repo(tmp_path: Path) -> Path:
@requires_bash
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must return paths when feature.json pins the feature dir."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
"""--paths-only must return paths without branch validation (main branch)."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json", "--paths-only"],
@@ -100,20 +88,20 @@ def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must also work when feature.json and SPECIFY_FEATURE agree."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
"""--paths-only must also work on a properly named spec branch."""
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=prereq_repo,
check=True,
)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
env = _clean_env()
env["SPECIFY_FEATURE"] = "001-my-feature"
result = subprocess.run(
["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
@@ -123,10 +111,7 @@ def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
@requires_bash
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only without --json must return text paths from feature.json."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
"""--paths-only without --json must return text paths on a non-spec branch."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--paths-only"],
@@ -143,7 +128,7 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without --paths-only, feature directory validation must still fail on main."""
"""Without --paths-only, branch validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json"],
@@ -154,20 +139,17 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(),
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Not on a feature branch" in result.stderr
# ── PowerShell tests ──────────────────────────────────────────────────────
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must return paths when feature.json pins the feature dir."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
"""-PathsOnly must return paths without branch validation (main branch)."""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo,
@@ -183,39 +165,34 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
assert "FEATURE_DIR" in data
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
"""-PathsOnly must also work on a properly named spec branch."""
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=prereq_repo,
check=True,
)
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
env = _clean_env()
env["SPECIFY_FEATURE"] = "001-my-feature"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "FEATURE_DIR" in data
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without -PathsOnly, feature directory validation must still fail on main."""
"""Without -PathsOnly, branch validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=prereq_repo,
@@ -225,5 +202,4 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(),
)
assert result.returncode != 0
combined = result.stdout + result.stderr
assert "Feature directory not found" in combined
assert "Not on a feature branch" in result.stderr

View File

@@ -90,7 +90,7 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
@@ -119,50 +119,6 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
return ext_dir
def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Path:
"""Create an extension whose command description contains non-ASCII characters."""
ext_dir = temp_dir / ext_id
ext_dir.mkdir()
description = "Prüfe Konformität der Implementierung"
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Unicode Extension",
"version": "1.0.0",
"description": description,
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.{ext_id}.hello",
"file": "commands/hello.md",
"description": description,
},
]
},
}
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
yaml.safe_dump(manifest_data, f, allow_unicode=True)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "hello.md").write_text(
"---\n"
f'description: "{description}"\n'
"---\n"
"\n"
"# Hello\n"
"\n"
"Body.\n",
encoding="utf-8",
)
return ext_dir
def _can_create_symlink(temp_dir: Path) -> bool:
"""Return True when the current platform/user can create file symlinks."""
target = temp_dir / "symlink-target.txt"
@@ -347,147 +303,6 @@ class TestExtensionSkillRegistration:
assert "description" in parsed
assert parsed["disable-model-invocation"] is False
def test_argument_hint_preserved_for_extension_command(
self, skills_project, temp_dir
):
"""argument-hint from an extension command must survive into SKILL.md.
Regression for #2903: the field was dropped for extension-provided
commands while being kept for core template commands. The source
description is intentionally long so it folds across multiple lines
when serialized, guarding against an in-place string injection that
would split the folded scalar and produce invalid YAML.
"""
project_dir, skills_dir = skills_project
long_description = (
"Build and maintain a lean, static context/ knowledge folder so "
"coding agents load only what is relevant and save tokens"
)
arg_hint = "<init | update | list | check> [area] [slug] [-- notes]"
ext_dir = temp_dir / "hint-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hint-ext",
"name": "Hint Extension",
"version": "1.0.0",
"description": "Extension exercising argument-hint preservation",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.hint-ext.build-context",
"file": "commands/build-context.md",
"description": long_description,
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "build-context.md").write_text(
"---\n"
f'description: "{long_description}"\n'
f'argument-hint: "{arg_hint}"\n'
"---\n"
"\n"
"# Build Context\n"
"\n"
"Do the thing.\n"
"$ARGUMENTS\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
skill_file = skills_dir / "speckit-hint-ext-build-context" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
# Frontmatter must parse cleanly even though the description folds.
parts = content.split("---", 2)
assert len(parts) >= 3
parsed = yaml.safe_load(parts[1])
assert parsed["argument-hint"] == arg_hint
assert parsed["description"] == long_description
def test_argument_hint_not_added_for_non_claude_agent(self, project_dir, temp_dir):
"""argument-hint must stay Claude-only — other skills agents are untouched.
The hint is carried only for integrations that support it (currently
Claude, the sole integration defining inject_argument_hint). A non-Claude
skills agent such as kimi must keep the shared build_skill_frontmatter
shape (name/description/compatibility/metadata) with no argument-hint.
"""
_create_init_options(project_dir, ai="kimi", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="kimi")
arg_hint = "<init | update | list | check> [area]"
ext_dir = temp_dir / "hint-ext-kimi"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hint-ext-kimi",
"name": "Hint Extension Kimi",
"version": "1.0.0",
"description": "Extension exercising argument-hint gating",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.hint-ext-kimi.build-context",
"file": "commands/build-context.md",
"description": "Build context",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "build-context.md").write_text(
"---\n"
'description: "Build context"\n'
f'argument-hint: "{arg_hint}"\n'
"---\n"
"\n"
"# Build Context\n"
"\n"
"Do the thing.\n"
"$ARGUMENTS\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
skill_file = skills_dir / "speckit-hint-ext-kimi-build-context" / "SKILL.md"
assert skill_file.exists()
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
assert "argument-hint" not in parsed
def test_skill_md_unicode(self, skills_project, temp_dir):
"""SKILL.md generation should preserve non-ASCII characters."""
project_dir, skills_dir = skills_project
ext_dir = _create_unicode_extension_dir(temp_dir)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
skill_file = skills_dir / "speckit-uni-ext-hello" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")
assert "Prüfe Konformität" in content
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
"""No skills should be created when ai_skills is false."""
manager = ExtensionManager(no_skills_project)
@@ -748,7 +563,7 @@ class TestExtensionSkillRegistration:
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
yaml.dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "plan.md").write_text(
@@ -803,7 +618,7 @@ class TestExtensionSkillRegistration:
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
yaml.dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "exists.md").write_text(
@@ -1359,7 +1174,7 @@ class TestExtensionSkillEdgeCases:
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
yaml.dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "plain.md").write_text(
@@ -1446,7 +1261,7 @@ class TestExtensionSkillEdgeCases:
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
yaml.dump(manifest_data, f)
(ext_dir / "commands").mkdir()
# Malformed YAML: invalid key-value syntax

View File

@@ -24,7 +24,6 @@ from specify_cli.extensions import (
CatalogEntry,
CORE_COMMAND_NAMES,
DEFAULT_HOOK_PRIORITY,
VALID_EFFECTS,
ExtensionManifest,
ExtensionRegistry,
ExtensionManager,
@@ -301,69 +300,6 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Invalid version"):
ExtensionManifest(manifest_path)
def test_valid_category(self, temp_dir, valid_manifest_data):
"""Test manifest with various category values (free-form string)."""
import yaml
for category in ("docs", "code", "process", "integration", "visibility", "custom-category"):
valid_manifest_data["extension"]["category"] = category
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.category == category
def test_valid_effect(self, temp_dir, valid_manifest_data):
"""Test manifest with valid effect values."""
import yaml
for effect in sorted(VALID_EFFECTS):
valid_manifest_data["extension"]["effect"] = effect
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.effect == effect
def test_invalid_category(self, temp_dir, valid_manifest_data):
"""Test manifest with empty category raises ValidationError."""
import yaml
valid_manifest_data["extension"]["category"] = ""
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension.category"):
ExtensionManifest(manifest_path)
def test_invalid_effect(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid effect raises ValidationError."""
import yaml
valid_manifest_data["extension"]["effect"] = "write-only"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension.effect"):
ExtensionManifest(manifest_path)
def test_category_and_effect_optional(self, temp_dir, valid_manifest_data):
"""Test that omitting category and effect still passes validation."""
import yaml
# Ensure no category/effect in data
valid_manifest_data["extension"].pop("category", None)
valid_manifest_data["extension"].pop("effect", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.category is None
assert manifest.effect is None
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
import yaml
@@ -1118,56 +1054,6 @@ class TestExtensionManager:
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_install_dir_is_rejected_without_data_loss(
self, extension_dir, project_dir
):
"""Installing from an extension's own install dir must fail without
deleting it (regression for issue #2990)."""
manager = ExtensionManager(project_dir)
# Install once so the extension lives at its install destination.
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
install_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert install_dir.exists()
# Re-installing from that same directory with --force must be rejected.
with pytest.raises(ValidationError, match="install destination"):
manager.install_from_directory(
install_dir, "0.1.0", register_commands=False, force=True
)
# The directory and its contents must be left intact (no data loss).
assert install_dir.exists()
assert (install_dir / "extension.yml").exists()
assert (install_dir / "commands" / "hello.md").exists()
def test_install_from_install_dir_is_rejected_when_resolve_fails(
self, extension_dir, project_dir, monkeypatch
):
"""Resolution failures must not bypass the self-install guard."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
install_dir = project_dir / ".specify" / "extensions" / "test-ext"
original_resolve = Path.resolve
def fail_resolve(self, *args, **kwargs):
if self in {install_dir, manager.extensions_dir / "test-ext"}:
raise OSError("cannot resolve path")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_resolve)
with pytest.raises(ValidationError, match="install destination"):
manager.install_from_directory(
install_dir, "0.1.0", register_commands=False, force=True
)
assert install_dir.exists()
assert (install_dir / "extension.yml").exists()
assert (install_dir / "commands" / "hello.md").exists()
def test_install_zip_force_reinstall(self, extension_dir, project_dir):
"""Test force-reinstalling from ZIP when already installed."""
import zipfile
@@ -3201,424 +3087,6 @@ class TestExtensionCatalog:
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``extensions`` is the wrong type.
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
{"schema_version": "1.0", "extensions": 42},
],
)
def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload):
"""Malformed catalog payloads raise ExtensionError, not AttributeError.
Without this guard, a payload like ``{"extensions": []}`` would pass the
key-presence check and then crash with ``AttributeError: 'list' object
has no attribute 'items'`` deep inside ``_get_merged_extensions``. The
sibling integration catalog reader already validates both the root
object and the nested mapping (see ``integrations/catalog.py``); the
extension catalog must stay consistent.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="Invalid catalog format"):
catalog._fetch_single_catalog(entry, force_refresh=True)
@pytest.mark.parametrize(
"cached_payload",
[
[],
"oops",
42,
None,
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
],
)
def test_fetch_single_catalog_rejects_malformed_cached_payload(
self, temp_dir, cached_payload
):
"""A poisoned cache silently falls back to the network instead of
crashing — cached payloads pass through the same shape validation
as freshly-fetched ones.
Without this, a cache poisoned by an older spec-kit version (or a
manual edit, or an upstream that briefly served a bad payload
before the network guards landed) would re-crash every invocation
of ``_get_merged_extensions`` despite the cache being "valid" by
age. The recovery contract is: if the cached payload fails
validation, drop it and refetch — never propagate
``AttributeError`` to the caller.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` is the
# branch that goes through ``is_cache_valid()`` (the non-default
# branch uses per-URL hashed cache files but the same code path
# below).
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(cached_payload))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
}
)
)
# Network refetch returns a valid payload so the recovery path
# can complete.
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = CatalogEntry(
url=ExtensionCatalog.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog._fetch_single_catalog(entry, force_refresh=False)
# The poisoned cache was discarded and the network payload returned.
assert result == valid
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``extensions`` is the wrong type.
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
],
)
def test_fetch_catalog_rejects_malformed_payload(self, temp_dir, payload):
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
Before this change ``fetch_catalog`` only checked key presence — so
a payload like ``42`` would crash with
``TypeError: argument of type 'int' is not iterable`` during the
``"schema_version" in catalog_data`` check, and an entry mapping
of the wrong type would crash downstream. Reusing
``_validate_catalog_payload`` keeps the network-side behaviour of
the legacy single-catalog method consistent with the multi-catalog
``_fetch_single_catalog`` path.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="Invalid catalog format"):
catalog.fetch_catalog(force_refresh=True)
def test_fetch_catalog_recovers_from_unreadable_cache(self, temp_dir):
"""An unreadable / wrong-encoded cache file silently refetches.
The cache contract is best-effort: a JSON-decode failure, an OS
read failure (permissions / disk / handle limit), or an invalid
text encoding on a cache file written by an older client must
all fall through to the network fetch rather than crash the
caller. Covers Copilot's review point that the previous
``except (json.JSONDecodeError,)`` was too narrow.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Write invalid UTF-8 bytes to the cache file so ``read_text``
# raises ``UnicodeDecodeError`` (a subclass of ``UnicodeError``).
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
}
),
encoding="utf-8",
)
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
# Recovered via network rather than crashing on the unreadable cache.
assert result == valid
def test_fetch_catalog_recovers_from_unreadable_metadata(self, temp_dir):
"""A wrongly-encoded metadata file degrades to a cache miss.
``is_cache_valid`` is consulted *before* the cache payload is
read; if the metadata file itself can't be decoded (e.g. it was
written on a Windows host whose default codec isn't UTF-8) the
validity check must return ``False`` rather than propagate
``UnicodeDecodeError``. Without that guard, a corrupted metadata
file would crash every invocation instead of falling through to
a network refetch.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
# is_cache_valid must absorb the decode failure, not crash.
assert catalog.is_cache_valid() is False
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
assert result == valid
@pytest.mark.parametrize(
"non_mapping_metadata",
[
"[]", # JSON array
'"oops"', # JSON string
"42", # JSON number
"true", # JSON bool
"null", # JSON null
],
)
def test_is_cache_valid_handles_non_mapping_metadata(
self, temp_dir, non_mapping_metadata
):
"""Metadata that parses to a non-mapping degrades to cache-invalid.
The cache-validity check calls ``metadata.get("cached_at", "")``
immediately after ``json.loads``. If the metadata file is valid
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
previously slipped past the except tuple and crashed the
caller. The contract documented on ``is_cache_valid`` says any
decode/shape failure should return ``False`` so ``fetch_catalog``
falls through to a network refetch. This test pins that
contract across every JSON non-mapping root type so a regression
in the except clause can't silently re-introduce the crash.
"""
catalog = self._make_catalog(temp_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
catalog.cache_metadata_file.write_text(
non_mapping_metadata, encoding="utf-8"
)
# Must not raise — the contract is "any decode/shape failure → False".
assert catalog.is_cache_valid() is False
def test_fetch_catalog_writes_cache_as_utf8(self, temp_dir, monkeypatch):
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
The earlier version of this test claimed to assert UTF-8 at the
byte level but actually only round-tripped a non-ASCII string
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
ever reached ``write_text`` — the bytes on disk were identical
regardless of the encoding kwarg, so a locale-encoded write
would have round-tripped just fine. The drift Copilot's review
flagged wasn't actually being caught.
Fix: directly observe the ``encoding`` argument passed to every
``write_text`` call made against the cache directory. This is
the production code's encoding choice, which is exactly what
the regression guard cares about; non-ASCII payload tricks are
unnecessary because the assertion is about the kwarg, not the
bytes.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = self._make_catalog(temp_dir)
payload = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# Record every ``write_text`` call's encoding kwarg so the
# assertion observes the production writer's argument directly.
recorded: list[dict] = []
real_write_text = _PathCls.write_text
def recording_write_text(self, data, *args, **kwargs):
recorded.append(
{"path": str(self), "encoding": kwargs.get("encoding")}
)
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
catalog.fetch_catalog(force_refresh=True)
# Filter to writes inside the catalog's cache directory so
# unrelated writes from other machinery don't pollute the
# assertion.
cache_writes = [
r for r in recorded if str(catalog.cache_dir) in r["path"]
]
assert cache_writes, "fetch_catalog made no writes to the cache dir"
for record in cache_writes:
assert record["encoding"] == "utf-8", (
f"write_text on {record['path']} used encoding "
f"{record['encoding']!r}; expected 'utf-8'"
)
def test_fetch_catalog_survives_unwritable_cache(self, temp_dir, monkeypatch):
"""An unwritable cache dir doesn't fail a successful fetch.
Cache writes are best-effort, mirroring the read side and the
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
raises ``OSError`` (read-only checkout, permissions), the
already-fetched-and-validated payload must still be returned
rather than surfacing the cache failure to the caller.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = self._make_catalog(temp_dir)
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# Simulate an unwritable cache dir: every write_text under the
# cache directory raises PermissionError (an OSError subclass).
real_write_text = _PathCls.write_text
def failing_write_text(self, data, *args, **kwargs):
if str(catalog.cache_dir) in str(self):
raise PermissionError("cache dir is read-only")
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
# Legacy single-catalog path.
assert catalog.fetch_catalog(force_refresh=True) == valid
# Multi-catalog path.
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
assert catalog._fetch_single_catalog(entry, force_refresh=True) == valid
def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir):
"""Per-entry guard: one malformed entry shouldn't poison the merge.
``_fetch_single_catalog`` validates that ``extensions`` is a mapping,
but it doesn't (and shouldn't) validate every entry inside it — a
single bad entry in an otherwise-valid catalog should be skipped, not
crash the whole resolve path. Mirrors the per-entry skip in
``integrations/catalog.py``: a malformed entry returns no error,
valid entries continue to merge normally.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Mix of valid entry, list-shaped entry, and string-shaped entry.
payload = {
"schema_version": "1.0",
"extensions": {
"good": {"name": "Good", "version": "1.0.0"},
"bad-list": [],
"bad-str": "oops",
},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response), \
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
merged = catalog._get_merged_extensions(force_refresh=True)
# Only the well-formed entry survives; the two malformed entries are
# silently dropped rather than raising or crashing.
assert [ext["id"] for ext in merged] == ["good"]
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
"""download_extension passes Authorization header when a provider is configured."""
from unittest.mock import patch, MagicMock

View File

@@ -187,4 +187,4 @@ class TestResolveGitHubReleaseAssetApiUrl:
capturing_open,
)
assert len(captured_urls) == 1
assert "releases/tags/v1%23beta" in captured_urls[0]
assert "releases/tags/v1%23beta" in captured_urls[0]

View File

@@ -1,90 +0,0 @@
"""Tests for Rich Live transient=False on Windows (GitHub issue #2927).
PowerShell 5.1's legacy console host does not support VT escape sequences
reliably. Rich's ``Live(transient=True)`` attempts cursor restoration on
exit, which hangs indefinitely on that console. The fix disables transient
mode when ``sys.platform == "win32"``.
These tests patch ``sys.platform`` and intercept the ``Live`` constructor
to verify the correct ``transient`` value reaches Rich.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# _console.py — Live in the select_with_arrows helper
# ---------------------------------------------------------------------------
def _invoke_select_with_arrows(platform: str) -> bool:
"""Patch sys.platform and Live, invoke select_with_arrows, return transient kwarg."""
captured = {}
mock_live_instance = MagicMock()
mock_live_instance.__enter__ = MagicMock(return_value=mock_live_instance)
mock_live_instance.__exit__ = MagicMock(return_value=False)
def fake_live(*args, **kwargs):
captured.update(kwargs)
return mock_live_instance
# Patch readchar so the loop immediately returns "enter"
import readchar
with (
patch("sys.platform", platform),
patch("specify_cli._console.Live", side_effect=fake_live),
patch("specify_cli._console.readchar.readkey", return_value=readchar.key.ENTER),
):
from specify_cli._console import select_with_arrows
select_with_arrows({"a": "Option A", "b": "Option B"}, "Pick one", "a")
return captured["transient"]
class TestSelectWithArrowsLiveTransient:
"""Verify that select_with_arrows passes transient=False on Windows."""
def test_transient_false_on_windows(self):
assert _invoke_select_with_arrows("win32") is False
def test_transient_true_on_linux(self):
assert _invoke_select_with_arrows("linux") is True
def test_transient_true_on_macos(self):
assert _invoke_select_with_arrows("darwin") is True
# ---------------------------------------------------------------------------
# init.py — verify source contains the platform guard (regression check)
# ---------------------------------------------------------------------------
class TestSourceContainsPlatformGuard:
"""Ensure the platform guard feeds into the Live() transient kwarg."""
# Single DOTALL regex: _transient assigned from win32 check, then used in Live()
_GUARD_RE = r"_transient\s*=\s*sys\.platform\s*!=\s*['\"]win32['\"].*Live\(.*transient\s*=\s*_transient"
def test_init_has_win32_guard(self):
"""init.py must assign _transient from platform check and pass it to Live."""
import re
init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py"
content = init_src.read_text(encoding="utf-8")
assert re.search(self._GUARD_RE, content, re.DOTALL)
def test_console_has_win32_guard(self):
"""_console.py must assign _transient from platform check and pass it to Live."""
import re
console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py"
content = console_src.read_text(encoding="utf-8")
assert re.search(self._GUARD_RE, content, re.DOTALL)
assert re.search(r"transient\s*=\s*_transient", content)
assert "transient=_transient" in content

View File

@@ -11,7 +11,6 @@ Tests cover:
"""
import pytest
import io
import json
import tempfile
import shutil
@@ -19,7 +18,6 @@ import warnings
import zipfile
from pathlib import Path
from datetime import datetime, timezone
from types import SimpleNamespace
import yaml
@@ -1516,421 +1514,6 @@ class TestPresetCatalog:
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``presets`` is the wrong type.
{"schema_version": "1.0", "presets": []},
{"schema_version": "1.0", "presets": "oops"},
{"schema_version": "1.0", "presets": None},
{"schema_version": "1.0", "presets": 42},
],
)
def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, payload):
"""Malformed catalog payloads raise PresetError, not AttributeError.
Without this guard, a payload like ``{"presets": []}`` would pass the
key-presence check and then crash with ``AttributeError: 'list' object
has no attribute 'items'`` deep inside ``_get_merged_packs``. The
sibling integration catalog reader already validates both the root
object and the nested mapping (see ``integrations/catalog.py``); the
preset catalog must stay consistent.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = PresetCatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(PresetError, match="Invalid preset catalog format"):
catalog._fetch_single_catalog(entry, force_refresh=True)
@pytest.mark.parametrize(
"cached_payload",
[
[],
"oops",
42,
None,
{"schema_version": "1.0", "presets": []},
{"schema_version": "1.0", "presets": "oops"},
{"schema_version": "1.0", "presets": None},
],
)
def test_fetch_single_catalog_rejects_malformed_cached_payload(
self, project_dir, cached_payload
):
"""A poisoned cache silently falls back to the network instead of
crashing — cached payloads pass through the same shape validation
as freshly-fetched ones.
Without this, a cache poisoned by an older spec-kit version (or a
manual edit, or an upstream that briefly served a bad payload
before the network guards landed) would re-crash every invocation
of ``_get_merged_packs`` despite the cache being "valid" by age.
The recovery contract is: if the cached payload fails validation,
drop it and refetch — never propagate ``AttributeError`` to the
caller.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and
# non-default URLs both flow through the same cache-load branch.
cache_file, metadata_file = catalog._get_cache_paths(
catalog.DEFAULT_CATALOG_URL
)
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(cached_payload))
metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog.DEFAULT_CATALOG_URL,
}
)
)
# Network refetch returns a valid payload so the recovery path
# can complete.
valid = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = PresetCatalogEntry(
url=catalog.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog._fetch_single_catalog(entry, force_refresh=False)
# The poisoned cache was discarded and the network payload returned.
assert result == valid
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``presets`` is the wrong type.
{"schema_version": "1.0", "presets": []},
{"schema_version": "1.0", "presets": "oops"},
{"schema_version": "1.0", "presets": None},
],
)
def test_fetch_catalog_rejects_malformed_payload(self, project_dir, payload):
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
Before this change ``fetch_catalog`` only checked key presence —
so a payload like ``42`` would crash with
``TypeError: argument of type 'int' is not iterable`` during the
``"schema_version" in catalog_data`` check, and an entry mapping
of the wrong type would crash downstream. Reusing
``_validate_catalog_payload`` keeps the network-side behaviour of
the legacy single-catalog method consistent with the multi-catalog
``_fetch_single_catalog`` path.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(PresetError, match="Invalid preset catalog format"):
catalog.fetch_catalog(force_refresh=True)
def test_fetch_catalog_recovers_from_unreadable_cache(self, project_dir):
"""An unreadable / wrong-encoded cache file silently refetches.
The cache contract is best-effort: a JSON-decode failure, an OS
read failure (permissions / disk / handle limit), or an invalid
text encoding on a cache file written by an older client must
all fall through to the network fetch rather than crash the
caller. Covers Copilot's review point that the previous
``except (json.JSONDecodeError, OSError)`` was missing
``UnicodeError``.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
# Invalid UTF-8 bytes so ``read_text`` raises ``UnicodeDecodeError``
# (a subclass of ``UnicodeError``).
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog.get_catalog_url(),
}
),
encoding="utf-8",
)
valid = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
# Recovered via network rather than crashing on the unreadable cache.
assert result == valid
def test_fetch_catalog_recovers_from_unreadable_metadata(self, project_dir):
"""A wrongly-encoded metadata file degrades to a cache miss.
``is_cache_valid`` is consulted *before* the cache payload is
read; if the metadata file itself can't be decoded (e.g. it was
written on a host whose default codec isn't UTF-8) the validity
check must return ``False`` rather than propagate
``UnicodeDecodeError``. Without that guard, a corrupted metadata
file would crash every invocation instead of falling through to
a network refetch.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
# is_cache_valid must absorb the decode failure, not crash.
assert catalog.is_cache_valid() is False
valid = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
assert result == valid
@pytest.mark.parametrize(
"non_mapping_metadata",
[
"[]", # JSON array
'"oops"', # JSON string
"42", # JSON number
"true", # JSON bool
"null", # JSON null
],
)
def test_is_cache_valid_handles_non_mapping_metadata(
self, project_dir, non_mapping_metadata
):
"""Metadata that parses to a non-mapping degrades to cache-invalid.
The cache-validity check calls ``metadata.get("cached_at", "")``
immediately after ``json.loads``. If the metadata file is valid
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
previously slipped past the except tuple and crashed the
caller. The contract documented on ``is_cache_valid`` says any
decode/shape failure should return ``False`` so ``fetch_catalog``
falls through to a network refetch. This test pins that
contract across every JSON non-mapping root type.
"""
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
catalog.cache_metadata_file.write_text(
non_mapping_metadata, encoding="utf-8"
)
# Must not raise — the contract is "any decode/shape failure → False".
assert catalog.is_cache_valid() is False
def test_fetch_catalog_writes_cache_as_utf8(self, project_dir, monkeypatch):
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
The earlier version of this test claimed to assert UTF-8 at the
byte level but actually only round-tripped a non-ASCII string
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
ever reached ``write_text`` — the bytes on disk were identical
regardless of the encoding kwarg. The drift Copilot's review
flagged wasn't actually being caught.
Fix: directly observe the ``encoding`` argument passed to every
``write_text`` call made against the cache directory. This is
the production code's encoding choice, which is exactly what
the regression guard cares about.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = PresetCatalog(project_dir)
payload = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# Record every ``write_text`` call's encoding kwarg so the
# assertion observes the production writer's argument directly.
recorded: list[dict] = []
real_write_text = _PathCls.write_text
def recording_write_text(self, data, *args, **kwargs):
recorded.append(
{"path": str(self), "encoding": kwargs.get("encoding")}
)
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
catalog.fetch_catalog(force_refresh=True)
cache_writes = [
r for r in recorded if str(catalog.cache_dir) in r["path"]
]
assert cache_writes, "fetch_catalog made no writes to the cache dir"
for record in cache_writes:
assert record["encoding"] == "utf-8", (
f"write_text on {record['path']} used encoding "
f"{record['encoding']!r}; expected 'utf-8'"
)
def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch):
"""An unwritable cache dir doesn't fail a successful fetch.
Cache writes are best-effort, mirroring the read side and the
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
raises ``OSError`` (read-only checkout, permissions), the
already-fetched-and-validated payload must still be returned —
not swallowed into the broad except and re-raised as a
``PresetError``.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = PresetCatalog(project_dir)
valid = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# Simulate an unwritable cache dir: every write_text under the
# cache directory raises PermissionError (an OSError subclass).
real_write_text = _PathCls.write_text
def failing_write_text(self, data, *args, **kwargs):
if str(catalog.cache_dir) in str(self):
raise PermissionError("cache dir is read-only")
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
# Legacy single-catalog path.
assert catalog.fetch_catalog(force_refresh=True) == valid
# Multi-catalog path.
entry = PresetCatalogEntry(
url=catalog.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
)
assert (
catalog._fetch_single_catalog(entry, force_refresh=True) == valid
)
def test_get_merged_packs_skips_non_mapping_entries(self, project_dir):
"""Per-entry guard: one malformed entry shouldn't poison the merge.
``_fetch_single_catalog`` validates that ``presets`` is a mapping,
but it doesn't (and shouldn't) validate every entry inside it — a
single bad entry in an otherwise-valid catalog should be skipped,
not crash the whole resolve path. Mirrors the per-entry skip in
``integrations/catalog.py``: a malformed entry returns no error,
valid entries continue to merge normally.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
payload = {
"schema_version": "1.0",
"presets": {
"good": {"name": "Good", "version": "1.0.0"},
"bad-list": [],
"bad-str": "oops",
},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = PresetCatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response), \
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
merged = catalog._get_merged_packs(force_refresh=True)
# Only the well-formed entry survives; the two malformed entries are
# silently dropped rather than raising or crashing.
assert list(merged.keys()) == ["good"]
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
"""download_pack passes Authorization header when configured."""
from unittest.mock import patch, MagicMock
@@ -4259,141 +3842,6 @@ class TestBundledPresetLocator:
assert "Lean Workflow" in result.output
assert "installed" in result.output.lower()
def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch):
"""URL installs reject redirects from HTTPS to non-loopback HTTP."""
import typer
from specify_cli.presets._commands import preset_add
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "http://example.com/preset.zip"
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
assert redirect_validator is not None
redirect_validator(url, "http://example.com/preset.zip")
return FakeResponse(b"zip")
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
installed = False
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
nonlocal installed
installed = True
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
with pytest.raises(typer.Exit) as exc_info:
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
assert exc_info.value.exit_code == 1
assert installed is False
def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir):
"""URL installs reject HTTPS URLs without a hostname before downloading."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.authentication.http.open_url") as open_url:
result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"])
assert result.exit_code == 1
output = strip_ansi(result.output)
assert "URL must use HTTPS with a hostname" in output
assert "got https://" not in output
open_url.assert_not_called()
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
import typer
from specify_cli.presets._commands import preset_add
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https:///preset.zip"
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
monkeypatch.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"),
)
monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None)
with pytest.raises(typer.Exit) as exc_info:
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
assert exc_info.value.exit_code == 1
output = strip_ansi(capsys.readouterr().out)
assert "redirected to a disallowed URL" in output
assert "must use HTTPS with a hostname" in output
def test_preset_add_from_url_streams_download_to_zip(self, project_dir, monkeypatch):
"""URL installs stream response bytes to disk before installing the ZIP."""
from specify_cli.presets._commands import preset_add
class FakeResponse(io.BytesIO):
def __init__(self, data):
super().__init__(data)
self.read_sizes = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https://example.com/preset.zip"
def read(self, size=-1):
assert size not in (-1, None)
self.read_sizes.append(size)
return super().read(size)
response = FakeResponse(b"zip-bytes")
installed = {}
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
installed["zip_bytes"] = Path(zip_path).read_bytes()
installed["speckit_version"] = speckit_version
installed["priority"] = priority
return SimpleNamespace(name="Test Preset", version="1.0.0")
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
monkeypatch.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: response,
)
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7)
assert response.read_sizes
assert installed == {
"zip_bytes": b"zip-bytes",
"speckit_version": "0.6.0",
"priority": 7,
}
def test_bundled_preset_in_catalog(self):
"""Verify the lean preset is listed in catalog.json with bundled marker."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
@@ -4483,7 +3931,7 @@ class TestPresetAddFromUrlResolution:
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
def fake_open_url(url, timeout=None, extra_headers=None):
captured_urls.append((url, extra_headers))
if "releases/tags/" in url:
return FakeResponse(json.dumps({
@@ -4541,7 +3989,7 @@ class TestPresetAddFromUrlResolution:
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
def fake_open_url(url, timeout=None, extra_headers=None):
captured_urls.append((url, extra_headers))
return FakeResponse(zip_bytes)

View File

@@ -18,7 +18,7 @@ SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1"
PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
def _install_bash_scripts(repo: Path) -> None:
@@ -41,13 +41,6 @@ def _minimal_templates(repo: Path) -> None:
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
def _write_feature_json(repo: Path, feature_directory: str) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
"""Return a copy of the current environment with any SPECIFY_* vars removed.
@@ -96,7 +89,10 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
(plan_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
encoding="utf-8",
)
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script)],
@@ -111,8 +107,12 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
@requires_bash
def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-plan must error."""
def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script)],
@@ -123,14 +123,13 @@ def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
env=_clean_env(),
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Not on a feature branch" in result.stderr
@requires_bash
def test_setup_plan_numbered_branch_works_with_feature_json(
def test_setup_plan_numbered_branch_unchanged_without_feature_json(
plan_repo: Path,
) -> None:
"""A numbered branch still works when feature.json explicitly pins the spec dir."""
subprocess.run(
["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
cwd=plan_repo,
@@ -139,7 +138,6 @@ def test_setup_plan_numbered_branch_works_with_feature_json(
feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script)],
@@ -153,7 +151,7 @@ def test_setup_plan_numbered_branch_works_with_feature_json(
assert (feat / "plan.md").is_file()
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: Path) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
@@ -163,9 +161,12 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
(plan_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
encoding="utf-8",
)
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script)],
cwd=plan_repo,
@@ -178,12 +179,17 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
assert (feat / "plan.md").is_file()
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_plan_ps_errors_without_feature_context(
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_plan_ps_fails_custom_branch_without_feature_json(
plan_repo: Path,
) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script)],
cwd=plan_repo,
@@ -192,6 +198,5 @@ def test_setup_plan_ps_errors_without_feature_context(
check=False,
env=_clean_env(),
)
combined = result.stderr + result.stdout
assert result.returncode != 0
assert "Feature directory not found" in combined
assert "Not on a feature branch" in result.stderr

View File

@@ -18,7 +18,7 @@ SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1"
PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
def _install_bash_scripts(repo: Path) -> None:
@@ -41,15 +41,6 @@ def _minimal_templates(repo: Path) -> None:
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
@@ -83,7 +74,6 @@ def plan_repo(tmp_path: Path) -> Path:
_minimal_templates(repo)
_install_bash_scripts(repo)
_install_ps_scripts(repo)
_write_feature_json(repo)
return repo
@@ -178,11 +168,11 @@ def test_setup_plan_json_parseable_on_first_run(plan_repo: Path) -> None:
# ── PowerShell tests ──────────────────────────────────────────────────────
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None:
"""First run must create plan.md from the template."""
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=plan_repo,
@@ -199,7 +189,7 @@ def test_ps_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None:
assert len(content) > 0
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None:
"""Rerun must not overwrite an existing plan.md."""
feat = plan_repo / "specs" / "001-my-feature"
@@ -208,7 +198,7 @@ def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None:
(feat / "plan.md").write_text(existing_content, encoding="utf-8")
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=plan_repo,

View File

@@ -1,4 +1,4 @@
"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation."""
import json
import os
@@ -20,7 +20,7 @@ CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
# ---------------------------------------------------------------------------
@@ -50,15 +50,6 @@ def _install_core_tasks_template(repo: Path) -> None:
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _minimal_feature(repo: Path) -> Path:
"""
Create a numbered branch-style feature directory with spec.md and plan.md
@@ -69,7 +60,6 @@ def _minimal_feature(repo: Path) -> Path:
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(repo)
return feat
@@ -95,7 +85,7 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
def _clean_env() -> dict[str, str]:
"""
Return os.environ with all SPECIFY_* variables stripped so the scripts
rely purely on feature.json and on-disk feature directories set up by each fixture.
rely purely on git branch + feature.json state set up by each fixture.
"""
env = os.environ.copy()
for key in list(env):
@@ -118,7 +108,7 @@ def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.Comple
def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
return subprocess.run(
[
exe,
@@ -163,8 +153,7 @@ def tasks_repo(tmp_path: Path) -> Path:
repo.mkdir()
_git_init(repo)
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
# feature directories from repository state rather than validating git branches.
# Switch to a numbered branch so branch validation passes without feature.json
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=repo,
@@ -503,7 +492,6 @@ def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
@@ -562,7 +550,11 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
(tasks_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-my-feature"}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
@@ -579,17 +571,21 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
@requires_bash
def test_setup_tasks_bash_errors_without_feature_context(
def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
tasks_repo: Path,
) -> None:
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.sh must error."""
main_feat = tasks_repo / "specs" / "main"
main_feat.mkdir(parents=True, exist_ok=True)
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
"""
On a non-standard branch with no feature.json, setup-tasks.sh must fail
and report that we are not on a feature branch.
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -600,13 +596,13 @@ def test_setup_tasks_bash_errors_without_feature_context(
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Not on a feature branch" in result.stderr
# ===========================================================================
# POWERSHELL TESTS
# ===========================================================================
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
"""
When the core tasks-template.md is present and all prerequisites are met,
@@ -615,7 +611,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
"""
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
@@ -635,7 +631,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
assert tasks_tmpl.name == "tasks-template.md"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
"""
When an override exists at .specify/templates/overrides/tasks-template.md,
@@ -649,7 +645,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
@@ -671,7 +667,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
)
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
"""
When tasks-template.md is absent from all locations, setup-tasks.ps1 must
@@ -683,7 +679,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
@@ -698,7 +694,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_normalizes_mixed_separators(
tasks_repo: Path,
) -> None:
@@ -717,7 +713,7 @@ def test_powershell_command_hint_normalizes_mixed_separators(
assert result.stdout.strip() == "/speckit-git-commit"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_preserves_hyphens_inside_segments(
tasks_repo: Path,
) -> None:
@@ -729,16 +725,15 @@ def test_powershell_command_hint_preserves_hyphens_inside_segments(
assert result.stdout.strip() == "/speckit.jira.sync-status"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
@@ -755,7 +750,7 @@ def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) ->
assert "/speckit.plan" not in output
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
tasks_repo: Path,
) -> None:
@@ -763,7 +758,7 @@ def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-RequireTasks"],
@@ -780,7 +775,7 @@ def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
assert "/speckit.tasks" not in output
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
tasks_repo: Path,
) -> None:
@@ -798,10 +793,14 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
(tasks_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-my-feature"}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
@@ -815,19 +814,23 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
assert result.returncode == 0, result.stderr + result.stdout
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_errors_without_feature_context(
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
tasks_repo: Path,
) -> None:
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.ps1 must error."""
main_feat = tasks_repo / "specs" / "main"
main_feat.mkdir(parents=True, exist_ok=True)
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
"""
On a non-standard branch with no feature.json, setup-tasks.ps1 must fail
and report that we are not on a feature branch.
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -836,7 +839,6 @@ def test_setup_tasks_ps_errors_without_feature_context(
check=False,
env=_clean_env(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Feature directory not found" in output
assert "Not on a feature branch" in result.stderr

View File

@@ -19,12 +19,14 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
EXT_CREATE_FEATURE = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
)
EXT_CREATE_FEATURE_PS = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
)
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
HAS_PWSH = shutil.which("pwsh") is not None
@@ -75,7 +77,7 @@ def ext_git_repo(tmp_path: Path) -> Path:
# Copy extension script
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
ext_dir.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature-branch.sh")
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh")
# Also copy git-common.sh if it exists
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
if git_common.exists():
@@ -104,7 +106,7 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
# Copy extension script
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
ext_ps.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature-branch.ps1")
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1")
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
if git_common_ps.exists():
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
@@ -277,13 +279,64 @@ class TestSequentialBranchPowerShell:
@requires_bash
class TestCoreCommonRemovesGitHelpers:
def test_check_feature_branch_removed(self):
result = source_and_call('declare -F check_feature_branch >/dev/null')
class TestCheckFeatureBranch:
def test_accepts_timestamp_branch(self):
"""Test 6: check_feature_branch accepts timestamp branch."""
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
assert result.returncode == 0
def test_accepts_sequential_branch(self):
"""Test 7: check_feature_branch accepts sequential branch."""
result = source_and_call('check_feature_branch "004-feat" "true"')
assert result.returncode == 0
def test_rejects_main(self):
"""Test 8: check_feature_branch rejects main."""
result = source_and_call('check_feature_branch "main" "true"')
assert result.returncode != 0
def test_has_git_removed(self):
result = source_and_call('declare -F has_git >/dev/null')
def test_accepts_four_digit_sequential_branch(self):
"""check_feature_branch accepts 4+ digit sequential branch."""
result = source_and_call('check_feature_branch "1234-feat" "true"')
assert result.returncode == 0
def test_rejects_partial_timestamp(self):
"""Test 9: check_feature_branch rejects 7-digit date."""
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
assert result.returncode != 0
def test_rejects_timestamp_without_slug(self):
"""check_feature_branch rejects timestamp-like branch missing trailing slug."""
result = source_and_call('check_feature_branch "20260319-143022" "true"')
assert result.returncode != 0
def test_rejects_7digit_timestamp_without_slug(self):
"""check_feature_branch rejects 7-digit date + 6-digit time without slug."""
result = source_and_call('check_feature_branch "2026031-143022" "true"')
assert result.returncode != 0
def test_accepts_single_prefix_sequential(self):
"""Optional gitflow-style prefix: one segment + sequential feature name."""
result = source_and_call('check_feature_branch "feat/004-my-feature" "true"')
assert result.returncode == 0
def test_accepts_single_prefix_timestamp(self):
"""Optional prefix + timestamp-style feature name."""
result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"')
assert result.returncode == 0
def test_rejects_invalid_suffix_with_single_prefix(self):
result = source_and_call('check_feature_branch "feat/main" "true"')
assert result.returncode != 0
assert "feat/main" in result.stderr
def test_rejects_two_level_prefix_before_feature(self):
"""More than one slash: no stripping; whole name must match (fails)."""
result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"')
assert result.returncode != 0
def test_rejects_malformed_timestamp_with_prefix(self):
result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"')
assert result.returncode != 0
@@ -291,11 +344,50 @@ class TestCoreCommonRemovesGitHelpers:
@requires_bash
class TestFindFeatureDirByPrefixRemoved:
def test_find_feature_dir_by_prefix_removed(self):
"""Directory scanning helper is removed from core common.sh."""
result = source_and_call('declare -F find_feature_dir_by_prefix >/dev/null')
assert result.returncode != 0
class TestFindFeatureDirByPrefix:
def test_timestamp_branch(self, tmp_path: Path):
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
def test_cross_branch_prefix(self, tmp_path: Path):
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
def test_four_digit_sequential_prefix(self, tmp_path: Path):
"""find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
(tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
def test_sequential_with_single_path_prefix(self, tmp_path: Path):
"""Strip one optional prefix segment before prefix directory lookup."""
(tmp_path / "specs" / "004-only-dir").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir"
def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
(tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical"
# ── get_feature_paths + single-prefix integration ───────────────────────────
@@ -303,29 +395,26 @@ class TestFindFeatureDirByPrefixRemoved:
class TestGetFeaturePathsSinglePrefix:
@requires_bash
def test_bash_specify_feature_prefixed_requires_explicit_feature_context(
self, tmp_path: Path
):
"""SPECIFY_FEATURE alone no longer triggers path lookup in bash."""
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup."""
(tmp_path / ".specify").mkdir()
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
cmd = (
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
f'source "{COMMON_SH}" && get_feature_paths'
f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"'
)
result = subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_specify_feature_prefixed_requires_explicit_feature_context(
self, git_repo: Path
):
"""PowerShell also requires feature.json or SPECIFY_FEATURE_DIRECTORY."""
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
spec_dir.mkdir(parents=True)
@@ -337,8 +426,14 @@ class TestGetFeaturePathsSinglePrefix:
text=True,
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
)
assert result.returncode != 0
assert "Feature directory not found" in (result.stderr + result.stdout)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip()
assert val == str(spec_dir)
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")
# ── get_current_branch Tests ─────────────────────────────────────────────────
@@ -358,11 +453,12 @@ class TestGetCurrentBranch:
@requires_bash
class TestNoGitTimestamp:
def test_no_git_timestamp(self, no_git_dir: Path):
"""Test 13: Timestamp mode works without git and creates a spec dir."""
"""Test 13: No-git repo + timestamp creates spec dir with warning."""
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
assert result.returncode == 0, result.stderr
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
assert len(spec_dirs) > 0, "spec dir not created"
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
@@ -371,65 +467,32 @@ class TestNoGitTimestamp:
@requires_bash
class TestE2EFlow:
def test_e2e_timestamp(self, git_repo: Path):
"""Test 14: E2E timestamp flow creates only a feature directory."""
before = subprocess.run(
"""Test 14: E2E timestamp flow — branch, dir, validation."""
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
assert result.returncode == 0, result.stderr
branch_name = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch_name = line.split(":", 1)[1].strip()
break
assert branch_name is not None
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch_name), f"branch: {branch_name}"
assert (git_repo / "specs" / branch_name).is_dir()
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}"
assert (git_repo / "specs" / branch).is_dir()
val = source_and_call(f'check_feature_branch "{branch}" "true"')
assert val.returncode == 0
def test_e2e_sequential(self, git_repo: Path):
"""Test 15: E2E sequential flow creates only a feature directory."""
before = subprocess.run(
"""Test 15: E2E sequential flow (regression guard)."""
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
assert result.returncode == 0, result.stderr
branch_name = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch_name = line.split(":", 1)[1].strip()
break
assert branch_name == "001-seq-feat"
assert (git_repo / "specs" / branch_name).is_dir()
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}"
assert (git_repo / "specs" / branch).is_dir()
val = source_and_call(f'check_feature_branch "{branch}" "true"')
assert val.returncode == 0
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
@@ -437,22 +500,67 @@ class TestE2EFlow:
@requires_bash
class TestAllowExistingBranch:
def test_allow_existing_reuses_existing_feature_dir(self, git_repo: Path):
"""T006: Existing feature directory can be reused when the flag is set."""
feature_dir = git_repo / "specs" / "004-pre-exist"
feature_dir.mkdir(parents=True)
def test_allow_existing_switches_to_branch(self, git_repo: Path):
"""T006: Pre-create branch, verify script switches to it."""
subprocess.run(
["git", "checkout", "-b", "004-pre-exist"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
"--number", "4", "Pre-existing feature",
)
assert result.returncode == 0, result.stderr
assert feature_dir.is_dir()
assert (feature_dir / "spec.md").exists()
current = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, capture_output=True, text=True,
).stdout.strip()
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
def test_allow_existing_already_on_branch(self, git_repo: Path):
"""T007: Verify success when already on the target branch."""
subprocess.run(
["git", "checkout", "-b", "005-already-on"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "already-on",
"--number", "5", "Already on branch",
)
assert result.returncode == 0, result.stderr
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
"""T008: Verify spec directory created on existing branch."""
subprocess.run(
["git", "checkout", "-b", "006-spec-dir"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
"--number", "6", "Spec dir feature",
)
assert result.returncode == 0, result.stderr
assert (git_repo / "specs" / "006-spec-dir").is_dir()
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
def test_without_flag_still_errors(self, git_repo: Path):
"""T009: Existing feature directories still fail without the flag."""
(git_repo / "specs" / "007-no-flag").mkdir(parents=True)
"""T009: Verify backwards compatibility (error without flag)."""
subprocess.run(
["git", "checkout", "-b", "007-no-flag"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
)
@@ -461,11 +569,18 @@ class TestAllowExistingBranch:
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
"""T010: Pre-create spec.md with content, verify it is preserved."""
subprocess.run(
["git", "checkout", "-b", "008-no-overwrite"],
cwd=git_repo, check=True, capture_output=True,
)
spec_dir = git_repo / "specs" / "008-no-overwrite"
spec_dir.mkdir(parents=True)
spec_file = spec_dir / "spec.md"
spec_file.write_text("# My custom spec content\n")
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
"--number", "8", "No overwrite feature",
@@ -473,20 +588,31 @@ class TestAllowExistingBranch:
assert result.returncode == 0, result.stderr
assert spec_file.read_text() == "# My custom spec content\n"
def test_allow_existing_creates_feature_dir_when_missing(self, git_repo: Path):
"""T011: Verify normal directory creation when the feature dir does not exist."""
def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path):
"""T011: Verify normal creation when branch doesn't exist."""
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
"New branch feature",
)
assert result.returncode == 0, result.stderr
assert (git_repo / "specs" / "001-new-branch").is_dir()
current = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, capture_output=True, text=True,
).stdout.strip()
assert "new-branch" in current
def test_allow_existing_with_json(self, git_repo: Path):
"""T012: Verify JSON output is correct."""
import json
(git_repo / "specs" / "009-json-test").mkdir(parents=True)
subprocess.run(
["git", "checkout", "-b", "009-json-test"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
"--number", "9", "JSON test",
@@ -496,26 +622,64 @@ class TestAllowExistingBranch:
assert data["BRANCH_NAME"] == "009-json-test"
def test_allow_existing_no_git(self, no_git_dir: Path):
"""T013: Verify flag also works in non-git repos."""
"""T013: Verify flag is silently ignored in non-git repos."""
result = run_script(
no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
"No git feature",
)
assert result.returncode == 0, result.stderr
def test_allow_existing_surfaces_checkout_error(self, git_repo: Path):
"""Checkout failures on an existing branch should include Git's stderr."""
shared_file = git_repo / "shared.txt"
shared_file.write_text("base\n")
subprocess.run(
["git", "add", "shared.txt"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "add shared file", "-q"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-b", "010-checkout-failure"],
cwd=git_repo, check=True, capture_output=True,
)
shared_file.write_text("branch version\n")
subprocess.run(
["git", "commit", "-am", "branch change", "-q"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
shared_file.write_text("uncommitted main change\n")
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "checkout-failure",
"--number", "10", "Checkout failure",
)
assert result.returncode != 0, "checkout should fail with conflicting local changes"
assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr
assert "would be overwritten by checkout" in result.stderr
assert "shared.txt" in result.stderr
class TestAllowExistingBranchPowerShell:
def test_powershell_supports_allow_existing_branch_flag(self):
"""Static guard: PS script exposes and uses -AllowExistingBranch."""
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "-AllowExistingBranch" in contents
# Ensure the flag is referenced in script logic, not just declared
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
def test_powershell_reuses_existing_feature_dir(self):
"""Static guard: PS script handles existing feature directories without git."""
def test_powershell_surfaces_checkout_errors(self):
"""Static guard: PS script preserves checkout stderr on existing-branch failures."""
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "Feature directory '$featureDir' already exists" in contents
assert "-not $AllowExistingBranch" in contents
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
@pytest.mark.skipif(
@@ -590,27 +754,20 @@ class TestDryRun:
branch = line.split(":", 1)[1].strip()
assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
def test_dry_run_does_not_change_git_branch(self, git_repo: Path):
"""T010: Dry-run leaves the current git branch untouched."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
def test_dry_run_no_branch_created(self, git_repo: Path):
"""T010: Dry-run does not create a git branch."""
result = run_script(
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
)
assert result.returncode == 0, result.stderr
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
branches = subprocess.run(
["git", "branch", "--list", "*no-branch*"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
)
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
def test_dry_run_no_spec_dir_created(self, git_repo: Path):
"""T011: Dry-run does not create any directories (including root specs/)."""
@@ -675,22 +832,50 @@ class TestDryRun:
real_branch = line.split(":", 1)[1].strip()
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
def test_dry_run_ignores_git_branches(self, git_repo: Path):
"""Dry-run uses only spec directories for numbering."""
def test_dry_run_accounts_for_remote_branches(self, git_repo: Path):
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering."""
(git_repo / "specs" / "001-existing").mkdir(parents=True)
# Set up a bare remote and push (use subdirs of git_repo for isolation)
remote_dir = git_repo / "test-remote.git"
subprocess.run(
["git", "checkout", "-b", "005-git-only"],
cwd=git_repo,
check=True,
capture_output=True,
["git", "init", "--bare", str(remote_dir)],
check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo,
check=True,
capture_output=True,
["git", "remote", "add", "origin", str(remote_dir)],
check=True, cwd=git_repo, capture_output=True,
)
subprocess.run(
["git", "push", "-u", "origin", "HEAD"],
check=True, cwd=git_repo, capture_output=True,
)
# Clone into a second copy, create a higher-numbered branch, push it
second_clone = git_repo / "test-second-clone"
subprocess.run(
["git", "clone", str(remote_dir), str(second_clone)],
check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=second_clone, check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=second_clone, check=True, capture_output=True,
)
# Create branch 005 on the remote (higher than local 001)
subprocess.run(
["git", "checkout", "-b", "005-remote-only"],
cwd=second_clone, check=True, capture_output=True,
)
subprocess.run(
["git", "push", "origin", "005-remote-only"],
cwd=second_clone, check=True, capture_output=True,
)
# Primary repo: dry-run should see 005 via ls-remote and return 006
dry_result = run_script(
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
)
@@ -699,7 +884,7 @@ class TestDryRun:
for line in dry_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
dry_branch = line.split(":", 1)[1].strip()
assert dry_branch == "002-remote-test", f"expected 002-remote-test, got: {dry_branch}"
assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}"
def test_dry_run_json_includes_field(self, git_repo: Path):
"""T015: JSON output includes DRY_RUN field when --dry-run is active."""
@@ -725,14 +910,7 @@ class TestDryRun:
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
def test_dry_run_with_timestamp(self, git_repo: Path):
"""T017: Dry-run works with --timestamp flag without mutating git state."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
"""T017: Dry-run works with --timestamp flag."""
result = run_script(
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
)
@@ -743,14 +921,15 @@ class TestDryRun:
branch = line.split(":", 1)[1].strip()
assert branch is not None, "no BRANCH_NAME in output"
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
# Verify no side effects
branches = subprocess.run(
["git", "branch", "--list", "*ts-feat*"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
)
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
assert branches.stdout.strip() == ""
def test_dry_run_with_number(self, git_repo: Path):
"""T018: Dry-run works with --number flag."""
@@ -810,27 +989,20 @@ class TestPowerShellDryRun:
branch = line.split(":", 1)[1].strip()
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
def test_ps_dry_run_does_not_change_git_branch(self, ps_git_repo: Path):
"""PowerShell -DryRun leaves the current git branch untouched."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=ps_git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create a git branch."""
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
)
assert result.returncode == 0, result.stderr
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
branches = subprocess.run(
["git", "branch", "--list", "*no-ps-branch*"],
cwd=ps_git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
)
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create specs/ directory."""
@@ -874,10 +1046,10 @@ class TestPowerShellDryRun:
@requires_bash
class TestGitBranchNameOverrideBash:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.sh."""
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
env={**os.environ, **env_extras})
@@ -929,10 +1101,10 @@ class TestGitBranchNameOverrideBash:
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
class TestGitBranchNameOverridePowerShell:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.ps1."""
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1."""
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
cwd=ext_ps_git_repo, capture_output=True, text=True,
@@ -1058,8 +1230,9 @@ class TestFeatureDirectoryResolution:
pytest.fail("FEATURE_DIR not found in output")
@requires_bash
def test_errors_without_env_var_or_feature_json(self, git_repo: Path):
"""Without env var or feature.json, get_feature_paths now errors."""
def test_fallback_to_branch_lookup(self, git_repo: Path):
"""Without env var or feature.json, falls back to branch-based lookup."""
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
spec_dir = git_repo / "specs" / "001-test-feat"
spec_dir.mkdir(parents=True)
@@ -1069,8 +1242,14 @@ class TestFeatureDirectoryResolution:
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(spec_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
@@ -1164,7 +1343,7 @@ class TestDescriptionQuoting:
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
"""Extension create-new-feature-branch.sh succeeds with special characters in description."""
"""Extension create-new-feature.sh succeeds with special characters in description."""
script = (
ext_git_repo
/ ".specify"
@@ -1172,7 +1351,7 @@ class TestDescriptionQuoting:
/ "git"
/ "scripts"
/ "bash"
/ "create-new-feature-branch.sh"
/ "create-new-feature.sh"
)
result = subprocess.run(
["bash", str(script), "--dry-run", "--short-name", "feat", description],

View File

@@ -1,6 +1,6 @@
"""Regression guard: utility and asset symbols importable from specify_cli."""
from specify_cli import (
check_tool, merge_json_files,
check_tool, is_git_repo, merge_json_files,
get_speckit_version,
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
)
@@ -9,6 +9,7 @@ from pathlib import Path
def test_utils_symbols_importable():
assert callable(check_tool)
assert callable(merge_json_files)
assert callable(is_git_repo)
def test_get_speckit_version_returns_string():
version = get_speckit_version()

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