mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
217730a8cd | ||
|
|
33fefde268 | ||
|
|
70f9242be9 | ||
|
|
7c1d4212db | ||
|
|
4f5c4971c0 | ||
|
|
13b8db2d87 | ||
|
|
68980c9a4e | ||
|
|
1b0556c711 | ||
|
|
f2710f32cf | ||
|
|
4384338ec1 | ||
|
|
dd9d84e7bc | ||
|
|
77af08ba22 | ||
|
|
f5d47720b9 | ||
|
|
4e899d3002 | ||
|
|
63a2a17305 | ||
|
|
36ad3cde1b | ||
|
|
5ae7ff53d0 | ||
|
|
902b98654d | ||
|
|
40e48ed22c | ||
|
|
45b88f62be | ||
|
|
7c610a38cd |
@@ -70,6 +70,8 @@ Use the existing entries as the format template. Required fields:
|
||||
"documentation": "<documentation>",
|
||||
"changelog": "<changelog>",
|
||||
"license": "<license>",
|
||||
"category": "<category>",
|
||||
"effect": "<effect>",
|
||||
"requires": {
|
||||
"speckit_version": "<speckit_version>"
|
||||
},
|
||||
@@ -87,6 +89,9 @@ Use the existing entries as the format template. Required fields:
|
||||
}
|
||||
```
|
||||
|
||||
**Category** — free-form string; common values: `docs`, `code`, `process`, `integration`, `visibility`
|
||||
**Effect** — one of: `read-only`, `read-write`
|
||||
|
||||
If the extension has optional tool dependencies, add a `"tools"` array inside `"requires"`:
|
||||
|
||||
```json
|
||||
@@ -113,8 +118,8 @@ Determine the category and effect from the extension's behavior:
|
||||
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
|
||||
```
|
||||
|
||||
**Category** — one of: `docs`, `code`, `process`, `integration`, `visibility`
|
||||
**Effect** — `Read-only` (produces reports only) or `Read+Write` (modifies project files)
|
||||
**Category** — free-form; common values: `docs`, `code`, `process`, `integration`, `visibility`
|
||||
**Effect** — write canonical values `read-only` or `read-write` in `extension.yml` and `catalog.community.json`; use `Read-only`/`Read+Write` only for the docs table display
|
||||
|
||||
### 6. Commit, push, and open PR
|
||||
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,36 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.10.3] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Superpowers Bridge extension to v1.6.0 (#2998)
|
||||
- Add Improve Extension to community catalog (#2997)
|
||||
- Update Product Forge extension to v1.7.0 (#2996)
|
||||
- Update Linear Integration extension to v0.5.0 (#2995)
|
||||
- Update Superpowers Implementation Bridge extension to v1.0.3 (#2993)
|
||||
- Update Ralph community extension to v1.1.1 (#2861)
|
||||
- Update Linear Integration extension to v0.4.0 (#2942)
|
||||
- Update DocGuard — CDD Enforcement to v0.26.0 (#2941)
|
||||
- Add SpecKit Companion extension to community catalog (#2937)
|
||||
- chore: release 0.10.2, begin 0.10.3.dev0 development (#2936)
|
||||
|
||||
## [0.10.2] - 2026-06-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Add Research Harness extension to community catalog (#2935)
|
||||
- Add Coding Standards Drift Control extension to community catalog (#2934)
|
||||
- Add Spec Trace extension to community catalog (#2527)
|
||||
- fix(extensions): preserve argument-hint in extension Claude SKILL.md (#2916)
|
||||
- fix(presets): harden preset URL installs against unsafe redirects (#2911)
|
||||
- fix: skip recovered files during refresh_managed overwrite check (#2918) (#2919)
|
||||
- Update multi-model-review extension to v0.1.1 (#2900)
|
||||
- feat: add category and effect as first-class fields in extension schema (#2899)
|
||||
- chore(catalog): add Jira Integration (Sync Engine) extension (#2895)
|
||||
- chore: release 0.10.1, begin 0.10.2.dev0 development (#2910)
|
||||
|
||||
## [0.10.1] - 2026-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):
|
||||
|
||||
**Categories:**
|
||||
**Categories** (common values, but any string is allowed):
|
||||
|
||||
- `docs` — reads, validates, or generates spec artifacts
|
||||
- `code` — reviews, validates, or modifies source code
|
||||
@@ -15,10 +15,13 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
- `integration` — syncs with external platforms
|
||||
- `visibility` — reports on project health or progress
|
||||
|
||||
**Effect:**
|
||||
**Effect** (canonical `extension.yml`/catalog values):
|
||||
|
||||
- `Read-only` — produces reports without modifying files
|
||||
- `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||
- `read-only` — produces reports without modifying files (displayed as `Read-only` in the table)
|
||||
- `read-write` — modifies files, creates artifacts, or updates specs (displayed as `Read+Write` in the table)
|
||||
|
||||
> [!TIP]
|
||||
> Extension authors can declare `category` and `effect` in their `extension.yml` under the `extension:` block. These fields are also available in `catalog.community.json` for tooling and the CLI (`specify extension info`).
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
@@ -41,6 +44,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
@@ -51,10 +55,12 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
|
||||
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
|
||||
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
|
||||
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
|
||||
| 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) |
|
||||
@@ -79,7 +85,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Product Forge | Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Forge | Full product-lifecycle orchestrator for Spec Kit: research → product-spec → plan → tasks → implement → verify → test → release-readiness, across express/lite/standard/v-model modes with human-in-the-loop gates. | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
@@ -88,6 +94,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Research Harness | State-externalizing research harness: budgeted exploration, evidence curation, and claim verification for spec-driven development | `process` | Read+Write | [spec-kit-harness](https://github.com/formin/spec-kit-harness) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
| Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) |
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
@@ -107,13 +114,15 @@ 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 Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
|
||||
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,14 @@ extension:
|
||||
# CUSTOMIZE: Brief description (under 200 characters)
|
||||
description: "Brief description of what your extension does"
|
||||
|
||||
# CUSTOMIZE: Extension category — describes what the extension operates on
|
||||
# Common values: docs, code, process, integration, visibility
|
||||
# category: "process"
|
||||
|
||||
# CUSTOMIZE: Extension effect — whether it modifies project files
|
||||
# One of: read-only | read-write
|
||||
# effect: "read-write"
|
||||
|
||||
# CUSTOMIZE: Your name or organization name
|
||||
author: "Your Name"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.10.1"
|
||||
version = "0.10.3"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -684,16 +684,44 @@ def preset_add(
|
||||
|
||||
elif from_url:
|
||||
# Validate URL scheme before downloading
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
_parsed = _urlparse(from_url)
|
||||
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
|
||||
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
|
||||
|
||||
def _is_allowed_download_url(parsed_url):
|
||||
host = parsed_url.hostname
|
||||
if not host:
|
||||
return False
|
||||
is_loopback = host == "localhost"
|
||||
if not is_loopback:
|
||||
try:
|
||||
is_loopback = ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
|
||||
pass
|
||||
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
|
||||
|
||||
def _validate_download_redirect(old_url, new_url):
|
||||
if not _is_allowed_download_url(_urlparse(new_url)):
|
||||
import urllib.error
|
||||
|
||||
raise urllib.error.URLError(
|
||||
"redirect target must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback"
|
||||
)
|
||||
|
||||
if not _is_allowed_download_url(_parsed):
|
||||
console.print(
|
||||
"[red]Error:[/red] URL must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
@@ -707,8 +735,25 @@ def preset_add(
|
||||
from_url = _resolved_from_url
|
||||
_preset_extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
with _open_url(from_url, timeout=60, extra_headers=_preset_extra_headers) as response:
|
||||
zip_path.write_bytes(response.read())
|
||||
with _open_url(
|
||||
from_url,
|
||||
timeout=60,
|
||||
extra_headers=_preset_extra_headers,
|
||||
redirect_validator=_validate_download_redirect,
|
||||
) as response:
|
||||
final_url = response.geturl() if hasattr(response, "geturl") else from_url
|
||||
if not _is_allowed_download_url(_urlparse(final_url)):
|
||||
console.print(
|
||||
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
|
||||
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
with zip_path.open("wb") as output:
|
||||
try:
|
||||
shutil.copyfileobj(response, output)
|
||||
except TypeError:
|
||||
output.write(response.read())
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||
raise typer.Exit(1)
|
||||
@@ -1186,7 +1231,7 @@ def preset_catalog_add(
|
||||
})
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
|
||||
install_label = "install allowed" if install_allowed else "discovery only"
|
||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||
@@ -1226,7 +1271,7 @@ def preset_catalog_remove(
|
||||
raise typer.Exit(1)
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
|
||||
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||
if not catalogs:
|
||||
@@ -1975,7 +2020,11 @@ def extension_info(
|
||||
author = ext_manifest.data.get("extension", {}).get("author")
|
||||
if author:
|
||||
console.print(f"[dim]Author:[/dim] {author}")
|
||||
console.print()
|
||||
if ext_manifest.category:
|
||||
console.print(f"[dim]Category:[/dim] {ext_manifest.category}")
|
||||
if ext_manifest.effect:
|
||||
console.print(f"[dim]Effect:[/dim] {ext_manifest.effect}")
|
||||
console.print()
|
||||
|
||||
if ext_manifest.commands:
|
||||
console.print("[bold]Commands:[/bold]")
|
||||
@@ -2025,6 +2074,12 @@ def _print_extension_info(ext_info: dict, manager):
|
||||
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
|
||||
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
|
||||
|
||||
# Category and Effect
|
||||
if ext_info.get('category'):
|
||||
console.print(f"[dim]Category:[/dim] {ext_info['category']}")
|
||||
if ext_info.get('effect'):
|
||||
console.print(f"[dim]Effect:[/dim] {ext_info['effect']}")
|
||||
|
||||
# Source catalog
|
||||
if ext_info.get("_catalog_name"):
|
||||
install_allowed = ext_info.get("_install_allowed", True)
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from fnmatch import fnmatch
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import get_provider
|
||||
@@ -56,22 +57,36 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
|
||||
RedirectValidator = Callable[[str, str], None]
|
||||
|
||||
def __init__(self, hosts: tuple[str, ...]) -> None:
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hosts: tuple[str, ...],
|
||||
redirect_validator: RedirectValidator | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._hosts = hosts
|
||||
self._redirect_validator = redirect_validator
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if self._redirect_validator is not None:
|
||||
self._redirect_validator(req.full_url, newurl)
|
||||
|
||||
original_auth = (
|
||||
req.get_header("Authorization")
|
||||
or req.unredirected_hdrs.get("Authorization")
|
||||
)
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
hostname = (urlparse(newurl).hostname or "").lower()
|
||||
if _hostname_in_hosts(hostname, self._hosts):
|
||||
old_scheme = urlparse(req.full_url).scheme
|
||||
new_parsed = urlparse(newurl)
|
||||
hostname = (new_parsed.hostname or "").lower()
|
||||
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
|
||||
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
|
||||
if original_auth:
|
||||
new_req.add_unredirected_header("Authorization", original_auth)
|
||||
else:
|
||||
@@ -103,7 +118,12 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None):
|
||||
def open_url(
|
||||
url: str,
|
||||
timeout: int = 10,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
redirect_validator: RedirectValidator | None = None,
|
||||
):
|
||||
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
|
||||
|
||||
1. Find ``auth.json`` entries whose hosts match the URL.
|
||||
@@ -113,6 +133,8 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
5. Non-auth errors (404, 500, network) raise immediately.
|
||||
|
||||
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
|
||||
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
|
||||
before following each redirect and may raise to reject the redirect.
|
||||
"""
|
||||
entries = find_entries_for_url(url, _load_config())
|
||||
|
||||
@@ -135,7 +157,7 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
continue
|
||||
|
||||
req = _make_req(provider.auth_headers(token, entry.auth))
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator))
|
||||
try:
|
||||
return opener.open(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
@@ -146,4 +168,7 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
|
||||
# No entry worked (or none matched) — unauthenticated fallback
|
||||
req = _make_req({})
|
||||
if redirect_validator is not None:
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
|
||||
return opener.open(req, timeout=timeout)
|
||||
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
|
||||
|
||||
@@ -41,6 +41,8 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
})
|
||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
||||
|
||||
VALID_EFFECTS = frozenset({"read-only", "read-write"})
|
||||
|
||||
DEFAULT_HOOK_PRIORITY = 10
|
||||
|
||||
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
|
||||
@@ -201,6 +203,21 @@ class ExtensionManifest:
|
||||
except pkg_version.InvalidVersion:
|
||||
raise ValidationError(f"Invalid version: {ext['version']}")
|
||||
|
||||
# Validate optional category field (free-form string)
|
||||
if "category" in ext:
|
||||
if not isinstance(ext["category"], str) or not ext["category"].strip():
|
||||
raise ValidationError(
|
||||
"Invalid extension.category: must be a non-empty string"
|
||||
)
|
||||
|
||||
# Validate optional effect field
|
||||
if "effect" in ext:
|
||||
if not isinstance(ext["effect"], str) or ext["effect"] not in VALID_EFFECTS:
|
||||
raise ValidationError(
|
||||
f"Invalid extension.effect '{ext.get('effect')}': "
|
||||
f"must be one of {sorted(VALID_EFFECTS)}"
|
||||
)
|
||||
|
||||
# Validate requires section
|
||||
requires = self.data["requires"]
|
||||
if "speckit_version" not in requires:
|
||||
@@ -374,6 +391,16 @@ class ExtensionManifest:
|
||||
"""Get extension description."""
|
||||
return self.data["extension"]["description"]
|
||||
|
||||
@property
|
||||
def category(self) -> Optional[str]:
|
||||
"""Get extension category (free-form; common values: docs, code, process, integration, visibility)."""
|
||||
return self.data["extension"].get("category")
|
||||
|
||||
@property
|
||||
def effect(self) -> Optional[str]:
|
||||
"""Get extension effect (read-only, read-write)."""
|
||||
return self.data["extension"].get("effect")
|
||||
|
||||
@property
|
||||
def requires_speckit_version(self) -> str:
|
||||
"""Get required spec-kit version range."""
|
||||
@@ -1026,6 +1053,22 @@ class ExtensionManager:
|
||||
description,
|
||||
f"extension:{manifest.id}",
|
||||
)
|
||||
# Preserve the command's argument-hint in the generated skill,
|
||||
# mirroring the core template path (ClaudeIntegration.setup injects
|
||||
# it for built-in commands). The value is added to the frontmatter
|
||||
# dict before serialization — rather than via the string-based
|
||||
# inject_argument_hint helper — so that a folded multi-line
|
||||
# description cannot be split by the inserted line. Gated on the
|
||||
# integration exposing inject_argument_hint so only argument-hint
|
||||
# aware agents receive the key, leaving build_skill_frontmatter's
|
||||
# shared shape unchanged for every other agent.
|
||||
argument_hint = frontmatter.get("argument-hint")
|
||||
if (
|
||||
argument_hint
|
||||
and integration is not None
|
||||
and hasattr(integration, "inject_argument_hint")
|
||||
):
|
||||
frontmatter_data["argument-hint"] = str(argument_hint)
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
|
||||
# Derive a human-friendly title from the command name
|
||||
|
||||
@@ -313,6 +313,8 @@ def install_shared_infra(
|
||||
expected = prior_hashes.get(rel)
|
||||
if not expected or not dst.is_file() or dst.is_symlink():
|
||||
return False
|
||||
if manifest.is_recovered(rel):
|
||||
return False
|
||||
try:
|
||||
return _sha256(dst) == expected
|
||||
except OSError:
|
||||
|
||||
@@ -1918,6 +1918,45 @@ class TestIntegrationSwitch:
|
||||
assert "/speckit.plan" in updated
|
||||
assert "/speckit-plan" not in updated
|
||||
|
||||
def test_switch_preserves_recovered_files(self, tmp_path):
|
||||
"""Regression for #2918: files marked recovered in the manifest are not overwritten.
|
||||
|
||||
When a file already exists on disk before init and is recorded with
|
||||
``recovered=True``, ``integration use``/``switch`` must not treat it as
|
||||
managed even when the on-disk hash matches the manifest hash.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
assert shared_script.is_file()
|
||||
|
||||
# Simulate a team-customized file that was recorded as recovered:
|
||||
# write custom content, then update the manifest to record its hash
|
||||
# with the recovered flag set.
|
||||
custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
rel = ".specify/scripts/bash/setup-tasks.sh"
|
||||
manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
|
||||
manifest_data.setdefault("recovered_files", []).append(rel)
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# Recovered file must NOT be overwritten — team content preserved.
|
||||
assert shared_script.read_bytes() == custom_bytes
|
||||
|
||||
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
|
||||
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
|
||||
|
||||
|
||||
@@ -793,6 +793,35 @@ class TestRedirectStripping:
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
def test_https_to_http_same_host_redirect_strips_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"http://github.com/org/repo")
|
||||
assert new_req is not None
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
def test_redirect_validator_can_reject_before_following_redirect(self):
|
||||
import urllib.error
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
def reject_http(old_url, new_url):
|
||||
if new_url.startswith("http://"):
|
||||
raise urllib.error.URLError("scheme downgrade")
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",), reject_http)
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
|
||||
with pytest.raises(urllib.error.URLError, match="scheme downgrade"):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"http://github.com/org/repo")
|
||||
|
||||
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
|
||||
"""Auth survives a multi-hop redirect chain within allowed hosts."""
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
|
||||
@@ -303,6 +303,135 @@ class TestExtensionSkillRegistration:
|
||||
assert "description" in parsed
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
|
||||
def test_argument_hint_preserved_for_extension_command(
|
||||
self, skills_project, temp_dir
|
||||
):
|
||||
"""argument-hint from an extension command must survive into SKILL.md.
|
||||
|
||||
Regression for #2903: the field was dropped for extension-provided
|
||||
commands while being kept for core template commands. The source
|
||||
description is intentionally long so it folds across multiple lines
|
||||
when serialized, guarding against an in-place string injection that
|
||||
would split the folded scalar and produce invalid YAML.
|
||||
"""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
long_description = (
|
||||
"Build and maintain a lean, static context/ knowledge folder so "
|
||||
"coding agents load only what is relevant and save tokens"
|
||||
)
|
||||
arg_hint = "<init | update | list | check> [area] [slug] [-- notes]"
|
||||
|
||||
ext_dir = temp_dir / "hint-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "hint-ext",
|
||||
"name": "Hint Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Extension exercising argument-hint preservation",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.hint-ext.build-context",
|
||||
"file": "commands/build-context.md",
|
||||
"description": long_description,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
(commands_dir / "build-context.md").write_text(
|
||||
"---\n"
|
||||
f'description: "{long_description}"\n'
|
||||
f'argument-hint: "{arg_hint}"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Build Context\n"
|
||||
"\n"
|
||||
"Do the thing.\n"
|
||||
"$ARGUMENTS\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
skill_file = skills_dir / "speckit-hint-ext-build-context" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
|
||||
# Frontmatter must parse cleanly even though the description folds.
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["argument-hint"] == arg_hint
|
||||
assert parsed["description"] == long_description
|
||||
|
||||
def test_argument_hint_not_added_for_non_claude_agent(self, project_dir, temp_dir):
|
||||
"""argument-hint must stay Claude-only — other skills agents are untouched.
|
||||
|
||||
The hint is carried only for integrations that support it (currently
|
||||
Claude, the sole integration defining inject_argument_hint). A non-Claude
|
||||
skills agent such as kimi must keep the shared build_skill_frontmatter
|
||||
shape (name/description/compatibility/metadata) with no argument-hint.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="kimi", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
||||
|
||||
arg_hint = "<init | update | list | check> [area]"
|
||||
ext_dir = temp_dir / "hint-ext-kimi"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "hint-ext-kimi",
|
||||
"name": "Hint Extension Kimi",
|
||||
"version": "1.0.0",
|
||||
"description": "Extension exercising argument-hint gating",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.hint-ext-kimi.build-context",
|
||||
"file": "commands/build-context.md",
|
||||
"description": "Build context",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
(commands_dir / "build-context.md").write_text(
|
||||
"---\n"
|
||||
'description: "Build context"\n'
|
||||
f'argument-hint: "{arg_hint}"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Build Context\n"
|
||||
"\n"
|
||||
"Do the thing.\n"
|
||||
"$ARGUMENTS\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
skill_file = skills_dir / "speckit-hint-ext-kimi-build-context" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
|
||||
assert "argument-hint" not in parsed
|
||||
|
||||
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
||||
"""No skills should be created when ai_skills is false."""
|
||||
manager = ExtensionManager(no_skills_project)
|
||||
|
||||
@@ -24,6 +24,7 @@ from specify_cli.extensions import (
|
||||
CatalogEntry,
|
||||
CORE_COMMAND_NAMES,
|
||||
DEFAULT_HOOK_PRIORITY,
|
||||
VALID_EFFECTS,
|
||||
ExtensionManifest,
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
@@ -300,6 +301,69 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid version"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_valid_category(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with various category values (free-form string)."""
|
||||
import yaml
|
||||
|
||||
for category in ("docs", "code", "process", "integration", "visibility", "custom-category"):
|
||||
valid_manifest_data["extension"]["category"] = category
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
assert manifest.category == category
|
||||
|
||||
def test_valid_effect(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with valid effect values."""
|
||||
import yaml
|
||||
|
||||
for effect in sorted(VALID_EFFECTS):
|
||||
valid_manifest_data["extension"]["effect"] = effect
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
assert manifest.effect == effect
|
||||
|
||||
def test_invalid_category(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with empty category raises ValidationError."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["extension"]["category"] = ""
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="Invalid extension.category"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_invalid_effect(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with invalid effect raises ValidationError."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["extension"]["effect"] = "write-only"
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="Invalid extension.effect"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_category_and_effect_optional(self, temp_dir, valid_manifest_data):
|
||||
"""Test that omitting category and effect still passes validation."""
|
||||
import yaml
|
||||
|
||||
# Ensure no category/effect in data
|
||||
valid_manifest_data["extension"].pop("category", None)
|
||||
valid_manifest_data["extension"].pop("effect", None)
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
assert manifest.category is None
|
||||
assert manifest.effect is None
|
||||
|
||||
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
|
||||
import yaml
|
||||
|
||||
@@ -187,4 +187,4 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
capturing_open,
|
||||
)
|
||||
assert len(captured_urls) == 1
|
||||
assert "releases/tags/v1%23beta" in captured_urls[0]
|
||||
assert "releases/tags/v1%23beta" in captured_urls[0]
|
||||
|
||||
@@ -11,6 +11,7 @@ Tests cover:
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
@@ -18,6 +19,7 @@ import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -4257,6 +4259,141 @@ class TestBundledPresetLocator:
|
||||
assert "Lean Workflow" in result.output
|
||||
assert "installed" in result.output.lower()
|
||||
|
||||
def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch):
|
||||
"""URL installs reject redirects from HTTPS to non-loopback HTTP."""
|
||||
import typer
|
||||
from specify_cli 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):
|
||||
"""Verify the lean preset is listed in catalog.json with bundled marker."""
|
||||
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
|
||||
@@ -4346,7 +4483,7 @@ class TestPresetAddFromUrlResolution:
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
@@ -4404,7 +4541,7 @@ class TestPresetAddFromUrlResolution:
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
return FakeResponse(zip_bytes)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user