Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 09b99f351a chore: bump version to 0.14.4 2026-07-29 12:08:59 +00:00
75 changed files with 408 additions and 11078 deletions
+2 -10
View File
@@ -29,20 +29,12 @@ def _dependency_diff_refs() -> tuple[str, str]:
def _dependency_inputs_changed() -> bool:
base_ref, head_ref = _dependency_diff_refs()
try:
merge_base = subprocess.run(
["git", "merge-base", base_ref, head_ref],
check=True,
cwd=REPO_ROOT,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
).stdout.strip()
result = subprocess.run(
[
"git",
"diff",
"--name-only",
merge_base,
base_ref,
head_ref,
"--",
*DEPENDENCY_INPUTS,
@@ -85,7 +77,6 @@ def main() -> int:
generated_requirements = Path(generated_requirements_env)
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
generated_requirements.write_bytes(COMMITTED_REQUIREMENTS.read_bytes())
subprocess.run(
[
@@ -96,6 +87,7 @@ def main() -> int:
"--extra",
"test",
"--universal",
"--upgrade",
"--generate-hashes",
"--quiet",
"--no-header",
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
+3 -3
View File
@@ -27,14 +27,14 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
- name: Check committed audit requirements are current
env:
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
DEPENDENCY_DIFF_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
run: python .github/scripts/check_security_requirements.py
@@ -58,7 +58,7 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
with:
# Days of inactivity before an issue or PR becomes stale
days-before-stale: 150
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
@@ -40,7 +40,7 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
-53
View File
@@ -2,59 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.15.1] - 2026-07-31
### Changed
- fix: escape Rich markup in `workflow resolve` output (#3879)
- chore(deps): bump actions/stale from 10.4.0 to 11.0.0 (#3877)
- chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#3876)
- feat: support tar archives for installs (#3874)
- fix: eliminate TOCTOU race in file unlink calls (#3819)
- fix(scripts): tolerate an unusable integration.json in the Python helper (#3785)
- fix(catalogs): validate the port in the shared catalog-URL validator, like its mirrors do (#3804)
- feat(presets): add opt-in constitution-sync preset (#3873)
- fix: reject non-object workflow caches (#3860)
- Harden extension URL download cache against symlink and junction races (#3869)
- fix: escape workflow step metadata (#3863)
- [bug-fix] Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller (#3452)
- fix: use chunked read for extension manifest hash (#3841)
- fix: preserve unreadable event config files (#3861)
- fix(scripts): use a .NET Framework-safe trim in the PowerShell init-dir resolver (#3872)
- Add ContextForge MCP extension to community catalog (#3487)
- fix: normalize non-UTF-8 integration manifests (#3862)
- feat: bind gate verdict to workflow input via verdict_input (#3725)
- docs: use absolute image URLs in README for PyPI rendering (#3867)
- chore: release 0.15.0, begin 0.15.1.dev0 development (#3871)
## [0.15.0] - 2026-07-30
### Changed
- Add yolo to community workflow catalog (#3864)
- fix(workflows): guard the shell step's timeout check against OverflowError (#3865)
- Add Intent Reconciliation extension to community catalog (#3858)
- fix(workflows): validate prompt step 'timeout' like the shell step (#3847)
- fix: add utf-8 encoding to registry file open calls (#3816)
- fix: eliminate TOCTOU race in file unlink calls (#3815)
- test(workflows): name the condition-rejection tests for the real boundary (#3808)
- fix: eliminate TOCTOU race in file unlink calls (#3811)
- fix(presets): escape user-supplied catalog name/URL in add/remove output (#3806)
- fix: add missing utf-8 encoding to registry file open calls (#3810)
- [bug-fix] Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade (#3853)
- fix(integrations): don't abort uninstall when the manifest can't be deleted (#3805)
- test(extensions): update stale manifest validation message assertion (#3859)
- fix(agents): coerce a non-string description in TOML command rendering (#3799)
- fix(workflows): make security requirements sync deterministic (#3832)
- fix(cli): render the literal [suffix] in --tag help and rejection message (#3800)
- fix(integrations): preserve non-UTF-8 VS Code settings (#3833)
- fix(bundler): treat an explicit-null manifest field as missing, not the text "None" (#3798)
- feat: first-class agent-native runtime hooks for integrations (#3704)
- fix(extensions): guard the required manifest sections so one bad extension cannot break `extension list` (#3797)
- fix(presets): escape installed preset metadata in Rich output (#3826)
- fix(workflows): dispatch prompt steps via the resolved executable (#3793)
- chore: release 0.14.4, begin 0.14.5.dev0 development (#3850)
## [0.14.4] - 2026-07-29
### Changed
+2 -2
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="https://raw.githubusercontent.com/github/spec-kit/main/media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
<img src="./media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
<h1>🌱 Spec Kit</h1>
<h3><em>Define what to build before building it — with any AI coding agent.</em></h3>
</div>
@@ -136,7 +136,7 @@ For detailed step-by-step instructions, see our [comprehensive guide](./spec-dri
Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)!
[![Spec Kit video header](https://raw.githubusercontent.com/github/spec-kit/main/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
[![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
## 🌍 Community
-2
View File
@@ -50,7 +50,6 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| ContextForge MCP | Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase | `code` | Read+Write | [contextforge-mcp](https://github.com/capatinore/contextforge-mcp) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF/JUnit output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 27 validators, stable finding codes, adoption baseline for legacy repos, compliance-evidence reports, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
@@ -67,7 +66,6 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| 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) |
| Intent Reconciliation | Reconcile implementation-discovered decisions against approved feature intent | `process` | Read+Write | [spec-kit-reconcile](https://github.com/SuhaibAslam/spec-kit-reconcile) |
| 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) |
+3 -72
View File
@@ -39,21 +39,6 @@ specify workflow run my-pipeline.yml --json
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
For `failed` and `aborted` runs, the payload includes an `error` field carrying the terminal step's error message:
```json
{
"run_id": "662bf791",
"workflow_id": "build-and-review",
"status": "failed",
"current_step_id": "boom",
"current_step_index": 0,
"error": "Command exited with code 3"
}
```
`completed` and `paused` runs omit the `error` field. The error is persisted in the run's `state.json`, so `specify workflow status <run_id> --json` surfaces the same message after the fact.
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
## Resume a Workflow
@@ -103,17 +88,10 @@ specify workflow add <source>
| Option | Description |
| --------------- | ------------------------------------------------------ |
| `--dev` | Install from a local YAML file, package directory, or archive |
| `--dev` | Install from a local workflow YAML file or directory |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
Installs a workflow from the catalog, an HTTPS URL, a local YAML file, a
directory containing `workflow.yml`, or a `.zip`, `.tar.gz`, or `.tgz`
archive. Archives may contain `workflow.yml` at the root or inside one
top-level directory.
Directory and archive installs preserve the complete workflow package,
including scripts and other companion files. ZIP, `.tar.gz`, and `.tgz`
archives follow the same validation and installation behavior.
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
## Workflow Overlays
@@ -288,9 +266,7 @@ Lower priority values have higher precedence. Change this overlay to `priority:
### Interaction with Bundles and Updates
`specify workflow add <local-directory>` installs the complete local workflow
package into `.specify/workflows/<id>/`. Archive installs preserve the same
package contents.
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
@@ -578,51 +554,6 @@ Each workflow run persists its state at `.specify/workflows/runs/<run_id>/`:
This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed.
### Gate Verdict Inputs
`verdict_input` binds a gate's verdict to a named workflow input. The input must be declared in the workflow's `inputs` block; `specify workflow validate` reports an undeclared reference.
`verdict_input` is not supported inside a `fan-out` template. Fan-out items
share workflow inputs, while workflow state can represent only one paused
gate. Place a gate before the fan-out to approve the whole batch, or after a
fan-in to review the aggregated results.
**Input value semantics:**
| Value | Behavior |
|---|---|
| Non-empty string, matches an option (case-insensitive) | Gate auto-decides; `output.choice` is set to the configured option spelling |
| Non-empty string, no match | Gate fails immediately |
| Non-string | Gate fails immediately |
| Missing or empty | Gate prompts on a TTY; pauses otherwise |
**Default value semantics:** A non-empty `default` is consumed as a verdict on the first run — matching an option auto-decides the gate, not matching fails it immediately.
```yaml
inputs:
spec_verdict:
type: string
default: ""
steps:
- id: review-spec
type: gate
message: "Approve the specification?"
options: [approve, reject]
on_reject: retry
verdict_input: spec_verdict
```
Supply a verdict when resuming:
```bash
specify workflow resume <run_id> --input spec_verdict=approve
```
For `on_reject: retry`, a bound reject verdict is consumed before the gate
pauses: the named stored input is reset to `""`. A later resume therefore
prompts or pauses again until another verdict is supplied. Approve, abort, and
skip outcomes leave the input unchanged.
## FAQ
### What happens when a workflow hits a gate step?
-68
View File
@@ -208,74 +208,6 @@ Restart your IDE to refresh the command list.
---
## Behavior change: `/constitution` no longer propagates into templates
The `/constitution` command ([#3790](https://github.com/github/spec-kit/pull/3790)) is scoped to
its own artifact. It updates
`.specify/memory/constitution.md` and writes a Sync Impact Report, and **no longer edits**
`plan-template.md`, `spec-template.md`, `tasks-template.md`, installed command files, or
guidance docs.
### Why
Spec Kit uses **runtime resolution**: `plan`, `tasks`, and `analyze` read
`.specify/memory/constitution.md` live on every run, and `analyze` is the dedicated drift
checker. The governed templates carry a pointer, not a copy — `plan-template.md` ships
`[Gates determined based on constitution file]`, and `/plan` fills that section from the live
constitution each run. Propagation duplicated the single source of truth and fought the
preset/override composition system (a `replace` preset shadows an edited core template).
More broadly, presets and extensions — not in-place file edits — are how Spec Kit now governs
shared assets. Composing policy through the resolution stack keeps it centrally owned, versioned,
and auditable across repositories, instead of frozen into per-repo copies no core team can see.
### Is this a breaking change for existing projects?
**No — your workflow keeps working.** You would only notice a difference if you relied on
`/constitution` editing those files in place. The templates are scaffolds, not authorities. When you
run `/plan`, it copies the template into a per-feature `plan.md` and re-derives the Constitution
Check from the live constitution; `/analyze` validates against it. Even if a previous
`/constitution` run materialized concrete gate text into `.specify/templates/plan-template.md`,
the live constitution remains the source of truth at runtime.
On a **non-forced upgrade**, a materialized template is *preserved* (its hash diverges from the
recorded managed copy, so the refresh treats it as a customization and does not overwrite it).
Nothing regresses.
### Optional cleanup — return to the runtime pointer
A frozen, pre-filled Constitution Check is a slightly misleading scaffold and can bias the first
`/plan` pass. To move fully back to runtime resolution, reset the section body in
`.specify/templates/plan-template.md` to the pointer:
```text
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
```
Leave the rest of the file untouched. This is cleanup, not a required migration.
### Keeping the old behavior (opt-in)
If your team treats the materialized templates as **reviewed, committed artifacts** and wants
`/constitution` to keep propagating, install the bundled **`constitution-sync`** preset:
```bash
specify preset add constitution-sync
```
It wraps the core `/constitution` command and re-adds the propagation pass. It does **not** edit
versioned preset- or extension-provided templates or command files (those are owned by their
packages and are recomposed on reconciliation). Note that this edit-in-place propagation model
conflicts with the composition model used by the rest of the SDD commands when they are
preset/extension-managed — see the "Interaction with the resolution stack" section in
`presets/constitution-sync/README.md` for the tradeoffs and when to prefer the default instead.
---
## Common Scenarios
### Scenario 1: "I just want new slash commands"
+1 -70
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-29T00:00:00Z",
"updated_at": "2026-07-28T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1070,41 +1070,6 @@
"created_at": "2026-03-29T00:00:00Z",
"updated_at": "2026-03-29T00:00:00Z"
},
"contextforge-mcp": {
"name": "ContextForge MCP",
"id": "contextforge-mcp",
"description": "Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase.",
"author": "capatinore",
"version": "0.1.0",
"download_url": "https://github.com/capatinore/contextforge-mcp/releases/download/ext-v0.1.0/contextforge-mcp-speckit-extension.zip",
"repository": "https://github.com/capatinore/contextforge-mcp",
"homepage": "https://github.com/capatinore/contextforge-mcp",
"documentation": "https://github.com/capatinore/contextforge-mcp/blob/main/README.md",
"changelog": "",
"license": "MIT",
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.10.0"
},
"provides": {
"commands": 4,
"hooks": 0
},
"tags": [
"mcp",
"code-intelligence",
"context-compression",
"tokens",
"claude",
"spec-driven-development"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"cost": {
"name": "Cost Tracker",
"id": "cost",
@@ -1896,40 +1861,6 @@
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-30T00:00:00Z"
},
"intent": {
"name": "Intent Reconciliation",
"id": "intent",
"description": "Reconcile implementation-discovered decisions against approved feature intent",
"author": "SuhaibAslam",
"version": "1.0.2",
"download_url": "https://github.com/SuhaibAslam/spec-kit-reconcile/archive/refs/tags/v1.0.2.zip",
"repository": "https://github.com/SuhaibAslam/spec-kit-reconcile",
"homepage": "https://github.com/SuhaibAslam/spec-kit-reconcile",
"documentation": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/README.md",
"changelog": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.12.0"
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"intent",
"decisions",
"reconciliation",
"drift",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-29T00:00:00Z",
"updated_at": "2026-07-29T00:00:00Z"
},
"issue": {
"name": "GitHub Issues Integration 2",
"id": "issue",
-23
View File
@@ -25,29 +25,6 @@
"workflow",
"core"
]
},
"constitution-sync": {
"name": "Constitution Template Sync",
"id": "constitution-sync",
"version": "1.0.0",
"description": "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"license": "MIT",
"bundled": true,
"requires": {
"speckit_version": ">=0.14.4"
},
"provides": {
"commands": 1,
"templates": 0
},
"tags": [
"constitution",
"governance",
"templates",
"compatibility"
]
}
}
}
-126
View File
@@ -1,126 +0,0 @@
# Constitution Template Sync
An **opt-in** preset that restores `/constitution`'s ability to propagate amended guidance into your
project's own templates and command files. After you update the constitution, it aligns
`plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
guidance docs so they reflect the current principles.
This propagation used to be built into `/constitution`; it was dropped when the command moved to the
preset model. Installing this preset opts you back into it: you get the guidance materialized into
reviewed, committed artifacts instead of relying on runtime resolution alone.
> **What you're opting into.** Propagation was removed deliberately — it duplicates the constitution
> as the source of truth and can fight the composition stack (materialized edits get shadowed or
> clobbered on the next recompose). This preset knowingly **reintroduces** that behavior, and those
> tradeoffs, for teams that want it. Read the [caveats](#caveats-you-take-on) before installing.
For most projects the default composable stack is the **recommended** approach, and at organization
scale it is usually the stronger governance model. Runtime resolution keeps the live constitution as
the single source of truth (nothing to re-sync, so nothing drifts), and the stack composes the
**entire** Spec Kit ecosystem — not just the SDD commands, but every command, template, script and
extension — with explicit priority levels, strategies, and independent versioning. It is a
capability, not automatic governance: a core team authors its own organizational presets and
extensions, then owns, versions, and audits that policy in one place and rolls it across many
repositories, instead of scattering frozen, per-repo copies no central team can see. This preset is
a supported escape hatch for teams whose workflow depends on reviewing materialized artifacts
directly — useful as a bridge, though for org-wide policy the better long-term path is usually a
versioned preset a core team maintains.
## What it does
Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
current core command (via `{CORE_TEMPLATE}`), so it stays forward-compatible with core changes, and
appends a propagation pass that, after the constitution is written:
- Aligns `plan/spec/tasks-template.md` in `.specify/templates/` with the updated principles.
- Updates **project-local** command files and guidance docs to correct stale references.
- Extends the Sync Impact Report in `.specify/memory/constitution.md` with the files it touched.
## What it does not do
- It does **not** change behavior for anyone who does not install it — the default runtime
resolution model is untouched.
- It does **not** disable runtime resolution. `plan`, `tasks`, and `analyze` still read the live
constitution every run; this preset adds materialized copies on top — it does not replace the
source of truth.
- It does **not** edit versioned, package-owned files — templates or command files provided or
wrapped by another preset or extension. Those are recomposed from the resolution stack, so it
only ever writes into your project's own `.specify/templates/` scaffolds and command files that
are not managed by a preset/extension.
## When to use it
Install it **only** if your team treats the materialized templates and commands as
**reviewed, committed artifacts** — for example, if `plan-template.md`'s Constitution Check is
read in PRs as "here are our current gates" and is expected to track the constitution.
If you rely on the default runtime-resolution model, you do **not** need this preset: the live
constitution is already the single source of truth and there is nothing to sync.
## Caveats you take on
The preset resolution stack is how Spec Kit composes templates and commands going forward: they are
**layered, package-owned artifacts recomposed on demand**, not frozen files you edit in place.
Propagation is the opposite idea — it **materializes** guidance into files and freezes it. That
tension is the main thing to understand before installing:
- **Materialized copies can drift.** Anything propagated is a snapshot; if you amend the
constitution and do not re-run `/constitution`, the copies fall out of sync. The default runtime
model has no drift because it reads the live constitution every run.
- **Edits to composed files do not survive reconciliation.** If the rest of your SDD flow is
preset/extension-managed, the commands it materializes (`speckit.plan`, `speckit.specify`,
`speckit.tasks`, `speckit.analyze`, `speckit.implement`, …) are recomputed from the stack. Any
guidance propagated into them is clobbered the next time the stack reconciles — on
`specify integration use <key>` / `switch`, `specify integration upgrade`, or any preset/extension
install or remove. The same applies to templates owned by another preset/extension. This is why
the preset restricts itself to project-local files; propagation is reliable **only** for
artifacts you own outright.
- **A pre-filled Constitution Check can bias `/plan`.** Materializing concrete gates into
`plan-template.md` replaces the runtime pointer, so the first `/plan` pass may anchor on the
frozen text. Keep the pointer unless you specifically want committed gates.
**Bottom line:** this preset fits projects whose governed templates and commands are project-local
artifacts they review, with the rest of the SDD flow on the plain bundled core. If your
`plan`/`specify`/`tasks`/`analyze` commands or templates come from other presets or extensions,
prefer the default runtime-resolution model.
## Installation
```bash
# constitution-sync is a bundled preset — no download needed
specify preset add constitution-sync
```
## Development
```bash
# Test from local directory
specify preset add --dev ./presets/constitution-sync
# Verify the wrapped command resolves
specify preset resolve speckit.constitution
# Remove when done
specify preset remove constitution-sync
```
## Migrating back to the default
To move back to runtime resolution, reset each materialized `## Constitution Check` section in
`.specify/templates/plan-template.md` to the pointer:
```text
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
```
Then remove this preset. See `docs/upgrade.md` for details.
## License
MIT
@@ -1,54 +0,0 @@
---
description: Create or update the project constitution, then propagate the amended guidance into dependent templates and installed command files (opt-in template sync).
strategy: wrap
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
---
{CORE_TEMPLATE}
## Constitution Template Sync
> **This section supersedes the "Scope Guard" above for template and command propagation.**
> The core Scope Guard states that dependent templates and commands "are not modified here" —
> that restriction is intentionally lifted by this preset. Propagating amended guidance into the
> project's own dependent scaffolds and installed command files (as described below) **is in
> scope** whenever this preset is installed. The Scope Guard's other constraints still apply:
> do not implement features, generate application code, or touch artifacts unrelated to the
> constitution/template workflow.
After you have written the updated constitution above, perform a consistency propagation pass
so the dependent artifacts reflect the amended principles:
1. Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align
with the updated principles. Only materialize concrete gate text here if your team intends to
review it as committed content; otherwise leave the runtime pointer
`[Gates determined based on constitution file]` in place so `/plan` fills it from the live
constitution.
2. Read `.specify/templates/spec-template.md` for scope/requirements alignment — update if the
constitution adds/removes mandatory sections or constraints.
3. Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or
removed principle-driven task types (e.g., observability, versioning, testing discipline).
4. Read each installed Spec Kit command file for your agent (including this one) — named
`speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as
`speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`,
`.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify
no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance
is required. **Only hand-edit a command file if it is a project-local file not managed by a
preset or extension.** Command files that are composed from the resolution stack (anything
provided or wrapped by a preset/extension) must be regenerated through the stack — do **not**
edit them in place, because reconciliation (`specify integration use`, `specify integration
upgrade`, or any preset/extension install/remove) will clobber the edits.
5. Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific
guidance files if present) and update references to principles that changed.
Then extend the Sync Impact Report at the top of `.specify/memory/constitution.md` with:
- Templates requiring updates (✅ updated / ⚠ pending) with file paths.
**Do not edit versioned preset- or extension-provided template or command files directly.** Those
artifacts are owned by their packages and are recomposed on the package's next update or on stack
reconciliation — hand edits are clobbered. Limit propagation to the project's own
`.specify/templates/` scaffolds and to command files that are not managed by a preset or extension.
-30
View File
@@ -1,30 +0,0 @@
schema_version: "1.0"
preset:
id: "constitution-sync"
name: "Constitution Template Sync"
version: "1.0.0"
description: "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts."
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"
requires:
# Requires the runtime-resolution baseline (#3790, shipped in 0.14.4) where the
# core /constitution command no longer propagates. Installing this preset on an
# older core would double-apply propagation.
speckit_version: ">=0.14.4"
provides:
templates:
- type: "command"
name: "speckit.constitution"
file: "commands/speckit.constitution.md"
description: "Wrap /constitution to also propagate guidance into dependent templates and command files"
strategy: "wrap"
tags:
- "constitution"
- "governance"
- "templates"
- "compatibility"
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.15.1"
version = "0.14.4"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
@@ -49,7 +49,6 @@ packages = ["src/specify_cli"]
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
"presets/lean" = "specify_cli/core_pack/presets/lean"
"presets/constitution-sync" = "specify_cli/core_pack/presets/constitution-sync"
# Community bundle catalog snapshot (used for offline discovery)
"bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json"
+3 -11
View File
@@ -55,17 +55,9 @@ function Resolve-SpecifyInitDir {
}
# Resolve-Path echoes back any trailing separator from the input; trim it so
# the returned root matches the bash resolver, whose `cd && pwd` never yields
# one. TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework, as
# Get-FeaturePathsEnv already does below. Unlike a bare TrimEnd, the
# GetPathRoot check preserves a path that *is* its own root ('C:\' must not
# become 'C:', which every later API re-resolves against the current
# directory instead of the drive root). No-op on a path with no trailing
# separator.
$initRoot = $resolved.Path.TrimEnd('/', '\')
if ($initRoot.Length -lt [System.IO.Path]::GetPathRoot($resolved.Path).Length) {
$initRoot = $resolved.Path
}
# one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
# that already has no trailing separator.
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
if ($ReturnNullOnError) { return $null }
+8 -19
View File
@@ -258,27 +258,16 @@ def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
return "."
# Split the parse out of the lookup and guard the top-level shape, matching
# read_feature_json_feature_directory above and the bash/PowerShell twins,
# which both fall back to "." for any unusable integration.json:
# * a non-mapping top level ([], "forge", 42, null) is valid JSON, so
# json.JSONDecodeError never fires and state.get(...) raised
# AttributeError;
# * a non-UTF-8 file raises UnicodeDecodeError, which is a ValueError --
# not an OSError -- so it escaped the except tuple. Realistic on
# Windows, where PowerShell 5.1's Out-File/`>` default to UTF-16.
try:
state = json.loads(integration_json.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return "."
if not isinstance(state, dict):
return "."
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
return entry["invoke_separator"]
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
return entry["invoke_separator"]
except (OSError, json.JSONDecodeError):
pass
return "."
-5
View File
@@ -514,11 +514,6 @@ _register_extension_cmds(app)
from .integrations._commands import register as _register_integration_cmds # noqa: E402
_register_integration_cmds(app)
# ===== Event Commands =====
from .commands.event import register as _register_event_cmds # noqa: E402
_register_event_cmds(app)
# Re-export selected helpers to preserve the public import surface.
from .integrations._helpers import ( # noqa: E402
_clear_init_options_for_integration as _clear_init_options_for_integration,
+15 -376
View File
@@ -7,7 +7,6 @@ import re
import socket
import stat
import struct
import tarfile
import unicodedata
import zipfile
from collections.abc import Iterator
@@ -15,12 +14,11 @@ from contextlib import ExitStack, contextmanager
from ipaddress import IPv4Address, IPv6Address, ip_address
from itertools import pairwise
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import BinaryIO, Literal, NoReturn, TypeVar
from typing import NoReturn, TypeVar
from urllib.parse import ParseResult, urlparse
ErrorT = TypeVar("ErrorT", bound=Exception)
ArchiveFormat = Literal["zip", "tar.gz"]
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
MAX_ZIP_ENTRIES = 512
@@ -69,130 +67,6 @@ _ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
)
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
"application/gzip": "tar.gz",
"application/x-gzip": "tar.gz",
"application/x-tar+gzip": "tar.gz",
"application/zip": "zip",
"application/x-zip-compressed": "zip",
}
def archive_format_from_name(name: str) -> ArchiveFormat | None:
"""Return the supported archive format declared by a path or URL."""
try:
path = urlparse(name).path.lower()
except (TypeError, ValueError):
return None
if path.endswith(".tar.gz") or path.endswith(".tgz"):
return "tar.gz"
if path.endswith(".zip"):
return "zip"
return None
def archive_format_from_content_type(content_type: str | None) -> ArchiveFormat | None:
"""Return the supported archive format declared by an HTTP Content-Type."""
if not isinstance(content_type, str):
return None
media_type = content_type.partition(";")[0].strip().lower()
return _ARCHIVE_CONTENT_TYPES.get(media_type)
def archive_suffix(archive_format: ArchiveFormat) -> str:
"""Return the canonical filename suffix for *archive_format*."""
if archive_format == "zip":
return ".zip"
if archive_format == "tar.gz":
return ".tar.gz"
raise ValueError(f"Unsupported archive format: {archive_format!r}")
def detect_archive_format(
archive_path: Path,
*,
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
error_type: type[ErrorT] = ValueError,
) -> ArchiveFormat:
"""Validate the declared archive format against the file contents.
A recognized path/URL suffix is authoritative. For remote responses whose
final URL has no archive suffix, a recognized Content-Type may declare the
format instead. When both declarations are recognized they must agree, and
the resulting declaration must match the archive bytes.
"""
archive_path = Path(archive_path)
name_format = archive_format_from_name(
source_name if source_name is not None else str(archive_path)
)
content_format = archive_format_from_content_type(content_type)
if (
name_format is not None
and content_format is not None
and name_format != content_format
):
_raise(
error_type,
f"Archive format mismatch: filename declares {name_format} but "
f"Content-Type declares {content_format}",
)
declared_format = name_format or content_format
with ExitStack() as stack:
if archive_file is None:
try:
archive_file = stack.enter_context(archive_path.open("rb"))
except OSError as exc:
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
try:
archive_file.seek(0)
is_zip = zipfile.is_zipfile(archive_file)
archive_file.seek(0)
signature = archive_file.read(4)
# Let the bounded ZIP preflight report structural errors such as
# impossible entry counts. ``is_zipfile`` rejects those before the
# extractor can produce the established security diagnostic.
is_zip = is_zip or signature in {
b"PK\x03\x04",
b"PK\x05\x06",
b"PK\x07\x08",
}
is_gzip = signature[:2] == b"\x1f\x8b"
archive_file.seek(0)
is_tar_gz = False
if is_gzip:
try:
with tarfile.open(fileobj=archive_file, mode="r:gz"):
is_tar_gz = True
except tarfile.TarError:
pass
archive_file.seek(0)
except OSError as exc:
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
actual_format: ArchiveFormat | None
if is_zip and not is_tar_gz:
actual_format = "zip"
elif is_tar_gz and not is_zip:
actual_format = "tar.gz"
else:
actual_format = None
if declared_format is None:
if actual_format is None:
_raise(
error_type,
"Unsupported archive format; expected .zip, .tar.gz, or .tgz",
)
declared_format = actual_format
if actual_format != declared_format:
actual_label = actual_format or "invalid/unsupported data"
_raise(
error_type,
f"Archive format mismatch: expected {declared_format}, got {actual_label}",
)
return declared_format
def _ip_address_without_scope(
@@ -418,7 +292,6 @@ def build_safe_download_path(
*,
error_type: type[ErrorT] = ValueError,
label: str = "archive",
suffix: str = ".zip",
) -> Path:
"""Build a portable single-component archive path inside *target_dir*."""
if not isinstance(identifier, str) or not isinstance(version, str):
@@ -428,9 +301,7 @@ def build_safe_download_path(
f"{identifier!r} and {version!r}",
)
if suffix not in {".zip", ".tar.gz", ".tgz"}:
_raise(error_type, f"Unsupported archive download suffix: {suffix!r}")
filename = f"{identifier}-{version}{suffix}"
filename = f"{identifier}-{version}.zip"
try:
filename_too_long = (
len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
@@ -507,25 +378,24 @@ def read_zip_member_limited(
)
def normalize_archive_member_name(
def normalize_zip_member_name(
name: str,
*,
archive_label: str = "archive",
error_type: type[ErrorT] = ValueError,
) -> str:
"""Return a normalized, portable archive member name or raise if unsafe."""
"""Return a normalized, portable ZIP member name or raise if unsafe."""
if "\x00" in name:
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
normalized = name.replace("\\", "/")
try:
encoded_name = normalized.encode("utf-8")
except UnicodeEncodeError:
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
if len(encoded_name) > MAX_ZIP_PATH_BYTES:
_raise(
error_type,
f"Unsafe path in {archive_label} archive: {name!r} "
f"Unsafe path in ZIP archive: {name!r} "
"(not portable across supported filesystems)",
)
path = PurePosixPath(normalized)
@@ -545,8 +415,7 @@ def normalize_archive_member_name(
):
_raise(
error_type,
f"Unsafe path in {archive_label} archive: {name!r} "
"(potential path traversal)",
f"Unsafe path in ZIP archive: {name!r} (potential path traversal)",
)
for part in raw_parts:
reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
@@ -563,26 +432,13 @@ def normalize_archive_member_name(
):
_raise(
error_type,
f"Unsafe path in {archive_label} archive: {name!r} "
f"Unsafe path in ZIP archive: {name!r} "
"(not portable across supported filesystems)",
)
return normalized
def normalize_zip_member_name(
name: str,
*,
error_type: type[ErrorT] = ValueError,
) -> str:
"""Return a normalized, portable ZIP member name or raise if unsafe."""
return normalize_archive_member_name(
name,
archive_label="ZIP",
error_type=error_type,
)
def portable_archive_path_key(name: str) -> tuple[str, ...]:
def portable_zip_path_key(name: str) -> tuple[str, ...]:
"""Return a comparison key for filesystems with case/Unicode folding."""
normalized_name = name.replace("\\", "/")
return tuple(
@@ -591,11 +447,6 @@ def portable_archive_path_key(name: str) -> tuple[str, ...]:
)
def portable_zip_path_key(name: str) -> tuple[str, ...]:
"""Backward-compatible ZIP-specific alias for portable archive keys."""
return portable_archive_path_key(name)
def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
_raise(
error_type,
@@ -854,7 +705,6 @@ def _preflight_zip_central_directory(
def open_zip_bounded(
zip_path: Path,
*,
archive_file: BinaryIO | None = None,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
) -> Iterator[zipfile.ZipFile]:
@@ -862,11 +712,10 @@ def open_zip_bounded(
_validate_non_negative_int(max_entries, "max_entries")
zip_path = Path(zip_path)
with ExitStack() as stack:
if archive_file is None:
try:
archive_file = stack.enter_context(zip_path.open("rb"))
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
try:
archive_file = stack.enter_context(zip_path.open("rb"))
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
try:
_preflight_zip_central_directory(
archive_file,
@@ -888,7 +737,6 @@ def safe_extract_zip(
zip_path: Path,
target_dir: Path,
*,
archive_file: BinaryIO | None = None,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
@@ -904,7 +752,6 @@ def safe_extract_zip(
with open_zip_bounded(
zip_path,
archive_file=archive_file,
error_type=error_type,
max_entries=max_entries,
) as zf:
@@ -927,7 +774,7 @@ def safe_extract_zip(
error_type=error_type,
)
is_dir = member.is_dir() or normalized_name.endswith("/")
path_key = portable_archive_path_key(normalized_name)
path_key = portable_zip_path_key(normalized_name)
existing = validated_paths.get(path_key)
if existing is not None:
@@ -1047,211 +894,3 @@ def safe_extract_zip(
)
if limit_error is not None:
_raise(error_type, limit_error)
def safe_extract_tar(
archive_path: Path,
target_dir: Path,
*,
archive_file: BinaryIO | None = None,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
) -> None:
"""Extract a gzip-compressed tar after ZIP-equivalent safety validation."""
_validate_non_negative_int(max_entries, "max_entries")
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
archive_path = Path(archive_path)
try:
target_root = target_dir.resolve()
except OSError as exc:
_raise_from(error_type, f"Invalid tar extraction target: {target_dir}", exc)
try:
if archive_file is not None:
archive_file.seek(0)
archive = tarfile.open(
archive_path if archive_file is None else None,
mode="r:gz",
fileobj=archive_file,
)
except (tarfile.TarError, OSError) as exc:
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
with archive:
validated: list[tuple[tarfile.TarInfo, str, bool]] = []
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
total_size = 0
try:
for index, member in enumerate(archive, start=1):
if index > max_entries:
_raise(
error_type,
f"tar.gz archive contains too many entries "
f"({index} > {max_entries})",
)
normalized_name = normalize_archive_member_name(
member.name,
archive_label="tar.gz",
error_type=error_type,
)
is_dir = member.isdir()
if member.issym():
_raise(
error_type,
f"Unsafe symlink in tar.gz archive: {member.name}",
)
if member.islnk():
_raise(
error_type,
f"Unsafe hard link in tar.gz archive: {member.name}",
)
if not is_dir and not member.isreg():
_raise(
error_type,
f"Unsafe member type in tar.gz archive: {member.name}",
)
path_key = portable_archive_path_key(normalized_name)
existing = validated_paths.get(path_key)
if existing is not None:
_raise(
error_type,
f"Conflicting path in tar.gz archive: {member.name} "
f"conflicts with {existing[0]}",
)
validated_paths[path_key] = (member.name, is_dir)
member_path = (target_dir / normalized_name).resolve()
try:
member_path.relative_to(target_root)
except ValueError:
_raise(
error_type,
f"Unsafe path in tar.gz archive: {member.name} "
"(potential path traversal)",
)
if not is_dir:
if member.size > max_member_bytes:
_raise(
error_type,
f"tar.gz member {member.name} exceeds maximum size "
f"of {max_member_bytes} bytes",
)
total_size += member.size
if total_size > max_total_bytes:
_raise(
error_type,
f"tar.gz archive exceeds maximum uncompressed size "
f"of {max_total_bytes} bytes",
)
validated.append((member, normalized_name, is_dir))
except (tarfile.TarError, OSError) as exc:
_raise_from(
error_type,
f"Invalid tar.gz archive: {archive_path}",
exc,
)
for (
(path_key, (original, is_dir)),
(next_key, (next_original, _next_is_dir)),
) in pairwise(sorted(validated_paths.items())):
if (
not is_dir
and len(next_key) > len(path_key)
and next_key[: len(path_key)] == path_key
):
_raise(
error_type,
f"Conflicting path in tar.gz archive: {original} conflicts "
f"with {next_original}",
)
total_written = 0
for member, normalized_name, is_dir in validated:
member_path = target_dir / normalized_name
if is_dir:
try:
member_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
_raise_from(
error_type,
f"Failed to create tar.gz directory {member.name}: {exc}",
exc,
)
continue
try:
member_path.parent.mkdir(parents=True, exist_ok=True)
source = archive.extractfile(member)
if source is None:
_raise(
error_type,
f"Failed to read tar.gz member {member.name}",
)
written = 0
limit_error: str | None = None
with source, member_path.open("wb") as dest:
while True:
chunk = source.read(READ_CHUNK_SIZE)
if not chunk:
break
written += len(chunk)
if written > max_member_bytes:
limit_error = (
f"tar.gz member {member.name} exceeds maximum size "
f"of {max_member_bytes} bytes"
)
break
total_written += len(chunk)
if total_written > max_total_bytes:
limit_error = (
f"tar.gz archive exceeds maximum uncompressed size "
f"of {max_total_bytes} bytes"
)
break
dest.write(chunk)
except Exception as exc:
_raise_from(
error_type,
f"Failed to extract tar.gz member {member.name}: {exc}",
exc,
)
if limit_error is not None:
_raise(error_type, limit_error)
def safe_extract_archive(
archive_path: Path,
target_dir: Path,
*,
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
) -> ArchiveFormat:
"""Detect and securely extract a supported archive."""
archive_format = detect_archive_format(
archive_path,
archive_file=archive_file,
source_name=source_name,
content_type=content_type,
error_type=error_type,
)
extractor = safe_extract_zip if archive_format == "zip" else safe_extract_tar
extractor(
archive_path,
target_dir,
archive_file=archive_file,
error_type=error_type,
max_entries=max_entries,
max_member_bytes=max_member_bytes,
max_total_bytes=max_total_bytes,
)
return archive_format
+2 -13
View File
@@ -27,7 +27,6 @@ from pathlib import Path
import typer
from packaging.version import InvalidVersion, Version
from rich.markup import escape as _escape_markup
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ._console import console
@@ -1231,10 +1230,7 @@ def self_upgrade(
tag: str | None = typer.Option(
None,
"--tag",
# Typer renders help through Rich, so escape the literal bracket (\[)
# or `[suffix]` is parsed as a style tag and dropped -- `--help` then
# advertises only `(vX.Y.Z)`, contradicting docs/upgrade.md and README.
help="Pin the target version (vX.Y.Z\\[suffix]). Without --tag, the "
help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the "
"latest stable release is resolved via GitHub Releases.",
),
) -> None:
@@ -1274,14 +1270,7 @@ def self_upgrade(
try:
tag = _validate_tag(tag)
except typer.BadParameter as exc:
# Escape at the print site rather than baking `\[` into
# _INVALID_TAG_MESSAGE: the message is also raised through
# typer.BadParameter, which Click renders without Rich, so the
# constant must stay plain text. Unescaped, Rich parses the literal
# `[suffix]` as a style tag and drops it, leaving the user with
# "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
# accepted form when -rc1 / .dev0 / +build.42 are all valid.
console.print(_escape_markup(str(exc)), soft_wrap=True)
console.print(str(exc), soft_wrap=True)
raise typer.Exit(1) from exc
plan, failure_reason = _build_upgrade_plan(target_tag_override=tag)
+1 -13
View File
@@ -302,20 +302,8 @@ class CommandRegistrar:
toml_lines = []
if "description" in frontmatter:
# Frontmatter comes from ``yaml.safe_load``, so ``description`` can
# be any YAML type: ``description:`` with no value yields None,
# ``description: 2`` an int, an unquoted ``true`` a bool.
# ``_render_basic_toml_string`` iterates the value and calls ord()
# on each character, so a non-string raises a raw TypeError -- and a
# list of single-character items is silently concatenated into a
# wrong value (``["a", "b"]`` -> ``"ab"``). Coerce first, matching
# ``render_yaml_command`` below and ``TomlIntegration
# ._extract_description``, which both normalise it already.
description = frontmatter["description"]
if not isinstance(description, str):
description = str(description) if description is not None else ""
toml_lines.append(
f"description = {self._render_basic_toml_string(description)}"
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
)
toml_lines.append("")
+10 -26
View File
@@ -96,19 +96,19 @@ class BundleManifest:
if not isinstance(data, dict):
raise BundlerError("Manifest must be a YAML mapping at the top level.")
schema_version = _text(data.get("schema_version"))
schema_version = str(data.get("schema_version", "")).strip()
bundle_raw = data.get("bundle")
if not isinstance(bundle_raw, dict):
raise BundlerError("Manifest is missing the required 'bundle' mapping.")
meta = BundleMeta(
id=_text(bundle_raw.get("id")),
name=_text(bundle_raw.get("name")),
version=_text(bundle_raw.get("version")),
role=_text(bundle_raw.get("role")),
description=_text(bundle_raw.get("description")),
author=_text(bundle_raw.get("author")),
license=_text(bundle_raw.get("license")),
id=str(bundle_raw.get("id", "")).strip(),
name=str(bundle_raw.get("name", "")).strip(),
version=str(bundle_raw.get("version", "")).strip(),
role=str(bundle_raw.get("role", "")).strip(),
description=str(bundle_raw.get("description", "")).strip(),
author=str(bundle_raw.get("author", "")).strip(),
license=str(bundle_raw.get("license", "")).strip(),
)
requires_raw = data.get("requires")
@@ -117,7 +117,7 @@ class BundleManifest:
elif not isinstance(requires_raw, dict):
raise BundlerError("'requires' must be a mapping when present.")
requires = Requires(
speckit_version=_text(requires_raw.get("speckit_version")),
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
tools=_parse_str_list(requires_raw.get("tools"), "requires.tools"),
mcp=_parse_str_list(requires_raw.get("mcp"), "requires.mcp"),
)
@@ -220,22 +220,6 @@ class BundleManifest:
return self.integration is None
def _text(raw: Any) -> str:
"""Coerce a manifest scalar into stripped text, mapping an explicit null to ``""``.
A ``.get(key, "")`` default only covers a *missing* key. A key that is
present but null -- how YAML spells an empty field (``author:`` with nothing
after it) -- yields ``None``, and ``str(None)`` is the literal ``"None"``.
That text is non-empty, so it sailed past the ``if not value`` required-field
checks in :meth:`BundleManifest.structural_errors`: an empty required field
was silently accepted and the bundle shipped ``"None"`` as its
author/license/description.
"""
if raw is None:
return ""
return str(raw).strip()
def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]:
"""Coerce a manifest list-of-strings field into a tuple of strings.
@@ -263,7 +247,7 @@ def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
refs.append(
ComponentRef(
kind=kind,
id=_text(item.get("id")),
id=str(item.get("id", "")).strip(),
version=(str(item["version"]).strip() if item.get("version") else None),
source=(str(item["source"]).strip() if item.get("source") else None),
priority=priority,
@@ -227,10 +227,6 @@ class DefaultPrimitiveInstaller:
manager = self._manager_for(component, project_root)
manager.install(component)
def refresh(self, project_root: Path, component: ComponentRef) -> None:
manager = self._manager_for(component, project_root)
manager.refresh(component)
def remove(self, project_root: Path, component: ComponentRef) -> None:
manager = self._manager_for(component, project_root)
manager.remove(component)
+7 -63
View File
@@ -85,17 +85,11 @@ def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None:
class _KindManager(Protocol):
def is_installed(self, component: ComponentRef) -> bool:
pass
def is_installed(self, component: ComponentRef) -> bool: ...
def install(self, component: ComponentRef) -> None:
pass
def install(self, component: ComponentRef) -> None: ...
def refresh(self, component: ComponentRef) -> None:
pass
def remove(self, component: ComponentRef) -> None:
pass
def remove(self, component: ComponentRef) -> None: ...
def primitive_manager(
@@ -157,12 +151,6 @@ class _PresetKindManager:
return False
def install(self, component: ComponentRef) -> None:
self._do_install(component, force=False)
def refresh(self, component: ComponentRef) -> None:
self._do_install(component, force=True)
def _do_install(self, component: ComponentRef, *, force: bool) -> None:
from ... import get_speckit_version
from ..._assets import _locate_bundled_preset
@@ -180,9 +168,7 @@ class _PresetKindManager:
component.version,
_bundled_manifest_version(bundled / "preset.yml", "preset"),
)
self._manager.install_from_directory(
bundled, speckit_version, priority, **({"force": True} if force else {})
)
self._manager.install_from_directory(bundled, speckit_version, priority)
return
if not self._allow_network:
@@ -208,9 +194,7 @@ class _PresetKindManager:
)
zip_path = catalog.download_pack(component.id)
try:
self._manager.install_from_zip(
zip_path, speckit_version, priority, **({"force": True} if force else {})
)
self._manager.install_from_zip(zip_path, speckit_version, priority)
finally:
with contextlib.suppress(Exception):
if zip_path.exists():
@@ -240,12 +224,6 @@ class _ExtensionKindManager:
return False
def install(self, component: ComponentRef) -> None:
self._do_install(component, force=False)
def refresh(self, component: ComponentRef) -> None:
self._do_install(component, force=True)
def _do_install(self, component: ComponentRef, *, force: bool) -> None:
from ... import get_speckit_version
from ..._assets import _locate_bundled_extension
@@ -264,7 +242,7 @@ class _ExtensionKindManager:
_bundled_manifest_version(bundled / "extension.yml", "extension"),
)
self._manager.install_from_directory(
bundled, speckit_version, priority=priority, force=force
bundled, speckit_version, priority=priority
)
return
@@ -294,7 +272,7 @@ class _ExtensionKindManager:
zip_path = catalog.download_extension(component.id)
try:
self._manager.install_from_zip(
zip_path, speckit_version, priority=priority, force=force
zip_path, speckit_version, priority=priority
)
finally:
with contextlib.suppress(Exception):
@@ -340,11 +318,6 @@ class _WorkflowKindManager:
lambda: workflow_add(component.id),
)
def refresh(self, component: ComponentRef) -> None:
# workflow_add is idempotent for already-installed workflows; delegate
# to the standard install path which handles version refresh correctly.
self.install(component)
def _assert_pinned_version(self, component: ComponentRef) -> None:
if not component.version:
return
@@ -405,35 +378,6 @@ class _StepKindManager:
lambda: workflow_step_add(component.id),
)
def refresh(self, component: ComponentRef) -> None:
# Preserve an existing step until we've validated we can perform refresh.
# For already-installed steps, keep a backup and restore it if the
# remove+reinstall path fails.
if not (self._allow_network and self.is_installed(component)):
self.install(component)
return
import shutil
import tempfile
step_dir = self._registry.steps_dir / component.id
metadata = self._registry.get(component.id)
backup_dir = Path(tempfile.mkdtemp(prefix="speckit-step-refresh-")) / component.id
try:
if step_dir.exists():
shutil.copytree(step_dir, backup_dir)
self.remove(component)
try:
self.install(component)
except BundlerError:
if backup_dir.exists():
shutil.copytree(backup_dir, step_dir, dirs_exist_ok=True)
if metadata is not None and not self._registry.is_installed(component.id):
self._registry.add(component.id, metadata)
raise
finally:
shutil.rmtree(backup_dir.parent, ignore_errors=True)
def remove(self, component: ComponentRef) -> None:
from ... import workflow_step_remove
-7
View File
@@ -74,13 +74,6 @@ class CatalogStackBase:
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Accessing ``port`` performs urllib's syntax/range validation;
# ``hostname`` alone does not, so a non-numeric or out-of-range
# port would otherwise pass validation here and only fail later,
# at fetch time, as an error this module does not translate --
# a raw http.client.InvalidURL for a non-numeric port, and a
# socket-layer failure for one that is merely out of range.
_ = parsed.port
except ValueError:
raise cls._error(f"Catalog URL is malformed: {url}") from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
-39
View File
@@ -1,39 +0,0 @@
"""specify event * command handlers."""
from __future__ import annotations
from pathlib import Path
import sys
import typer
event_app = typer.Typer(
name="event",
help="Manage and execute event-driven commands",
add_completion=False,
)
@event_app.command("run")
def event_run(
command_name: str = typer.Argument(..., help="Name of the command to execute"),
event_name: str = typer.Argument(..., help="Canonical event name (e.g., session_start)"),
timeout: int = typer.Argument(
120, help="Per-handler timeout in seconds (passed through from the native hook config)"
),
):
"""Resolve and run an event-driven command script with stdin payload."""
from ..events import resolve_and_run_event_command
# Read payload from stdin if available
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
# Run the event command
project_root = Path.cwd() # The agent runs events from project root
exit_code = resolve_and_run_event_command(
command_name, event_name, payload, project_root, timeout=timeout
)
raise typer.Exit(code=exit_code)
def register(app: typer.Typer) -> None:
app.add_typer(event_app, name="event")
-8
View File
@@ -443,20 +443,12 @@ def register(app: typer.Typer) -> None:
if extra:
integration_parsed_options.update(extra)
from ..events import resolve_events
events_map = resolve_events(
resolved_integration.key,
resolved_integration.config,
project_path,
integration_parsed_options or None,
)
resolved_integration.setup(
project_path,
manifest,
parsed_options=integration_parsed_options or None,
script_type=selected_script,
raw_options=integration_options,
events=events_map,
)
manifest.save()
File diff suppressed because it is too large Load Diff
+33 -181
View File
@@ -20,7 +20,7 @@ import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Set
from typing import Any, Callable, Dict, List, Optional, Set
import pathspec
import yaml
@@ -29,14 +29,11 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
from .._assets import _locate_core_pack, _repo_root
from .._download_security import (
archive_format_from_name,
archive_suffix,
MAX_JSON_CATALOG_BYTES,
build_safe_download_path,
detect_archive_format,
is_https_or_localhost_http,
read_response_limited,
safe_extract_archive,
safe_extract_zip,
)
from .._init_options import is_ai_skills_enabled
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
@@ -266,25 +263,8 @@ class ExtensionManifest:
f"(expected {self.SCHEMA_VERSION})"
)
# The REQUIRED_FIELDS loop above only checks key PRESENCE, so a section
# that is written but left empty (``provides:`` -> None) or given the
# wrong shape (``provides: []``) passes it and then fails on first use:
# ``field not in None`` raises TypeError and ``None.get(...)`` raises
# AttributeError. Neither is a ValidationError, so both escape the
# callers that already handle malformed manifests -- list_installed()'s
# "Corrupted extension" fallback catches ValidationError only, so one bad
# extension made ``specify extension list`` exit 1 with a raw
# AttributeError instead of listing the rest. Guard each required
# section's shape, mirroring the nested guards below ("Invalid
# provides.commands: expected a list", "Invalid hooks: expected a
# mapping") and _load_yaml's document-root check.
# Validate extension metadata
ext = self.data["extension"]
if not isinstance(ext, dict):
raise ValidationError(
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
)
for field in ["id", "name", "version", "description"]:
if field not in ext:
raise ValidationError(f"Missing extension.{field}")
@@ -319,37 +299,24 @@ class ExtensionManifest:
# Validate requires section
requires = self.data["requires"]
if not isinstance(requires, dict):
raise ValidationError(
f"Invalid requires: expected a mapping, got {type(requires).__name__}"
)
if "speckit_version" not in requires:
raise ValidationError("Missing requires.speckit_version")
# Validate provides section
provides = self.data["provides"]
if not isinstance(provides, dict):
raise ValidationError(
f"Invalid provides: expected a mapping, got {type(provides).__name__}"
)
commands = provides.get("commands", [])
hooks = self.data.get("hooks")
events = self.data.get("events")
if "commands" in provides and not isinstance(commands, list):
raise ValidationError("Invalid provides.commands: expected a list")
if "hooks" in self.data and not isinstance(hooks, dict):
raise ValidationError("Invalid hooks: expected a mapping")
if "events" in self.data:
from ..events import validate_events
validate_events(self.data)
has_commands = bool(commands)
has_hooks = bool(hooks)
has_events = bool(events)
if not has_commands and not has_hooks and not has_events:
raise ValidationError("Extension must provide at least one command, hook, or event")
if not has_commands and not has_hooks:
raise ValidationError("Extension must provide at least one command or hook")
# Validate hook values (if present).
# Each event is a single mapping or a list of mappings.
@@ -473,33 +440,6 @@ class ExtensionManifest:
f"The extension author should update the manifest."
)
# C11: apply the same rename + alias-lift canonicalization to event
# command references. Without this, an event referencing a command
# that was auto-corrected (e.g. speckit.boot -> speckit.<id>.boot)
# keeps the obsolete name, dispatch reports no command, and the event
# silently no-ops.
events_data = self.data.get("events", {})
if isinstance(events_data, dict):
for event_name, event_config in events_data.items():
if not isinstance(event_config, dict):
continue
command_ref = event_config.get("command")
if not isinstance(command_ref, str):
continue
after_rename = rename_map.get(command_ref, command_ref)
parts = after_rename.split(".")
if len(parts) == 2 and parts[0] == ext["id"]:
final_ref = f"speckit.{ext['id']}.{parts[1]}"
else:
final_ref = after_rename
if final_ref != command_ref:
event_config["command"] = final_ref
self.warnings.append(
f"Event '{event_name}' referenced command '{command_ref}'; "
f"updated to canonical form '{final_ref}'. "
f"The extension author should update the manifest."
)
@staticmethod
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
"""Try to auto-correct a non-conforming command name to the required pattern.
@@ -569,11 +509,8 @@ class ExtensionManifest:
def get_hash(self) -> str:
"""Calculate SHA256 hash of manifest file."""
h = hashlib.sha256()
with open(self.path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return f"sha256:{h.hexdigest()}"
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
class ExtensionRegistry:
@@ -598,7 +535,7 @@ class ExtensionRegistry:
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
try:
with open(self.registry_path, "r", encoding="utf-8") as f:
with open(self.registry_path, "r") as f:
data = json.load(f)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
@@ -614,7 +551,7 @@ class ExtensionRegistry:
def _save(self):
"""Save registry to disk."""
self.extensions_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, "w", encoding="utf-8") as f:
with open(self.registry_path, "w") as f:
json.dump(self.data, f, indent=2)
def add(self, extension_id: str, metadata: dict):
@@ -1330,7 +1267,6 @@ class ExtensionManager:
manifest: ExtensionManifest,
extension_dir: Path,
link_outputs: bool = False,
force: bool = False,
) -> List[str]:
"""Generate SKILL.md files for extension commands as agent skills.
@@ -1344,11 +1280,6 @@ class ExtensionManager:
extension_dir: Installed extension directory.
link_outputs: If True, create dev-mode symlinks for rendered
skill files when supported by the OS.
force: If True, overwrite existing SKILL.md files even when they
are not dev-mode symlinks. Use in the upgrade path, where
``setup()`` has just freshly regenerated core-template skill
files and the skip guard would otherwise prevent extension
content from being layered on top.
Returns:
List of skill names that were created (for registry storage).
@@ -1436,16 +1367,13 @@ class ExtensionManager:
)
# Do not overwrite user-customized skills, but allow dev-mode
# symlinks that point back to this extension's generated cache
# to be refreshed on a subsequent dev install. In the upgrade
# path (force=True) the file was just written by setup(), so
# overwriting it with the composed extension content is correct.
if not is_expected_dev_symlink and not force:
# to be refreshed on a subsequent dev install.
if not is_expected_dev_symlink:
continue
elif skill_dir_preexists and not force:
elif skill_dir_preexists:
# Never add files to a pre-existing user directory. Without a
# verifiable SKILL.md ownership marker, rollback/removal cannot
# distinguish our output from unrelated user artifacts.
# Skipped when force=True (upgrade path).
continue
# Create skill directory; track whether we created it so we can clean
@@ -2406,7 +2334,7 @@ class ExtensionManager:
pass # Best-effort; install already committed to the registry.
# Restore execute bits on shipped POSIX scripts. copytree here (and the
# archive extraction in install_from_archive, which delegates here, does
# zipfile.extractall in install_from_zip, which delegates to this method) does
# not restore a stripped Unix mode, so a bundled *.sh would land non-executable
# and a documented `.specify/extensions/<id>/scripts/...` invocation would fail
# with "Permission denied". This is the single sink every install route funnels
@@ -2425,27 +2353,21 @@ class ExtensionManager:
return manifest
def install_from_archive(
def install_from_zip(
self,
archive_path: Path,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
*,
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
) -> ExtensionManifest:
"""Install an extension from a supported archive.
"""Install extension from ZIP file.
Args:
archive_path: Path to a .zip, .tar.gz, or .tgz archive
zip_path: Path to extension ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and extension is already installed, remove it first
before proceeding with installation
archive_file: Already-open archive stream to consume instead of
reopening ``zip_path``
Returns:
Installed extension manifest
@@ -2461,14 +2383,7 @@ class ExtensionManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
safe_extract_archive(
archive_path,
temp_path,
archive_file=archive_file,
source_name=source_name,
content_type=content_type,
error_type=ValidationError,
)
safe_extract_zip(zip_path, temp_path, error_type=ValidationError)
# Find extension directory (may be nested)
extension_dir = temp_path
@@ -2482,35 +2397,13 @@ class ExtensionManager:
manifest_path = extension_dir / "extension.yml"
if not manifest_path.exists():
raise ValidationError("No extension.yml found in archive")
raise ValidationError("No extension.yml found in ZIP file")
# Install from extracted directory
return self.install_from_directory(
extension_dir, speckit_version, priority=priority, force=force
)
def install_from_zip(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
*,
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
) -> ExtensionManifest:
"""Backward-compatible wrapper for archive installation."""
return self.install_from_archive(
zip_path,
speckit_version,
priority=priority,
force=force,
archive_file=archive_file,
source_name=source_name,
content_type=content_type,
)
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
"""Remove an installed extension.
@@ -2719,7 +2612,7 @@ class ExtensionManager:
if updates:
self.registry.update(ext_id, updates)
def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
"""Register installed, enabled extensions for ``agent_name``.
Command-file registration is scoped to the explicit ``agent_name``
@@ -2837,7 +2730,7 @@ class ExtensionManager:
if agent_name == active_agent:
try:
registered_skills = self._register_extension_skills(
manifest, ext_dir, force=force
manifest, ext_dir
)
except Exception as skills_err:
# Skills are a companion artifact. If command registration
@@ -3828,14 +3721,14 @@ class ExtensionCatalog(CatalogStackBase):
def download_extension(
self, extension_id: str, target_dir: Optional[Path] = None
) -> Path:
"""Download an extension archive from a catalog.
"""Download extension ZIP from catalog.
Args:
extension_id: ID of the extension to download
target_dir: Directory to save the archive
target_dir: Directory to save ZIP file (defaults to temp directory)
Returns:
Path to the downloaded archive
Path to downloaded ZIP file
Raises:
ExtensionError: If extension not found or download fails
@@ -3894,93 +3787,52 @@ class ExtensionCatalog(CatalogStackBase):
target_dir = self.cache_dir / "downloads"
target_dir = Path(target_dir)
version = ext_info.get("version", "unknown")
declared_format = archive_format_from_name(download_url)
build_safe_download_path(
zip_path = build_safe_download_path(
target_dir,
extension_id,
version,
error_type=ExtensionError,
label="extension",
suffix=archive_suffix(declared_format or "tar.gz"),
)
target_dir.mkdir(parents=True, exist_ok=True)
original_download_url = download_url
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
if resolved_download_url:
download_url = resolved_download_url
extra_headers = {"Accept": "application/octet-stream"}
staging_path: Path | None = None
# Download the ZIP file
try:
with self._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
archive_data = read_response_limited(
zip_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension '{extension_id}' download",
)
final_url = (
response.geturl()
if hasattr(response, "geturl")
else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
verify_archive_sha256(
archive_data, ext_info.get("sha256"), extension_id, ExtensionError
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
)
with tempfile.NamedTemporaryFile(
prefix="extension-download-",
suffix=".archive",
dir=target_dir,
delete=False,
) as staging_file:
staging_path = Path(staging_file.name)
staging_file.write(archive_data)
archive_format = detect_archive_format(
staging_path,
source_name=(
final_url
if archive_format_from_name(final_url) is not None
else original_download_url
),
content_type=content_type,
error_type=ExtensionError,
)
archive_path = build_safe_download_path(
target_dir,
extension_id,
version,
error_type=ExtensionError,
label="extension",
suffix=archive_suffix(archive_format),
)
os.replace(staging_path, archive_path)
staging_path = None
return archive_path
zip_path.write_bytes(zip_data)
return zip_path
except urllib.error.URLError as e:
raise ExtensionError(
f"Failed to download extension from {download_url}: {e}"
)
except IOError as e:
raise ExtensionError(f"Failed to save extension archive: {e}")
finally:
if staging_path is not None:
staging_path.unlink(missing_ok=True)
raise ExtensionError(f"Failed to save extension ZIP: {e}")
def clear_cache(self):
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
self.cache_file.unlink(missing_ok=True)
self.cache_metadata_file.unlink(missing_ok=True)
if self.cache_file.exists():
self.cache_file.unlink()
if self.cache_metadata_file.exists():
self.cache_metadata_file.unlink()
# Also clear any per-URL hash-based cache files
if self.cache_dir.exists():
for extra_cache in self.cache_dir.glob("catalog-*.json"):
+146 -506
View File
@@ -8,12 +8,11 @@ which re-fetch from the parent package at call time so test monkeypatching of
"""
from __future__ import annotations
import errno
import hashlib
import os
import shutil
import stat
import tempfile
import zipfile
from pathlib import Path
from typing import Optional
from uuid import uuid4
@@ -27,11 +26,12 @@ from rich.table import Table
from .._console import console
from .._assets import get_speckit_version
from .._download_security import (
archive_format_from_name,
detect_archive_format,
is_https_or_localhost_http,
normalize_zip_member_name,
open_zip_bounded,
portable_zip_path_key,
read_response_limited,
safe_extract_archive,
read_zip_member_limited,
)
from .._init_options import is_ai_skills_enabled
@@ -71,30 +71,6 @@ def _display_project_path(*args, **kwargs):
return _f(*args, **kwargs)
def _refresh_events_and_warn(project_root: Path) -> None:
"""Refresh native event config and surface failures (R3).
The extension has already been added/removed/enabled/disabled by the time
this runs, so a refresh failure must not abort the command — but it must
be surfaced, because a stale native hook may still be active (e.g. a
disabled extension's hook still resolves and runs). Prints a warning with
the per-integration failures so the user knows deactivation was incomplete.
"""
from ..events import EventRefreshError, refresh_integration_events
try:
refresh_integration_events(project_root)
except EventRefreshError as exc:
console.print(
f"\n[yellow]⚠[/yellow] Extension updated, but event refresh failed "
f"for {len(exc.failures)} integration(s); a stale native hook may "
f"still be active. Re-run [cyan]specify integration upgrade "
f"<key>[cyan][/cyan][/cyan] to retry."
)
for key, detail in exc.failures:
console.print(f" {key}: {_escape_markup(detail)}")
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
"""Load extension catalog CLI config with user-facing shape errors."""
try:
@@ -437,278 +413,6 @@ def catalog_remove(
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
# Relative path, below the project root, of the extension URL download cache.
_CACHE_REL_PARTS = (".specify", "extensions", ".cache", "downloads")
def _has_secure_dir_fd() -> bool:
"""Whether this platform supports the strongest (POSIX) hardening path.
The descriptor-anchored walk needs ``O_NOFOLLOW`` plus ``dir_fd`` support
for ``os.open``/``os.mkdir``/``os.unlink``. When any of those is missing
(notably on Windows) the caller falls back to the portable path-wise walk,
which reproduces the same guarantees using symlink/reparse-point rejection,
resolve-under-root containment checks, and post-open inode-identity
verification instead of file descriptors.
"""
return bool(
getattr(os, "O_NOFOLLOW", 0)
and os.open in os.supports_dir_fd
and os.mkdir in os.supports_dir_fd
and os.unlink in os.supports_dir_fd
)
def _is_symlink_refusal_errno(exc: OSError) -> bool:
"""Whether an ``os.open``/``os.mkdir`` error means a component is a symlink.
Opening an ``O_NOFOLLOW`` path whose final component is a symlink raises
``ELOOP`` on Linux and ``EMLINK`` on some BSDs, while a symlinked component
that no longer resolves to a directory surfaces as ``ENOTDIR``.
"""
return exc.errno in (errno.ELOOP, errno.ENOTDIR, getattr(errno, "EMLINK", -1))
def _verify_leaf_identity(fd: int, path: Path) -> None:
"""Confirm ``fd`` still refers to the regular file at ``path``.
Mirrors the workflow installer's staged-file check: comparing the open
descriptor's ``fstat`` against a ``lstat`` of the pathname detects a leaf
that was swapped for a symlink/reparse point between creation and use, so
the portable (dir_fd-less) path is not vulnerable to an ancestor swap race.
"""
path_stat = path.stat(follow_symlinks=False)
open_stat = os.fstat(fd)
if (
not stat.S_ISREG(path_stat.st_mode)
or path_stat.st_dev != open_stat.st_dev
or path_stat.st_ino != open_stat.st_ino
):
raise OSError(
errno.ENOTDIR, "Download file changed between creation and open"
)
def _validate_safe_cache_dir(project_root: Path) -> Path:
"""Create and validate the extension URL download cache one component at a
time, refusing symlinked/junctioned components on every supported platform."""
download_dir = project_root.joinpath(*_CACHE_REL_PARTS)
try:
if _has_secure_dir_fd():
_validate_cache_dir_via_dir_fd(project_root, download_dir)
else:
_validate_cache_dir_via_paths(project_root, download_dir)
except typer.Exit:
raise
except FileExistsError:
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
except OSError as exc:
if _is_symlink_refusal_errno(exc):
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
console.print(
"[red]Error:[/red] Could not prepare download cache directory: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
return download_dir
def _validate_cache_dir_via_dir_fd(project_root: Path, download_dir: Path) -> None:
"""POSIX cache-dir walk anchored on ``dir_fd`` + ``O_NOFOLLOW`` descriptors."""
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
o_directory = getattr(os, "O_DIRECTORY", 0)
o_cloexec = getattr(os, "O_CLOEXEC", 0)
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
project_root_resolved = project_root.resolve()
parent_fd = os.open(project_root, walk_flags)
current_path = project_root
try:
for part in _CACHE_REL_PARTS:
current_path = current_path / part
try:
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
except FileNotFoundError:
try:
os.mkdir(part, dir_fd=parent_fd)
except FileExistsError:
pass
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
try:
current_path.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
try:
os.close(child_fd)
except OSError:
pass
console.print(
"[red]Error:[/red] Download cache directory escapes project root"
)
raise typer.Exit(1)
os.close(parent_fd)
parent_fd = child_fd
finally:
if parent_fd >= 0:
try:
os.close(parent_fd)
except OSError:
pass
def _validate_cache_dir_via_paths(project_root: Path, download_dir: Path) -> None:
"""Portable cache-dir walk for platforms without ``dir_fd`` (e.g. Windows).
Each component is created individually while a symlink/junction is rejected
both before and after creation, and every component is required to resolve
back under the project root so a mount-point alias or reparse point cannot
redirect the cache outside the project.
"""
project_root_resolved = project_root.resolve()
current_path = project_root
for part in _CACHE_REL_PARTS:
current_path = current_path / part
if current_path.is_symlink():
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
try:
current_path.mkdir()
except FileExistsError:
pass
# Re-check after creation: a component swapped for a symlink/junction
# (or an existing non-directory) between the check and mkdir is caught
# here before the walk descends into it.
if current_path.is_symlink() or not current_path.is_dir():
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
try:
current_path.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
console.print(
"[red]Error:[/red] Download cache directory escapes project root"
)
raise typer.Exit(1)
def _safe_open_download_zip(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""Exclusively create a download ZIP and return an owned descriptor.
The archive never persists as a nameable on-disk file: the POSIX path
unlinks the leaf immediately after exclusive creation (anonymous inode),
while the portable path opens it with ``O_TEMPORARY`` so the OS deletes it
when the last handle closes. Installation proceeds entirely through the
returned descriptor, removing the pathname-reopen and cleanup-walk TOCTOU
classes on every supported platform.
"""
if _has_secure_dir_fd():
return _open_download_zip_via_dir_fd(
project_root, download_dir, zip_filename
)
return _open_download_zip_via_paths(project_root, download_dir, zip_filename)
def _open_download_zip_via_dir_fd(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""POSIX leaf create: descriptor walk, ``O_EXCL`` create, immediate unlink."""
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
o_directory = getattr(os, "O_DIRECTORY", 0)
o_cloexec = getattr(os, "O_CLOEXEC", 0)
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
rel_parts = download_dir.relative_to(project_root).parts
parent_fd = os.open(project_root, walk_flags)
try:
for part in rel_parts:
new_fd = os.open(part, walk_flags, dir_fd=parent_fd)
os.close(parent_fd)
parent_fd = new_fd
download_fd = os.open(
zip_filename,
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_nofollow | o_cloexec,
0o600,
dir_fd=parent_fd,
)
try:
os.unlink(zip_filename, dir_fd=parent_fd)
except OSError:
os.close(download_fd)
raise
return download_fd
finally:
os.close(parent_fd)
def _open_download_zip_via_paths(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""Portable leaf create for platforms without ``dir_fd`` (e.g. Windows).
The cache directory is re-validated (real directory, under the project
root) immediately before an exclusive create. ``O_EXCL`` guarantees an
attacker cannot pre-stage the leaf as a symlink/junction, ``O_TEMPORARY``
makes the OS delete it on close, and a post-open inode-identity check
detects a leaf swapped underneath us. The returned descriptor is the only
handle installation ever uses, so the cache pathname is never reopened.
"""
zip_path = download_dir / zip_filename
project_root_resolved = project_root.resolve()
if download_dir.is_symlink() or not download_dir.is_dir():
raise OSError(
errno.ENOTDIR, "Download cache directory is not a real directory"
)
try:
download_dir.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
raise OSError(errno.ENOTDIR, "Download cache directory escapes project root")
if zip_path.is_symlink():
raise OSError(errno.ELOOP, "Refusing to write through a symlinked download file")
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
flags |= getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_BINARY", 0)
o_temporary = getattr(os, "O_TEMPORARY", 0)
flags |= o_temporary
download_fd = os.open(zip_path, flags, 0o600)
try:
_verify_leaf_identity(download_fd, zip_path)
except OSError:
os.close(download_fd)
# Without O_TEMPORARY the leaf is not auto-deleted, so remove the file
# we just exclusively created (best effort, never through a symlink).
if not o_temporary:
try:
if not zip_path.is_symlink():
zip_path.unlink()
except OSError:
pass
raise
return download_fd
@extension_app.command("add")
def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
@@ -810,18 +514,22 @@ def extension_add(
)
elif from_url:
# Install from an archive URL.
# Install from URL (ZIP file)
import io
import urllib.error
console.print(f"Downloading from {safe_url}...")
download_dir = _validate_safe_cache_dir(project_root)
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
# Only used for diagnostic messages: the real archive is a
# transient inode (unlinked on POSIX, O_TEMPORARY on Windows)
# consumed via ``archive_file`` below, so this path is never
# opened again.
archive_path = download_dir / archive_filename
# Download ZIP to temp location
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
download_dir.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
prefix="extension-url-download-",
suffix=".zip",
dir=download_dir,
delete=False,
) as download_file:
zip_path = Path(download_file.name)
try:
# Use the catalog's authenticated fetch so configured
@@ -839,105 +547,37 @@ def extension_add(
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
archive_data = read_response_limited(
zip_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension {from_url}",
)
final_url = (
response.geturl()
if hasattr(response, "geturl")
else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
if not zipfile.is_zipfile(io.BytesIO(zip_data)):
console.print(
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
f"(got {len(zip_data)} bytes). This usually means the request "
f"was not authenticated and a login/HTML page was returned. "
f"Verify the URL is correct and that credentials for its host "
f"are configured in ~/.specify/auth.json."
)
raise typer.Exit(1)
download_fd = -1
download_file = None
try:
try:
download_fd = _safe_open_download_zip(
project_root, download_dir, archive_filename
)
except OSError as exc:
console.print(
"[red]Error:[/red] Could not safely create download file: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
zip_path.write_bytes(zip_data)
try:
download_file = os.fdopen(download_fd, "w+b")
download_fd = -1
download_file.write(archive_data)
download_file.flush()
download_file.seek(0)
except OSError as exc:
console.print(
"[red]Error:[/red] Could not safely write download file: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
format_source = (
final_url
if archive_format_from_name(final_url) is not None
else from_url
)
try:
detect_archive_format(
archive_path,
archive_file=download_file,
source_name=format_source,
content_type=content_type,
error_type=ExtensionError,
)
except ExtensionError:
console.print(
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
"or tar.gz/tgz archive "
f"(got {len(archive_data)} bytes). This usually means "
"the request was not authenticated and a login/HTML page was "
"returned. Verify the URL and configured credentials."
)
raise typer.Exit(1)
# Consume the transient inode reserved above rather
# than reopening the cache pathname during extraction.
try:
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority=priority,
force=force,
archive_file=download_file,
)
except OSError as exc:
console.print(
"[red]Error:[/red] Could not install extension from downloaded archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
finally:
if download_file is not None:
try:
download_file.close()
except OSError:
pass
elif download_fd >= 0:
try:
os.close(download_fd)
except OSError:
pass
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
except urllib.error.URLError as e:
console.print(
f"[red]Error:[/red] Failed to download from {safe_url}: "
f"{_escape_markup(str(e))}"
)
raise typer.Exit(1)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
zip_path.unlink()
else:
# Try bundled extensions first (shipped with spec-kit)
bundled_path = _locate_bundled_extension(extension)
@@ -996,30 +636,23 @@ def extension_add(
)
raise typer.Exit(1)
# Download extension archive (use the resolved catalog ID).
# Download extension ZIP (use resolved ID, not original argument which may be display name)
extension_id = ext_info['id']
console.print(f"Downloading {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(ext_info.get('version', 'unknown')))}...")
archive_path = catalog.download_extension(extension_id)
zip_path = catalog.download_extension(extension_id)
try:
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority=priority,
force=force,
)
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
finally:
if archive_path.exists():
archive_path.unlink()
# Clean up downloaded ZIP
if zip_path.exists():
zip_path.unlink()
console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})")
console.print(f" {_escape_markup(str(manifest.description))}")
# #1: regenerate native event config for installed event-capable
# integrations so the new extension's events take effect immediately.
_refresh_events_and_warn(project_root)
for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
@@ -1126,10 +759,6 @@ def extension_remove(
console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/")
else:
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/")
# #1: regenerate native event config so the removed extension's events
# are stripped from installed integrations.
_refresh_events_and_warn(project_root)
console.print(f"\nTo reinstall: specify extension add {safe_extension_id}")
else:
console.print("[red]Error:[/red] Failed to remove extension")
@@ -1221,7 +850,7 @@ def extension_search(
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
console.print(
f" Add to an approved catalog with install_allowed: true, "
f"or install from an archive URL: specify extension add {safe_id} --from <archive-url>"
f"or install from a ZIP URL: specify extension add {safe_id} --from <zip-url>"
)
console.print()
@@ -1849,105 +1478,131 @@ def extension_update(
backup_hooks[hook_name] = ext_hooks
# 5. Download new version
archive_path = catalog.download_extension(extension_id)
zip_path = catalog.download_extension(extension_id)
try:
# 6. Validate the archive and extension ID before modifying
# the existing installation. The shared extractor applies
# the same bounded security checks to ZIP and tar archives.
with tempfile.TemporaryDirectory(
prefix="speckit-update-archive-"
) as archive_tmpdir:
extracted_root = Path(archive_tmpdir)
try:
safe_extract_archive(archive_path, extracted_root)
except ValueError as exc:
# 6. Validate extension ID from ZIP BEFORE modifying installation
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
with open_zip_bounded(zip_path) as zf:
import yaml
manifest_data = None
manifest_bytes = None
namelist = zf.namelist()
# Read the manifest under a hard size cap: this happens
# before install_from_zip()'s safe_extract_zip(), so a
# raw zf.open().read() here would bypass that bound and
# let a zip-bomb extension.yml exhaust memory.
# Normalize separators before choosing the manifest so
# this pre-scan cannot approve one entry while extraction
# later overwrites it with a backslash alias.
manifest_candidates = []
archive_entries = []
for name in namelist:
normalized_name = normalize_zip_member_name(name)
parts = normalized_name.removesuffix("/").split(
"/"
)
path_key = portable_zip_path_key(normalized_name)
archive_entries.append(
(normalized_name, parts)
)
if (
"Conflicting path" in str(exc)
and "extension.yml" in str(exc).casefold()
len(parts) in {1, 2}
and path_key[-1] == "extension.yml"
):
manifest_candidates.append(
(name, normalized_name, path_key)
)
seen_manifest_keys = {}
for name, _normalized_name, path_key in manifest_candidates:
previous = seen_manifest_keys.get(path_key)
if previous is not None:
raise ValueError(
"Downloaded extension archive contains multiple "
"extension.yml manifests"
) from exc
raise
manifest_root = extracted_root
top_level = list(extracted_root.iterdir())
root_manifest_entries = [
entry
for entry in top_level
if entry.name.casefold() == "extension.yml"
]
if any(
entry.name != "extension.yml"
for entry in root_manifest_entries
):
raise ValueError(
"Archive must use canonical 'extension.yml' casing"
)
canonical_root_manifest = next(
)
seen_manifest_keys[path_key] = name
for _name, normalized_name, _path_key in manifest_candidates:
if normalized_name.split("/")[-1] != "extension.yml":
raise ValueError(
"Downloaded extension archive manifest "
"filenames must use canonical "
"'extension.yml' casing"
)
root_manifest = next(
(
entry
for entry in root_manifest_entries
if entry.name == "extension.yml"
name
for name, _normalized_name, path_key
in manifest_candidates
if path_key == ("extension.yml",)
),
None,
)
if canonical_root_manifest is not None:
manifest_path = canonical_root_manifest
else:
top_level_dirs = [
entry for entry in top_level if entry.is_dir()
]
if len(top_level_dirs) != 1:
nested_manifests = [
(name, normalized_name)
for name, normalized_name, path_key
in manifest_candidates
if len(path_key) == 2
and path_key[-1] == "extension.yml"
]
manifest_path = root_manifest
if manifest_path is None and len(nested_manifests) == 1:
manifest_path, normalized_manifest_path = (
nested_manifests[0]
)
manifest_root = normalized_manifest_path.split(
"/", 1
)[0]
top_level_dirs = {
parts[0]
for normalized_name, parts in archive_entries
if (
len(parts) > 1
or normalized_name.endswith("/")
)
}
if top_level_dirs != {manifest_root}:
raise ValueError(
"Downloaded extension archive must contain exactly "
"Downloaded extension archive with a "
"nested extension.yml must contain exactly "
"one top-level directory"
)
manifest_root = top_level_dirs[0]
nested_manifest_entries = [
entry
for entry in manifest_root.iterdir()
if entry.name.casefold() == "extension.yml"
]
if any(
entry.name != "extension.yml"
for entry in nested_manifest_entries
):
raise ValueError(
"Archive must use canonical 'extension.yml' casing"
)
manifest_path = next(
(
entry
for entry in nested_manifest_entries
if entry.name == "extension.yml"
),
manifest_root / "extension.yml",
if manifest_path is not None:
manifest_bytes = read_zip_member_limited(
zf, manifest_path
)
if not manifest_path.is_file():
raise ValueError(
"Downloaded extension archive is missing 'extension.yml'"
parsed_manifest = yaml.safe_load(
manifest_bytes
)
manifest_bytes = manifest_path.read_bytes()
parsed_manifest = yaml.safe_load(manifest_bytes)
manifest_data = (
parsed_manifest if parsed_manifest is not None else {}
)
manifest_data = (
parsed_manifest
if parsed_manifest is not None
else {}
)
if manifest_data is None:
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
if not isinstance(manifest_data, dict):
raise ValueError(
"Invalid extension manifest in downloaded archive: "
"expected YAML mapping"
"Invalid extension manifest in downloaded archive: expected YAML mapping"
)
extension_data = manifest_data.get("extension", {})
if not isinstance(extension_data, dict):
raise ValueError(
"Invalid extension manifest in downloaded archive: "
"expected 'extension' mapping"
"Invalid extension manifest in downloaded archive: expected 'extension' mapping"
)
# Run the same manifest and compatibility validation as a
# normal install while the existing extension is still
# untouched. Reuse the exact bounded bytes selected above.
if manifest_bytes is None:
raise ValueError(
"Downloaded extension archive is missing 'extension.yml'"
)
with tempfile.TemporaryDirectory(
prefix="speckit-update-manifest-"
) as manifest_tmpdir:
@@ -2143,7 +1798,7 @@ def extension_update(
manager.remove(extension_id, keep_config=True)
# 8. Install new version
_ = manager.install_from_zip(archive_path, speckit_version)
_ = manager.install_from_zip(zip_path, speckit_version)
# Restore user config files from backup after successful install.
new_extension_dir = manager.extensions_dir / extension_id
@@ -2189,12 +1844,12 @@ def extension_update(
hook["enabled"] = False
hook_executor.save_project_config(config)
finally:
# Archive cleanup is housekeeping: never replace an install
# ZIP cleanup is housekeeping: never replace an install
# error or roll back an already committed update because a
# scanner temporarily locks the download on Windows.
if archive_path.exists():
if zip_path.exists():
try:
archive_path.unlink()
zip_path.unlink()
except OSError as error:
zip_cleanup_error = error
@@ -2471,13 +2126,6 @@ def extension_update(
console.print(f" • {_escape_markup(str(ext_name))}: {_escape_markup(str(error))}")
raise typer.Exit(1)
# S4: regenerate native event config after a successful update. An
# update replaces the installed extension.yml, so any added/removed/
# changed event declarations would otherwise leave native configs
# stale until a manual integration upgrade.
if updated_extensions:
_refresh_events_and_warn(project_root)
except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
@@ -2527,10 +2175,6 @@ def extension_enable(
console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled")
# #1: regenerate native event config so the enabled extension's events
# are re-emitted in installed integrations.
_refresh_events_and_warn(project_root)
@extension_app.command("disable")
def extension_disable(
@@ -2575,10 +2219,6 @@ def extension_disable(
console.print("\nCommands will no longer be available. Hooks will not execute.")
console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}")
# #1: regenerate native event config so the disabled extension's events
# are stripped from installed integrations.
_refresh_events_and_warn(project_root)
@extension_app.command("set-priority")
def extension_set_priority(
+3 -8
View File
@@ -121,7 +121,8 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str
def _remove_integration_json(project_root: Path) -> None:
"""Remove ``.specify/integration.json`` if it exists."""
path = project_root / INTEGRATION_JSON
path.unlink(missing_ok=True)
if path.exists():
path.unlink()
# ---------------------------------------------------------------------------
@@ -394,7 +395,6 @@ def _register_extensions_for_agent(
agent_key: str,
*,
continuing: str,
force: bool = False,
) -> None:
"""Register all enabled extensions' commands/skills for ``agent_key``.
@@ -408,11 +408,6 @@ def _register_extensions_for_agent(
before registering), so extension *skill* rendering — which is scoped to
the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``.
When ``force=True``, existing skill files are overwritten even when they
are not dev-mode symlinks. Pass ``force=True`` in the upgrade path so that
extension content is layered on top of the core-template files that
``setup()`` just regenerated (fixes the skip-guard bug for skills mode).
Best-effort: never aborts the surrounding integration operation. Callers
invoke it *after* the use/upgrade/switch transaction has committed so a
failure here cannot trigger a rollback.
@@ -420,7 +415,7 @@ def _register_extensions_for_agent(
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key, force=force),
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key),
phase="register extension artifacts for",
continuing=continuing,
)
@@ -143,21 +143,12 @@ def integration_install(
integration.key, project_root, version=_get_speckit_version()
)
from ..events import resolve_events
events_map = resolve_events(
integration.key,
integration.config,
project_root,
parsed_options,
)
try:
integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
events=events_map,
)
manifest.save()
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
@@ -466,20 +466,12 @@ def integration_switch(
target_integration.key, project_root, version=_get_speckit_version()
)
from ..events import resolve_events
events_map = resolve_events(
target_integration.key,
target_integration.config,
project_root,
parsed_options,
)
try:
target_integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
events=events_map,
)
manifest.save()
_set_default_integration(
@@ -771,13 +763,6 @@ def integration_upgrade(
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
from ..events import resolve_events
events_map = resolve_events(
key,
integration.config,
project_root,
parsed_options,
)
try:
integration.setup(
project_root,
@@ -785,7 +770,6 @@ def integration_upgrade(
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
events=events_map,
)
settings = _with_integration_setting(
current,
@@ -886,7 +870,6 @@ def integration_upgrade(
_register_extensions_for_agent(
project_root,
key,
force=True,
continuing="The integration was upgraded, but installed extensions may need re-registration.",
)
_register_presets_for_agent(
+2 -65
View File
@@ -30,7 +30,6 @@ import yaml
from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent
from .._toml_string import escape_toml_basic as _escape_toml_basic
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
from ..events import install_integration_events, remove_integration_events
if TYPE_CHECKING:
from .manifest import IntegrationManifest
@@ -160,17 +159,7 @@ class IntegrationBase(ABC):
@classmethod
def options(cls) -> list[IntegrationOption]:
"""Return options this integration accepts. Default: none."""
opts = []
if bool(getattr(cls, "CANONICAL_TO_NATIVE", None) and getattr(cls, "events_config_file", None)):
opts.append(
IntegrationOption(
"--events",
is_flag=False,
default="true",
help="Enable/disable runtime events (true|false, default: true)",
)
)
return opts
return []
def effective_invoke_separator(
self,
@@ -491,11 +480,7 @@ class IntegrationBase(ABC):
tracking) would otherwise be deleted even though they are still
managed. Subclasses list such paths here to protect them.
"""
exclusions = set()
if self.supports_events():
from ..events import events_stale_exclusions
exclusions.update(events_stale_exclusions(self.key))
return exclusions
return set()
def commands_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the commands output directory.
@@ -931,32 +916,8 @@ class IntegrationBase(ABC):
Returns ``(removed, skipped)`` file lists.
"""
self.remove_events(project_root, manifest)
return manifest.uninstall(project_root, force=force)
def emit_events(
self,
project_root: Path,
manifest: IntegrationManifest,
events: dict[str, dict[str, Any]] | None = None,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Emit native event configuration for this integration."""
return install_integration_events(self, project_root, manifest, events or {})
def remove_events(
self,
project_root: Path,
manifest: IntegrationManifest,
) -> None:
"""Remove Specify-authored event entries from native config."""
remove_integration_events(self, project_root, manifest)
def supports_events(self) -> bool:
"""Return True if this integration supports agent-native events."""
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
# -- Convenience helpers for subclasses -------------------------------
def install(
@@ -1061,12 +1022,6 @@ class MarkdownIntegration(IntegrationBase):
created.append(dst_file)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
@@ -1274,12 +1229,6 @@ class TomlIntegration(IntegrationBase):
created.append(dst_file)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
@@ -1516,12 +1465,6 @@ class YamlIntegration(IntegrationBase):
created.append(dst_file)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
@@ -1798,10 +1741,4 @@ class SkillsIntegration(IntegrationBase):
created.append(dst)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
@@ -54,17 +54,6 @@ class ClaudeIntegration(SkillsIntegration):
}
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".claude/settings.json"
events_format = "json-nested"
@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
+3 -16
View File
@@ -29,17 +29,6 @@ class CodexIntegration(SkillsIntegration):
dev_no_symlink = True
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".codex/config.toml"
events_format = "toml"
def build_exec_args(
self,
prompt: str,
@@ -60,13 +49,11 @@ class CodexIntegration(SkillsIntegration):
@classmethod
def options(cls) -> list[IntegrationOption]:
opts = super().options()
opts.append(
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Codex)",
)
)
return opts
),
]
@@ -118,19 +118,6 @@ class CopilotIntegration(IntegrationBase):
"extension": ".agent.md",
}
CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
"pre_tool_use": "preToolUse",
"post_tool_use": "postToolUse",
"session_end": "sessionEnd",
"user_prompt_submit": "userPromptSubmitted",
# Copilot CLI supports the canonical per-turn stop lifecycle as native
# agentStop (U3); mapping it so an extension's stop handler fires.
"stop": "agentStop",
}
events_config_file = ".github/hooks/speckit.json"
events_format = "copilot-json"
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
@@ -175,19 +162,14 @@ class CopilotIntegration(IntegrationBase):
@classmethod
def options(cls) -> list[IntegrationOption]:
# Compose with super() so the base class declares --events for this
# event-capable integration; otherwise --integration-options
# "--events false" is rejected as unknown (#9).
opts = super().options()
opts.append(
return [
IntegrationOption(
"--skills",
is_flag=True,
default=False,
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
),
)
return opts
]
def _resolve_executable(self) -> str:
"""Return the Copilot CLI executable, respecting the env-var override.
@@ -346,9 +328,7 @@ class CopilotIntegration(IntegrationBase):
be flagged stale and deleted, destroying user settings (and the file
the integration still manages).
"""
exclusions = super().stale_cleanup_exclusions()
exclusions.add(".vscode/settings.json")
return exclusions
return {".vscode/settings.json"}
def post_process_skill_content(self, content: str) -> str:
"""Inject shared hook guidance into Copilot skill content.
@@ -375,18 +355,10 @@ class CopilotIntegration(IntegrationBase):
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
if self._skills_mode:
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
else:
if "skills" not in parsed_options:
_warn_legacy_markdown_default()
created = self._setup_default(project_root, manifest, parsed_options, **opts)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
return self._setup_skills(project_root, manifest, parsed_options, **opts)
if "skills" not in parsed_options:
_warn_legacy_markdown_default()
return self._setup_default(project_root, manifest, parsed_options, **opts)
def _setup_default(
self,
@@ -525,7 +497,7 @@ class CopilotIntegration(IntegrationBase):
"""
try:
existing = json.loads(dst.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
except (json.JSONDecodeError, OSError):
# Cannot parse existing file (likely JSONC with comments).
# Skip merge to preserve the user's settings, but show
# what they should add manually.
@@ -38,17 +38,6 @@ class CursorAgentIntegration(SkillsIntegration):
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
"pre_tool_use": "preToolUse",
"post_tool_use": "postToolUse",
"session_end": "sessionEnd",
"user_prompt_submit": "beforeSubmitPrompt",
"stop": "stop",
}
events_config_file = ".cursor/hooks.json"
events_format = "json-flat"
def build_exec_args(
self,
prompt: str,
@@ -103,13 +92,11 @@ class CursorAgentIntegration(SkillsIntegration):
@classmethod
def options(cls) -> list[IntegrationOption]:
opts = super().options()
opts.append(
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (recommended for Cursor)",
)
)
return opts
),
]
+2 -21
View File
@@ -31,20 +31,6 @@ class DevinIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".devin/hooks.v1.json"
# Devin's hooks.v1.json is a root event map ({"PreToolUse": [...]}) with no
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
# json-root-nested writer/remover operate directly on the root event keys.
events_format = "json-root-nested"
def build_exec_args(
self,
prompt: str,
@@ -69,16 +55,11 @@ class DevinIntegration(SkillsIntegration):
@classmethod
def options(cls) -> list[IntegrationOption]:
# Compose with super() so the base class declares --events for this
# event-capable integration; otherwise --integration-options
# "--events false" is rejected as unknown (#8).
opts = super().options()
opts.append(
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Devin)",
),
)
return opts
]
@@ -19,22 +19,3 @@ class GeminiIntegration(TomlIntegration):
"extension": ".toml",
}
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "BeforeTool",
"post_tool_use": "AfterTool",
"session_end": "SessionEnd",
# Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle
# point (S6); its own Claude-hook migration maps UserPromptSubmit to
# BeforeAgent. Mapping it so extension handlers fire.
"user_prompt_submit": "BeforeAgent",
"stop": "AfterAgent",
}
events_config_file = ".gemini/settings.json"
events_format = "json-nested"
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
# which use seconds. The shared formatter converts via _native_timeout (#7)
# so the default 60s becomes 60000ms instead of terminating the dispatcher
# after 60ms.
events_timeout_unit = "ms"
+1 -17
View File
@@ -400,19 +400,7 @@ class IntegrationManifest:
# Remove the manifest file itself
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
if remove_manifest and manifest.exists():
try:
manifest.unlink()
except OSError:
# An undeletable manifest (read-only file, a directory left at
# the path, a Windows lock) must not abort the uninstall after
# the tracked files were already removed: the caller would lose
# the (removed, skipped) result and never run its post-uninstall
# bookkeeping. Report it like any other file we could not
# remove, mirroring the path.unlink() guard above. The
# empty-parent cleanup below is left unconditional: with the
# manifest still on disk its parent is non-empty, so the first
# rmdir() raises and breaks immediately.
skipped.append(manifest)
manifest.unlink()
parent = manifest.parent
while parent != root:
try:
@@ -471,10 +459,6 @@ class IntegrationManifest:
path = inst.manifest_path
try:
data = json.loads(path.read_text(encoding="utf-8"))
except UnicodeDecodeError as exc:
raise ValueError(
f"Integration manifest at {path} is not valid UTF-8"
) from exc
except json.JSONDecodeError as exc:
raise ValueError(
f"Integration manifest at {path} contains invalid JSON"
@@ -20,15 +20,6 @@ class OpencodeIntegration(MarkdownIntegration):
"extension": ".md",
}
CANONICAL_TO_NATIVE = {
"pre_tool_use": "tool.execute.before",
"post_tool_use": "tool.execute.after",
"session_start": "session.created",
"session_end": "session.deleted",
}
events_config_file = "opencode.json"
events_format = "ts-plugin"
def build_exec_args(
self,
prompt: str,
@@ -19,20 +19,3 @@ class QwenIntegration(MarkdownIntegration):
"extension": ".md",
}
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".qwen/settings.json"
events_format = "json-nested"
# Qwen Code's command hooks measure timeout in milliseconds (default
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
# emitting timeout: 60 (60 ms), which would terminate the dispatcher
# before it starts (U1).
events_timeout_unit = "ms"
@@ -19,23 +19,3 @@ class TabnineIntegration(TomlIntegration):
"extension": ".toml",
}
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "BeforeTool",
"post_tool_use": "AfterTool",
"session_end": "SessionEnd",
# Tabnine's Gemini-compatible schema also provides BeforeAgent and
# AfterAgent (S7); mapping them so user_prompt_submit and stop
# extension handlers fire instead of being skipped.
"user_prompt_submit": "BeforeAgent",
"stop": "AfterAgent",
}
events_config_file = ".tabnine/agent/settings.json"
events_format = "json-nested"
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
# emitting timeout: 60 (60 ms), which would terminate the dispatcher
# before it starts (R5).
events_timeout_unit = "ms"
+23 -95
View File
@@ -27,14 +27,11 @@ from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .._download_security import (
archive_format_from_name,
archive_suffix,
MAX_JSON_CATALOG_BYTES,
build_safe_download_path,
detect_archive_format,
is_https_or_localhost_http,
read_response_limited,
safe_extract_archive,
safe_extract_zip,
)
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .._init_options import (
@@ -484,7 +481,7 @@ class PresetRegistry:
}
try:
with open(self.registry_path, 'r', encoding='utf-8') as f:
with open(self.registry_path, 'r') as f:
data = json.load(f)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
@@ -505,7 +502,7 @@ class PresetRegistry:
def _save(self):
"""Save registry to disk."""
self.packs_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, 'w', encoding='utf-8') as f:
with open(self.registry_path, 'w') as f:
json.dump(self.data, f, indent=2)
def add(self, pack_id: str, metadata: dict):
@@ -3362,7 +3359,6 @@ class PresetManager:
source_dir: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> PresetManifest:
"""Install preset from a local directory.
@@ -3370,7 +3366,6 @@ class PresetManager:
source_dir: Path to preset directory
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and the preset is already installed, remove it first
Returns:
Installed preset manifest
@@ -3389,12 +3384,10 @@ class PresetManager:
self.check_compatibility(manifest, speckit_version)
if self.registry.is_installed(manifest.id):
if not force:
raise PresetError(
f"Preset '{manifest.id}' is already installed. "
f"Use 'specify preset remove {manifest.id}' first."
)
self.remove(manifest.id)
raise PresetError(
f"Preset '{manifest.id}' is already installed. "
f"Use 'specify preset remove {manifest.id}' first."
)
dest_dir = self.presets_dir / manifest.id
if dest_dir.exists():
@@ -3537,20 +3530,18 @@ class PresetManager:
return
_materialize_constitution_template(self.project_root, memory_constitution)
def install_from_archive(
def install_from_zip(
self,
archive_path: Path,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> PresetManifest:
"""Install a preset from a supported archive.
"""Install preset from ZIP file.
Args:
archive_path: Path to a .zip, .tar.gz, or .tgz archive
zip_path: Path to preset ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and the preset is already installed, remove it first
Returns:
Installed preset manifest
@@ -3566,11 +3557,7 @@ class PresetManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
safe_extract_archive(
archive_path,
temp_path,
error_type=PresetValidationError,
)
safe_extract_zip(zip_path, temp_path, error_type=PresetValidationError)
pack_dir = temp_path
manifest_path = pack_dir / "preset.yml"
@@ -3583,25 +3570,10 @@ class PresetManager:
if not manifest_path.exists():
raise PresetValidationError(
"No preset.yml found in archive"
"No preset.yml found in ZIP file"
)
return self.install_from_directory(pack_dir, speckit_version, priority, force=force)
def install_from_zip(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> PresetManifest:
"""Backward-compatible wrapper for archive installation."""
return self.install_from_archive(
zip_path,
speckit_version,
priority,
force=force,
)
return self.install_from_directory(pack_dir, speckit_version, priority)
def remove(self, pack_id: str) -> bool:
"""Remove an installed preset.
@@ -4627,14 +4599,14 @@ class PresetCatalog:
def download_pack(
self, pack_id: str, target_dir: Optional[Path] = None
) -> Path:
"""Download a preset archive from a catalog.
"""Download preset ZIP from catalog.
Args:
pack_id: ID of the preset to download
target_dir: Directory to save the archive
target_dir: Directory to save ZIP file (defaults to cache directory)
Returns:
Path to the downloaded archive
Path to downloaded ZIP file
Raises:
PresetError: If pack not found or download fails
@@ -4703,86 +4675,42 @@ class PresetCatalog:
target_dir = self.cache_dir / "downloads"
target_dir = Path(target_dir)
version = pack_info.get("version", "unknown")
declared_format = archive_format_from_name(download_url)
build_safe_download_path(
zip_path = build_safe_download_path(
target_dir,
pack_id,
version,
error_type=PresetError,
label="preset",
suffix=archive_suffix(declared_format or "tar.gz"),
)
target_dir.mkdir(parents=True, exist_ok=True)
original_download_url = download_url
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
if resolved_download_url:
download_url = resolved_download_url
extra_headers = {"Accept": "application/octet-stream"}
staging_path: Path | None = None
try:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
archive_data = read_response_limited(
zip_data = read_response_limited(
response,
error_type=PresetError,
label=f"preset '{pack_id}' download",
)
final_url = (
response.geturl()
if hasattr(response, "geturl")
else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
verify_archive_sha256(
archive_data, pack_info.get("sha256"), pack_id, PresetError
zip_data, pack_info.get("sha256"), pack_id, PresetError
)
with tempfile.NamedTemporaryFile(
prefix="preset-download-",
suffix=".archive",
dir=target_dir,
delete=False,
) as staging_file:
staging_path = Path(staging_file.name)
staging_file.write(archive_data)
archive_format = detect_archive_format(
staging_path,
source_name=(
final_url
if archive_format_from_name(final_url) is not None
else original_download_url
),
content_type=content_type,
error_type=PresetError,
)
archive_path = build_safe_download_path(
target_dir,
pack_id,
version,
error_type=PresetError,
label="preset",
suffix=archive_suffix(archive_format),
)
os.replace(staging_path, archive_path)
staging_path = None
return archive_path
zip_path.write_bytes(zip_data)
return zip_path
except urllib.error.URLError as e:
raise PresetError(
f"Failed to download preset from {download_url}: {e}"
)
except IOError as e:
raise PresetError(f"Failed to save preset archive: {e}")
finally:
if staging_path is not None:
staging_path.unlink(missing_ok=True)
raise PresetError(f"Failed to save preset ZIP: {e}")
def clear_cache(self):
"""Clear all catalog cache files, including per-URL hashed caches."""
+38 -116
View File
@@ -17,9 +17,6 @@ from rich.markup import escape as _escape_markup
from .._console import console
from .._download_security import (
archive_format_from_name,
archive_suffix,
detect_archive_format,
is_https_or_localhost_http,
is_safe_download_redirect,
read_response_limited,
@@ -62,11 +59,8 @@ def preset_list():
for pack in installed:
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
pri = pack.get('priority', 10)
name = _escape_markup(str(pack['name']))
pack_id = _escape_markup(str(pack['id']))
version = _escape_markup(str(pack['version']))
console.print(f" [bold]{name}[/bold] ({pack_id}) v{version} — {status} — priority {pri}")
console.print(f" {_escape_markup(str(pack['description']))}")
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
console.print(f" {pack['description']}")
tags = pack.get("tags", [])
if isinstance(tags, list) and tags:
tags_str = _escape_markup(", ".join(str(t) for t in tags))
@@ -78,11 +72,7 @@ def preset_list():
@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 .zip, .tar.gz, or .tgz URL",
),
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)"),
):
@@ -149,7 +139,7 @@ def preset_add(
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
archive_path = Path(tmpdir) / "preset.archive"
zip_path = Path(tmpdir) / "preset.zip"
try:
from specify_cli.authentication.http import open_url as _open_url
from specify_cli.authentication.http import github_provider_hosts
@@ -177,33 +167,13 @@ def preset_add(
"or HTTP for localhost (127.0.0.1, ::1)."
)
raise typer.Exit(1)
archive_data = read_response_limited(
response,
error_type=PresetError,
label=f"preset {from_url}",
zip_path.write_bytes(
read_response_limited(
response,
error_type=PresetError,
label=f"preset {from_url}",
)
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
archive_path.write_bytes(archive_data)
format_source = (
final_url
if archive_format_from_name(final_url) is not None
else from_url
)
archive_format = detect_archive_format(
archive_path,
source_name=format_source,
content_type=content_type,
error_type=PresetError,
)
detected_path = archive_path.with_suffix(
archive_suffix(archive_format)
)
os.replace(archive_path, detected_path)
archive_path = detected_path
except (urllib.error.URLError, PresetError) as e:
console.print(
f"[red]Error:[/red] Failed to download: "
@@ -211,11 +181,7 @@ def preset_add(
)
raise typer.Exit(1)
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority,
)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
@@ -258,16 +224,12 @@ def preset_add(
console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
try:
archive_path = catalog.download_pack(preset_id)
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority,
)
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 'archive_path' in locals() and archive_path.exists():
archive_path.unlink(missing_ok=True)
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)
@@ -355,20 +317,13 @@ def preset_resolve(
project_root = _require_specify_project()
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name)
safe_template_name = _escape_markup(str(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]{safe_template_name}[/bold]: "
f"{_escape_markup(str(display_layer['path']))}"
)
console.print(
f" [dim](top layer from: "
f"{_escape_markup(str(display_layer['source']))})[/dim]"
)
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"
@@ -380,10 +335,7 @@ def preset_resolve(
composed = resolver.resolve_content(template_name)
except Exception as exc:
composed = None
console.print(
f" [yellow]Warning: composition error: "
f"{_escape_markup(str(exc))}[/yellow]"
)
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:
@@ -406,27 +358,15 @@ def preset_resolve(
strategy_label = layer["strategy"]
if strategy_label == "replace" and i == 0:
strategy_label = "base"
# Escape the literal bracket (\[) so Rich renders `[<strategy>]`
# instead of parsing it as a style tag and swallowing the label,
# mirroring `workflow info`'s step-graph line.
console.print(
f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] "
f"{_escape_markup(str(layer['source']))} → "
f"{_escape_markup(str(layer['path']))}"
)
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]{safe_template_name}[/bold]: "
f"{_escape_markup(str(result['path']))}"
)
console.print(
f" [dim](from: {_escape_markup(str(result['source']))})[/dim]"
)
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
console.print(f" [dim](from: {result['source']})[/dim]")
else:
console.print(f" [yellow]{safe_template_name}[/yellow]: not found")
console.print(f" [yellow]{template_name}[/yellow]: not found")
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
@@ -446,32 +386,24 @@ def preset_info(
local_pack = manager.get_pack(preset_id)
if local_pack:
console.print(
f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n"
)
console.print(f" ID: {_escape_markup(str(local_pack.id))}")
console.print(f" Version: {_escape_markup(str(local_pack.version))}")
console.print(
f" Description: {_escape_markup(str(local_pack.description))}"
)
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: {_escape_markup(str(local_pack.author))}")
console.print(f" Author: {local_pack.author}")
local_tags = local_pack.tags
if isinstance(local_tags, list) and local_tags:
tags_str = _escape_markup(", ".join(str(t) for t in local_tags))
console.print(f" Tags: {tags_str}")
console.print(f" Tags: {', '.join(str(t) for t in local_tags)}")
console.print(f" Templates: {len(local_pack.templates)}")
for tmpl in local_pack.templates:
tmpl_name = _escape_markup(str(tmpl['name']))
tmpl_type = _escape_markup(str(tmpl['type']))
tmpl_desc = _escape_markup(str(tmpl.get('description', '')))
console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}")
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
repo = local_pack.data.get("preset", {}).get("repository")
if repo:
console.print(f" Repository: {_escape_markup(str(repo))}")
console.print(f" Repository: {repo}")
license_val = local_pack.data.get("preset", {}).get("license")
if license_val:
console.print(f" License: {_escape_markup(str(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)
@@ -506,8 +438,7 @@ def preset_info(
)
catalog_tags = pack_info.get("tags", [])
if isinstance(catalog_tags, list) and catalog_tags:
catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags))
console.print(f" Tags: {catalog_tags_str}")
console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}")
if pack_info.get("repository"):
console.print(
f" Repository: {_escape_markup(str(pack_info['repository']))}"
@@ -752,15 +683,10 @@ def preset_catalog_add(
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
# Only rendering is escaped — the raw values are what get persisted and
# compared below, so a name containing markup still round-trips exactly.
safe_name = _escape_markup(str(name))
safe_url = _escape_markup(str(url))
# 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 '{safe_name}' already exists.")
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)
@@ -776,11 +702,10 @@ def preset_catalog_add(
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]{safe_name}[/bold]' ({install_label})")
console.print(f" URL: {safe_url}")
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
console.print(f" URL: {url}")
console.print(f" Priority: {priority}")
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
console.print(f"\nConfig saved to {config_label}")
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
@preset_catalog_app.command("remove")
@@ -808,20 +733,17 @@ def preset_catalog_remove(
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
# Rendering only — the raw name drives the comparison below.
safe_name = _escape_markup(str(name))
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 '{safe_name}' not found.")
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 '{safe_name}'")
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]")
+32 -553
View File
@@ -21,17 +21,10 @@ from rich.markup import escape as _escape_markup
from .._console import console, err_console
from .._download_security import (
archive_format_from_content_type,
archive_format_from_name,
archive_suffix,
detect_archive_format,
is_https_or_localhost_http,
is_safe_download_redirect,
read_response_limited,
safe_extract_archive,
)
from .._project import _resolve_init_dir_override
from ..shared_infra import verify_archive_sha256
workflow_app = typer.Typer(
name="workflow",
@@ -462,42 +455,6 @@ def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes
return b"".join(chunks)
def _workflow_yaml_is_declared(
source_name: str, content_type: str | None
) -> bool:
"""Return whether response metadata explicitly identifies workflow YAML."""
from urllib.parse import urlparse
path = urlparse(source_name).path.casefold()
media_type = (content_type or "").split(";", 1)[0].strip().casefold()
return path.endswith((".yml", ".yaml")) or media_type in {
"application/yaml",
"application/x-yaml",
"text/yaml",
"text/x-yaml",
}
def _sniff_workflow_archive_format(data: bytes):
"""Return a supported archive format when suffixless response bytes match."""
from io import BytesIO
try:
return detect_archive_format(
Path("workflow-download"),
archive_file=BytesIO(data),
)
except ValueError:
return None
def _enforce_workflow_yaml_size(data: bytes) -> None:
if len(data) > _MAX_WORKFLOW_YAML_BYTES:
raise ValueError(
f"response exceeds the {_MAX_WORKFLOW_YAML_BYTES}-byte workflow size limit"
)
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
"""Validate that ``workflow_id`` is a safe installed-workflow directory name."""
if (
@@ -922,231 +879,6 @@ def _discard_committed_backup_file(backup_file: Path | None) -> None:
)
def _workflow_package_root(extracted_root: Path) -> Path:
"""Resolve a root-level or single-nested workflow package."""
if (extracted_root / "workflow.yml").is_file():
return extracted_root
entries = list(extracted_root.iterdir())
if (
len(entries) == 1
and entries[0].is_dir()
and not entries[0].is_symlink()
and (entries[0] / "workflow.yml").is_file()
):
return entries[0]
raise ValueError(
"Archive must contain workflow.yml at its root or in exactly one "
"top-level directory"
)
def _validate_local_workflow_package(package_dir: Path) -> None:
"""Reject links and special files before copying a local package."""
import stat
for root, dirnames, filenames in os.walk(package_dir, followlinks=False):
root_path = Path(root)
for name in [*dirnames, *filenames]:
path = root_path / name
mode = path.lstat().st_mode
if stat.S_ISLNK(mode):
raise ValueError(f"Workflow package contains symlink: {path}")
if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode):
raise ValueError(f"Workflow package contains unsupported file: {path}")
def _workflow_package_has_companions(package_dir: Path) -> bool:
"""Return whether a directory contains anything beyond workflow.yml."""
return any(path.name != "workflow.yml" for path in package_dir.iterdir())
def _install_workflow_package(
project_root: Path,
workflows_dir: Path,
package_dir: Path,
source_label: str,
*,
expected_id: str | None = None,
expected_version: str | None = None,
expected_installed_version: str | None = None,
catalog_info: dict[str, Any] | None = None,
) -> None:
"""Validate and atomically install a complete workflow package directory."""
import shutil
import tempfile
from .engine import WorkflowDefinition, validate_workflow
workflow_file = package_dir / "workflow.yml"
try:
_validate_local_workflow_package(package_dir)
workflow_bytes = workflow_file.read_bytes()
definition = WorkflowDefinition.from_string(workflow_bytes.decode("utf-8"))
except (OSError, UnicodeDecodeError, ValueError, yaml.YAMLError) as exc:
console.print(
f"[red]Error:[/red] Invalid workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
errors = validate_workflow(definition)
if errors:
console.print("[red]Error:[/red] Workflow validation failed:")
for error in errors:
console.print(f" • {_escape_markup(str(error))}")
raise typer.Exit(1)
if not isinstance(definition.id, str) or not definition.id.strip():
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
raise typer.Exit(1)
if expected_id is not None and definition.id != expected_id:
console.print(
f"[red]Error:[/red] Workflow ID in YAML "
f"({_escape_markup(repr(definition.id))}) does not match the requested "
f"workflow ID ({_escape_markup(repr(expected_id))})."
)
raise typer.Exit(1)
if expected_version is not None and str(definition.version) != expected_version:
console.print(
f"[red]Error:[/red] Downloaded workflow version "
f"({_escape_markup(str(definition.version))}) does not match the catalog "
f"version ({_escape_markup(expected_version)})."
)
raise typer.Exit(1)
dest_dir = _safe_workflow_id_dir(workflows_dir, definition.id)
staged_dir = Path(
tempfile.mkdtemp(prefix=f".{definition.id}.installing-", dir=workflows_dir)
)
try:
package_root = package_dir.resolve()
def ignore_reserved_package_entries(
source: str, names: list[str]
) -> set[str]:
if Path(source).resolve() == package_root and "overlays" in names:
return {"overlays"}
return set()
shutil.copytree(
package_dir,
staged_dir,
dirs_exist_ok=True,
ignore=ignore_reserved_package_entries,
)
except OSError as exc:
shutil.rmtree(staged_dir, ignore_errors=True)
console.print(
f"[red]Error:[/red] Failed to stage workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
backup_dir: Path | None = None
try:
with _workflow_install_transaction(project_root):
registry = _open_workflow_registry(project_root)
existing = registry.get(definition.id)
if expected_installed_version is not None and (
not isinstance(existing, dict)
or existing.get("source") != "catalog"
or str(existing.get("version")) != expected_installed_version
):
console.print(
f"[yellow]Warning:[/yellow] Workflow "
f"'{_escape_markup(definition.id)}' changed during update; "
"rerun the command."
)
raise typer.Exit(1)
if dest_dir.exists():
backup_dir = Path(
tempfile.mkdtemp(
prefix=f".{definition.id}.backup-",
dir=workflows_dir,
)
)
backup_dir.rmdir()
os.replace(dest_dir, backup_dir)
try:
os.replace(staged_dir, dest_dir)
except BaseException:
if backup_dir is not None:
os.replace(backup_dir, dest_dir)
backup_dir = None
raise
entry = {
"name": definition.name,
"version": definition.version,
"description": definition.description,
"source": source_label,
}
if catalog_info is not None:
entry.update(
{
"source": "catalog",
"catalog_name": catalog_info.get("_catalog_name", ""),
"url": catalog_info.get("url", ""),
}
)
if isinstance(existing, dict) and not existing.get("enabled", True):
entry["enabled"] = False
try:
registry.add(definition.id, entry)
except (OSError, TypeError, ValueError):
failed_dir: Path | None = None
try:
failed_dir = Path(
tempfile.mkdtemp(
prefix=f".{definition.id}.failed-",
dir=workflows_dir,
)
)
failed_dir.rmdir()
os.replace(dest_dir, failed_dir)
if backup_dir is not None:
os.replace(backup_dir, dest_dir)
backup_dir = None
except OSError as rollback_exc:
console.print(
"[yellow]Warning:[/yellow] Failed to fully restore the prior "
f"workflow package: {_escape_markup(str(rollback_exc))}"
)
finally:
if failed_dir is not None and failed_dir.exists():
try:
shutil.rmtree(failed_dir)
except OSError as cleanup_exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove failed "
f"workflow package: {_escape_markup(str(cleanup_exc))}"
)
raise
except typer.Exit:
raise
except (OSError, TypeError, ValueError) as exc:
console.print(
f"[red]Error:[/red] Failed to install workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
finally:
if staged_dir.exists():
shutil.rmtree(staged_dir, ignore_errors=True)
if backup_dir is not None:
try:
shutil.rmtree(backup_dir)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Workflow installed, but its backup "
f"directory could not be removed: {_escape_markup(str(exc))}"
)
console.print(
f"[green]✓[/green] Workflow '{_escape_markup(definition.name)}' "
f"({_escape_markup(definition.id)}) installed"
)
# Root helper re-fetched at call time so test monkeypatching of
# `specify_cli._require_specify_project` keeps working after the move.
def _require_specify_project(*args, **kwargs):
@@ -1157,18 +889,6 @@ def _require_specify_project(*args, **kwargs):
return project_root
def _failed_step_error(state: Any) -> str | None:
"""Terminal error for a failed/aborted run, if any.
Returns the run-level error persisted by the engine at the moment
the run terminated. Returns ``None`` for non-terminal statuses so
the caller can print unconditionally.
"""
if getattr(state.status, "value", state.status) not in ("failed", "aborted"):
return None
return getattr(state, "error", None)
def _workflow_run_payload(state: Any) -> dict[str, Any]:
"""Machine-readable summary of a run/resume outcome."""
payload = {
@@ -1181,9 +901,6 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
gate = _gate_outcome(state)
if gate is not None:
payload["gate"] = gate
error = _failed_step_error(state)
if error is not None:
payload["error"] = error
return payload
@@ -1444,10 +1161,6 @@ def workflow_run(
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
err_msg = _failed_step_error(state)
if err_msg:
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
if state.status.value == "paused":
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
@@ -1558,10 +1271,6 @@ def workflow_resume(
color = status_colors.get(state.status.value, "white")
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
err_msg = _failed_step_error(state)
if err_msg:
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
raise typer.Exit(_run_outcome_exit_code(state.status.value))
@@ -1629,10 +1338,6 @@ def workflow_status(
if state.current_step_id:
console.print(f" Current: {state.current_step_id}")
err_msg = _failed_step_error(state)
if err_msg:
console.print(f" [red]Error: {_escape_markup(err_msg)}[/red]")
if state.step_results:
console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]")
for step_id, step_data in state.step_results.items():
@@ -1870,48 +1575,16 @@ def workflow_add(
if dev_path.is_file() and dev_path.suffix.lower() in (".yml", ".yaml"):
_validate_and_install_local(dev_path, str(dev_path))
return
if dev_path.is_file() and archive_format_from_name(str(dev_path)) is not None:
import tempfile
with tempfile.TemporaryDirectory(
prefix="speckit-workflow-archive-"
) as tmpdir:
extracted_root = Path(tmpdir)
try:
safe_extract_archive(dev_path, extracted_root)
package_root = _workflow_package_root(extracted_root)
except ValueError as exc:
console.print(
f"[red]Error:[/red] Invalid workflow archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
_install_workflow_package(
project_root,
workflows_dir,
package_root,
str(dev_path),
)
return
if dev_path.is_dir():
dev_wf_file = dev_path / "workflow.yml"
if not dev_wf_file.is_file():
console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}")
raise typer.Exit(1)
if _workflow_package_has_companions(dev_path):
_install_workflow_package(
project_root,
workflows_dir,
dev_path,
str(dev_path),
)
else:
_validate_and_install_local(dev_wf_file, str(dev_path))
_validate_and_install_local(dev_wf_file, str(dev_path))
return
console.print(
"[red]Error:[/red] --dev source must be a workflow YAML file, "
"supported archive, or directory containing workflow.yml: "
f"{_escape_markup(source)}"
"[red]Error:[/red] --dev source must be a workflow YAML file or a "
f"directory containing workflow.yml: {_escape_markup(source)}"
)
raise typer.Exit(1)
@@ -1974,7 +1647,6 @@ def workflow_add(
import tempfile
tmp_path: Path | None = None
downloaded_archive_format = None
try:
with _open_url(
download_url,
@@ -1988,48 +1660,13 @@ def workflow_add(
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
)
raise typer.Exit(1)
content_type = (
resp.getheader("Content-Type")
if hasattr(resp, "getheader")
else None
)
downloaded_archive_format = (
archive_format_from_name(final_url)
or archive_format_from_name(download_url)
or archive_format_from_content_type(content_type)
)
declared_yaml = _workflow_yaml_is_declared(final_url, content_type)
suffix = (
archive_suffix(downloaded_archive_format)
if downloaded_archive_format is not None
else ".yml" if declared_yaml else ".download"
)
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
# Assign tmp_path immediately: NamedTemporaryFile(delete=False)
# creates the file on disk right away, before any bytes are
# written, so a failure in the size-limited read below must
# still be able to find and remove it.
tmp_path = Path(tmp.name)
if downloaded_archive_format is not None:
downloaded_content = read_response_limited(
resp,
error_type=ValueError,
label="workflow archive download",
)
elif declared_yaml:
downloaded_content = _read_response_within_limit(resp)
else:
downloaded_content = read_response_limited(
resp,
error_type=ValueError,
label="workflow download",
)
downloaded_archive_format = (
_sniff_workflow_archive_format(downloaded_content)
)
if downloaded_archive_format is None:
_enforce_workflow_yaml_size(downloaded_content)
tmp.write(downloaded_content)
tmp.write(_read_response_within_limit(resp))
except typer.Exit:
raise
except Exception as exc:
@@ -2049,38 +1686,13 @@ def workflow_add(
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
raise typer.Exit(1)
try:
if downloaded_archive_format is None:
_validate_and_install_local(
tmp_path,
download_url,
expected_id=source if from_url else None,
)
else:
with tempfile.TemporaryDirectory(
prefix="speckit-workflow-archive-"
) as extract_dir:
extracted_root = Path(extract_dir)
try:
safe_extract_archive(
tmp_path,
extracted_root,
source_name=final_url,
content_type=content_type,
)
package_root = _workflow_package_root(extracted_root)
except ValueError as exc:
console.print(
f"[red]Error:[/red] Invalid workflow archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
_install_workflow_package(
project_root,
workflows_dir,
package_root,
download_url,
expected_id=source if from_url else None,
)
# When installed via --from, the positional argument names the
# workflow the user expects — enforce it like the catalog branch.
_validate_and_install_local(
tmp_path,
download_url,
expected_id=source if from_url else None,
)
finally:
# Best-effort: _validate_and_install_local may already have
# committed the file + registry entry (success) or already
@@ -2104,46 +1716,12 @@ def workflow_add(
if source_path.is_file() and source_path.suffix.lower() in (".yml", ".yaml"):
_validate_and_install_local(source_path, str(source_path))
return
elif (
source_path.is_file()
and archive_format_from_name(str(source_path)) is not None
):
import tempfile
with tempfile.TemporaryDirectory(
prefix="speckit-workflow-archive-"
) as tmpdir:
extracted_root = Path(tmpdir)
try:
safe_extract_archive(source_path, extracted_root)
package_root = _workflow_package_root(extracted_root)
except ValueError as exc:
console.print(
f"[red]Error:[/red] Invalid workflow archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
_install_workflow_package(
project_root,
workflows_dir,
package_root,
str(source_path),
)
return
elif source_path.is_dir():
wf_file = source_path / "workflow.yml"
if not wf_file.is_file():
console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}")
raise typer.Exit(1)
if _workflow_package_has_companions(source_path):
_install_workflow_package(
project_root,
workflows_dir,
source_path,
str(source_path),
)
else:
_validate_and_install_local(wf_file, str(source_path))
_validate_and_install_local(wf_file, str(source_path))
return
# Try from catalog
@@ -2248,9 +1826,6 @@ def _install_workflow_from_catalog(
)
raise typer.Exit(1)
original_workflow_url = workflow_url
downloaded_archive_format = None
archive_content_type = None
try:
from specify_cli.authentication.http import open_url as _open_url
from specify_cli.authentication.http import github_provider_hosts as _github_provider_hosts
@@ -2282,38 +1857,10 @@ def _install_workflow_from_catalog(
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
)
raise typer.Exit(1)
archive_content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
downloaded_archive_format = (
archive_format_from_name(final_url)
or archive_format_from_name(original_workflow_url)
or archive_format_from_content_type(archive_content_type)
)
# Written to the staging file, never workflow_file directly, so a
# reinstall's prior working copy is never touched until the
# atomic commit below runs.
if downloaded_archive_format is not None:
downloaded_content = read_response_limited(
response,
error_type=ValueError,
label=f"workflow '{workflow_id}' archive download",
)
elif _workflow_yaml_is_declared(final_url, archive_content_type):
downloaded_content = _read_response_within_limit(response)
else:
downloaded_content = read_response_limited(
response,
error_type=ValueError,
label=f"workflow '{workflow_id}' download",
)
downloaded_archive_format = _sniff_workflow_archive_format(
downloaded_content
)
if downloaded_archive_format is None:
_enforce_workflow_yaml_size(downloaded_content)
downloaded_content = _read_response_within_limit(response)
staged_file.write_bytes(downloaded_content)
except typer.Exit:
raise
@@ -2322,59 +1869,6 @@ def _install_workflow_from_catalog(
console.print(f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: {_escape_markup(str(exc))}")
raise typer.Exit(1)
if downloaded_archive_format is not None:
try:
verify_archive_sha256(
downloaded_content,
info.get("sha256"),
workflow_id,
ValueError,
)
import tempfile
from io import BytesIO
with tempfile.TemporaryDirectory(
prefix="speckit-workflow-archive-"
) as extract_dir:
extracted_root = Path(extract_dir)
safe_extract_archive(
staged_file.path,
extracted_root,
archive_file=BytesIO(downloaded_content),
source_name=original_workflow_url,
content_type=archive_content_type,
)
package_root = _workflow_package_root(extracted_root)
_safe_discard_staged_workflow_file(
staged_file,
workflow_dir,
existed_before,
)
_install_workflow_package(
project_root,
workflows_dir,
package_root,
workflow_url,
expected_id=workflow_id,
expected_version=expected_version,
expected_installed_version=expected_installed_version,
catalog_info={**info, "url": workflow_url},
)
except typer.Exit:
raise
except (OSError, ValueError) as exc:
_safe_discard_staged_workflow_file(
staged_file,
workflow_dir,
existed_before,
)
console.print(
f"[red]Error:[/red] Invalid workflow archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
return
# Validate the downloaded workflow (still staged, not yet committed)
# before registering.
try:
@@ -3055,10 +2549,9 @@ def workflow_step_list():
console.print(" [bold]Custom (installed):[/bold]")
for key in sorted(installed):
meta = installed[key] or {}
name = _escape_markup(str(meta.get("name", key)))
safe_key = _escape_markup(str(key))
version = _escape_markup(str(meta.get("version", "?")))
console.print(f" • [bold]{name}[/bold] ({safe_key}) v{version}")
name = meta.get("name", key)
version = meta.get("version", "?")
console.print(f" • [bold]{name}[/bold] ({key}) v{version}")
console.print()
if not built_in and not installed:
@@ -3602,15 +3095,13 @@ def workflow_step_search(
install_note = (
"" if step.get("_install_allowed", True) else " [dim](discovery only)[/dim]"
)
name = _escape_markup(str(step.get("name", step.get("id", "?"))))
step_id = _escape_markup(str(step.get("id", "?")))
version = _escape_markup(str(step.get("version", "?")))
console.print(
f" [bold]{name}[/bold] ({step_id}) v{version}{install_note}"
f" [bold]{step.get('name', step.get('id', '?'))}[/bold]"
f" ({step.get('id', '?')}) v{step.get('version', '?')}{install_note}"
)
desc = step.get("description", "")
if desc:
console.print(f" {_escape_markup(str(desc))}")
console.print(f" {desc}")
console.print()
@@ -3623,7 +3114,6 @@ def workflow_step_info(
from .catalog import StepCatalog, StepCatalogError, StepRegistry
project_root = _require_specify_project()
safe_step_id = _escape_markup(str(step_id))
registry = StepRegistry(project_root)
installed_meta = registry.get(step_id)
@@ -3633,27 +3123,20 @@ def workflow_step_info(
is_builtin = builtin_step is not None and not installed_meta
if is_builtin:
console.print(f"\n[bold cyan]{safe_step_id}[/bold cyan] [dim](built-in)[/dim]")
console.print(f" Type key: {safe_step_id}")
console.print(f"\n[bold cyan]{step_id}[/bold cyan] [dim](built-in)[/dim]")
console.print(f" Type key: {step_id}")
console.print(" [green]Built-in step type[/green]")
return
if installed_meta:
name = _escape_markup(str(installed_meta.get("name", step_id)))
version = _escape_markup(str(installed_meta.get("version", "?")))
console.print(
f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})"
f"\n[bold cyan]{installed_meta.get('name', step_id)}[/bold cyan] ({step_id})"
)
console.print(f" Version: {version}")
console.print(f" Version: {installed_meta.get('version', '?')}")
if installed_meta.get("author"):
console.print(
f" Author: {_escape_markup(str(installed_meta['author']))}"
)
console.print(f" Author: {installed_meta['author']}")
if installed_meta.get("description"):
console.print(
f" Description: "
f"{_escape_markup(str(installed_meta['description']))}"
)
console.print(f" Description: {installed_meta['description']}")
console.print(" [green]Installed[/green]")
return
@@ -3665,24 +3148,20 @@ def workflow_step_info(
info = None
if info:
name = _escape_markup(str(info.get("name", step_id)))
version = _escape_markup(str(info.get("version", "?")))
console.print(
f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})"
f"\n[bold cyan]{info.get('name', step_id)}[/bold cyan] ({step_id})"
)
console.print(f" Version: {version}")
console.print(f" Version: {info.get('version', '?')}")
if info.get("author"):
console.print(f" Author: {_escape_markup(str(info['author']))}")
console.print(f" Author: {info['author']}")
if info.get("description"):
console.print(
f" Description: {_escape_markup(str(info['description']))}"
)
console.print(f" Description: {info['description']}")
console.print(" [yellow]Not installed[/yellow]")
console.print(
f"\n Install with: [cyan]specify workflow step add {safe_step_id}[/cyan]"
f"\n Install with: [cyan]specify workflow step add {step_id}[/cyan]"
)
else:
console.print(f"[red]Error:[/red] Step type '{safe_step_id}' not found")
console.print(f"[red]Error:[/red] Step type '{step_id}' not found")
raise typer.Exit(1)
-3
View File
@@ -56,9 +56,6 @@ class StepContext:
#: Current fan-out item (set only inside fan-out iterations).
item: Any = None
#: Whether the current step is executing inside a fan-out template.
inside_fan_out: bool = False
#: Fan-in aggregated results (set only for fan-in steps).
fan_in: dict[str, Any] = field(default_factory=dict)
+2 -10
View File
@@ -495,8 +495,6 @@ class WorkflowCatalog:
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
if not isinstance(meta, dict):
return False
fetched_at = float(meta.get("fetched_at", 0))
return (time.time() - fetched_at) < self.CACHE_DURATION
except (json.JSONDecodeError, OSError, TypeError, ValueError):
@@ -511,9 +509,7 @@ class WorkflowCatalog:
if not force_refresh and self._is_url_cache_valid(entry.url):
try:
with open(cache_file, encoding="utf-8") as f:
cached = json.load(f)
if isinstance(cached, dict):
return cached
return json.load(f)
except (json.JSONDecodeError, OSError):
# Ignore invalid/unreadable cache and fall back to fetching from source.
pass
@@ -578,9 +574,7 @@ class WorkflowCatalog:
if cache_file.exists():
try:
with open(cache_file, encoding="utf-8") as f:
cached = json.load(f)
if isinstance(cached, dict):
return cached
return json.load(f)
except (json.JSONDecodeError, ValueError, OSError):
# Stale-cache read failed; let the original fetch error propagate.
pass
@@ -1190,8 +1184,6 @@ class StepCatalog:
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
if not isinstance(meta, dict):
return False
fetched_at = float(meta.get("fetched_at", 0))
return (time.time() - fetched_at) < self.CACHE_DURATION
except (json.JSONDecodeError, OSError, TypeError, ValueError):
+12 -105
View File
@@ -308,15 +308,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
errors.append("Workflow has no steps defined.")
seen_ids: set[str] = set()
# ``input_names`` is the set of declared workflow input names — used by
# ``_validate_steps`` to cross-reference gate ``verdict_input`` bindings.
# ``None`` means the inputs block itself is malformed (already reported
# above); the cross-check is then disabled so one authoring mistake does
# not cascade into N spurious "undeclared" errors.
input_names: set[str] | None = (
set(definition.inputs) if isinstance(definition.inputs, dict) else None
)
_validate_steps(definition.steps, seen_ids, errors, input_names)
_validate_steps(definition.steps, seen_ids, errors)
return errors
@@ -325,16 +317,8 @@ def _validate_steps(
steps: list[dict[str, Any]],
seen_ids: set[str],
errors: list[str],
input_names: set[str] | None = None,
inside_fan_out: bool = False,
) -> None:
"""Recursively validate a list of steps.
``input_names`` is the set of declared workflow input names (or ``None``
when the inputs block is malformed). ``inside_fan_out`` is threaded
through nested control-flow steps so gate verdict bindings can be rejected
anywhere inside a fan-out template.
"""
"""Recursively validate a list of steps."""
from . import STEP_REGISTRY
for step_config in steps:
@@ -427,73 +411,30 @@ def _validate_steps(
f"unknown or not-yet-declared step id {wid!r}."
)
# Gate verdict_input: fan-out items cannot bind shared workflow inputs
# as per-item verdicts. Outside fan-out, the binding must reference a
# declared workflow input because ``_resolve_inputs`` drops undeclared
# names at both initial run and resume. Only check a non-empty string;
# malformed shapes are already reported by ``GateStep.validate()``.
if step_type == "gate":
verdict_input = step_config.get("verdict_input")
if isinstance(verdict_input, str) and verdict_input:
if inside_fan_out:
errors.append(
f"Gate step {step_id!r}: 'verdict_input' is not "
"supported inside fan-out templates."
)
elif input_names is not None and verdict_input not in input_names:
errors.append(
f"Gate step {step_id!r}: 'verdict_input' references "
f"undeclared input {verdict_input!r}."
)
# Recursively validate nested steps
for nested_key in ("then", "else", "steps"):
nested = step_config.get(nested_key)
if isinstance(nested, list):
_validate_steps(
nested,
seen_ids,
errors,
input_names,
inside_fan_out=inside_fan_out,
)
_validate_steps(nested, seen_ids, errors)
# Validate switch cases
cases = step_config.get("cases")
if isinstance(cases, dict):
for _case_key, case_steps in cases.items():
if isinstance(case_steps, list):
_validate_steps(
case_steps,
seen_ids,
errors,
input_names,
inside_fan_out=inside_fan_out,
)
_validate_steps(case_steps, seen_ids, errors)
# Validate switch default
default = step_config.get("default")
if isinstance(default, list):
_validate_steps(
default,
seen_ids,
errors,
input_names,
inside_fan_out=inside_fan_out,
)
_validate_steps(default, seen_ids, errors)
# Validate fan-out nested step (template — not added to seen_ids
# since the engine generates parentId:templateId:index at runtime)
fan_step = step_config.get("step")
if isinstance(fan_step, dict):
fan_errors: list[str] = []
_validate_steps(
[fan_step],
set(),
fan_errors,
input_names,
inside_fan_out=True,
)
_validate_steps([fan_step], set(), fan_errors)
errors.extend(fan_errors)
@@ -619,7 +560,6 @@ class RunState:
self.created_at = datetime.now(timezone.utc).isoformat()
self.updated_at = self.created_at
self.log_entries: list[dict[str, Any]] = []
self.error: str | None = None
@property
def runs_dir(self) -> Path:
@@ -674,7 +614,6 @@ class RunState:
"workflow_dir": self.workflow_dir,
"created_at": self.created_at,
"updated_at": self.updated_at,
"error": self.error,
}
self._atomic_write_json(runs_dir / "state.json", state_data)
self._atomic_write_json(runs_dir / "inputs.json", {"inputs": self.inputs})
@@ -768,7 +707,6 @@ class RunState:
state.workflow_dir = state_data.get("workflow_dir")
state.created_at = state_data.get("created_at", "")
state.updated_at = state_data.get("updated_at", "")
state.error = state_data.get("error")
inputs_path = runs_dir / "inputs.json"
if inputs_path.exists():
@@ -963,7 +901,6 @@ class WorkflowEngine:
return state
except Exception as exc:
state.status = RunStatus.FAILED
state.error = str(exc)
state.append_log({"event": "workflow_failed", "error": str(exc)})
state.save()
raise
@@ -1022,7 +959,6 @@ class WorkflowEngine:
from . import STEP_REGISTRY
state.error = None
state.status = RunStatus.RUNNING
state.save()
@@ -1043,7 +979,6 @@ class WorkflowEngine:
return state
except Exception as exc:
state.status = RunStatus.FAILED
state.error = str(exc)
state.append_log({"event": "resume_failed", "error": str(exc)})
state.save()
raise
@@ -1103,7 +1038,6 @@ class WorkflowEngine:
step_impl = registry.get(step_type)
if not step_impl:
state.status = RunStatus.FAILED
state.error = f"Unknown step type: {step_type!r}"
state.append_log(
{
"event": "step_failed",
@@ -1131,7 +1065,6 @@ class WorkflowEngine:
or step_config.get("input", {}),
"output": result.output,
"status": result.status.value,
"error": result.error,
}
self._record_result(context, state, step_id, step_data)
@@ -1157,7 +1090,6 @@ class WorkflowEngine:
# is for transient/expected step failures only.
if result.output.get("aborted"):
state.status = RunStatus.ABORTED
state.error = result.error
state.append_log(
{
"event": "workflow_aborted",
@@ -1200,7 +1132,6 @@ class WorkflowEngine:
continue
state.status = RunStatus.FAILED
state.error = result.error
state.append_log(
{
"event": "step_failed",
@@ -1374,18 +1305,11 @@ class WorkflowEngine:
# Sequential path — identical to the historical behavior.
if workers <= 1:
results: list[Any] = []
previous_item = context.item
previous_inside_fan_out = context.inside_fan_out
context.inside_fan_out = True
try:
for item_idx, item_val in enumerate(items):
context.item = item_val
results.append(run_item(item_idx, context))
if state.status in halting:
break
finally:
context.item = previous_item
context.inside_fan_out = previous_inside_fan_out
for item_idx, item_val in enumerate(items):
context.item = item_val
results.append(run_item(item_idx, context))
if state.status in halting:
break
return results
# Concurrent path — bounded sliding window; results assembled in item order.
@@ -1396,14 +1320,7 @@ class WorkflowEngine:
# Each item runs against its own context copy so context.item is not
# clobbered across threads; the shared steps dict is written only on the
# disjoint parentId:templateId:index key (GIL-safe on distinct keys).
return run_item(
idx,
dataclasses.replace(
context,
item=items[idx],
inside_fan_out=True,
),
)
return run_item(idx, dataclasses.replace(context, item=items[idx]))
def item_halt_status(idx: int) -> RunStatus | None:
# If THIS item's own execution halted the run, return the resulting run
@@ -1484,16 +1401,6 @@ class WorkflowEngine:
# pool joined; restore the halting item's own outcome so the final run
# status matches the sequential semantics.
state.status = halted_status
# Restore the halting item's error so it matches the terminal
# status — a concurrent item may have overwritten state.error
# before the pool joined. Assign unconditionally when a record
# exists (even when the halting item's own error is falsy) so a
# third-party step returning FAILED with no message never inherits
# an unrelated concurrent item's error; this mirrors the sequential
# path, which sets state.error = result.error verbatim.
halt_rec = context.steps.get(item_id(halted_at))
if isinstance(halt_rec, dict):
state.error = halt_rec.get("error")
return slots[: halted_at + 1]
return slots[:collected]
@@ -7,7 +7,6 @@ from typing import Any
import typer
import yaml
from rich.markup import escape as _escape_markup
from ..._console import console, err_console
from ...extensions import normalize_priority
@@ -413,23 +412,14 @@ def workflow_resolve(project_root: Path, workflow_id: str) -> dict[str, Any] | N
priority = (
"n/a" if layer.tier == "base" else str(normalize_priority(layer.priority))
)
# ``\[`` keeps the literal bracket: unescaped, Rich parses ``[base]`` /
# ``[project-overlay]`` as a style tag and swallows the tier label whole.
console.print(
f" \u2022 \\[{_escape_markup(layer.tier)}] "
f"{_escape_markup(layer.source)} "
f" \u2022 [{layer.tier}] {layer.source} "
f"(priority={priority})"
)
console.print("Step attribution:")
for composed in attribution:
# Step IDs come from base-workflow / overlay YAML, which only bans ``:``
# \u2014 brackets pass validation, so they reach Rich as markup. A balanced
# ``[stuff]`` is swallowed; an unbalanced ``[/red]`` raises MarkupError.
console.print(
f" \u2022 {_escape_markup(composed.step_id)}: "
f"{_escape_markup(composed.source)}"
)
console.print(f" \u2022 {composed.step_id}: {composed.source}")
return {
"workflow_id": workflow_id,
@@ -26,8 +26,7 @@ class GateStep(StepBase):
later with ``specify workflow resume``.
The user's choice is stored in ``output.choice``. ``on_reject``
controls abort / skip / retry behaviour. ``verdict_input`` can name a
workflow input to use as the choice when resuming non-interactively.
controls abort / skip / retry behaviour.
"""
type_key = "gate"
@@ -43,8 +42,6 @@ class GateStep(StepBase):
options = config.get("options", ["approve", "reject"])
on_reject = config.get("on_reject", "abort")
has_verdict_input = "verdict_input" in config
verdict_input = config.get("verdict_input")
# ``validate`` rejects a non-list (or empty) ``options``, and requires
# every option to be a string, but the engine does not auto-validate
@@ -75,26 +72,6 @@ class GateStep(StepBase):
},
)
if has_verdict_input and (
not isinstance(verdict_input, str) or not verdict_input
):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
"a non-empty string."
),
)
if has_verdict_input and context.inside_fan_out:
return StepResult(
status=StepStatus.FAILED,
error=(
f"Gate step {config.get('id', '?')!r}: 'verdict_input' is "
"not supported inside fan-out templates."
),
)
show_file = config.get("show_file")
if isinstance(show_file, str) and "{{" in show_file:
show_file = evaluate_expression(show_file, context)
@@ -113,48 +90,16 @@ class GateStep(StepBase):
"choice": None,
}
choice: str | None = None
bound_verdict_input: str | None = None
if verdict_input is not None:
value = context.inputs.get(verdict_input)
if value is not None and value != "":
if not isinstance(value, str):
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
f"Gate step {config.get('id', '?')!r}: verdict input "
f"{verdict_input!r} must be a string, got "
f"{type(value).__name__}."
),
)
choice = next(
(option for option in options if option.lower() == value.lower()),
None,
)
if choice is None:
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
f"Gate step {config.get('id', '?')!r}: verdict input "
f"{verdict_input!r} value {value!r} does not match any "
"configured option."
),
)
bound_verdict_input = verdict_input
# Non-interactive: pause for later resume (the file is not read here)
if not sys.stdin.isatty():
return StepResult(status=StepStatus.PAUSED, output=output)
if choice is None:
# Non-interactive: pause for later resume (the file is not read here)
if not sys.stdin.isatty():
return StepResult(status=StepStatus.PAUSED, output=output)
# Interactive: prompt the user. ``show_file`` contents are folded
# into the displayed message so the operator can review the
# referenced material before choosing. Composing the prompt text
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
# adding review material never widens the interactive seam.
choice = self._prompt(self._compose_prompt(message, show_file), options)
# Interactive: prompt the user. ``show_file`` contents are folded
# into the displayed message so the operator can review the
# referenced material before choosing. Composing the prompt text
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
# adding review material never widens the interactive seam.
choice = self._prompt(self._compose_prompt(message, show_file), options)
output["choice"] = choice
# Match rejection case-insensitively. ``_prompt`` echoes the option's
@@ -174,8 +119,6 @@ class GateStep(StepBase):
)
if on_reject == "retry":
# Pause so the next resume re-executes this gate
if bound_verdict_input is not None:
context.inputs[bound_verdict_input] = ""
return StepResult(status=StepStatus.PAUSED, output=output)
# on_reject == "skip" → completed, downstream steps decide
return StepResult(status=StepStatus.COMPLETED, output=output)
@@ -291,13 +234,6 @@ class GateStep(StepBase):
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry'."
)
if "verdict_input" in config and (
not isinstance(config["verdict_input"], str) or not config["verdict_input"]
):
errors.append(
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
"a non-empty string."
)
# Only inspect option text when every option is a string; otherwise the
# `o.lower()` below would raise AttributeError on a non-string option
# (already reported above) and break validate_workflow's never-raise contract.
@@ -2,7 +2,6 @@
from __future__ import annotations
import math
import shutil
from pathlib import Path
from typing import Any
@@ -89,15 +88,6 @@ class PromptStep(StepBase):
),
)
# An invalid timeout reaches subprocess.run() and raises a raw
# TypeError ("unsupported operand type(s) for +: 'float' and 'str'")
# or ValueError, which the engine re-raises — taking down the whole
# run with a message that names neither the step nor 'timeout'. Fail
# this step cleanly instead, mirroring the shell step.
timeout_error = self._timeout_error(config)
if timeout_error is not None:
return StepResult(status=StepStatus.FAILED, error=timeout_error)
# Attempt CLI dispatch
timeout = config.get("timeout", 300)
dispatch_result = self._try_dispatch(
@@ -141,41 +131,6 @@ class PromptStep(StepBase):
),
)
@staticmethod
def _timeout_error(config: dict[str, Any]) -> str | None:
"""Return an error message if ``config['timeout']`` is invalid, else None.
Shared by execute() and validate() so both paths reject the same
values with the same message, mirroring the shell step. An absent
``timeout`` is valid (the default is used). bool is a subclass of int,
but ``timeout: true`` is a config error rather than a duration, so it
is rejected explicitly. Non-finite floats (YAML ``.inf``/``.nan``) pass
a plain ``> 0`` check but would raise in subprocess.run(), and a
non-positive timeout makes subprocess.run() report an immediate
TimeoutExpired, so both are rejected too.
"""
if "timeout" not in config:
return None
timeout = config["timeout"]
try:
valid_timeout = (
not isinstance(timeout, bool)
and isinstance(timeout, (int, float))
and timeout > 0
and math.isfinite(timeout)
)
except OverflowError:
# An int too large to convert to float (e.g. a 400-digit YAML
# scalar) clears every clause above and raises here — and would
# raise the same from subprocess.run(timeout=...).
valid_timeout = False
if not valid_timeout:
return (
f"Prompt step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
)
return None
@staticmethod
def _try_dispatch(
prompt: str,
@@ -214,17 +169,6 @@ class PromptStep(StepBase):
if not exec_args:
return None
# Windows: ``subprocess.run`` calls ``CreateProcess``, which does not
# consult ``PATHEXT``, so a bare command name like ``claude`` installed
# as ``claude.cmd`` (the usual npm shim layout) fails with
# ``WinError 2``. That OSError is swallowed below and reported as "CLI
# not found or not installed" -- even though the preflight above just
# found it. Reuse the already-resolved path so the shim is executed,
# mirroring ``IntegrationBase.dispatch_command``, which the ``command``
# step already goes through. On POSIX this is the same executable.
if fallback_cli_path:
exec_args = [fallback_cli_path, *exec_args[1:]]
import subprocess
project_root = (
@@ -295,7 +239,4 @@ class PromptStep(StepBase):
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
)
timeout_error = self._timeout_error(config)
if timeout_error is not None:
errors.append(timeout_error)
return errors
@@ -121,20 +121,12 @@ class ShellStep(StepBase):
if "timeout" not in config:
return None
timeout = config["timeout"]
try:
invalid_timeout = (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
)
except OverflowError:
# An int too large to convert to float (e.g. a 400-digit YAML
# scalar) is not a bool and *is* an int, so it clears every clause
# before ``isfinite()`` and raises there — and would raise the same
# from subprocess.run(timeout=...). Mirrors the prompt step.
invalid_timeout = True
if invalid_timeout:
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
):
return (
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
-39
View File
@@ -26,45 +26,6 @@ def test_missing_required_field_is_reported_by_name():
assert any("bundle.license" in e for e in errors)
@pytest.mark.parametrize(
"field", ["name", "role", "description", "author", "license"]
)
def test_explicit_null_bundle_field_is_reported_as_missing(field):
"""A field present but null is how YAML spells an empty value (`author:`).
`str(None)` is the literal text "None", which is non-empty, so it passed the
required-field checks: the bundle validated clean and shipped "None" as its
author/license/description.
"""
data = valid_manifest_dict()
data["bundle"][field] = None
manifest = BundleManifest.from_dict(data)
assert getattr(manifest.bundle, field) == ""
assert any(f"bundle.{field}" in e for e in manifest.structural_errors())
def test_explicit_null_speckit_version_is_reported_as_missing():
data = valid_manifest_dict()
data["requires"]["speckit_version"] = None
manifest = BundleManifest.from_dict(data)
assert manifest.requires.speckit_version == ""
assert any("speckit_version" in e for e in manifest.structural_errors())
def test_explicit_null_component_id_is_not_named_none():
"""A null component id must not become a component literally named "None"."""
data = valid_manifest_dict()
for kind, items in (data.get("provides") or {}).items():
if isinstance(items, list) and items and isinstance(items[0], dict):
items[0]["id"] = None
break
else: # pragma: no cover - fixture is expected to provide components
pytest.skip("fixture has no component list to null out")
manifest = BundleManifest.from_dict(data)
assert manifest.components, "fixture is expected to declare components"
assert all(ref.id != "None" for ref in manifest.components)
def test_unsupported_schema_version_is_rejected():
data = valid_manifest_dict(schema_version="9.9")
errors = BundleManifest.from_dict(data).structural_errors()
@@ -1,53 +0,0 @@
"""Contract tests: every bundled preset must ship inside the wheel's core_pack.
``specify preset add <id>`` resolves a bundled preset via
``specify_cli._assets._locate_bundled_preset``, which checks the wheel's
``specify_cli/core_pack/presets/<id>/`` directory first. Any preset marked
``bundled: true`` in ``presets/catalog.json`` must therefore be force-included
at build time; otherwise the released wheel advertises a bundled preset it does
not actually ship, and ``specify preset add <id>`` falls through and reports the
preset as missing.
"""
from __future__ import annotations
import json
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).parents[2]
def _force_include() -> dict[str, str]:
with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"]
def _bundled_preset_ids() -> list[str]:
catalog = json.loads((REPO_ROOT / "presets" / "catalog.json").read_text())
return sorted(
preset_id
for preset_id, entry in catalog["presets"].items()
if entry.get("bundled")
)
def test_every_bundled_preset_is_force_included():
force_include = _force_include()
bundled = _bundled_preset_ids()
assert bundled, "expected at least one bundled preset in presets/catalog.json"
for preset_id in bundled:
assert force_include.get(f"presets/{preset_id}") == (
f"specify_cli/core_pack/presets/{preset_id}"
), f"bundled preset '{preset_id}' is missing from the wheel force-include list"
def test_constitution_sync_is_bundled_and_shipped():
# Explicit regression guard: constitution-sync was advertised as bundled
# before it was added to the wheel force-include list.
assert "constitution-sync" in _bundled_preset_ids()
assert _force_include()["presets/constitution-sync"] == (
"specify_cli/core_pack/presets/constitution-sync"
)
File diff suppressed because it is too large Load Diff
@@ -116,15 +116,12 @@ class TestCatalogURLValidation:
[
"https://[::1", # unclosed ipv6 bracket
"https://[not-an-ip]/c.json", # bracketed non-ip host
"https://example.com:notaport/c.json", # non-numeric port
"https://example.com:65536/c.json", # out-of-range port
],
)
def test_malformed_url_rejected_cleanly(self, url):
# A malformed authority makes urlparse/hostname raise ValueError, and a
# bad port makes ``parsed.port`` raise it. The validator must turn that
# into its normal catalog error, not leak a raw ValueError to the caller
# (or, for a bad port, accept the URL and fail later at fetch time).
# A malformed authority makes urlparse/hostname raise ValueError. The
# validator must turn that into its normal catalog error, not leak a
# raw ValueError to the caller.
with pytest.raises(IntegrationCatalogError, match="malformed"):
IntegrationCatalog._validate_catalog_url(url)
@@ -109,21 +109,6 @@ class TestCopilotIntegration:
assert settings not in created
assert not any("settings.json" in k for k in m.files)
def test_setup_preserves_non_utf8_vscode_settings(self, tmp_path, caplog):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
vscode_dir = tmp_path / ".vscode"
vscode_dir.mkdir(parents=True)
settings = vscode_dir / "settings.json"
original = b'{"editor.fontSize": 14}\xff'
settings.write_bytes(original)
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
assert settings.read_bytes() == original
assert "Could not parse" in caplog.text
def test_all_created_files_tracked_in_manifest(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
@@ -3831,68 +3831,6 @@ class TestIntegrationUpgrade:
"upgrade of the active integration re-registers extension commands"
)
def test_upgrade_copilot_skills_restores_extension_skill_over_regenerated_dir(
self, tmp_path
):
"""End-to-end regression for #3849 (upgrade-overwrites-copilot-skills).
In Copilot skills mode, ``integration upgrade`` runs ``setup()`` — which
regenerates the core-template skill directories — *before* re-registering
installed extensions. The extension re-registration then hits the
``skill_dir_preexists`` guard in ``_register_extension_skills`` (the skill
sub-directory exists, courtesy of ``setup()``, but its ``SKILL.md`` has
not been rewritten with extension content), so pre-fix the extension
skill was silently left missing — its command content lost even though the
extension remained installed and registered.
The fix threads ``force=True`` from ``integration_upgrade()`` down to
``_register_extension_skills`` so the guard is bypassed and the extension
content is re-composed on top of the just-regenerated directory. This test
exercises the full ``specify integration upgrade`` command path and fails
without the fix (the skill is never recreated).
"""
project = _init_project(
tmp_path, "copilot", integration_options="--skills"
)
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
skill_dir = project / ".github" / "skills" / "speckit-git-feature"
skill_file = skill_dir / "SKILL.md"
assert skill_file.exists(), (
"precondition: git extension renders as a Copilot skill"
)
original = skill_file.read_text(encoding="utf-8")
assert "source: extension:git" in original, (
"precondition: skill carries the git extension ownership marker"
)
# Simulate the exact pre-condition the bug depends on: the skill file is
# gone but its directory survives (as it does once setup() regenerates the
# core-template layout during upgrade), triggering the skill_dir_preexists
# skip guard on re-registration.
skill_file.unlink()
assert skill_dir.exists() and not skill_file.exists()
result = _run_in_project(project, [
"integration", "upgrade", "copilot",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code == 0, result.output
assert skill_file.exists(), (
"upgrade must restore the extension skill even when its directory "
"already exists (regression #3849)"
)
restored = skill_file.read_text(encoding="utf-8")
assert "source: extension:git" in restored, (
"restored skill must contain the git extension content, not a bare "
"core-template stub"
)
assert "# Git Feature Skill" in restored
def test_upgrade_active_integration_reregisters_presets(self, tmp_path):
"""Upgrading the active integration restores missing preset artifacts."""
import yaml
-36
View File
@@ -242,34 +242,6 @@ class TestManifestUninstall:
"remove_manifest=False must keep the manifest file on disk"
)
def test_undeletable_manifest_is_skipped_not_raised(self, tmp_path):
"""An undeletable manifest must not abort the whole uninstall.
The tracked files are removed *before* the manifest, so raising here
loses the ``(removed, skipped)`` result the caller needs: the CLI's
post-uninstall bookkeeping (reassigning the default integration,
rewriting/removing ``integration.json``, clearing init options) never
runs, leaving a removed integration still recorded as installed.
Leaving a directory at the manifest path is a portable way to make
``unlink()`` fail with no chmod and no monkeypatch: it raises
``IsADirectoryError`` on Linux and ``PermissionError`` on
Windows/macOS, both ``OSError`` subclasses.
"""
m = IntegrationManifest("test", tmp_path, version="1.0")
m.record_file("f.txt", "content")
m.save()
m.manifest_path.unlink()
m.manifest_path.mkdir()
removed, skipped = m.uninstall()
assert removed == [tmp_path / "f.txt"]
assert not (tmp_path / "f.txt").exists()
assert m.manifest_path in skipped, (
"an undeletable manifest must be reported in skipped"
)
def test_cleans_empty_parent_dirs(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("a/b/c/f.txt", "content")
@@ -375,14 +347,6 @@ class TestManifestLoadValidation:
with pytest.raises(ValueError, match="invalid JSON"):
IntegrationManifest.load("bad", tmp_path)
def test_load_non_utf8_json_raises_value_error(self, tmp_path):
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
path.parent.mkdir(parents=True)
path.write_bytes(b"\xff\xfe")
with pytest.raises(ValueError, match="valid UTF-8"):
IntegrationManifest.load("bad", tmp_path)
def test_load_filters_recovered_files_not_in_files(self, tmp_path):
# Finding B (Round-9): a recovered_files entry referencing a path
# not present in files indicates an internally-inconsistent manifest
@@ -370,69 +370,3 @@ def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) ->
assert py.returncode == 0, py.stderr
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
class TestGetInvokeSeparatorTolerance:
"""`get_invoke_separator` must fall back to "." for an unusable
`integration.json`, matching its bash and PowerShell twins.
The bash twin tries jq -> python3 -> awk and keeps its `separator="."`
default on any parse failure; the PowerShell twin likewise returns ".".
The Python twin instead indexed the parsed value directly, so two shapes
escaped its `except (OSError, json.JSONDecodeError)`:
* a non-mapping top level (`[]`, `"forge"`, `42`, `null`) is valid JSON,
so JSONDecodeError never fires and `.get()` raised AttributeError;
* a non-UTF-8 file raises UnicodeDecodeError -- a ValueError, not an
OSError. Realistic on Windows, where PowerShell 5.1's `Out-File`/`>`
default to UTF-16.
The sibling `read_feature_json_feature_directory` in the same module
already guards both.
"""
@staticmethod
def _load_common():
import importlib.util
spec = importlib.util.spec_from_file_location("_speckit_common_py", COMMON_PY)
module = importlib.util.module_from_spec(spec)
# Register before exec: the module defines @dataclass types, and
# dataclasses resolves cls.__module__ through sys.modules.
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
except Exception: # pragma: no cover - defensive cleanup
sys.modules.pop(spec.name, None)
raise
return module
def _repo(self, tmp_path: Path, body: str | bytes) -> Path:
(tmp_path / ".specify").mkdir(parents=True, exist_ok=True)
target = tmp_path / ".specify" / "integration.json"
if isinstance(body, bytes):
target.write_bytes(body)
else:
target.write_text(body, encoding="utf-8")
return tmp_path
@pytest.mark.parametrize(
"body", ["[]", '[{"a": 1}]', '"forge"', "42", "true", "null"]
)
def test_non_mapping_integration_json_falls_back(self, tmp_path: Path, body: str):
common = self._load_common()
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "."
def test_non_utf8_integration_json_falls_back(self, tmp_path: Path):
common = self._load_common()
raw = '{"default_integration": "forge"}'.encode("utf-16")
assert common.get_invoke_separator(self._repo(tmp_path, raw)) == "."
def test_hyphen_separator_is_still_honoured(self, tmp_path: Path):
"""Regression guard: the real feature must keep working."""
common = self._load_common()
body = json.dumps({
"default_integration": "droid",
"integration_settings": {"droid": {"invoke_separator": "-"}},
})
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "-"
-176
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import io
import stat
import struct
import tarfile
import weakref
import zipfile
import zlib
@@ -14,16 +13,11 @@ import pytest
from specify_cli._download_security import (
MAX_ZIP_CENTRAL_DIRECTORY_BYTES,
archive_format_from_content_type,
archive_format_from_name,
build_safe_download_path,
detect_archive_format,
is_https_or_localhost_http,
is_loopback_url,
read_response_limited,
read_zip_member_limited,
safe_extract_archive,
safe_extract_tar,
safe_extract_zip,
)
@@ -320,176 +314,6 @@ def test_build_safe_download_path_rejects_nonportable_identifiers(
)
@pytest.mark.parametrize(
("name", "expected"),
[
("package.zip", "zip"),
("PACKAGE.TAR.GZ", "tar.gz"),
("https://example.com/package.tgz?download=1", "tar.gz"),
("package.tar", None),
],
)
def test_archive_format_from_name(name, expected):
assert archive_format_from_name(name) == expected
@pytest.mark.parametrize(
("content_type", "expected"),
[
("application/zip", "zip"),
("application/x-zip-compressed; charset=binary", "zip"),
("application/gzip", "tar.gz"),
("application/x-gzip", "tar.gz"),
("application/octet-stream", None),
],
)
def test_archive_format_from_content_type(content_type, expected):
assert archive_format_from_content_type(content_type) == expected
def _write_tar_gz(path, members):
with tarfile.open(path, "w:gz") as archive:
for name, content in members:
info = tarfile.TarInfo(name)
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
def test_detect_archive_format_accepts_tar_suffixes(tmp_path, suffix):
archive_path = tmp_path / f"package{suffix}"
_write_tar_gz(archive_path, [("file.txt", b"contents")])
assert detect_archive_format(archive_path) == "tar.gz"
def test_detect_archive_format_allows_content_type_fallback(tmp_path):
archive_path = tmp_path / "download"
_write_tar_gz(archive_path, [("file.txt", b"contents")])
assert (
detect_archive_format(
archive_path,
source_name="https://example.com/download",
content_type="application/gzip",
)
== "tar.gz"
)
def test_detect_archive_format_rejects_suffix_content_mismatch(tmp_path):
archive_path = tmp_path / "package.zip"
_write_tar_gz(archive_path, [("file.txt", b"contents")])
with pytest.raises(ValueError, match="format mismatch"):
detect_archive_format(archive_path)
def test_detect_archive_format_rejects_suffix_header_mismatch(tmp_path):
archive_path = tmp_path / "package.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("file.txt", "contents")
with pytest.raises(ValueError, match="Content-Type"):
detect_archive_format(
archive_path,
content_type="application/gzip",
)
def test_build_safe_download_path_uses_archive_suffix(tmp_path):
path = build_safe_download_path(tmp_path, "package", "1.0.0", suffix=".tar.gz")
assert path.name == "package-1.0.0.tar.gz"
@pytest.mark.parametrize(
"member_name",
["../evil.txt", "nested/../../evil.txt", "C:/Windows/evil.txt"],
)
def test_safe_extract_tar_rejects_traversal(tmp_path, member_name):
archive_path = tmp_path / "bad.tar.gz"
_write_tar_gz(archive_path, [(member_name, b"nope")])
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_tar(archive_path, tmp_path / "out")
@pytest.mark.parametrize(
("link_type", "message"),
[(tarfile.SYMTYPE, "symlink"), (tarfile.LNKTYPE, "hard link")],
)
def test_safe_extract_tar_rejects_links_without_partial_extraction(
tmp_path, link_type, message
):
archive_path = tmp_path / "bad.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
safe = tarfile.TarInfo("safe.txt")
safe.size = 4
archive.addfile(safe, io.BytesIO(b"safe"))
link = tarfile.TarInfo("escape")
link.type = link_type
link.linkname = "../../outside"
archive.addfile(link)
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match=message):
safe_extract_tar(archive_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
def test_safe_extract_tar_rejects_special_file(tmp_path):
archive_path = tmp_path / "bad.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
fifo = tarfile.TarInfo("pipe")
fifo.type = tarfile.FIFOTYPE
archive.addfile(fifo)
with pytest.raises(ValueError, match="Unsafe member type"):
safe_extract_tar(archive_path, tmp_path / "out")
def test_safe_extract_tar_rejects_conflicting_paths(tmp_path):
archive_path = tmp_path / "bad.tar.gz"
_write_tar_gz(
archive_path,
[("Folder/file.txt", b"one"), ("folder/FILE.txt", b"two")],
)
with pytest.raises(ValueError, match="Conflicting path"):
safe_extract_tar(archive_path, tmp_path / "out")
def test_safe_extract_tar_enforces_entry_and_size_limits(tmp_path):
archive_path = tmp_path / "bad.tar.gz"
_write_tar_gz(
archive_path,
[("one.txt", b"1234"), ("two.txt", b"5678")],
)
with pytest.raises(ValueError, match="too many entries"):
safe_extract_tar(archive_path, tmp_path / "entries", max_entries=1)
with pytest.raises(ValueError, match="member.*maximum size"):
safe_extract_tar(archive_path, tmp_path / "member", max_member_bytes=3)
with pytest.raises(ValueError, match="uncompressed size"):
safe_extract_tar(archive_path, tmp_path / "total", max_total_bytes=7)
@pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"])
def test_safe_extract_archive_has_format_parity(tmp_path, suffix):
archive_path = tmp_path / f"package{suffix}"
if suffix == ".zip":
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("nested/file.txt", b"contents")
else:
_write_tar_gz(archive_path, [("nested/file.txt", b"contents")])
out_dir = tmp_path / f"out-{suffix.replace('.', '-')}"
safe_extract_archive(archive_path, out_dir)
assert (out_dir / "nested" / "file.txt").read_bytes() == b"contents"
@pytest.mark.parametrize(
"member_name",
[
-401
View File
@@ -1,401 +0,0 @@
"""Security tests for the extension URL download cache."""
from __future__ import annotations
import io
import os
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import typer
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.extensions import ExtensionCatalog, ExtensionManager
from specify_cli.extensions import _commands
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
runner = CliRunner()
def _require_secure_dir_fd() -> None:
if (
not getattr(os, "O_NOFOLLOW", 0)
or os.open not in os.supports_dir_fd
or os.mkdir not in os.supports_dir_fd
):
pytest.skip("requires dir_fd and O_NOFOLLOW support")
def _symlink_directory(link: Path, target: Path) -> None:
try:
link.symlink_to(target, target_is_directory=True)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"directory symlinks are unavailable: {exc}")
@pytest.fixture
def project_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
project = tmp_path / "project"
project.mkdir()
(project / ".specify").mkdir()
monkeypatch.chdir(project)
return project
@pytest.mark.parametrize(
"ancestor_parts",
[
("extensions",),
("extensions", ".cache"),
("extensions", ".cache", "downloads"),
],
)
def test_symlinked_cache_ancestor_is_refused(
project_dir: Path, tmp_path: Path, ancestor_parts: tuple[str, ...]
) -> None:
_require_secure_dir_fd()
outside = tmp_path / "outside"
outside.mkdir()
parent = project_dir / ".specify"
for part in ancestor_parts[:-1]:
parent = parent / part
parent.mkdir()
_symlink_directory(parent / ancestor_parts[-1], outside)
with pytest.raises(typer.Exit):
_commands._validate_safe_cache_dir(project_dir)
assert list(outside.iterdir()) == []
@pytest.mark.parametrize(
"ancestor_parts",
[
("extensions",),
("extensions", ".cache"),
("extensions", ".cache", "downloads"),
],
)
def test_symlinked_cache_ancestor_is_refused_without_dir_fd(
project_dir: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
ancestor_parts: tuple[str, ...],
) -> None:
"""The portable (Windows) validation path must also refuse a symlinked
cache ancestor and never create anything under the symlink target."""
monkeypatch.setattr(os, "supports_dir_fd", set())
outside = tmp_path / "outside"
outside.mkdir()
parent = project_dir / ".specify"
for part in ancestor_parts[:-1]:
parent = parent / part
parent.mkdir()
_symlink_directory(parent / ancestor_parts[-1], outside)
with pytest.raises(typer.Exit):
_commands._validate_safe_cache_dir(project_dir)
assert list(outside.iterdir()) == []
def test_cache_ancestor_resolving_outside_project_is_refused(
project_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_require_secure_dir_fd()
cache_root = project_dir / ".specify" / "extensions" / ".cache"
cache_root.mkdir(parents=True)
outside = tmp_path / "outside"
outside.mkdir()
real_resolve = Path.resolve
def fake_resolve(self: Path, *args, **kwargs) -> Path:
if self == cache_root:
return real_resolve(outside, *args, **kwargs)
return real_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fake_resolve)
with pytest.raises(typer.Exit):
_commands._validate_safe_cache_dir(project_dir)
assert list(outside.iterdir()) == []
def test_safe_open_refuses_exclusive_leaf_collision(project_dir: Path) -> None:
_require_secure_dir_fd()
download_dir = _commands._validate_safe_cache_dir(project_dir)
zip_filename = "extension-url-download-collision.zip"
collision = download_dir / zip_filename
collision.write_bytes(b"sentinel")
with pytest.raises(OSError):
_commands._safe_open_download_zip(
project_dir, download_dir, zip_filename
)
assert collision.read_bytes() == b"sentinel"
def test_safe_open_refuses_swapped_cache_ancestor(
project_dir: Path, tmp_path: Path
) -> None:
_require_secure_dir_fd()
download_dir = _commands._validate_safe_cache_dir(project_dir)
cache_root = project_dir / ".specify" / "extensions" / ".cache"
outside = tmp_path / "outside"
outside.mkdir()
shutil.rmtree(cache_root)
_symlink_directory(cache_root, outside)
with pytest.raises(OSError):
_commands._safe_open_download_zip(
project_dir,
download_dir,
"extension-url-download-swapped.zip",
)
assert list(outside.iterdir()) == []
def test_safe_open_refuses_symlinked_project_root(
project_dir: Path, tmp_path: Path
) -> None:
_require_secure_dir_fd()
project_link = tmp_path / "project-link"
_symlink_directory(project_link, project_dir)
download_dir = project_link / ".specify" / "extensions" / ".cache" / "downloads"
with pytest.raises(OSError):
_commands._safe_open_download_zip(
project_link,
download_dir,
"extension-url-download-project-link.zip",
)
def test_safe_open_succeeds_without_dir_fd_support(
project_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""On a platform without dir_fd (e.g. Windows) the portable path must
still hand back a usable, exclusively-created descriptor rather than
failing closed."""
monkeypatch.setattr(os, "supports_dir_fd", set())
download_dir = _commands._validate_safe_cache_dir(project_dir)
assert download_dir == (
project_dir / ".specify" / "extensions" / ".cache" / "downloads"
)
fd = _commands._safe_open_download_zip(
project_dir, download_dir, "extension-url-download-portable.zip"
)
try:
os.write(fd, b"payload")
os.lseek(fd, 0, os.SEEK_SET)
assert os.read(fd, 7) == b"payload"
finally:
os.close(fd)
def test_safe_open_without_dir_fd_refuses_symlinked_leaf(
project_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The portable path must refuse a leaf pre-staged as a symlink so an
attacker cannot redirect the exclusive create outside the project."""
monkeypatch.setattr(os, "supports_dir_fd", set())
download_dir = _commands._validate_safe_cache_dir(project_dir)
outside = tmp_path / "outside.zip"
zip_filename = "extension-url-download-symlink-leaf.zip"
try:
(download_dir / zip_filename).symlink_to(outside)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"symlinks are unavailable: {exc}")
with pytest.raises(OSError):
_commands._safe_open_download_zip(project_dir, download_dir, zip_filename)
assert not outside.exists()
def test_url_install_succeeds_without_dir_fd_support(
project_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A full ``--from`` install must work on platforms without dir_fd rather
than failing closed, exercising the portable hardened download path."""
captured: dict[str, object] = {}
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def fake_install(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
*,
archive_file=None,
):
captured["bytes"] = archive_file.read()
archive_file.seek(0)
return SimpleNamespace(
id="test-ext",
name="Test Extension",
version="1.0.0",
description="",
warnings=[],
commands=[],
)
monkeypatch.setattr(os, "supports_dir_fd", set())
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
monkeypatch.setattr(
ExtensionCatalog,
"_open_url",
lambda *args, **kwargs: FakeResponse(_MINIMAL_ZIP_BYTES),
)
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install)
monkeypatch.setattr(_commands, "_refresh_events_and_warn", lambda root: None)
monkeypatch.setattr(_commands, "load_init_options", lambda root: {})
result = runner.invoke(
app,
[
"extension",
"add",
"test-ext",
"--from",
"https://example.com/test-ext.zip",
],
)
assert result.exit_code == 0, result.output
assert captured["bytes"] == _MINIMAL_ZIP_BYTES
def test_url_install_writes_and_cleans_up_secure_download(
project_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_require_secure_dir_fd()
captured: dict[str, object] = {}
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def fake_install(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
*,
archive_file=None,
):
captured["path"] = zip_path
captured["mode"] = os.fstat(archive_file.fileno()).st_mode & 0o777
captured["exists_during_install"] = zip_path.exists()
captured["bytes"] = archive_file.read()
archive_file.seek(0)
return SimpleNamespace(
id="test-ext",
name="Test Extension",
version="1.0.0",
description="",
warnings=[],
commands=[],
)
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
monkeypatch.setattr(
ExtensionCatalog,
"_open_url",
lambda *args, **kwargs: FakeResponse(_MINIMAL_ZIP_BYTES),
)
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install)
monkeypatch.setattr(_commands, "_refresh_events_and_warn", lambda root: None)
monkeypatch.setattr(_commands, "load_init_options", lambda root: {})
result = runner.invoke(
app,
[
"extension",
"add",
"test-ext",
"--from",
"https://example.com/test-ext.zip",
],
)
assert result.exit_code == 0, result.output
assert captured["bytes"] == _MINIMAL_ZIP_BYTES
assert captured["mode"] == 0o600
# The archive is an anonymous inode: it is never visible on disk, even
# while installation consumes the open descriptor.
assert captured["exists_during_install"] is False
zip_path = captured["path"]
assert isinstance(zip_path, Path)
assert zip_path.parent == (
project_dir / ".specify" / "extensions" / ".cache" / "downloads"
)
assert zip_path.name.startswith("extension-url-download-")
assert not zip_path.exists()
def test_url_install_open_error_surfaces_as_controlled_exit(
project_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An ``OSError`` from the hardened create (e.g. an exclusive-leaf
collision or a swapped ancestor) must fail closed as ``typer.Exit(1)``
rather than escaping as an unhandled traceback, and installation must
not run."""
download_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
monkeypatch.setattr(
ExtensionCatalog,
"_open_url",
lambda *args, **kwargs: io.BytesIO(_MINIMAL_ZIP_BYTES),
)
monkeypatch.setattr(
_commands, "_validate_safe_cache_dir", lambda root: download_dir
)
download_dir.mkdir(parents=True, exist_ok=True)
def _raise_collision(project_root, dir_, zip_filename):
raise FileExistsError("leaf already exists")
monkeypatch.setattr(_commands, "_safe_open_download_zip", _raise_collision)
install_spy = MagicMock()
monkeypatch.setattr(ExtensionManager, "install_from_zip", install_spy)
result = runner.invoke(
app,
[
"extension",
"add",
"test-ext",
"--from",
"https://example.com/test-ext.zip",
],
)
assert result.exit_code == 1
assert "Could not safely create download file" in result.output
install_spy.assert_not_called()
-129
View File
@@ -2943,135 +2943,6 @@ class TestExtensionSkillRegistration:
assert "speckit-early-ext-world" in metadata["registered_skills"]
# ===== Regression test: upgrade-overwrites-copilot-skills (#3849) =====
class TestRegisterExtensionSkillsForceFlag:
"""Regression tests for the ``force`` flag on ``_register_extension_skills``.
Issue #3849: ``integration upgrade --force`` called ``setup()`` which
regenerated all core-template SKILL.md files, then called
``register_enabled_extensions_for_agent()``. The skip-guard in
``_register_extension_skills`` treated the freshly-written core files as
existing user content and skipped every extension skill, leaving only core
template content on disk.
The fix introduces ``force=True`` in the upgrade path so the guard does not
fire for core-template files that setup() just wrote.
"""
def test_force_false_skips_existing_skill(self, project_dir, temp_dir):
"""Without force=True the skip guard must still protect existing files."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = _create_extension_dir(temp_dir)
# Manually pre-create a SKILL.md as if setup() had already written it
skill_subdir = skills_dir / "speckit-test-ext-hello"
skill_subdir.mkdir(parents=True, exist_ok=True)
skill_file = skill_subdir / "SKILL.md"
skill_file.write_text("core-template content only", encoding="utf-8")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(ext_dir / "extension.yml")
# Default (force=False): existing file must not be overwritten
written = manager._register_extension_skills(manifest, ext_dir, force=False)
assert "speckit-test-ext-hello" not in written
assert skill_file.read_text(encoding="utf-8") == "core-template content only"
def test_force_true_overwrites_existing_skill(self, project_dir, temp_dir):
"""With force=True the function must overwrite the existing SKILL.md.
This is the core regression test for #3849: calling
``_register_extension_skills(force=True)`` after ``setup()`` has
written a fresh core-template SKILL.md must replace it with the
composed extension content.
"""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = _create_extension_dir(temp_dir)
# Simulate what setup() writes: a bare core-template SKILL.md
skill_subdir = skills_dir / "speckit-test-ext-hello"
skill_subdir.mkdir(parents=True, exist_ok=True)
skill_file = skill_subdir / "SKILL.md"
skill_file.write_text("core-template content only", encoding="utf-8")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(ext_dir / "extension.yml")
# Upgrade path (force=True): extension content should replace the core file
written = manager._register_extension_skills(manifest, ext_dir, force=True)
assert "speckit-test-ext-hello" in written, (
"force=True should overwrite the core-template file and return the skill name"
)
content = skill_file.read_text(encoding="utf-8")
assert "Run this to say hello." in content, (
"Extension command body must appear in the overwritten SKILL.md"
)
assert "core-template content only" not in content, (
"Core-template placeholder must have been replaced by extension content"
)
def test_register_enabled_extensions_for_agent_force_flag_threads_through(
self, project_dir, temp_dir
):
"""force=True on register_enabled_extensions_for_agent must reach _register_extension_skills.
End-to-end check: after an upgrade writes a fresh core-template SKILL.md,
``register_enabled_extensions_for_agent(force=True)`` must produce a
SKILL.md that contains the extension content.
"""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = _create_extension_dir(temp_dir)
manager = ExtensionManager(project_dir)
# Install extension so it is in the registry
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
# Simulate a freshly-regenerated core-template SKILL.md (as setup() would write)
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
skill_file.write_text("core-template content only", encoding="utf-8")
# Re-register with force=True (upgrade path)
manager.register_enabled_extensions_for_agent("claude", force=True)
content = skill_file.read_text(encoding="utf-8")
assert "Run this to say hello." in content, (
"After register_enabled_extensions_for_agent(force=True), the SKILL.md "
"must contain the extension body, not just the core-template stub."
)
def test_force_true_with_preexisting_dir_but_no_skill_file(
self, project_dir, temp_dir
):
"""force=True must write into a pre-existing directory with no SKILL.md.
The second skip guard (``elif skill_dir_preexists``) should also be
bypassed by force=True so an upgrade can create a missing SKILL.md
even when the skill sub-directory already exists.
"""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = _create_extension_dir(temp_dir)
# Create the skill directory without the SKILL.md file
skill_subdir = skills_dir / "speckit-test-ext-hello"
skill_subdir.mkdir(parents=True, exist_ok=True)
skill_file = skill_subdir / "SKILL.md"
assert not skill_file.exists()
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(ext_dir / "extension.yml")
written = manager._register_extension_skills(manifest, ext_dir, force=True)
assert "speckit-test-ext-hello" in written
assert skill_file.exists()
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
# ===== Extension Skill Unregistration Tests =====
class TestExtensionSkillUnregistration:
+6 -265
View File
@@ -49,38 +49,6 @@ from specify_cli._utils import version_satisfies
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
def _open_test_download_zip(project_root, download_dir, zip_filename):
"""Cross-platform stand-in for the POSIX-only secure cache primitive.
Mirrors production behavior by making the leaf disappear from disk while
the descriptor stays open. On POSIX the file is unlinked immediately; on
Windows an in-use file cannot be unlinked, so it is opened with
``O_TEMPORARY`` and removed automatically when the descriptor closes.
"""
target = download_dir / zip_filename
o_temporary = getattr(os, "O_TEMPORARY", 0)
if o_temporary:
return os.open(
target,
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary,
0o600,
)
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
try:
os.unlink(target)
except OSError:
os.close(fd)
raise
return fd
def _validate_safe_cache_dir_test_stand_in(project_root):
"""Cross-platform stand-in for the secure cache validator."""
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
download_dir.mkdir(parents=True, exist_ok=True)
return download_dir
def can_create_symlink(tmp_path: Path) -> bool:
"""Return True when the current platform/user can create file symlinks."""
target = tmp_path / "symlink-target.txt"
@@ -608,7 +576,7 @@ class TestExtensionManifest:
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"):
with pytest.raises(ValidationError, match="must provide at least one command or hook"):
ExtensionManifest(manifest_path)
def test_hooks_only_extension(self, temp_dir, valid_manifest_data):
@@ -646,67 +614,6 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Invalid provides.commands"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["extension", "requires", "provides"])
@pytest.mark.parametrize("bad", [None, [], "text"])
def test_required_section_not_mapping_rejected(
self, temp_dir, valid_manifest_data, section, bad
):
"""A required section that is written but empty or wrongly shaped must
raise ValidationError, not a raw TypeError/AttributeError.
REQUIRED_FIELDS only checks key presence, so `provides:` with no value
passed it and then hit `None.get(...)`. That AttributeError escaped
list_installed()'s ValidationError-only "Corrupted extension" fallback,
so one bad extension made `specify extension list` exit 1 instead of
listing the others.
"""
import yaml
valid_manifest_data[section] = bad
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match=f"Invalid {section}"):
ExtensionManifest(manifest_path)
def test_empty_provides_mapping_is_still_accepted_with_hooks(
self, temp_dir, valid_manifest_data
):
"""Regression guard: `provides: {}` is a well-SHAPED mapping, so the new
shape check must not reject it — an extension may provide only hooks."""
import yaml
valid_manifest_data["provides"] = {}
assert valid_manifest_data.get("hooks"), "fixture is expected to define hooks"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
ExtensionManifest(manifest_path) # must not raise
def test_empty_provides_and_no_hooks_keeps_its_own_message(
self, temp_dir, valid_manifest_data
):
"""...and with no hooks (or events) either, it reports the "nothing
provided" message rather than the new shape error."""
import yaml
valid_manifest_data["provides"] = {}
valid_manifest_data.pop("hooks", None)
valid_manifest_data.pop("events", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(
ValidationError, match="at least one command, hook, or event"
):
ExtensionManifest(manifest_path)
def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data):
"""Test manifest with hooks as a list is rejected."""
import yaml
@@ -2261,79 +2168,6 @@ class TestExtensionManager:
assert not manager.registry.is_installed("test-ext")
@pytest.mark.skipif(os.name == "nt", reason="requires replacing an open file")
def test_install_from_zip_uses_open_archive_after_path_replacement(
self, extension_dir, project_dir, temp_dir
):
"""An authoritative archive stream must survive pathname replacement."""
import zipfile
zip_path = temp_dir / "original-extension.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
archive.write(file_path, file_path.relative_to(extension_dir))
manager = ExtensionManager(project_dir)
with zip_path.open("rb") as archive_file:
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w"):
pass
manifest = manager.install_from_zip(
zip_path,
"0.1.0",
archive_file=archive_file,
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
@pytest.mark.parametrize("nested", [False, True])
def test_install_from_tar_archive(
self, extension_dir, project_dir, temp_dir, suffix, nested
):
"""Tar archives install with the same flat/nested behavior as ZIP."""
import tarfile
archive_path = temp_dir / f"test-ext{suffix}"
with tarfile.open(archive_path, "w:gz") as archive:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
relative = file_path.relative_to(extension_dir)
arcname = Path("test-ext-v1") / relative if nested else relative
archive.add(file_path, arcname=arcname)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_archive(archive_path, "0.1.0")
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_tar_rejects_symlink_entry(
self, extension_dir, project_dir, temp_dir
):
import tarfile
archive_path = temp_dir / "symlink-extension.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
archive.add(
file_path,
arcname=file_path.relative_to(extension_dir),
)
link = tarfile.TarInfo("templates/escape")
link.type = tarfile.SYMTYPE
link.linkname = "../../outside"
archive.addfile(link)
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Unsafe symlink"):
manager.install_from_archive(archive_path, "0.1.0")
assert not manager.registry.is_installed("test-ext")
assert not manager.registry.is_installed("test-ext")
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
"""Test that duplicate install error message suggests --force."""
manager = ExtensionManager(project_dir)
@@ -2828,36 +2662,6 @@ Real body starts here.
assert parsed["description"] == "first line\nsecond line\n"
@pytest.mark.parametrize(
("description", "expected"),
[
(None, ""), # "description:" with no value
(42, "42"), # unquoted number
(True, "True"), # unquoted boolean
(["a", "b"], "['a', 'b']"), # was silently concatenated to "ab"
],
)
def test_render_toml_command_coerces_non_string_description(
self, description, expected
):
"""Frontmatter comes from yaml.safe_load, so description can be any type.
_render_basic_toml_string iterates the value and calls ord() per
character, so a non-string raised a raw TypeError and a list of
single-character items was silently concatenated into a wrong value.
render_yaml_command (same class) already coerces; this brings the TOML
branch to parity.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": description}, "body", "extension:test-ext"
)
parsed = tomllib.loads(output)
assert parsed["description"] == expected
def test_render_toml_command_escapes_control_characters(self):
"""Control characters and a lone CR must be escaped so the TOML parses.
@@ -5811,35 +5615,6 @@ class TestExtensionCatalog:
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].get_header("Accept") == "application/octet-stream"
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
def test_download_extension_preserves_tar_archive_format(
self, temp_dir, suffix
):
import tarfile
from unittest.mock import patch
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
content = b"extension:\n id: test-ext\n"
member = tarfile.TarInfo("extension.yml")
member.size = len(content)
archive.addfile(member, io.BytesIO(content))
archive_bytes = archive_buffer.getvalue()
catalog = self._make_catalog(temp_dir)
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": f"https://example.com/test-ext{suffix}",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(archive_bytes)):
archive_path = catalog.download_extension("test-ext", target_dir=temp_dir)
assert archive_path.name == "test-ext-1.0.0.tar.gz"
assert archive_path.read_bytes() == archive_bytes
# ===== CatalogEntry Tests =====
@@ -7525,15 +7300,7 @@ class TestExtensionAddCLI:
manifest_id = "[red]bad[/red]"
def fake_install_from_zip(
self_obj,
zip_path,
speckit_version,
priority=10,
force=False,
*,
archive_file=None,
):
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
return SimpleNamespace(
id=manifest_id,
name="Bad Extension",
@@ -7547,9 +7314,7 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \
patch.object(ExtensionRegistry, "get", return_value={}):
result = runner.invoke(
@@ -7597,7 +7362,6 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch(
"specify_cli.authentication.http.open_url",
side_effect=urllib.error.URLError("bad [red]download[/red]"),
@@ -7639,7 +7403,6 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(b"<!DOCTYPE html><html>Sign in</html>"),
@@ -7690,7 +7453,6 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(_MINIMAL_ZIP_BYTES),
@@ -7746,15 +7508,7 @@ class TestExtensionAddCLI:
seen["headers"] = extra_headers
return FakeResponse(_MINIMAL_ZIP_BYTES)
def fake_install(
self_obj,
zip_path,
speckit_version,
priority=10,
force=False,
*,
archive_file=None,
):
def fake_install(self_obj, zip_path, speckit_version, priority=10, force=False):
return SimpleNamespace(
id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[]
)
@@ -7762,10 +7516,8 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
patch.object(ExtensionManager, "install_from_zip", fake_install):
result = runner.invoke(
app,
@@ -7838,19 +7590,10 @@ class TestExtensionAddCLI:
downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
installed = {}
def fake_install_from_zip(
self_obj,
zip_path,
speckit_version,
priority=10,
force=False,
*,
archive_file=None,
):
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
captured_path = Path(zip_path)
installed["zip_path"] = captured_path
installed["zip_bytes"] = archive_file.read()
archive_file.seek(0)
installed["zip_bytes"] = captured_path.read_bytes()
return SimpleNamespace(
id="escape",
name="Escape Test",
@@ -7864,9 +7607,7 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
result = runner.invoke(
app,
@@ -7927,7 +7668,7 @@ class TestDownloadExtensionBundled:
}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(_MINIMAL_ZIP_BYTES).read
mock_response.read.side_effect = io.BytesIO(b"fake zip data").read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
-138
View File
@@ -11,7 +11,6 @@ See proposals/monorepo-support and github/spec-kit discussion #2834.
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
@@ -31,11 +30,6 @@ HAS_PWSH = shutil.which("pwsh") is not None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
_PS_EXE = "pwsh" if HAS_PWSH else _POWERSHELL
# Windows PowerShell 5.1 (.NET Framework) specifically, never pwsh: the only
# host that lacks the .NET Core-only [System.IO.Path] members, so it is the only
# one that can pin a 5.1 compatibility regression (issue #3749).
_WINDOWS_POWERSHELL = _POWERSHELL if os.name == "nt" else None
def _clean_env() -> dict[str, str]:
"""Inherited env minus all SPECIFY_* vars, so a developer/CI override
@@ -107,10 +101,6 @@ requires_pwsh = pytest.mark.skipif(
not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available"
)
requires_windows_powershell = pytest.mark.skipif(
_WINDOWS_POWERSHELL is None, reason="Windows PowerShell 5.1 not available"
)
# ── Bash: positive cases ────────────────────────────────────────────────────
@@ -475,131 +465,3 @@ def test_ps_file_path_errors_no_fallback(tmp_path: Path) -> None:
result = _ps("Get-RepoRoot", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
# ── Windows PowerShell 5.1 compatibility (issue #3749) ──────────────────────
#
# The CI matrix runs these PowerShell tests under `pwsh` on every OS, and pwsh
# is .NET Core, so a .NET Framework-only regression is invisible to it. The
# static test below therefore runs everywhere and is the one that actually
# guards CI; the runtime tests pin the real behavior where a 5.1 host exists.
# .NET Core-only [System.IO.Path] members. Absent on .NET Framework, so calling
# one throws "does not contain a method named ..." on Windows PowerShell 5.1.
_DOTNET_CORE_ONLY_PATH_MEMBERS = (
"TrimEndingDirectorySeparator",
"EndsInDirectorySeparator",
"GetRelativePath",
"Join",
)
@pytest.mark.parametrize("member", _DOTNET_CORE_ONLY_PATH_MEMBERS)
def test_shipped_ps1_avoids_dotnet_core_only_path_members(member: str) -> None:
"""No shipped .ps1 may call a .NET Core-only [System.IO.Path] member.
Windows PowerShell 5.1 ships on every Windows box and runs on .NET
Framework, where these members do not exist. A call is not a graceful
degradation but a hard "Method invocation failed" at the call site, which
for a root resolver aborts the command before it starts.
Runs on all platforms because the CI matrix only has pwsh (.NET Core),
where such a call works fine -- so this static check is what keeps CI able
to catch the regression at all.
"""
# Anchored to the Path type: [string]::Join and other same-named members on
# .NET Framework types are unaffected and must not be flagged.
pattern = re.compile(
r"\[(?:System\.IO\.)?Path\]::" + re.escape(member) + r"\s*\(",
re.IGNORECASE,
)
offenders = []
for ps1 in sorted(PROJECT_ROOT.glob("scripts/powershell/*.ps1")) + sorted(
PROJECT_ROOT.glob("extensions/*/scripts/powershell/*.ps1")
):
for lineno, line in enumerate(
ps1.read_text(encoding="utf-8").splitlines(), start=1
):
code = line.split("#", 1)[0]
if pattern.search(code):
offenders.append(f"{ps1.relative_to(PROJECT_ROOT)}:{lineno}")
assert not offenders, (
f"[System.IO.Path]::{member}() is .NET Core only and throws on Windows "
f"PowerShell 5.1; found at {offenders}. Use a .NET Framework-safe "
f"equivalent (e.g. TrimEnd('/', '\\') for a trailing separator)."
)
@requires_windows_powershell
def test_ps51_init_dir_resolves(tmp_path: Path) -> None:
"""SPECIFY_INIT_DIR must resolve under Windows PowerShell 5.1 (issue #3749).
Before the fix, Resolve-SpecifyInitDir called the .NET Core-only
[System.IO.Path]::TrimEndingDirectorySeparator, so every 5.1 invocation
threw at that line -- root resolution failed before the requested command
ran, and $initRoot stayed $null so the very next Join-Path threw too.
"""
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = subprocess.run(
[_WINDOWS_POWERSHELL, "-NoProfile", "-Command", f'. "{COMMON_PS}"; Get-RepoRoot'],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env=env,
)
assert "does not contain a method named" not in result.stderr, result.stderr
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_windows_powershell
def test_ps51_init_dir_trailing_separator_trimmed(tmp_path: Path) -> None:
"""The 5.1-safe trim must still strip a trailing separator, for bash parity.
Resolve-Path echoes back the input's trailing separator; the bash resolver's
`cd && pwd` never yields one, so the two must agree.
"""
web = _make_project(tmp_path, "web")
for suffix in ("/", "\\"):
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}{suffix}"}
result = subprocess.run(
[
_WINDOWS_POWERSHELL,
"-NoProfile",
"-Command",
f'. "{COMMON_PS}"; Get-RepoRoot',
],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_drive_root_reports_root_not_bare_drive(tmp_path: Path) -> None:
"""A path that IS its own root must survive the trim intact.
A bare TrimEnd('/', '\\') -- the obvious 5.1-safe swap -- turns 'C:\\' into
'C:', which is not the drive root but a drive-relative reference that every
later path API re-resolves against the *current directory*. Validation would
then probe the wrong tree entirely and, on a cwd that happens to contain
.specify/, silently accept 'C:' as the project root. The drive root
normally has no .specify/, so assert on the error naming the intact root.
"""
root = Path(tmp_path.anchor or "/")
if (root / ".specify").exists():
pytest.skip("filesystem root is itself a Spec Kit project")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(root)}
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
assert result.returncode != 0
assert "not a Spec Kit project" in result.stderr
# The error echoes the resolved root, so it pins what the trim produced:
# 'C:\' (or '/') intact, never the bare 'C:' (or '') a naive TrimEnd leaves.
reported = result.stderr.replace("\r", "").rstrip("\n").split("directory): ", 1)[-1]
assert reported == str(root)
+2 -463
View File
@@ -14,7 +14,6 @@ import pytest
import io
import json
import tempfile
import tarfile
import shutil
import warnings
import zipfile
@@ -671,27 +670,6 @@ class TestPresetManager:
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_zip_forwards_force(
self, project_dir, pack_dir, temp_dir
):
"""The compatibility wrapper must retain forced reinstall behavior."""
zip_path = temp_dir / "test-pack.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for file_path in pack_dir.rglob("*"):
if file_path.is_file():
zf.write(file_path, file_path.relative_to(pack_dir))
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manifest = manager.install_from_zip(
zip_path,
"0.1.5",
force=True,
)
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):
"""Test installing from ZIP with nested directory."""
zip_path = temp_dir / "test-pack.zip"
@@ -737,45 +715,6 @@ class TestPresetManager:
assert not manager.registry.is_installed("test-pack")
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
@pytest.mark.parametrize("nested", [False, True])
def test_install_from_tar_archive(
self, project_dir, pack_dir, temp_dir, suffix, nested
):
"""Tar archives install with the same flat/nested behavior as ZIP."""
archive_path = temp_dir / f"test-pack{suffix}"
with tarfile.open(archive_path, "w:gz") as archive:
for file_path in pack_dir.rglob("*"):
if file_path.is_file():
relative = file_path.relative_to(pack_dir)
arcname = Path("test-pack-v1") / relative if nested else relative
archive.add(file_path, arcname=arcname)
manager = PresetManager(project_dir)
manifest = manager.install_from_archive(archive_path, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_tar_rejects_symlink_entry(
self, project_dir, pack_dir, temp_dir
):
archive_path = temp_dir / "symlink-preset.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
for file_path in pack_dir.rglob("*"):
if file_path.is_file():
archive.add(file_path, arcname=file_path.relative_to(pack_dir))
link = tarfile.TarInfo("templates/escape")
link.type = tarfile.SYMTYPE
link.linkname = "../../outside"
archive.addfile(link)
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="Unsafe symlink"):
manager.install_from_archive(archive_path, "0.1.5")
assert not manager.registry.is_installed("test-pack")
def test_remove(self, project_dir, pack_dir):
"""Test removing a preset."""
manager = PresetManager(project_dir)
@@ -2729,39 +2668,6 @@ class TestPresetCatalog:
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].get_header("Accept") == "application/octet-stream"
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
def test_download_pack_preserves_tar_archive_format(
self, project_dir, suffix
):
from unittest.mock import patch, MagicMock
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
content = b"preset:\n id: test-pack\n"
member = tarfile.TarInfo("preset.yml")
member.size = len(content)
archive.addfile(member, io.BytesIO(content))
archive_bytes = archive_buffer.getvalue()
response = MagicMock()
response.read.side_effect = io.BytesIO(archive_bytes).read
response.__enter__.return_value = response
response.__exit__.return_value = False
catalog = PresetCatalog(project_dir)
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": f"https://example.com/test-pack{suffix}",
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=response):
archive_path = catalog.download_pack("test-pack", target_dir=project_dir)
assert archive_path.name == "test-pack-1.0.0.tar.gz"
assert archive_path.read_bytes() == archive_bytes
# ===== Integration Tests =====
@@ -2920,80 +2826,6 @@ class TestPresetCatalogMultiCatalog:
assert "https://example.com/[cat].json" in result.output
assert "desc [with] brackets" in result.output
def test_catalog_add_escapes_rich_markup(self, project_dir):
"""`preset catalog add` must not parse the name/url as Rich markup.
An unbalanced closing tag raised MarkupError *after* the entry was
already written to preset-catalogs.yml, so the user saw a traceback
and no confirmation for a catalog that had in fact been added.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
name = "[/red]my-catalog"
url = "https://example.com/[bold]c.json"
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["preset", "catalog", "add", url, "--name", name]
)
assert result.exit_code == 0, result.output
# Rendered verbatim, not swallowed as markup.
assert name in result.output
assert url in result.output
# Only rendering is escaped: the raw values still round-trip to disk.
config = yaml.safe_load(
(project_dir / ".specify" / "preset-catalogs.yml").read_text(
encoding="utf-8"
)
)
assert config["catalogs"][0]["name"] == name
assert config["catalogs"][0]["url"] == url
def test_catalog_remove_escapes_rich_markup(self, project_dir):
"""`preset catalog remove` must not parse the name as Rich markup."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
name = "[/red]my-catalog"
(project_dir / ".specify" / "preset-catalogs.yml").write_text(
yaml.dump({
"catalogs": [
{
"name": name,
"url": "https://example.com/c.json",
"priority": 1,
"install_allowed": False,
}
]
}),
encoding="utf-8",
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "catalog", "remove", name])
assert result.exit_code == 0, result.output
assert name in result.output
def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir):
"""The not-found error path renders the name too."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
(project_dir / ".specify" / "preset-catalogs.yml").write_text(
yaml.dump({"catalogs": []}), encoding="utf-8"
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["preset", "catalog", "remove", "[/red]absent"]
)
assert result.exit_code == 1
assert "[/red]absent" in result.output
def test_env_var_overrides_catalogs(self, project_dir, monkeypatch):
"""Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults."""
monkeypatch.setenv(
@@ -10178,7 +10010,7 @@ class TestBundledPresetLocator:
self.read_sizes.append(size)
return super().read(size)
response = FakeResponse(b"PK\x05\x06" + b"\x00" * 18)
response = FakeResponse(b"zip-bytes")
installed = {}
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
@@ -10199,7 +10031,7 @@ class TestBundledPresetLocator:
assert response.read_sizes
assert installed == {
"zip_bytes": b"PK\x05\x06" + b"\x00" * 18,
"zip_bytes": b"zip-bytes",
"speckit_version": "0.6.0",
"priority": 7,
}
@@ -12377,296 +12209,3 @@ class TestPresetCatalogRichMarkup:
):
value = self.MARKUP_PRESET[field]
assert value in output
# Tags are joined into a single line, so assert on the rendered join.
assert ", ".join(self.MARKUP_PRESET["tags"]) in output
class TestInstalledPresetRichMarkup:
"""Locally installed preset metadata must render as literal text.
``preset.yml`` is user-editable, so its fields can contain ``[...]``.
``TestPresetCatalogRichMarkup`` covers the catalog branch of these
commands; the installed-preset branch of ``preset list``/``preset info``
and all of ``preset resolve`` were left unescaped, so a field like
``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an
unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``,
aborting the command with a traceback.
"""
MARKUP_FIELDS = {
"name": "[green]Markup Name[/green]",
"version": "1.0.0",
"description": "[yellow]Markup Description[/yellow]",
"author": "[magenta]Markup Author[/magenta]",
"repository": "[bold]Markup Repository[/bold]",
"license": "[cyan]Markup License[/cyan]",
}
def _install(self, temp_dir, project_dir, preset_overrides=None, strategy=None,
pack_id="markup-pack", priority=10, tmpl_description=None):
"""Install a preset from a directory built with the given manifest fields."""
from specify_cli.presets import PresetManager
src = temp_dir / f"src-{pack_id}"
(src / "templates").mkdir(parents=True)
(src / "templates" / "spec-template.md").write_text("# tmpl\n")
preset_section = {
"id": pack_id,
"name": pack_id,
"version": "1.0.0",
"description": "plain description",
}
preset_section.update(preset_overrides or {})
tmpl = {
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
}
if tmpl_description is not None:
tmpl["description"] = tmpl_description
if strategy:
tmpl["strategy"] = strategy
(src / "preset.yml").write_text(yaml.dump({
"schema_version": "1.0",
"preset": preset_section,
"requires": {"speckit_version": ">=0.0.1"},
"provides": {"templates": [tmpl]},
"tags": ["[italic]markup-tag[/italic]"],
}))
manager = PresetManager(project_dir)
manager.install_from_directory(src, "9.9.9", priority)
return manager
def _invoke(self, project_dir, args):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
with patch.object(Path, "cwd", return_value=project_dir):
return CliRunner().invoke(app, args)
def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir):
"""Every ``preset.yml`` field must survive verbatim in list/info output."""
self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS)
for args in (["preset", "list"], ["preset", "info", "markup-pack"]):
result = self._invoke(project_dir, args)
assert result.exit_code == 0, result.output
output = " ".join(strip_ansi(result.output).split())
# `preset list` does not render repository/license.
fields = ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS
for field in fields:
assert self.MARKUP_FIELDS[field] in output, (field, args, output)
assert "[italic]markup-tag[/italic]" in output, (args, output)
def test_info_does_not_swallow_template_description(self, temp_dir, project_dir):
"""The per-template line in ``preset info`` must escape the template description.
``name``/``type`` are format-restricted by manifest validation, but
``description`` is free-form, so it is the field that can carry markup.
"""
self._install(
temp_dir,
project_dir,
tmpl_description="Template [desc] here",
)
result = self._invoke(project_dir, ["preset", "info", "markup-pack"])
assert result.exit_code == 0, result.output
output = " ".join(strip_ansi(result.output).split())
assert "spec-template (template): Template [desc] here" in output, output
def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir):
"""An unbalanced tag must not raise MarkupError and abort the command."""
self._install(
temp_dir,
project_dir,
preset_overrides={"description": "Broken [/red] tag"},
)
for args in (["preset", "list"], ["preset", "info", "markup-pack"]):
result = self._invoke(project_dir, args)
assert result.exit_code == 0, (args, result.output, result.exception)
assert "Broken [/red] tag" in strip_ansi(result.output)
def test_resolve_escapes_template_name(self, project_dir):
"""``preset resolve`` echoes its argument; an unbalanced tag must not crash."""
result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"])
assert result.exit_code == 0, (result.output, result.exception)
assert "no[/red]such" in strip_ansi(result.output)
def test_resolve_escapes_layer_path_and_source(self, project_dir):
"""The top-layer path/source lines must render markup literally.
A preset can be installed from any directory, so the resolved path can
contain ``[...]``; the layer source carries the pack id and version.
"""
from unittest.mock import patch
from specify_cli.presets import PresetResolver
# A closing tag cannot live inside a path segment: `Path` treats its
# `/` as a separator on POSIX and rewrites it to `\` on Windows. The
# opening tag covers the swallowing case for the path; the unbalanced
# closing tag rides on `source`, which is a plain string.
layer = {
"path": Path("/tmp/[red]dir/spec-template.md"),
"source": "pack [/red] v1.0.0",
"strategy": "replace",
}
with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]):
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
assert result.exit_code == 0, (result.output, result.exception)
output = " ".join(strip_ansi(result.output).split())
assert "[red]dir" in output, output
assert "pack [/red] v1.0.0" in output, output
def test_resolve_escapes_fallback_path_and_source(self, project_dir):
"""The no-layer fallback branch must escape ``resolve_with_source`` output."""
from unittest.mock import patch
from specify_cli.presets import PresetResolver
with patch.object(
PresetResolver, "collect_all_layers", return_value=[]
), patch.object(
PresetResolver,
"resolve_with_source",
return_value={
"path": "/tmp/[blue]fallback[/blue]/spec-template.md",
"source": "fallback [/red] source",
},
):
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
assert result.exit_code == 0, (result.output, result.exception)
output = " ".join(strip_ansi(result.output).split())
assert "[blue]fallback[/blue]" in output, output
assert "fallback [/red] source" in output, output
def test_resolve_escapes_composition_error(self, project_dir):
"""A composition exception message must not be parsed as markup."""
from unittest.mock import patch
from specify_cli.presets import PresetResolver
layers = [
{
"path": Path("/tmp/top/spec-template.md"),
"source": "top-pack v1.0.0",
"strategy": "append",
},
{
"path": Path("/tmp/base/spec-template.md"),
"source": "base-pack v1.0.0",
"strategy": "append",
},
]
with patch.object(
PresetResolver, "collect_all_layers", return_value=layers
), patch.object(
PresetResolver,
"resolve_content",
side_effect=RuntimeError("compose failed: [/red] bad layer"),
):
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
assert result.exit_code == 0, (result.output, result.exception)
output = " ".join(strip_ansi(result.output).split())
assert "compose failed: [/red] bad layer" in output, output
def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir):
"""The composition chain's ``[<strategy>]`` label must not be eaten as a tag."""
self._install(temp_dir, project_dir, strategy="replace",
pack_id="base-pack", priority=20)
self._install(temp_dir, project_dir, strategy="append",
pack_id="app-pack", priority=5)
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
assert result.exit_code == 0, (result.output, result.exception)
output = strip_ansi(result.output)
assert "Composition chain" in output, output
assert "[base]" in output, output
assert "[append]" in output, output
class TestConstitutionSyncPreset:
"""The bundled opt-in ``constitution-sync`` preset re-adds propagation.
Follow-up to #3790: core ``/constitution`` no longer propagates guidance
into templates. This preset restores that behavior for teams that treat
materialized templates as reviewed artifacts, delivered as a ``wrap`` of
the core command so it stays forward-compatible with core changes.
"""
PRESET_DIR = Path(__file__).parent.parent / "presets" / "constitution-sync"
def test_manifest_provides_wrap_of_constitution(self):
manifest = yaml.safe_load((self.PRESET_DIR / "preset.yml").read_text())
assert manifest["preset"]["id"] == "constitution-sync"
entries = manifest["provides"]["templates"]
assert len(entries) == 1
entry = entries[0]
assert entry["type"] == "command"
assert entry["name"] == "speckit.constitution"
assert entry["strategy"] == "wrap"
# Must target the post-#3790 baseline so propagation is not double-applied.
assert manifest["requires"]["speckit_version"] == ">=0.14.4"
def test_wrapper_uses_core_template_and_propagates(self):
text = (self.PRESET_DIR / "commands" / "speckit.constitution.md").read_text()
# Parse the Markdown frontmatter as YAML rather than substring-matching,
# so `strategy: wrap` is asserted structurally (not as text that could
# appear in the body) and {CORE_TEMPLATE} is asserted in the body only.
assert text.startswith("---\n")
_, frontmatter_block, body = text.split("---", 2)
frontmatter = yaml.safe_load(frontmatter_block)
assert frontmatter["strategy"] == "wrap"
assert "{CORE_TEMPLATE}" in body
assert "strategy: wrap" not in body # only in frontmatter
# The three governed scaffolds the old checklist propagated into.
assert "plan-template.md" in body
assert "spec-template.md" in body
assert "tasks-template.md" in body
# Must not mutate versioned preset/extension artifacts.
assert "Do not edit versioned preset- or extension-provided template or command files" in body
def test_catalog_lists_bundled_preset(self):
manifest = yaml.safe_load((self.PRESET_DIR / "preset.yml").read_text())
catalog = json.loads((self.PRESET_DIR.parent / "catalog.json").read_text())
entry = catalog["presets"]["constitution-sync"]
assert entry["bundled"] is True
assert entry["version"] == manifest["preset"]["version"]
assert entry["provides"]["commands"] == 1
assert entry["provides"]["templates"] == 0
def test_wrap_composes_over_core_constitution(self, project_dir):
"""Installing the preset yields a wrap layer atop the bundled core."""
manager = PresetManager(project_dir)
manager.install_from_directory(self.PRESET_DIR, "0.15.0")
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("speckit.constitution", "command")
assert len(layers) >= 2, "expected preset wrap layer plus a core base"
assert layers[0]["strategy"] == "wrap"
assert any("constitution-sync" in str(layer["path"]) for layer in layers)
assert layers[-1]["source"] == "core (bundled)"
def test_resolved_content_embeds_core_and_sync_pass(self, project_dir):
"""resolve_content substitutes {CORE_TEMPLATE} so the effective command
contains both the bundled core body and the propagation pass."""
manager = PresetManager(project_dir)
manager.install_from_directory(self.PRESET_DIR, "0.15.0")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("speckit.constitution", "command")
assert content is not None
# {CORE_TEMPLATE} must be replaced, not left literal.
assert "{CORE_TEMPLATE}" not in content
# Core body is present (distinctive core-only heading).
assert "## Scope Guard" in content
# The wrapper's propagation pass is present and supersedes the guard.
assert "## Constitution Template Sync" in content
assert "supersedes the \"Scope Guard\" above" in content
assert "plan-template.md" in content
+5 -21
View File
@@ -30,7 +30,7 @@ LOCAL_REFRESH_TEST_EXTRA_DEPS = (
f"--quiet --no-header --output-file {COMMITTED_AUDIT_REQUIREMENTS}"
)
WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS = (
"uv pip compile pyproject.toml --extra test --universal --generate-hashes "
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
"--quiet --no-header --output-file"
)
WORKFLOW_SYNC_SCRIPT = "python .github/scripts/check_security_requirements.py"
@@ -99,9 +99,7 @@ class TestDependencyAuditWorkflow:
assert sync_check["env"]["DEPENDENCY_DIFF_BASE"] == (
"${{ github.event.pull_request.base.sha || github.event.before || '' }}"
)
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == (
"${{ github.event.pull_request.head.sha || github.sha }}"
)
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == "${{ github.sha }}"
assert sync_check["run"] == WORKFLOW_SYNC_SCRIPT
assert committed_audit["run"] == LOCAL_PIP_AUDIT
@@ -241,14 +239,10 @@ class TestDependencyAuditWorkflow:
def test_sync_script_skips_when_dependency_inputs_are_unchanged(self, monkeypatch, capsys):
sync_script = _load_sync_script()
commands = []
def fake_run(command, **kwargs):
commands.append(command)
if command[:2] == ["git", "merge-base"]:
return subprocess.CompletedProcess(command, 0, stdout="base123\n", stderr="")
assert command == [
"git", "diff", "--name-only", "base123", "HEAD", "--",
"git", "diff", "--name-only", "HEAD^", "HEAD", "--",
"pyproject.toml", ".github/security-audit-requirements.txt",
]
assert kwargs["check"] is True
@@ -257,21 +251,16 @@ class TestDependencyAuditWorkflow:
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
assert sync_script.main() == 0
assert commands[0] == ["git", "merge-base", "HEAD^", "HEAD"]
assert "sync check skipped" in capsys.readouterr().out
def test_sync_script_uses_github_diff_refs_when_available(self, monkeypatch):
sync_script = _load_sync_script()
monkeypatch.setenv("DEPENDENCY_DIFF_BASE", "abc123")
monkeypatch.setenv("DEPENDENCY_DIFF_HEAD", "def456")
commands = []
def fake_run(command, **_kwargs):
commands.append(command)
if command[:2] == ["git", "merge-base"]:
return subprocess.CompletedProcess(command, 0, stdout="merge123\n", stderr="")
assert command == [
"git", "diff", "--name-only", "merge123", "def456", "--",
"git", "diff", "--name-only", "abc123", "def456", "--",
"pyproject.toml", ".github/security-audit-requirements.txt",
]
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
@@ -279,7 +268,6 @@ class TestDependencyAuditWorkflow:
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
assert sync_script._dependency_inputs_changed() is False
assert commands[0] == ["git", "merge-base", "abc123", "def456"]
def test_sync_script_compiles_and_compares_when_dependency_inputs_changed(
self, monkeypatch, tmp_path
@@ -296,13 +284,10 @@ class TestDependencyAuditWorkflow:
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
def fake_run(command, **kwargs):
if command[:2] == ["git", "merge-base"]:
return subprocess.CompletedProcess(command, 0, stdout="base123\n", stderr="")
if command[:2] == ["git", "diff"]:
if command[0] == "git":
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
compile_commands.append(command)
assert kwargs["check"] is True
assert generated_requirements.read_text(encoding="utf-8") == "pytest==1\n"
generated_requirements.write_text("pytest==1\n", encoding="utf-8")
return subprocess.CompletedProcess(command, 0)
@@ -312,7 +297,6 @@ class TestDependencyAuditWorkflow:
assert len(compile_commands) == 1
compile_command = " ".join(compile_commands[0])
assert WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS in compile_command
assert "--upgrade" not in compile_commands[0]
assert "--output-file" in compile_commands[0]
assert str(generated_requirements) in compile_commands[0]
-20
View File
@@ -472,26 +472,6 @@ class TestTagValidation:
output = strip_ansi(result.output)
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
def test_rejection_message_keeps_the_suffix_token(
self, uv_tool_argv0, clean_environ
):
"""Rich must not swallow the literal `[suffix]`.
Unescaped it is parsed as a style tag and dropped, so the user is told
only "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
accepted form, when -rc1 / .dev0 / +build.42 are all valid and are
documented as such in docs/upgrade.md and README.md.
"""
result = runner.invoke(app, ["self", "upgrade", "--tag", "latest"])
assert result.exit_code == 1
assert "expected vMAJOR.MINOR.PATCH[suffix]" in strip_ansi(result.output)
def test_tag_option_help_keeps_the_suffix_token(self):
"""Typer renders option help through Rich, so `--help` dropped it too."""
result = runner.invoke(app, ["self", "upgrade", "--help"])
assert result.exit_code == 0
assert "[suffix]" in strip_ansi(result.output)
class TestUnknownCurrent:
"""'unknown' current version renders literally in notice and success message."""
+3 -1516
View File
File diff suppressed because it is too large Load Diff
-119
View File
@@ -20,7 +20,6 @@ from specify_cli.bundler.services.primitives import (
_WorkflowKindManager,
primitive_manager,
)
from tests.bundler_helpers import valid_manifest_dict
def _component(kind: str, cid: str = "x") -> ComponentRef:
@@ -216,121 +215,3 @@ def test_bundled_preset_pin_match_installs(tmp_path: Path, monkeypatch):
manager.install(ComponentRef(kind="presets", id="my-preset", version="1.0.0"))
manager.install(ComponentRef(kind="presets", id="my-preset", version=None))
assert len(called) == 2
def test_extension_refresh_calls_install_with_force(tmp_path: Path, monkeypatch):
"""_ExtensionKindManager.refresh() must pass force=True to install_from_directory
so an already-installed extension is overwritten instead of raising an error."""
import specify_cli._assets as assets
from specify_cli.extensions import ExtensionManager
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
force_values: list = []
monkeypatch.setattr(
ExtensionManager, "install_from_directory",
lambda self, *a, **k: force_values.append(k.get("force", False)),
)
manager = primitive_manager("extensions", tmp_path, allow_network=False)
manager.refresh(ComponentRef(kind="extensions", id="my-ext"))
assert force_values == [True], "refresh() must pass force=True"
def test_preset_refresh_calls_install_with_force(tmp_path: Path, monkeypatch):
"""_PresetKindManager.refresh() must pass force=True to install_from_directory
so an already-installed preset is overwritten instead of raising an error."""
import specify_cli._assets as assets
from specify_cli.presets import PresetManager
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
force_values: list = []
monkeypatch.setattr(
PresetManager, "install_from_directory",
lambda self, *a, **k: force_values.append(k.get("force", False)),
)
manager = primitive_manager("presets", tmp_path, allow_network=False)
manager.refresh(ComponentRef(kind="presets", id="my-preset"))
assert force_values == [True], "refresh() must pass force=True"
def test_default_installer_refresh_dispatches_to_kind_manager(tmp_path: Path, monkeypatch):
"""DefaultPrimitiveInstaller.refresh() must call the kind manager's refresh(),
which is the hook _refresh_component() will find — fixing the --force leak."""
import specify_cli._assets as assets
from specify_cli.extensions import ExtensionManager
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
force_values: list = []
monkeypatch.setattr(
ExtensionManager, "install_from_directory",
lambda self, *a, **k: force_values.append(k.get("force", False)),
)
installer = DefaultPrimitiveInstaller(allow_network=False)
installer.refresh(tmp_path, _component("extensions", "my-ext"))
assert force_values == [True], "DefaultPrimitiveInstaller.refresh() must use force=True"
def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch):
"""Regression: bundle update (refresh=True) of an already-installed extension
must succeed and pass force=True to install_from_directory."""
from specify_cli.bundler.services.installer import install_bundle
from specify_cli.bundler.models.manifest import BundleManifest
import specify_cli._assets as assets
from specify_cli.extensions import ExtensionManager
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
# Simulate refresh succeeding (force=True removes the duplicate-install guard)
force_seen: list = []
def _fake_install_from_directory(self, *a, **k):
force_seen.append(k.get("force", False))
self.registry.add("my-ext", {"version": "1.0.0"})
monkeypatch.setattr(
ExtensionManager, "install_from_directory", _fake_install_from_directory
)
raw = valid_manifest_dict(
bundle={
"id": "test-bundle",
"name": "Test",
"version": "1.0.0",
"role": "developer",
"description": "Test bundle",
"author": "Spec Kit",
"license": "MIT",
},
provides={
"extensions": [{"id": "my-ext", "version": "1.0.0"}],
"presets": [],
"steps": [],
"workflows": [],
},
)
manifest = BundleManifest.from_dict(raw)
installer = DefaultPrimitiveInstaller(allow_network=False)
# First install
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
# Refresh (bundle update) — must not raise with --force hint
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True)
# force=True must have been passed during the refresh call
assert True in force_seen, "refresh path should have called install_from_directory with force=True"
def _plan(manifest):
from specify_cli.bundler.services.installer import InstallPlan
from specify_cli.bundler.models.manifest import ComponentRef as CR
components = [CR(kind=c.kind, id=c.id) for c in manifest.components]
return InstallPlan(
bundle_id=manifest.bundle.id,
version=manifest.bundle.version,
role=manifest.bundle.role,
effective_integration=None,
components=components,
)
-80
View File
@@ -509,86 +509,6 @@ class TestOverlayCli:
assert payload["layers"][-1]["tier"] == "base"
assert payload["layers"][-1]["priority"] is None
def test_workflow_resolve_prints_tier_labels(self, project_dir, monkeypatch):
"""Layer tiers render literally; an unescaped ``[base]`` is eaten as markup."""
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
_write_workflow(
project_dir,
"wf",
{
"schema_version": "1.0",
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
"steps": [{"id": "a", "type": "command", "command": "echo"}],
},
)
_write_overlay(
project_dir,
"wf",
"ov1",
{
"id": "ov1",
"extends": "wf",
"priority": 10,
"edits": [
{
"operation": "insert_after",
"anchor": "a",
"step": {"id": "new", "type": "command", "command": "echo"},
}
],
},
)
result = runner.invoke(app, ["workflow", "resolve", "wf"])
assert result.exit_code == 0, result.output
assert "[base]" in result.output
assert "[project-overlay]" in result.output
@pytest.mark.parametrize(
"step_id",
[
# Balanced tag: silently swallowed, so the step vanishes from output.
"new[stuff]",
# Unbalanced closer: raises MarkupError -> traceback and exit 1.
"new[/red]",
],
)
def test_workflow_resolve_escapes_rich_markup_in_step_id(
self, project_dir, monkeypatch, step_id
):
"""Step IDs are unvalidated for brackets, so they must be escaped."""
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
_write_workflow(
project_dir,
"wf",
{
"schema_version": "1.0",
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
"steps": [{"id": "a", "type": "command", "command": "echo"}],
},
)
_write_overlay(
project_dir,
"wf",
"ov1",
{
"id": "ov1",
"extends": "wf",
"priority": 10,
"edits": [
{
"operation": "insert_after",
"anchor": "a",
"step": {"id": step_id, "type": "command", "command": "echo"},
}
],
},
)
result = runner.invoke(app, ["workflow", "resolve", "wf"])
assert result.exit_code == 0, result.output
assert step_id in result.output
def test_workflow_resolve_equal_priority_layers_sort_by_source(self, project_dir, monkeypatch):
"""Equal-priority overlays are listed alphabetically by source."""
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
+1 -24
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-29T00:00:00Z",
"updated_at": "2026-07-22T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
"workflows": {
"pipeline": {
@@ -22,29 +22,6 @@
],
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
},
"yolo": {
"id": "yolo",
"name": "Full SDD Cycle - no gates",
"description": "Runs specify → plan → tasks → implement without review gates",
"author": "clintcparker",
"version": "0.1.0",
"url": "https://raw.githubusercontent.com/clintcparker/speckit-addons/yolo-v0.1.0/workflows/yolo/workflow.yml",
"repository": "https://github.com/clintcparker/speckit-addons",
"documentation": "https://github.com/clintcparker/speckit-addons/blob/yolo-v0.1.0/workflows/yolo/README.md",
"changelog": "https://github.com/clintcparker/speckit-addons/blob/yolo-v0.1.0/workflows/yolo/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.12"
},
"tags": [
"sdd",
"full-cycle",
"no-gates",
"automation"
],
"created_at": "2026-07-29T00:00:00Z",
"updated_at": "2026-07-29T00:00:00Z"
}
}
}