Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
9405c845ee chore: bump version to 0.10.1 2026-06-09 22:11:50 +00:00
16 changed files with 28 additions and 934 deletions

View File

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

View File

@@ -2,21 +2,6 @@
<!-- insert new changelog below this comment --> <!-- insert new changelog below this comment -->
## [0.10.2] - 2026-06-11
### Changed
- Add Research Harness extension to community catalog (#2935)
- Add Coding Standards Drift Control extension to community catalog (#2934)
- Add Spec Trace extension to community catalog (#2527)
- fix(extensions): preserve argument-hint in extension Claude SKILL.md (#2916)
- fix(presets): harden preset URL installs against unsafe redirects (#2911)
- fix: skip recovered files during refresh_managed overwrite check (#2918) (#2919)
- Update multi-model-review extension to v0.1.1 (#2900)
- feat: add category and effect as first-class fields in extension schema (#2899)
- chore(catalog): add Jira Integration (Sync Engine) extension (#2895)
- chore: release 0.10.1, begin 0.10.2.dev0 development (#2910)
## [0.10.1] - 2026-06-09 ## [0.10.1] - 2026-06-09
### Changed ### Changed

View File

@@ -7,7 +7,7 @@
The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json): The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):
**Categories** (common values, but any string is allowed): **Categories:**
- `docs` — reads, validates, or generates spec artifacts - `docs` — reads, validates, or generates spec artifacts
- `code` — reviews, validates, or modifies source code - `code` — reviews, validates, or modifies source code
@@ -15,13 +15,10 @@ The following community-contributed extensions are available in [`catalog.commun
- `integration` — syncs with external platforms - `integration` — syncs with external platforms
- `visibility` — reports on project health or progress - `visibility` — reports on project health or progress
**Effect** (canonical `extension.yml`/catalog values): **Effect:**
- `read-only` — produces reports without modifying files (displayed as `Read-only` in the table) - `Read-only` — produces reports without modifying files
- `read-write` — modifies files, creates artifacts, or updates specs (displayed as `Read+Write` in the table) - `Read+Write` — modifies files, creates artifacts, or updates specs
> [!TIP]
> Extension authors can declare `category` and `effect` in their `extension.yml` under the `extension:` block. These fields are also available in `catalog.community.json` for tooling and the CLI (`specify extension info`).
| Extension | Purpose | Category | Effect | URL | | Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----| |-----------|---------|----------|--------|-----|
@@ -44,7 +41,6 @@ The following community-contributed extensions are available in [`catalog.commun
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | | 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) | | Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) | | 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) |
@@ -59,7 +55,6 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) | | Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | 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 | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) | | Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) | | MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
@@ -93,7 +88,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) | | Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
| Research Harness | State-externalizing research harness: budgeted exploration, evidence curation, and claim verification for spec-driven development | `process` | Read+Write | [spec-kit-harness](https://github.com/formin/spec-kit-harness) |
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) | | Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
| Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) | | Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) |
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | | Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
@@ -113,7 +107,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) | | Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) | | Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -684,44 +684,16 @@ def preset_add(
elif from_url: elif from_url:
# Validate URL scheme before downloading # Validate URL scheme before downloading
from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse from urllib.parse import urlparse as _urlparse
_parsed = _urlparse(from_url) _parsed = _urlparse(from_url)
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
def _is_allowed_download_url(parsed_url): if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
host = parsed_url.hostname console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
if not host:
return False
is_loopback = host == "localhost"
if not is_loopback:
try:
is_loopback = ip_address(host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
def _validate_download_redirect(old_url, new_url):
if not _is_allowed_download_url(_urlparse(new_url)):
import urllib.error
raise urllib.error.URLError(
"redirect target must use HTTPS with a hostname, "
"or HTTP for localhost/loopback"
)
if not _is_allowed_download_url(_parsed):
console.print(
"[red]Error:[/red] URL must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
)
raise typer.Exit(1) raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
import urllib.error import urllib.error
import tempfile import tempfile
import shutil
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip" zip_path = Path(tmpdir) / "preset.zip"
@@ -735,25 +707,8 @@ def preset_add(
from_url = _resolved_from_url from_url = _resolved_from_url
_preset_extra_headers = {"Accept": "application/octet-stream"} _preset_extra_headers = {"Accept": "application/octet-stream"}
with _open_url( with _open_url(from_url, timeout=60, extra_headers=_preset_extra_headers) as response:
from_url, zip_path.write_bytes(response.read())
timeout=60,
extra_headers=_preset_extra_headers,
redirect_validator=_validate_download_redirect,
) as response:
final_url = response.geturl() if hasattr(response, "geturl") else from_url
if not _is_allowed_download_url(_urlparse(final_url)):
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
)
raise typer.Exit(1)
with zip_path.open("wb") as output:
try:
shutil.copyfileobj(response, output)
except TypeError:
output.write(response.read())
except urllib.error.URLError as e: except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {e}") console.print(f"[red]Error:[/red] Failed to download: {e}")
raise typer.Exit(1) raise typer.Exit(1)
@@ -1231,7 +1186,7 @@ def preset_catalog_add(
}) })
config["catalogs"] = catalogs config["catalogs"] = catalogs
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
install_label = "install allowed" if install_allowed else "discovery only" install_label = "install allowed" if install_allowed else "discovery only"
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
@@ -1271,7 +1226,7 @@ def preset_catalog_remove(
raise typer.Exit(1) raise typer.Exit(1)
config["catalogs"] = catalogs config["catalogs"] = catalogs
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
console.print(f"[green]✓[/green] Removed catalog '{name}'") console.print(f"[green]✓[/green] Removed catalog '{name}'")
if not catalogs: if not catalogs:
@@ -2020,11 +1975,7 @@ def extension_info(
author = ext_manifest.data.get("extension", {}).get("author") author = ext_manifest.data.get("extension", {}).get("author")
if author: if author:
console.print(f"[dim]Author:[/dim] {author}") console.print(f"[dim]Author:[/dim] {author}")
if ext_manifest.category: console.print()
console.print(f"[dim]Category:[/dim] {ext_manifest.category}")
if ext_manifest.effect:
console.print(f"[dim]Effect:[/dim] {ext_manifest.effect}")
console.print()
if ext_manifest.commands: if ext_manifest.commands:
console.print("[bold]Commands:[/bold]") console.print("[bold]Commands:[/bold]")
@@ -2074,12 +2025,6 @@ def _print_extension_info(ext_info: dict, manager):
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
# Category and Effect
if ext_info.get('category'):
console.print(f"[dim]Category:[/dim] {ext_info['category']}")
if ext_info.get('effect'):
console.print(f"[dim]Effect:[/dim] {ext_info['effect']}")
# Source catalog # Source catalog
if ext_info.get("_catalog_name"): if ext_info.get("_catalog_name"):
install_allowed = ext_info.get("_install_allowed", True) install_allowed = ext_info.get("_install_allowed", True)

View File

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

View File

@@ -41,8 +41,6 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
}) })
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
VALID_EFFECTS = frozenset({"read-only", "read-write"})
DEFAULT_HOOK_PRIORITY = 10 DEFAULT_HOOK_PRIORITY = 10
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
@@ -203,21 +201,6 @@ class ExtensionManifest:
except pkg_version.InvalidVersion: except pkg_version.InvalidVersion:
raise ValidationError(f"Invalid version: {ext['version']}") raise ValidationError(f"Invalid version: {ext['version']}")
# Validate optional category field (free-form string)
if "category" in ext:
if not isinstance(ext["category"], str) or not ext["category"].strip():
raise ValidationError(
"Invalid extension.category: must be a non-empty string"
)
# Validate optional effect field
if "effect" in ext:
if not isinstance(ext["effect"], str) or ext["effect"] not in VALID_EFFECTS:
raise ValidationError(
f"Invalid extension.effect '{ext.get('effect')}': "
f"must be one of {sorted(VALID_EFFECTS)}"
)
# Validate requires section # Validate requires section
requires = self.data["requires"] requires = self.data["requires"]
if "speckit_version" not in requires: if "speckit_version" not in requires:
@@ -391,16 +374,6 @@ class ExtensionManifest:
"""Get extension description.""" """Get extension description."""
return self.data["extension"]["description"] return self.data["extension"]["description"]
@property
def category(self) -> Optional[str]:
"""Get extension category (free-form; common values: docs, code, process, integration, visibility)."""
return self.data["extension"].get("category")
@property
def effect(self) -> Optional[str]:
"""Get extension effect (read-only, read-write)."""
return self.data["extension"].get("effect")
@property @property
def requires_speckit_version(self) -> str: def requires_speckit_version(self) -> str:
"""Get required spec-kit version range.""" """Get required spec-kit version range."""
@@ -1053,22 +1026,6 @@ class ExtensionManager:
description, description,
f"extension:{manifest.id}", f"extension:{manifest.id}",
) )
# Preserve the command's argument-hint in the generated skill,
# mirroring the core template path (ClaudeIntegration.setup injects
# it for built-in commands). The value is added to the frontmatter
# dict before serialization — rather than via the string-based
# inject_argument_hint helper — so that a folded multi-line
# description cannot be split by the inserted line. Gated on the
# integration exposing inject_argument_hint so only argument-hint
# aware agents receive the key, leaving build_skill_frontmatter's
# shared shape unchanged for every other agent.
argument_hint = frontmatter.get("argument-hint")
if (
argument_hint
and integration is not None
and hasattr(integration, "inject_argument_hint")
):
frontmatter_data["argument-hint"] = str(argument_hint)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
# Derive a human-friendly title from the command name # Derive a human-friendly title from the command name

