Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
38b800cde3 chore: bump version to 0.10.0 2026-06-09 11:19:45 +00:00
32 changed files with 878 additions and 5039 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

View File

@@ -2,65 +2,6 @@
<!-- insert new changelog below this comment -->
## [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

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,13 +7,13 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, inclusive-content guidance, and didactic inline-code-comment review | 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 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) |
| 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) |
| 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) |

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,11 +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 three common ways to manage
those artifacts over time.
## 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

@@ -126,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,10 +41,6 @@
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

File diff suppressed because it is too large Load Diff

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-14T00: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.3.0",
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, and didactic inline-code-comment review to Spec Kit.",
"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.3.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": [
@@ -29,16 +29,16 @@
"inclusion"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-05T00: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",
@@ -46,7 +46,7 @@
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 6,
"templates": 9,
"commands": 3
},
"tags": [
@@ -59,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",
@@ -171,11 +171,11 @@
"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",
@@ -188,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",

View File

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

View File

@@ -57,7 +57,7 @@ from ._console import (
)
from ._assets import (
_locate_bundled_extension,
_locate_bundled_preset as _locate_bundled_preset,
_locate_bundled_preset,
_locate_bundled_workflow as _locate_bundled_workflow,
_locate_core_pack,
_repo_root,
@@ -576,6 +576,20 @@ catalog_app = typer.Typer(
)
extension_app.add_typer(catalog_app, name="catalog")
preset_app = typer.Typer(
name="preset",
help="Manage spec-kit presets",
add_completion=False,
)
app.add_typer(preset_app, name="preset")
preset_catalog_app = typer.Typer(
name="catalog",
help="Manage preset catalogs",
add_completion=False,
)
preset_app.add_typer(preset_catalog_app, name="catalog")
# ===== Integration Commands =====
@@ -603,9 +617,620 @@ def _require_specify_project() -> Path:
# ===== Preset Commands =====
# Moved to presets/_commands.py — registered here to preserve CLI surface.
from .presets._commands import register as _register_preset_cmds # noqa: E402
_register_preset_cmds(app)
@preset_app.command("list")
def preset_list():
"""List installed presets."""
from .presets 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 .presets 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 urllib.parse import urlparse as _urlparse
_parsed = _urlparse(from_url)
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
import urllib.error
import tempfile
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) as response:
zip_path.write_bytes(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 .presets 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 .presets 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 .presets 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 .extensions import normalize_priority
from .presets 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 .presets 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 .presets 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 .presets 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 .presets 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 .presets 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.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."""
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.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]")
# ===== Extension Commands =====
@@ -1350,11 +1975,7 @@ def extension_info(
author = ext_manifest.data.get("extension", {}).get("author")
if author:
console.print(f"[dim]Author:[/dim] {author}")
if ext_manifest.category:
console.print(f"[dim]Category:[/dim] {ext_manifest.category}")
if ext_manifest.effect:
console.print(f"[dim]Effect:[/dim] {ext_manifest.effect}")
console.print()
console.print()
if ext_manifest.commands:
console.print("[bold]Commands:[/bold]")
@@ -1404,12 +2025,6 @@ def _print_extension_info(ext_info: dict, manager):
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
# Category and Effect
if ext_info.get('category'):
console.print(f"[dim]Category:[/dim] {ext_info['category']}")
if ext_info.get('effect'):
console.print(f"[dim]Effect:[/dim] {ext_info['effect']}")
# Source catalog
if ext_info.get("_catalog_name"):
install_allowed = ext_info.get("_install_allowed", True)

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

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

@@ -41,8 +41,6 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
})
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
VALID_EFFECTS = frozenset({"read-only", "read-write"})
DEFAULT_HOOK_PRIORITY = 10
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
@@ -203,21 +201,6 @@ class ExtensionManifest:
except pkg_version.InvalidVersion:
raise ValidationError(f"Invalid version: {ext['version']}")
# Validate optional category field (free-form string)
if "category" in ext:
if not isinstance(ext["category"], str) or not ext["category"].strip():
raise ValidationError(
"Invalid extension.category: must be a non-empty string"
)
# Validate optional effect field
if "effect" in ext:
if not isinstance(ext["effect"], str) or ext["effect"] not in VALID_EFFECTS:
raise ValidationError(
f"Invalid extension.effect '{ext.get('effect')}': "
f"must be one of {sorted(VALID_EFFECTS)}"
)
# Validate requires section
requires = self.data["requires"]
if "speckit_version" not in requires:
@@ -391,16 +374,6 @@ class ExtensionManifest:
"""Get extension description."""
return self.data["extension"]["description"]
@property
def category(self) -> Optional[str]:
"""Get extension category (free-form; common values: docs, code, process, integration, visibility)."""
return self.data["extension"].get("category")
@property
def effect(self) -> Optional[str]:
"""Get extension effect (read-only, read-write)."""
return self.data["extension"].get("effect")
@property
def requires_speckit_version(self) -> str:
"""Get required spec-kit version range."""
@@ -1053,22 +1026,6 @@ class ExtensionManager:
description,
f"extension:{manifest.id}",
)
# Preserve the command's argument-hint in the generated skill,
# mirroring the core template path (ClaudeIntegration.setup injects
# it for built-in commands). The value is added to the frontmatter
# dict before serialization — rather than via the string-based
# inject_argument_hint helper — so that a folded multi-line
# description cannot be split by the inserted line. Gated on the
# integration exposing inject_argument_hint so only argument-hint
# aware agents receive the key, leaving build_skill_frontmatter's
# shared shape unchanged for every other agent.
argument_hint = frontmatter.get("argument-hint")
if (
argument_hint
and integration is not None
and hasattr(integration, "inject_argument_hint")
):
frontmatter_data["argument-hint"] = str(argument_hint)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
# Derive a human-friendly title from the command name
@@ -1948,44 +1905,6 @@ class ExtensionCatalog(CatalogStackBase):
download_url, self._open_url, timeout=timeout
)
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
"""Validate a parsed 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_extensions`` on subsequent calls.
Checking only key presence would let a payload like
``{"extensions": []}`` or ``{"extensions": null}`` slip through
here and then crash with ``AttributeError: 'list' object has no
attribute 'items'`` deep inside ``_get_merged_extensions``. The
sibling integration catalog reader already guards both the root
object and the nested mapping (see ``integrations/catalog.py``);
the extension catalog must stay consistent so a malformed payload
surfaces as the user-facing ``Invalid 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:
ExtensionError: If the payload's shape is invalid.
"""
if not isinstance(catalog_data, dict):
raise ExtensionError(
f"Invalid catalog format from {url}: expected a JSON object"
)
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
raise ExtensionError(f"Invalid catalog format from {url}")
if not isinstance(catalog_data.get("extensions"), dict):
raise ExtensionError(
f"Invalid catalog format from {url}: "
"'extensions' must be a JSON object"
)
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
@@ -2101,51 +2020,21 @@ class ExtensionCatalog(CatalogStackBase):
is_valid = False
if not force_refresh and cache_file.exists() and cache_meta_file.exists():
try:
metadata = json.loads(
cache_meta_file.read_text(encoding="utf-8")
)
metadata = json.loads(cache_meta_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)
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
is_valid = age < self.CACHE_DURATION
except (
json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
KeyError,
TypeError,
AttributeError,
):
# Cache validity is best-effort: invalid/missing metadata
# fields, an unreadable metadata file (permissions / disk),
# a wrongly-encoded metadata file (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):
# If metadata is invalid or missing expected fields, treat cache as invalid
pass
# Use cache if valid. A previously-cached payload must clear the
# same shape checks as a freshly-fetched one — otherwise a once-
# poisoned cache (older spec-kit version, manual edit, upstream
# served a bad payload before the network-side guards were added)
# 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.
# Use cache if valid
if is_valid:
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, ExtensionError):
# 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
# Fetch from network
@@ -2153,32 +2042,16 @@ class ExtensionCatalog(CatalogStackBase):
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 "extensions" not in catalog_data:
raise ExtensionError(f"Invalid catalog format from {entry.url}")
# Save to cache. Both files are explicitly UTF-8 to match the
# ``read_text(encoding="utf-8")`` on the read side and the
# ``integrations/catalog.py`` precedent (see the cache write
# helpers in ``CatalogCache`` there). Without this, platforms
# whose default encoding isn't UTF-8 would write locale-encoded
# bytes that the read path can't decode, forcing an unnecessary
# network refetch on every invocation. The write itself is
# best-effort, matching the read side: an unwritable cache dir
# (read-only checkout, permissions) must not fail a fetch whose
# payload 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"
)
cache_meta_file.write_text(
json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
}, indent=2),
encoding="utf-8",
)
except OSError:
pass # Cache is best-effort; proceed with fetched data
# Save to cache
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(catalog_data, indent=2))
cache_meta_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
}, indent=2))
return catalog_data
@@ -2225,16 +2098,6 @@ class ExtensionCatalog(CatalogStackBase):
continue
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
# Per-entry guard: ``_fetch_single_catalog`` already validates
# that ``catalog_data["extensions"]`` 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
# ``{"extensions": {"foo": [], "bar": {...}}}`` still merges
# the valid entries without crashing on ``**ext_data``.
# Mirrors ``integrations/catalog.py:245``.
if not isinstance(ext_data, dict):
continue
if ext_id not in merged: # Higher-priority catalog wins
merged[ext_id] = {
**ext_data,
@@ -2251,12 +2114,6 @@ class ExtensionCatalog(CatalogStackBase):
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 the per-URL cache check below.
Returns:
True if cache exists and is within cache duration
"""
@@ -2264,28 +2121,13 @@ class ExtensionCatalog(CatalogStackBase):
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)
age_seconds = (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 is
# valid JSON but 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]:
@@ -2300,62 +2142,36 @@ class ExtensionCatalog(CatalogStackBase):
Raises:
ExtensionError: If catalog cannot be fetched
"""
catalog_url = self.get_catalog_url()
# Check the cache first unless ``force_refresh`` was requested,
# then fall through to a network fetch. 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. ``is_cache_valid`` itself swallows
# OSError/UnicodeError on the metadata read, so a cache-validity
# check can't crash this method before the read-side fallback
# runs.
# Check cache first unless force refresh
if not force_refresh and self.is_cache_valid():
try:
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, ExtensionError):
return json.loads(self.cache_file.read_text())
except json.JSONDecodeError:
pass # Fall through to network fetch
# Fetch from network
catalog_url = self.get_catalog_url()
try:
import urllib.error
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)
# Validate catalog structure
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
raise ExtensionError("Invalid 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 abort a
# fetch whose payload 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"
)
# Save to cache
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
# Save cache metadata
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
# Save cache metadata
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

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

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

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

@@ -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,9 +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 .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .integrations.base import IntegrationBase
from ._init_options import is_ai_skills_enabled
def _substitute_core_template(
@@ -676,7 +676,7 @@ class PresetManager:
commands_to_register.append(cmd)
try:
from ..agents import CommandRegistrar
from .agents import CommandRegistrar
except ImportError:
return {}
@@ -692,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
@@ -715,7 +715,7 @@ class PresetManager:
return
try:
from ..agents import CommandRegistrar
from .agents import CommandRegistrar
except ImportError:
return
@@ -767,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 = [
@@ -891,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
@@ -1042,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)
@@ -1075,7 +1075,7 @@ class PresetManager:
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)
@@ -1110,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:
@@ -1158,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"
@@ -1253,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):
@@ -1382,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"
@@ -1712,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:
@@ -1892,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.
@@ -2095,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)
@@ -2103,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]:
@@ -2137,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
@@ -2211,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:
@@ -2232,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
"""
@@ -2245,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)
@@ -2255,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]:
@@ -2285,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
@@ -2450,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. "
@@ -2769,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
@@ -2995,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:

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

