mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 21:16:02 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
742ee5ce0c | ||
|
|
36fd5f6f49 | ||
|
|
f20e8ee6f7 | ||
|
|
3b6c4e7419 | ||
|
|
04c74eef49 | ||
|
|
194fd08bd8 | ||
|
|
b22834bd4a | ||
|
|
860a49edb1 | ||
|
|
7a3710242c | ||
|
|
97d5376fc7 | ||
|
|
4d871d7a5b | ||
|
|
33fefde268 | ||
|
|
70f9242be9 | ||
|
|
7c1d4212db | ||
|
|
4f5c4971c0 | ||
|
|
13b8db2d87 | ||
|
|
68980c9a4e | ||
|
|
1b0556c711 | ||
|
|
f2710f32cf | ||
|
|
4384338ec1 | ||
|
|
dd9d84e7bc | ||
|
|
77af08ba22 | ||
|
|
f5d47720b9 | ||
|
|
4e899d3002 | ||
|
|
63a2a17305 | ||
|
|
36ad3cde1b | ||
|
|
5ae7ff53d0 | ||
|
|
902b98654d | ||
|
|
40e48ed22c | ||
|
|
45b88f62be | ||
|
|
7c610a38cd | ||
|
|
a72ba95460 | ||
|
|
fa93572e27 | ||
|
|
0b82a1ddf1 | ||
|
|
d3f872f484 | ||
|
|
8373a60107 | ||
|
|
9c4fa31cec | ||
|
|
de88c23bb6 | ||
|
|
f65d9f9382 | ||
|
|
ad9f047aaa |
@@ -70,6 +70,8 @@ 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>"
|
||||
},
|
||||
@@ -87,6 +89,9 @@ 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
|
||||
@@ -113,8 +118,8 @@ Determine the category and effect from the extension's behavior:
|
||||
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
|
||||
```
|
||||
|
||||
**Category** — one of: `docs`, `code`, `process`, `integration`, `visibility`
|
||||
**Effect** — `Read-only` (produces reports only) or `Read+Write` (modifies project files)
|
||||
**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
|
||||
|
||||
### 6. Commit, push, and open PR
|
||||
|
||||
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -2,6 +2,79 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
- feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
|
||||
- [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
|
||||
- test(workflows): cover executable override fallback preflight (#2843)
|
||||
- Add GitHub Copilot CLI guidance to readme (#2891)
|
||||
- Update Security Review extension to v1.5.3 (#2898)
|
||||
- Update Architecture Guard extension to v1.8.17 (#2897)
|
||||
- feat(extensions): per-event hook lists with priority ordering (#2798)
|
||||
- feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
|
||||
- chore: release 0.9.5, begin 0.9.6.dev0 development (#2875)
|
||||
|
||||
## [0.9.5] - 2026-06-05
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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:**
|
||||
**Categories** (common values, but any string is allowed):
|
||||
|
||||
- `docs` — reads, validates, or generates spec artifacts
|
||||
- `code` — reviews, validates, or modifies source code
|
||||
@@ -15,10 +15,13 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
- `integration` — syncs with external platforms
|
||||
- `visibility` — reports on project health or progress
|
||||
|
||||
**Effect:**
|
||||
**Effect** (canonical `extension.yml`/catalog values):
|
||||
|
||||
- `Read-only` — produces reports without modifying files
|
||||
- `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||
- `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`).
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
@@ -41,22 +44,27 @@ 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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](https://github.com/ashbrener/spec-kit-linear) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -68,7 +76,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 | 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) |
|
||||
| MemoryLint | Evidence-driven instruction drift checker: audits agent memory files for boundary, reality, conflict, and redundancy drift. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| Multi-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) |
|
||||
@@ -79,7 +87,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 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 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 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) |
|
||||
@@ -88,6 +96,7 @@ 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) |
|
||||
@@ -102,18 +111,21 @@ 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 | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge | 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 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) |
|
||||
|
||||
@@ -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, 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) |
|
||||
| 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) |
|
||||
| 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, 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
83
docs/concepts/complex-features.md
Normal file
83
docs/concepts/complex-features.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
@@ -11,6 +11,11 @@ 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 |
|
||||
|
||||
107
docs/concepts/spec-persistence.md
Normal file
107
docs/concepts/spec-persistence.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 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.
|
||||
@@ -126,6 +126,27 @@ 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`:
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
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
@@ -13,6 +13,14 @@ 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"
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-03T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00: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.2.0",
|
||||
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.",
|
||||
"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.",
|
||||
"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.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.3.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": 9,
|
||||
"templates": 10,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -29,16 +29,16 @@
|
||||
"inclusion"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
"updated_at": "2026-06-05T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.3.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": 9,
|
||||
"templates": 6,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -59,7 +59,7 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-05-31T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00: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.1.0",
|
||||
"description": "Adds Bash and PowerShell parity, dry-run/WhatIf parity, man-page expectations, and Verb-Noun Cmdlet discipline.",
|
||||
"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.",
|
||||
"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.1.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.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,13 +188,18 @@
|
||||
},
|
||||
"tags": [
|
||||
"cross-platform",
|
||||
"governance",
|
||||
"bash",
|
||||
"powershell",
|
||||
"man-page",
|
||||
"cmdlet"
|
||||
"cmdlet",
|
||||
"verb-noun",
|
||||
"windows",
|
||||
"macos",
|
||||
"linux"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.9.6.dev0"
|
||||
version = "0.10.4"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -57,7 +57,7 @@ from ._console import (
|
||||
)
|
||||
from ._assets import (
|
||||
_locate_bundled_extension,
|
||||
_locate_bundled_preset,
|
||||
_locate_bundled_preset as _locate_bundled_preset,
|
||||
_locate_bundled_workflow as _locate_bundled_workflow,
|
||||
_locate_core_pack,
|
||||
_repo_root,
|
||||
@@ -576,20 +576,6 @@ 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 =====
|
||||
|
||||
@@ -617,620 +603,9 @@ def _require_specify_project() -> Path:
|
||||
|
||||
# ===== Preset Commands =====
|
||||
|
||||
|
||||
@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]")
|
||||
# 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)
|
||||
|
||||
|
||||
# ===== Extension Commands =====
|
||||
@@ -1975,7 +1350,11 @@ def extension_info(
|
||||
author = ext_manifest.data.get("extension", {}).get("author")
|
||||
if author:
|
||||
console.print(f"[dim]Author:[/dim] {author}")
|
||||
console.print()
|
||||
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()
|
||||
|
||||
if ext_manifest.commands:
|
||||
console.print("[bold]Commands:[/bold]")
|
||||
@@ -2025,6 +1404,12 @@ 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)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Shared GitHub-authenticated HTTP helpers.
|
||||
"""Shared GitHub HTTP request helpers.
|
||||
|
||||
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.
|
||||
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`.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -54,28 +56,6 @@ 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,
|
||||
@@ -147,20 +127,3 @@ 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)
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -56,22 +57,36 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
|
||||
RedirectValidator = Callable[[str, str], None]
|
||||
|
||||
def __init__(self, hosts: tuple[str, ...]) -> None:
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hosts: tuple[str, ...],
|
||||
redirect_validator: RedirectValidator | None = None,
|
||||
) -> 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:
|
||||
hostname = (urlparse(newurl).hostname or "").lower()
|
||||
if _hostname_in_hosts(hostname, self._hosts):
|
||||
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:
|
||||
if original_auth:
|
||||
new_req.add_unredirected_header("Authorization", original_auth)
|
||||
else:
|
||||
@@ -103,7 +118,12 @@ 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):
|
||||
def open_url(
|
||||
url: str,
|
||||
timeout: int = 10,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
redirect_validator: RedirectValidator | None = None,
|
||||
):
|
||||
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
|
||||
|
||||
1. Find ``auth.json`` entries whose hosts match the URL.
|
||||
@@ -113,6 +133,8 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
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())
|
||||
|
||||
@@ -135,7 +157,7 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
continue
|
||||
|
||||
req = _make_req(provider.auth_headers(token, entry.auth))
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator))
|
||||
try:
|
||||
return opener.open(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
@@ -146,4 +168,7 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
|
||||
# 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
|
||||
|
||||
@@ -41,6 +41,8 @@ _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"
|
||||
@@ -201,6 +203,21 @@ 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:
|
||||
@@ -374,6 +391,16 @@ 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."""
|
||||
@@ -1026,6 +1053,22 @@ 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
|
||||
@@ -1905,6 +1948,44 @@ 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.
|
||||
|
||||
@@ -2020,21 +2101,51 @@ 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())
|
||||
metadata = json.loads(
|
||||
cache_meta_file.read_text(encoding="utf-8")
|
||||
)
|
||||
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, ValueError, KeyError, TypeError):
|
||||
# If metadata is invalid or missing expected fields, treat cache as invalid
|
||||
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.
|
||||
pass
|
||||
|
||||
# Use cache if valid
|
||||
# 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.
|
||||
if is_valid:
|
||||
try:
|
||||
return json.loads(cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
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.
|
||||
pass
|
||||
|
||||
# Fetch from network
|
||||
@@ -2042,16 +2153,32 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError(f"Invalid catalog format from {entry.url}")
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
|
||||
# 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))
|
||||
# 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
|
||||
|
||||
return catalog_data
|
||||
|
||||
@@ -2098,6 +2225,16 @@ 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,
|
||||
@@ -2114,6 +2251,12 @@ 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
|
||||
"""
|
||||
@@ -2121,13 +2264,28 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
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, ValueError, KeyError, TypeError):
|
||||
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.
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2142,36 +2300,62 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
Raises:
|
||||
ExtensionError: If catalog cannot be fetched
|
||||
"""
|
||||
# Check cache first unless force refresh
|
||||
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.
|
||||
if not force_refresh and self.is_cache_valid():
|
||||
try:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
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):
|
||||
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
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError("Invalid catalog format")
|
||||
# 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)
|
||||
|
||||
# Save to cache
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
# 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 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))
|
||||
# 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
|
||||
|
||||
return catalog_data
|
||||
|
||||
|
||||
@@ -25,17 +25,14 @@ class IntegrationReadError:
|
||||
schema: int | None = None
|
||||
|
||||
|
||||
def try_read_integration_json(
|
||||
def _read_integration_json_data(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``.specify/integration.json`` without raising.
|
||||
"""Read raw integration state without normalizing or raising.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
|
||||
@@ -70,9 +67,41 @@ def try_read_integration_json(
|
||||
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():
|
||||
|
||||
663
src/specify_cli/integration_status.py
Normal file
663
src/specify_cli/integration_status.py
Normal file
@@ -0,0 +1,663 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
|
||||
"""specify integration list/status/use/search/info + catalog list/add/remove command handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
from rich.markup import escape as _rich_escape
|
||||
from rich.table import Table
|
||||
|
||||
from .._console import console
|
||||
@@ -120,6 +122,86 @@ 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"),
|
||||
|
||||
@@ -108,11 +108,23 @@ 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 = "") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
project_root: Path,
|
||||
version: str = "",
|
||||
*,
|
||||
resolve_project_root: bool = True,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.project_root = project_root.resolve()
|
||||
self.project_root = (
|
||||
project_root.resolve()
|
||||
if resolve_project_root
|
||||
else project_root.absolute()
|
||||
)
|
||||
self.version = version
|
||||
self._files: dict[str, str] = {} # rel_path → sha256 hex
|
||||
self._recovered_files: set[str] = set()
|
||||
@@ -387,12 +399,18 @@ class IntegrationManifest:
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def load(cls, key: str, project_root: Path) -> IntegrationManifest:
|
||||
def load(
|
||||
cls,
|
||||
key: str,
|
||||
project_root: Path,
|
||||
*,
|
||||
resolve_project_root: bool = True,
|
||||
) -> IntegrationManifest:
|
||||
"""Load an existing manifest from disk.
|
||||
|
||||
Raises ``FileNotFoundError`` if the manifest does not exist.
|
||||
"""
|
||||
inst = cls(key, project_root)
|
||||
inst = cls(key, project_root, resolve_project_root=resolve_project_root)
|
||||
path = inst.manifest_path
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -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 .integrations.base import IntegrationBase
|
||||
from ._init_options import is_ai_skills_enabled
|
||||
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from ..integrations.base import IntegrationBase
|
||||
|
||||
|
||||
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,6 +1892,48 @@ 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.
|
||||
|
||||
@@ -2053,7 +2095,7 @@ class PresetCatalog:
|
||||
if not cache_file.exists() or not metadata_file.exists():
|
||||
return False
|
||||
try:
|
||||
metadata = json.loads(metadata_file.read_text())
|
||||
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2061,7 +2103,23 @@ class PresetCatalog:
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
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.
|
||||
return False
|
||||
|
||||
def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2079,29 +2137,55 @@ 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:
|
||||
return json.loads(cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
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.
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "presets" not in catalog_data
|
||||
):
|
||||
raise PresetError("Invalid preset catalog format")
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
|
||||
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))
|
||||
# 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
|
||||
|
||||
return catalog_data
|
||||
|
||||
@@ -2127,6 +2211,17 @@ 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:
|
||||
@@ -2137,6 +2232,12 @@ 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
|
||||
"""
|
||||
@@ -2144,7 +2245,9 @@ class PresetCatalog:
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2152,7 +2255,20 @@ class PresetCatalog:
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
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.
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2169,35 +2285,61 @@ 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())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
if metadata.get("catalog_url") == catalog_url:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Cache is corrupt or unreadable; fall through to network fetch
|
||||
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.
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "presets" not in catalog_data
|
||||
):
|
||||
raise PresetError("Invalid preset catalog format")
|
||||
# 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)
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
# 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"
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, 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
|
||||
|
||||
return catalog_data
|
||||
|
||||
@@ -2308,7 +2450,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. "
|
||||
@@ -2627,7 +2769,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
|
||||
@@ -2853,7 +2995,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:
|
||||
711
src/specify_cli/presets/_commands.py
Normal file
711
src/specify_cli/presets/_commands.py
Normal file
@@ -0,0 +1,711 @@
|
||||
"""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")
|
||||
@@ -313,6 +313,8 @@ 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:
|
||||
|
||||
@@ -22,12 +22,28 @@ 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={
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
@@ -47,6 +50,32 @@ 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}"))
|
||||
@@ -126,6 +155,823 @@ 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1072,6 +1918,45 @@ 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.
|
||||
|
||||
|
||||
@@ -793,6 +793,35 @@ 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
|
||||
|
||||
@@ -303,6 +303,135 @@ 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)
|
||||
|
||||
@@ -24,6 +24,7 @@ from specify_cli.extensions import (
|
||||
CatalogEntry,
|
||||
CORE_COMMAND_NAMES,
|
||||
DEFAULT_HOOK_PRIORITY,
|
||||
VALID_EFFECTS,
|
||||
ExtensionManifest,
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
@@ -300,6 +301,69 @@ 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
|
||||
@@ -3087,6 +3151,424 @@ 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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -11,6 +11,7 @@ Tests cover:
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
@@ -18,6 +19,7 @@ import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -1514,6 +1516,421 @@ 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
|
||||
@@ -3842,6 +4259,141 @@ 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"
|
||||
@@ -3931,7 +4483,7 @@ class TestPresetAddFromUrlResolution:
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
@@ -3989,7 +4541,7 @@ class TestPresetAddFromUrlResolution:
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
return FakeResponse(zip_bytes)
|
||||
|
||||
|
||||
@@ -1475,9 +1475,9 @@ class TestFanOutStep:
|
||||
assert result.output["item_count"] == 2
|
||||
assert result.output["max_concurrency"] == 3
|
||||
|
||||
def test_execute_non_list_items_resolves_empty(self):
|
||||
def test_execute_non_list_items_fails_loudly(self):
|
||||
from specify_cli.workflows.steps.fan_out import FanOutStep
|
||||
from specify_cli.workflows.base import StepContext
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = FanOutStep()
|
||||
ctx = StepContext()
|
||||
@@ -1487,8 +1487,24 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user