View File

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

View File

@@ -1918,45 +1918,6 @@ class TestIntegrationSwitch:
assert "/speckit.plan" in updated assert "/speckit.plan" in updated
assert "/speckit-plan" not in updated assert "/speckit-plan" not in updated
def test_switch_preserves_recovered_files(self, tmp_path):
"""Regression for #2918: files marked recovered in the manifest are not overwritten.
When a file already exists on disk before init and is recorded with
``recovered=True``, ``integration use``/``switch`` must not treat it as
managed even when the on-disk hash matches the manifest hash.
"""
import hashlib
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert shared_script.is_file()
# Simulate a team-customized file that was recorded as recovered:
# write custom content, then update the manifest to record its hash
# with the recovered flag set.
custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
shared_script.write_bytes(custom_bytes)
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
rel = ".specify/scripts/bash/setup-tasks.sh"
manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
manifest_data.setdefault("recovered_files", []).append(rel)
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Recovered file must NOT be overwritten — team content preserved.
assert shared_script.read_bytes() == custom_bytes
def test_switch_skips_symlinked_parent_directory(self, tmp_path): def test_switch_skips_symlinked_parent_directory(self, tmp_path):
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it. """Regression: if .specify/scripts/bash is a symlink, switch must not write through it.

View File

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

View File

@@ -303,135 +303,6 @@ class TestExtensionSkillRegistration:
assert "description" in parsed assert "description" in parsed
assert parsed["disable-model-invocation"] is False assert parsed["disable-model-invocation"] is False
def test_argument_hint_preserved_for_extension_command(
self, skills_project, temp_dir
):
"""argument-hint from an extension command must survive into SKILL.md.
Regression for #2903: the field was dropped for extension-provided
commands while being kept for core template commands. The source
description is intentionally long so it folds across multiple lines
when serialized, guarding against an in-place string injection that
would split the folded scalar and produce invalid YAML.
"""
project_dir, skills_dir = skills_project
long_description = (
"Build and maintain a lean, static context/ knowledge folder so "
"coding agents load only what is relevant and save tokens"
)
arg_hint = "<init | update | list | check> [area] [slug] [-- notes]"
ext_dir = temp_dir / "hint-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hint-ext",
"name": "Hint Extension",
"version": "1.0.0",
"description": "Extension exercising argument-hint preservation",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.hint-ext.build-context",
"file": "commands/build-context.md",
"description": long_description,
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "build-context.md").write_text(
"---\n"
f'description: "{long_description}"\n'
f'argument-hint: "{arg_hint}"\n'
"---\n"
"\n"
"# Build Context\n"
"\n"
"Do the thing.\n"
"$ARGUMENTS\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
skill_file = skills_dir / "speckit-hint-ext-build-context" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
# Frontmatter must parse cleanly even though the description folds.
parts = content.split("---", 2)
assert len(parts) >= 3
parsed = yaml.safe_load(parts[1])
assert parsed["argument-hint"] == arg_hint
assert parsed["description"] == long_description
def test_argument_hint_not_added_for_non_claude_agent(self, project_dir, temp_dir):
"""argument-hint must stay Claude-only — other skills agents are untouched.
The hint is carried only for integrations that support it (currently
Claude, the sole integration defining inject_argument_hint). A non-Claude
skills agent such as kimi must keep the shared build_skill_frontmatter
shape (name/description/compatibility/metadata) with no argument-hint.
"""
_create_init_options(project_dir, ai="kimi", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="kimi")
arg_hint = "<init | update | list | check> [area]"
ext_dir = temp_dir / "hint-ext-kimi"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hint-ext-kimi",
"name": "Hint Extension Kimi",
"version": "1.0.0",
"description": "Extension exercising argument-hint gating",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.hint-ext-kimi.build-context",
"file": "commands/build-context.md",
"description": "Build context",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "build-context.md").write_text(
"---\n"
'description: "Build context"\n'
f'argument-hint: "{arg_hint}"\n'
"---\n"
"\n"
"# Build Context\n"
"\n"
"Do the thing.\n"
"$ARGUMENTS\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
skill_file = skills_dir / "speckit-hint-ext-kimi-build-context" / "SKILL.md"
assert skill_file.exists()
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
assert "argument-hint" not in parsed
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
"""No skills should be created when ai_skills is false.""" """No skills should be created when ai_skills is false."""
manager = ExtensionManager(no_skills_project) manager = ExtensionManager(no_skills_project)

View File

@@ -24,7 +24,6 @@ from specify_cli.extensions import (
CatalogEntry, CatalogEntry,
CORE_COMMAND_NAMES, CORE_COMMAND_NAMES,
DEFAULT_HOOK_PRIORITY, DEFAULT_HOOK_PRIORITY,
VALID_EFFECTS,
ExtensionManifest, ExtensionManifest,
ExtensionRegistry, ExtensionRegistry,
ExtensionManager, ExtensionManager,
@@ -301,69 +300,6 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Invalid version"): with pytest.raises(ValidationError, match="Invalid version"):
ExtensionManifest(manifest_path) ExtensionManifest(manifest_path)
def test_valid_category(self, temp_dir, valid_manifest_data):
"""Test manifest with various category values (free-form string)."""
import yaml
for category in ("docs", "code", "process", "integration", "visibility", "custom-category"):
valid_manifest_data["extension"]["category"] = category
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.category == category
def test_valid_effect(self, temp_dir, valid_manifest_data):
"""Test manifest with valid effect values."""
import yaml
for effect in sorted(VALID_EFFECTS):
valid_manifest_data["extension"]["effect"] = effect
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.effect == effect
def test_invalid_category(self, temp_dir, valid_manifest_data):
"""Test manifest with empty category raises ValidationError."""
import yaml
valid_manifest_data["extension"]["category"] = ""
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension.category"):
ExtensionManifest(manifest_path)
def test_invalid_effect(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid effect raises ValidationError."""
import yaml
valid_manifest_data["extension"]["effect"] = "write-only"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension.effect"):
ExtensionManifest(manifest_path)
def test_category_and_effect_optional(self, temp_dir, valid_manifest_data):
"""Test that omitting category and effect still passes validation."""
import yaml
# Ensure no category/effect in data
valid_manifest_data["extension"].pop("category", None)
valid_manifest_data["extension"].pop("effect", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.category is None
assert manifest.effect is None
def test_invalid_command_name(self, temp_dir, valid_manifest_data): def test_invalid_command_name(self, temp_dir, valid_manifest_data):
"""Test manifest with command name that cannot be auto-corrected raises ValidationError.""" """Test manifest with command name that cannot be auto-corrected raises ValidationError."""
import yaml import yaml

View File

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

View File

@@ -11,7 +11,6 @@ Tests cover:
""" """
import pytest import pytest
import io
import json import json
import tempfile import tempfile
import shutil import shutil
@@ -19,7 +18,6 @@ import warnings
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace
import yaml import yaml
@@ -4259,141 +4257,6 @@ class TestBundledPresetLocator:
assert "Lean Workflow" in result.output assert "Lean Workflow" in result.output
assert "installed" in result.output.lower() assert "installed" in result.output.lower()
def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch):
"""URL installs reject redirects from HTTPS to non-loopback HTTP."""
import typer
from specify_cli import preset_add
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "http://example.com/preset.zip"
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
assert redirect_validator is not None
redirect_validator(url, "http://example.com/preset.zip")
return FakeResponse(b"zip")
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
installed = False
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
nonlocal installed
installed = True
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
with pytest.raises(typer.Exit) as exc_info:
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
assert exc_info.value.exit_code == 1
assert installed is False
def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir):
"""URL installs reject HTTPS URLs without a hostname before downloading."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.authentication.http.open_url") as open_url:
result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"])
assert result.exit_code == 1
output = strip_ansi(result.output)
assert "URL must use HTTPS with a hostname" in output
assert "got https://" not in output
open_url.assert_not_called()
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
import typer
from specify_cli import preset_add
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https:///preset.zip"
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
monkeypatch.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"),
)
monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None)
with pytest.raises(typer.Exit) as exc_info:
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
assert exc_info.value.exit_code == 1
output = strip_ansi(capsys.readouterr().out)
assert "redirected to a disallowed URL" in output
assert "must use HTTPS with a hostname" in output
def test_preset_add_from_url_streams_download_to_zip(self, project_dir, monkeypatch):
"""URL installs stream response bytes to disk before installing the ZIP."""
from specify_cli import preset_add
class FakeResponse(io.BytesIO):
def __init__(self, data):
super().__init__(data)
self.read_sizes = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https://example.com/preset.zip"
def read(self, size=-1):
assert size not in (-1, None)
self.read_sizes.append(size)
return super().read(size)
response = FakeResponse(b"zip-bytes")
installed = {}
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
installed["zip_bytes"] = Path(zip_path).read_bytes()
installed["speckit_version"] = speckit_version
installed["priority"] = priority
return SimpleNamespace(name="Test Preset", version="1.0.0")
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
monkeypatch.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: response,
)
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7)
assert response.read_sizes
assert installed == {
"zip_bytes": b"zip-bytes",
"speckit_version": "0.6.0",
"priority": 7,
}
def test_bundled_preset_in_catalog(self): def test_bundled_preset_in_catalog(self):
"""Verify the lean preset is listed in catalog.json with bundled marker.""" """Verify the lean preset is listed in catalog.json with bundled marker."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
@@ -4483,7 +4346,7 @@ class TestPresetAddFromUrlResolution:
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): def fake_open_url(url, timeout=None, extra_headers=None):
captured_urls.append((url, extra_headers)) captured_urls.append((url, extra_headers))
if "releases/tags/" in url: if "releases/tags/" in url:
return FakeResponse(json.dumps({ return FakeResponse(json.dumps({
@@ -4541,7 +4404,7 @@ class TestPresetAddFromUrlResolution:
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): def fake_open_url(url, timeout=None, extra_headers=None):
captured_urls.append((url, extra_headers)) captured_urls.append((url, extra_headers))
return FakeResponse(zip_bytes) return FakeResponse(zip_bytes)