@@ -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,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
@@ -50,32 +47,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}"))
@@ -155,823 +126,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 ──────────────────────────────────────────────────────────
@@ -1918,45 +1072,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

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

@@ -303,135 +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_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)

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

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

@@ -1475,9 +1475,9 @@ class TestFanOutStep:
assert result.output["item_count"] == 2
assert result.output["max_concurrency"] == 3
def test_execute_non_list_items_fails_loudly(self):
def test_execute_non_list_items_resolves_empty(self):
from specify_cli.workflows.steps.fan_out import FanOutStep
from specify_cli.workflows.base import StepContext, StepStatus
from specify_cli.workflows.base import StepContext
step = FanOutStep()
ctx = StepContext()
@@ -1487,24 +1487,8 @@ class TestFanOutStep:
"step": {"id": "impl", "command": "speckit.implement"},
}
result = step.execute(config, ctx)
assert result.status == StepStatus.FAILED
assert "'items' must resolve to a list" in (result.error or "")
assert result.output["item_count"] == 0
def test_execute_empty_list_items_is_valid(self):
from specify_cli.workflows.steps.fan_out import FanOutStep
from specify_cli.workflows.base import StepContext, StepStatus
step = FanOutStep()
ctx = StepContext(steps={"tasks": {"output": {"task_list": []}}})
config = {
"id": "parallel",
"items": "{{ steps.tasks.output.task_list }}",
"step": {"id": "impl", "command": "speckit.implement"},
}
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["item_count"] == 0
assert result.output["items"] == []
def test_validate_missing_fields(self):
from specify_cli.workflows.steps.fan_out import FanOutStep