mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee883a1d4e |
@@ -65,8 +65,7 @@
|
||||
},
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
".specify/scripts/bash/": true,
|
||||
".specify/scripts/powershell/": true,
|
||||
".specify/scripts/python/": true
|
||||
".specify/scripts/powershell/": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Run ruff check
|
||||
run: uvx ruff@0.15.0 check src tests
|
||||
run: uvx ruff check src tests
|
||||
|
||||
pytest:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
43
AGENTS.md
43
AGENTS.md
@@ -187,7 +187,7 @@ context_markers:
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh`, `.ps1`, and `extensions/agent-context/scripts/python/update_agent_context.py`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
|
||||
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
|
||||
- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly.
|
||||
|
||||
Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run.
|
||||
@@ -268,25 +268,6 @@ echo "✅ Done"
|
||||
|
||||
## Command File Formats
|
||||
|
||||
### Script References (`scripts:` frontmatter)
|
||||
|
||||
Core command templates (`templates/commands/*.md`) that invoke a helper script declare it in a `scripts:` frontmatter block with one line per supported script type. The `{SCRIPT}` placeholder in the command body is replaced at install time with the entry matching the project's selected script type (`--script sh|ps|py`):
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
sh: scripts/bash/setup-plan.sh --json
|
||||
ps: scripts/powershell/setup-plan.ps1 -Json
|
||||
py: scripts/python/setup_plan.py --json
|
||||
```
|
||||
|
||||
| Key | Script type | Location |
|
||||
| ---- | ---------------------- | -------------------------- |
|
||||
| `sh` | POSIX shell (bash/zsh) | `scripts/bash/*.sh` |
|
||||
| `ps` | PowerShell | `scripts/powershell/*.ps1` |
|
||||
| `py` | Python | `scripts/python/*.py` |
|
||||
|
||||
All three entries must be present and behaviorally equivalent — agents parse the same stdout contract (`FEATURE_DIR:…`, `AVAILABLE_DOCS:…`, `--json` shapes) regardless of which one runs. (The bundled `agent-context` and `git` extension command templates also invoke helpers but do not yet use `scripts:` frontmatter — see [Script Types and Migration](#script-types-and-migration).)
|
||||
|
||||
### Markdown Format
|
||||
|
||||
**Standard format:**
|
||||
@@ -347,29 +328,9 @@ Different agents use different argument placeholders. The placeholder used in co
|
||||
- **TOML-based**: `{{args}}` (e.g., Gemini)
|
||||
- **YAML-based**: `{{args}}` (e.g., Goose)
|
||||
- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`)
|
||||
- **Script placeholders**: `{SCRIPT}` (replaced with the resolved command from the template's `scripts:` frontmatter, per the project's `--script sh|ps|py` selection)
|
||||
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
|
||||
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
|
||||
|
||||
## Script Types and Migration
|
||||
|
||||
Spec Kit ships every core workflow script in three interchangeable variants — POSIX shell (`sh`), PowerShell (`ps`), and Python (`py`) — selected per project with `specify init --script sh|ps|py`. Each core command template that invokes a helper script carries all three in its `scripts:` frontmatter (templates that don't call a script, e.g. `constitution`/`specify`, have no `scripts:` block); see [Script References](#script-references-scripts-frontmatter).
|
||||
|
||||
### Why Python is recommended
|
||||
|
||||
- **No extra runtime.** The `specify` CLI is already Python, so the interpreter is guaranteed present — `py` adds no new dependency.
|
||||
- **Path toward a single source of truth.** The shell variants require paired `.sh` + `.ps1` maintenance and diverge on JSON handling (`jq` vs manual parsing). The Python variant avoids `jq` and is intended to eventually replace that dual-maintenance — but that consolidation has not happened yet: all three variants are still maintained in parallel (see the parity rule below).
|
||||
- **Parity-tested.** The Python ports are covered by tests — output-parity tests against the shell scripts where the contract is stdout-based, and direct unit tests elsewhere — so the stdout contract agents rely on stays stable.
|
||||
|
||||
### Defaults and availability
|
||||
|
||||
- `py` is available today for the core command templates (via their `scripts:` frontmatter). The bundled extensions (`agent-context`, `git`) ship Python script variants on disk, but their command templates still hard-code the Bash/PowerShell invocations, so `--script py` does not yet route those extension commands to Python — wiring `py` into the extension command templates is tracked separately.
|
||||
- Selection is per project: interactive `specify init` prompts for the script type, while non-interactive runs default to a shell variant by OS (`sh` on Linux/macOS, `ps` on Windows). `py` is chosen at the prompt or via `--script py`.
|
||||
- `sh` and `ps` remain fully supported. Nothing is removed, and `py` is not yet the default.
|
||||
|
||||
### Parity rule for contributors
|
||||
|
||||
All three script types are first-class: any change to a workflow script must update `sh`, `ps`, and `py` together and keep their tests (parity and unit) green. Making `py` the default and eventually retiring `sh`/`ps` is future work gated on adoption, tracked under the script-unification epic ([#3277](https://github.com/github/spec-kit/issues/3277)) — not something to act on from this doc.
|
||||
|
||||
## Special Processing Requirements
|
||||
|
||||
Some agents require custom processing beyond the standard template transformations:
|
||||
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -2,56 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.14.1] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Agent Parity Governance preset to v0.4.0 (#3697)
|
||||
- fix(bundler): InstallResult.changed counts uninstalled as a change (#3692)
|
||||
- [preset] Update Cross-Platform Governance preset to v0.2.1 (#3695)
|
||||
- Update A11Y Governance preset to v0.4.1 (#3693)
|
||||
- fix(workflows): escape step-graph brackets in `workflow info` so the type shows (#3690)
|
||||
- fix(workflows): filter parser rejects trailing tokens (fullmatch, not match) (#3689)
|
||||
- Update iSAQB Architecture Governance preset to v0.2.1 (#3687)
|
||||
- fix(extensions): parse SKILL.md on the --- delimiter line during removal (#3634)
|
||||
- fix(cli): guard lazy .hostname ValueError in extension/preset add --from (#3651)
|
||||
- Update Architecture Governance preset to v0.5.1 (#3686)
|
||||
- fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
|
||||
- Update Security Governance preset to v0.6.1 (#3685)
|
||||
- fix(integrations): declare OmpIntegration multi_install_safe (#3650)
|
||||
- feat(git-extension): add configurable Conventional Commit support (#3390) (#3413)
|
||||
- fix(extensions): hyphenate command names in the Forge post-install listing (#3669)
|
||||
- fix(bundler): reject falsy non-mapping requires/provides in CatalogEntry.from_dict (#3667)
|
||||
- fix(bundler): reject falsy non-list bundles/contributed_components in records (#3666)
|
||||
- Update Intake Authoring Governance preset to v0.1.1 (#3678)
|
||||
- docs(extensions): clarify agent-context README and add config examples (#3389)
|
||||
- chore: release 0.14.0, begin 0.14.1.dev0 development (#3677)
|
||||
|
||||
## [0.14.0] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: add spec-kit-copilot to community friends (#3675)
|
||||
- fix(integrations): recompute invoke_separator from retained parsed_options (#3664)
|
||||
- fix(workflows): preserve intra-overlay order for multiple insert_after edits (#3662)
|
||||
- fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict (#3661)
|
||||
- fix(bundler): dump_yaml writes literal UTF-8 (allow_unicode=True) (#3660)
|
||||
- fix(integrations): declare kiro-cli multi-install safe (#3477)
|
||||
- fix(git-extension): trim trailing whitespace before stripping commit-message quotes (#3673)
|
||||
- fix(bundler): order bundle members by canonical POSIX arcname (reproducible builds) (#3658)
|
||||
- fix(integrations): Cline overrides post_process_command_content (correct hook name) (#3657)
|
||||
- docs(workflows): gate step docstring lists the 'retry' on_reject behaviour (#3656)
|
||||
- fix: harden bounded reads and redirect validation (#3671)
|
||||
- fix(packaging): bundle scripts/python into the wheel core_pack (#3665) (#3670)
|
||||
- fix: bundle scripts/python in wheel so --script py works (#3665) (#3668)
|
||||
- docs(workflows): init step docstring lists the 'py' script type (#3655)
|
||||
- fix(integrations): declare LingmaIntegration multi_install_safe (#3654)
|
||||
- fix: guard constitution command against feature execution (#3646)
|
||||
- Fix duplicate step numbering in specify command (#3647)
|
||||
- docs(scripts): document the 'py' script type and sh/ps migration plan (#3284) (#3653)
|
||||
- harden: bound HTTP reads and enforce strict redirects (#3140)
|
||||
- chore: release 0.13.4, begin 0.13.5.dev0 development (#3649)
|
||||
|
||||
## [0.13.4] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Community Friends
|
||||
|
||||
> [!NOTE]
|
||||
> Community projects listed here are independently created and maintained by their respective authors. Unless explicitly marked as a **first-party GitHub project**, they are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
|
||||
> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
|
||||
|
||||
Community projects that extend, visualize, or build on Spec Kit:
|
||||
|
||||
@@ -16,5 +16,3 @@ Community projects that extend, visualize, or build on Spec Kit:
|
||||
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
|
||||
|
||||
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.
|
||||
|
||||
- **[spec-kit-copilot](https://github.com/github/spec-kit-copilot)** — _First-party GitHub project._ A GitHub Copilot **skills plugin** that exposes the Spec Kit `specify` CLI to the Copilot agent in both the Copilot CLI and the GitHub Copilot app. It provides a focused skill per `specify` command group — setup, init, check, extensions, presets, bundles, workflows, workflow steps, and self-upgrade — so you can navigate and drive the entire Spec Kit ecosystem through natural language, letting Copilot decide when and how to run the right `specify` commands on your behalf.
|
||||
|
||||
@@ -7,28 +7,28 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| Intake Authoring Governance | Creates traceable Spec Kit intakes from ordered text sources and now truthfully adopts legacy intakes without inventing predecessor receipts. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Authoring Governance | Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
|
||||
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first.
|
||||
|
||||
> Scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
|
||||
> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps`.
|
||||
|
||||
## 1. Clone and Switch Branches
|
||||
|
||||
@@ -189,7 +189,7 @@ rm -rf .venv dist build *.egg-info
|
||||
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
|
||||
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
|
||||
| Git commands unavailable | Install the git extension with `specify extension add git` |
|
||||
| Wrong script type downloaded | Pass `--script sh`, `--script ps`, or `--script py` explicitly |
|
||||
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
|
||||
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
||||
|
||||
## 14. Next Steps
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This guide will help you get started with Spec-Driven Development using Spec Kit. Throughout, we illustrate each step with a running example: **Taskify**, a small team productivity platform.
|
||||
|
||||
> [!NOTE]
|
||||
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
|
||||
> Automation scripts are provided as both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on your OS unless you pass `--script sh|ps`.
|
||||
|
||||
> [!NOTE]
|
||||
> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
|
||||
|
||||
@@ -86,7 +86,7 @@ specify integration install <key>
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe |
|
||||
| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
|
||||
|
||||
@@ -122,7 +122,7 @@ specify integration switch <key>
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
|
||||
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
|
||||
| `--integration-options` | Options for the target integration when it is not already installed |
|
||||
@@ -150,7 +150,7 @@ specify integration upgrade [<key>]
|
||||
| Option | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--force` | Overwrite files even if they have been modified |
|
||||
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--integration-options` | Options for the integration |
|
||||
|
||||
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
|
||||
@@ -257,31 +257,31 @@ Spec Kit tracks one default integration in `.specify/integration.json` with `def
|
||||
|
||||
An integration is multi-install safe when it uses a static, unique agent root and command directory, stable command invocation settings, and a separate install manifest whose managed files do not overlap another safe integration. Registry tests enforce those path and manifest invariants. Shared Spec Kit templates remain aligned to the single default integration.
|
||||
|
||||
The Command directory column below lists the directory each integration installs its commands or skills into. Context-file targeting is a separate concern from integration multi-install safety: `multi_install_safe` is an integration declaration about command/skill paths, whereas the optional agent-context extension manages a per-agent context file (for example `AGENTS.md` or `CLAUDE.md`) and can even synchronize several anchors at once via its `context_files` setting. Multiple agents mapping to the same context file is expected there and does not affect whether an integration is multi-install safe; see the agent-context extension for details.
|
||||
The Isolation column below lists paths Spec Kit manages for that integration (skills/commands roots and any integration-owned rule files). It is not a full inventory of every file an agent may read.
|
||||
|
||||
**Agent-context defaults are separate.** The optional agent-context extension maps each integration to a default context file in `extensions/agent-context/agent-context-defaults.json`. Those defaults are independent of multi-install safety: several agents may share a root file such as `AGENTS.md` when the extension is enabled. Multi-install safety does not require a unique context file per safe integration.
|
||||
|
||||
The currently declared multi-install safe integrations are:
|
||||
|
||||
| Key | Command directory |
|
||||
| --- | ----------------- |
|
||||
| `auggie` | `.augment/commands` |
|
||||
| `claude` | `.claude/skills` |
|
||||
| `cline` | `.clinerules/workflows` |
|
||||
| `codebuddy` | `.codebuddy/commands` |
|
||||
| `codex` | `.agents/skills` |
|
||||
| `cursor-agent` | `.cursor/skills` |
|
||||
| `firebender` | `.firebender/commands` |
|
||||
| `gemini` | `.gemini/commands` |
|
||||
| Key | Isolation |
|
||||
| --- | --------- |
|
||||
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
|
||||
| `claude` | `.claude/skills`, `CLAUDE.md` |
|
||||
| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` |
|
||||
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
|
||||
| `codex` | `.agents/skills`, `AGENTS.md` |
|
||||
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
|
||||
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
|
||||
| `gemini` | `.gemini/commands`, `GEMINI.md` |
|
||||
| `grok` | `.grok/skills` |
|
||||
| `junie` | `.junie/commands` |
|
||||
| `kilocode` | `.kilocode/workflows` |
|
||||
| `kiro-cli` | `.kiro/prompts` |
|
||||
| `omp` | `.omp/commands` |
|
||||
| `qodercli` | `.qoder/commands` |
|
||||
| `qwen` | `.qwen/commands` |
|
||||
| `shai` | `.shai/commands` |
|
||||
| `tabnine` | `.tabnine/agent/commands` |
|
||||
| `trae` | `.trae/skills` |
|
||||
| `zcode` | `.zcode/skills` |
|
||||
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
|
||||
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
|
||||
| `qodercli` | `.qoder/commands`, `QODER.md` |
|
||||
| `qwen` | `.qwen/commands`, `QWEN.md` |
|
||||
| `shai` | `.shai/commands`, `SHAI.md` |
|
||||
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
|
||||
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
|
||||
| `zcode` | `.zcode/skills`, `ZCODE.md` |
|
||||
|
||||
Integrations that share a command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
|
||||
|
||||
|
||||
@@ -2,55 +2,55 @@
|
||||
|
||||
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
|
||||
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`). For `.mdc` files, it also ensures the YAML frontmatter (the metadata block at the top of the file) contains `alwaysApply: true`. Otherwise, everything outside the managed section is untouched.
|
||||
|
||||
> NOTE: Spec Kit itself never touches your agent context file. This extension is the only thing that does, and it's opt-in: install it if you want the block kept in sync, skip it if you'd rather manage that file yourself.
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
|
||||
|
||||
## Why an extension?
|
||||
|
||||
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:
|
||||
|
||||
- **Choose whether to install it at all** - `specify init` does **not** install it. Add it explicitly when you want Spec Kit to manage the agent context file; when it is absent, the file is never modified, and when it is disabled, its automatic hooks do not run.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in this repo) - the bundled scripts honor the `context_markers` value.
|
||||
- **Choose whether to install it at all** — `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value.
|
||||
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
|
||||
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in [extension.yml](./extension.yml) (`after_specify`, `after_plan`).
|
||||
|
||||
## Installation
|
||||
|
||||
To install the extension, from the root of an initialized Spec Kit project, run:
|
||||
|
||||
```bash
|
||||
specify extension add agent-context
|
||||
```
|
||||
|
||||
## Disabling
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
|
||||
# Re-enable it
|
||||
specify extension enable agent-context
|
||||
```
|
||||
|
||||
While this extension is disabled (or not installed), nothing in Spec Kit creates, updates, or removes the managed block - the `__CONTEXT_FILE__` placeholder in any template is left as-is, and the extension's own config is never read.
|
||||
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
|
||||
|
||||
> NOTE: The command ID above is canonical. Invoke it using the syntax for your integration: `/speckit.agent-context.update` for dot-command integrations; `/speckit-agent-context-update` for hyphen/skills integrations (including Forge and Cline); `$speckit-agent-context-update` for Codex or ZCode in skills mode; or `/skill:speckit-agent-context-update` for Kimi.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in the repo).
|
||||
All configuration flows through the extension's own config file at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: CLAUDE.md
|
||||
|
||||
# Optional list of coding agent context files to manage together.
|
||||
# When non-empty, this takes precedence over context_file.
|
||||
context_files:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
|
||||
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
|
||||
|
||||
## Requirements
|
||||
|
||||
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
|
||||
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports _"PyYAML is required … not available in the current Python environment"_, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
@@ -58,6 +58,10 @@ pip install pyyaml
|
||||
/path/to/speckit-python -m pip install pyyaml
|
||||
```
|
||||
|
||||
## Issues
|
||||
## Disable
|
||||
|
||||
For any other issues, please create an issue in the [official GitHub repo](https://github.com/github/spec-kit/issues).
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
```
|
||||
|
||||
When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out.
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
# Coding Agent Context Extension Configuration
|
||||
# These values are populated automatically by `specify init` and
|
||||
# `specify integration use` / `specify integration install`.
|
||||
|
||||
# WHAT: The single agent context file relative to the project root (the directory containing .specify/). Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
# REQUIREMENT: OPTIONAL. Use this if you want to manually specify a single context file. If you leave this entry blank, it will use the default context file for the coding agent you picked when you set up Spec Kit. See `agent-context-defaults.json` for the defaults.
|
||||
# EXAMPLE: context_file: CLAUDE.md
|
||||
# Path (relative to the project root) to the default coding agent context file
|
||||
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
|
||||
# .github/copilot-instructions.md). Set automatically from the active
|
||||
# integration and regenerated during `specify init` or integration switches.
|
||||
context_file: ""
|
||||
|
||||
# WHAT: List of agent context files relative to the project root (the directory containing .specify/). If you have both `context_file` and `context_files` filled, then this (`context_files`) takes precedence. Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
# REQUIREMENT: OPTIONAL. Use this if your project requires you to keep multiple agent context files in sync.
|
||||
# EXAMPLE:
|
||||
# context_files:
|
||||
# - AGENTS.md
|
||||
# - CLAUDE.md
|
||||
# Optional list of project-relative coding agent context files managed by this
|
||||
# extension. When non-empty, this list takes precedence over `context_file`.
|
||||
# Use this for projects that intentionally keep multiple agent anchors in sync.
|
||||
context_files: []
|
||||
|
||||
# WHAT: Markers (delimiters) for the managed Spec Kit section. This extension injects information only between these markers.
|
||||
# REQUIREMENT: OPTIONAL. Only change if you wish to have a custom marker name.
|
||||
# EXAMPLE:
|
||||
# context_markers:
|
||||
# start: "<!-- AGENT SPEC KIT CONTEXT START -->"
|
||||
# end: "<!-- AGENT SPEC KIT CONTEXT END -->"
|
||||
# Delimiters for the managed Spec Kit section.
|
||||
# Edit these to use custom markers.
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
|
||||
@@ -10,7 +10,7 @@ This extension provides Git operations as an optional, self-contained module. It
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages, or Conventional Commit messages generated by the agent)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -66,11 +66,6 @@ branch_prefix: ""
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style for auto-commit hooks: "fixed" (default) uses the
|
||||
# messages below; "conventional" asks the agent to generate a Conventional
|
||||
# Commit message (e.g. "feat: add OAuth spec") from the diff instead.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit per command (all disabled by default)
|
||||
# Example: enable auto-commit after specify
|
||||
auto_commit:
|
||||
|
||||
@@ -14,37 +14,23 @@ This command is invoked as a hook after (or before) core commands. It:
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Determines the commit message based on `commit_style` (see below)
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Commit Message Styles
|
||||
|
||||
Controlled by the `commit_style` key in `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
- **`fixed`** (default): use the per-command `message` if configured, otherwise a generic `[Spec Kit] Auto-commit <phase> <command>` message.
|
||||
- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Write this message to a temporary file and pass the file's path to the script (see Execution below). The configured `message` values are ignored in this mode.
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name> [--message-file <path>]`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> [-MessageFile <path>]`
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass a generated message when `commit_style: conventional` is configured — first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:
|
||||
|
||||
- If `conventional`: inspect the diff and generate a Conventional Commit message. **Do not interpolate the generated message directly into a shell command string** — its content is derived from repository changes and may contain characters (quotes, `$(...)`, backticks) that a shell would execute or that would break command quoting. Instead, write the message to a temporary file using your file-editing tool (not a shell `echo`/`printf`), then pass that file's path via `--message-file <path>` (Bash) or `-MessageFile <path>` (PowerShell).
|
||||
- If `fixed` or absent: run the script with just `<event_name>`; it uses the configured/static message.
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
# "fixed" (default) uses the messages below; "conventional" asks the agent
|
||||
# to generate a Conventional Commit message from the diff instead.
|
||||
commit_style: fixed
|
||||
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
@@ -60,4 +46,3 @@ auto_commit:
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
- If `commit_style: conventional` is set and no generated message was supplied: fails with a clear error instead of silently falling back to the fixed message format
|
||||
|
||||
@@ -17,13 +17,6 @@ branch_prefix: ""
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style used by auto-commit hooks (speckit.git.commit):
|
||||
# "fixed" - default; use the configured/static messages below.
|
||||
# "conventional" - ask the agent to inspect the diff and generate a
|
||||
# Conventional Commit message (e.g. "feat: add OAuth spec")
|
||||
# instead of using the messages configured below.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
|
||||
@@ -17,13 +17,6 @@ branch_prefix: ""
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style used by auto-commit hooks (speckit.git.commit):
|
||||
# "fixed" - default; use the configured/static messages below.
|
||||
# "conventional" - ask the agent to inspect the diff and generate a
|
||||
# Conventional Commit message (e.g. "feat: add OAuth spec")
|
||||
# instead of using the messages configured below.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
|
||||
@@ -3,57 +3,16 @@
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.sh <event_name> [generated_message]
|
||||
# auto-commit.sh <event_name> --message-file <path>
|
||||
# Usage: auto-commit.sh <event_name>
|
||||
# e.g.: auto-commit.sh after_specify
|
||||
# e.g.: auto-commit.sh after_specify --message-file /tmp/commit-msg.txt (commit_style: conventional)
|
||||
#
|
||||
# --message-file is the preferred way to supply an agent-generated commit
|
||||
# message: it reads the message from a file instead of a shell argument,
|
||||
# so message content (which may contain quotes, `$(...)`, backticks, etc.)
|
||||
# is never interpolated into a shell command line.
|
||||
|
||||
set -e
|
||||
|
||||
EVENT_NAME="${1:-}"
|
||||
if [ -z "$EVENT_NAME" ]; then
|
||||
echo "Usage: $0 <event_name> [generated_message | --message-file <path>]" >&2
|
||||
echo "Usage: $0 <event_name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
# Optional second argument: an agent-generated commit message (used when
|
||||
# commit_style: conventional is configured). Prefer --message-file over
|
||||
# passing the message directly as a shell argument.
|
||||
GENERATED_MESSAGE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--message-file)
|
||||
_message_file="${2:-}"
|
||||
if [ -z "$_message_file" ]; then
|
||||
echo "[specify] Error: --message-file requires a path argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$_message_file" ]; then
|
||||
echo "[specify] Error: message file '$_message_file' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
GENERATED_MESSAGE="$(cat "$_message_file")"
|
||||
# The message file is a transport-only artifact: its content is
|
||||
# now captured above, so remove it immediately. Otherwise, if it
|
||||
# was written inside the worktree, it would be picked up as an
|
||||
# untracked change by both the "any changes?" check below and by
|
||||
# `git add .`, polluting the commit or defeating the no-changes
|
||||
# short-circuit even when nothing else changed.
|
||||
rm -f "$_message_file"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
GENERATED_MESSAGE="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
@@ -87,22 +46,8 @@ fi
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
_enabled=false
|
||||
_commit_msg=""
|
||||
_commit_style="fixed"
|
||||
|
||||
if [ -f "$_config_file" ]; then
|
||||
# Top-level scalar key: commit_style (fixed | conventional)
|
||||
_style_val=$(grep -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr '[:upper:]' '[:lower:]')
|
||||
if [ -n "$_style_val" ]; then
|
||||
case "$_style_val" in
|
||||
fixed|conventional)
|
||||
_commit_style="$_style_val"
|
||||
;;
|
||||
*)
|
||||
echo "[specify] Warning: unknown commit_style '$_style_val' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Parse the auto_commit section for this event.
|
||||
# Look for auto_commit.<event_name>.enabled and .message
|
||||
# Also check auto_commit.default as fallback.
|
||||
@@ -149,12 +94,7 @@ if [ -f "$_config_file" ]; then
|
||||
[ "$_val" = "false" ] && _enabled=false
|
||||
fi
|
||||
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
||||
# Trim trailing whitespace before stripping the closing quote:
|
||||
# a value like `message: "Done" ` (trailing spaces after the
|
||||
# quote) would otherwise leave the quote dangling (`Done" `),
|
||||
# since the closing-quote strip is anchored to end-of-string.
|
||||
# The PowerShell twin .Trim()s first; match it for parity.
|
||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -183,17 +123,6 @@ if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null &&
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# In conventional mode, the commit message must be supplied by the agent
|
||||
# (via the generated_message argument); never fall back to the fixed message.
|
||||
if [ "$_commit_style" = "conventional" ]; then
|
||||
if [ -n "$GENERATED_MESSAGE" ]; then
|
||||
_commit_msg="$GENERATED_MESSAGE"
|
||||
else
|
||||
echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass --message-file <path>, or a raw message as arg 2, or set commit_style: fixed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
# e.g., after_specify -> specify, before_plan -> plan
|
||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
||||
|
||||
@@ -3,47 +3,14 @@
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.ps1 <event_name> [generated_message]
|
||||
# auto-commit.ps1 <event_name> -MessageFile <path>
|
||||
# Usage: auto-commit.ps1 <event_name>
|
||||
# e.g.: auto-commit.ps1 after_specify
|
||||
# e.g.: auto-commit.ps1 after_specify -MessageFile C:\temp\commit-msg.txt (commit_style: conventional)
|
||||
#
|
||||
# -MessageFile is the preferred way to supply an agent-generated commit
|
||||
# message: it reads the message from a file instead of a shell argument,
|
||||
# so message content (which may contain quotes, $(...), backticks, etc.)
|
||||
# is never interpolated into a shell command line.
|
||||
param(
|
||||
[Parameter(Position = 0, Mandatory = $true)]
|
||||
[string]$EventName,
|
||||
|
||||
# Optional agent-generated commit message (used when commit_style: conventional is configured).
|
||||
# Prefer -MessageFile over passing the message directly as a shell argument.
|
||||
[Parameter(Position = 1, Mandatory = $false)]
|
||||
[string]$GeneratedMessage = "",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MessageFile = ""
|
||||
[string]$EventName
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($MessageFile) {
|
||||
if (-not (Test-Path $MessageFile -PathType Leaf)) {
|
||||
Write-Warning "[specify] Error: message file '$MessageFile' not found"
|
||||
exit 1
|
||||
}
|
||||
$GeneratedMessage = (Get-Content -Path $MessageFile -Raw)
|
||||
if ($null -ne $GeneratedMessage) {
|
||||
$GeneratedMessage = $GeneratedMessage.TrimEnd("`r", "`n")
|
||||
}
|
||||
# The message file is a transport-only artifact: its content is now
|
||||
# captured above, so remove it immediately. Otherwise, if it was written
|
||||
# inside the worktree, it would be picked up as an untracked change by
|
||||
# both the "any changes?" check below and by `git add .`, polluting the
|
||||
# commit or defeating the no-changes short-circuit even when nothing
|
||||
# else changed.
|
||||
Remove-Item -Path $MessageFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
@@ -88,25 +55,8 @@ if (-not $isRepo) {
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
$enabled = $false
|
||||
$commitMsg = ""
|
||||
$commitStyle = "fixed"
|
||||
|
||||
if (Test-Path $configFile) {
|
||||
# Top-level scalar key: commit_style (fixed | conventional)
|
||||
foreach ($line in Get-Content $configFile) {
|
||||
if ($line -match '^commit_style:\s*(.+)$') {
|
||||
$styleVal = (($matches[1] -replace '\s+#.*$', '').Trim()) -replace '^["'']' -replace '["'']$'
|
||||
if ($styleVal) {
|
||||
$styleVal = $styleVal.ToLower()
|
||||
if ($styleVal -eq 'fixed' -or $styleVal -eq 'conventional') {
|
||||
$commitStyle = $styleVal
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: unknown commit_style '$styleVal' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Parse YAML to find auto_commit section
|
||||
$inAutoCommit = $false
|
||||
$inEvent = $false
|
||||
@@ -190,17 +140,6 @@ if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# In conventional mode, the commit message must be supplied by the agent
|
||||
# (via the GeneratedMessage argument); never fall back to the fixed message.
|
||||
if ($commitStyle -eq 'conventional') {
|
||||
if ($GeneratedMessage) {
|
||||
$commitMsg = $GeneratedMessage
|
||||
} else {
|
||||
Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass -MessageFile <path>, or a raw message as arg 2, or set commit_style: fixed)"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
||||
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
||||
|
||||
@@ -33,15 +33,7 @@ def _value_after_colon(line: str) -> str:
|
||||
|
||||
|
||||
def _strip_quotes(value: str) -> str:
|
||||
"""Strip surrounding whitespace, then one leading quote and all trailing quotes.
|
||||
|
||||
Trimming first matters when the YAML value has trailing whitespace after a
|
||||
closing quote (``message: "Done" ``): stripping quotes anchored to the end
|
||||
of string would leave the closing quote dangling (``Done" ``) because the
|
||||
quote is no longer at the end. The PowerShell twin ``.Trim()``s before
|
||||
stripping, so trim here too to keep all three script variants in parity.
|
||||
"""
|
||||
value = value.strip()
|
||||
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
|
||||
value = re.sub(r"^[\"']", "", value)
|
||||
return re.sub(r"[\"']*$", "", value)
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-23T00:00:00Z",
|
||||
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.4.1",
|
||||
"description": "Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence.",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -34,18 +33,18 @@
|
||||
"didactic-comments"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing.",
|
||||
"version": "0.3.0",
|
||||
"description": "Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.0/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -64,7 +63,7 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
@@ -97,13 +96,13 @@
|
||||
"architecture-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "architecture-governance",
|
||||
"version": "0.5.1",
|
||||
"description": "Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"version": "0.5.0",
|
||||
"description": "Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -130,7 +129,7 @@
|
||||
"assurance"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
@@ -244,13 +243,13 @@
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -272,7 +271,7 @@
|
||||
"linux"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
@@ -368,13 +367,13 @@
|
||||
"intake-authoring-governance": {
|
||||
"name": "Intake Authoring Governance",
|
||||
"id": "intake-authoring-governance",
|
||||
"version": "0.1.1",
|
||||
"description": "Creates traceable Spec Kit intakes from ordered text sources and now truthfully adopts legacy intakes without inventing predecessor receipts.",
|
||||
"version": "0.1.0",
|
||||
"description": "Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
@@ -389,10 +388,10 @@
|
||||
"authoring",
|
||||
"governance",
|
||||
"traceability",
|
||||
"legacy-adoption"
|
||||
"clarification"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-07-22T00:00:00Z"
|
||||
},
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
@@ -426,13 +425,13 @@
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -453,7 +452,7 @@
|
||||
"technical-debt"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
@@ -636,13 +635,13 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.6.1",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
|
||||
"version": "0.6.0",
|
||||
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA to Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -681,7 +680,7 @@
|
||||
"regulatory"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"sicario-core": {
|
||||
"name": "SicarioSpec Core",
|
||||
|
||||
@@ -8,24 +8,6 @@ description: Create or update the project constitution.
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
## Scope Guard
|
||||
|
||||
This command's own work is limited to creating or updating the project constitution and
|
||||
propagating constitution-driven changes to dependent Spec Kit artifacts.
|
||||
|
||||
- Classify every part of the user input as constitution content or a separate non-governance
|
||||
intent. Feature implementation, code generation, refactoring, build, and deployment requests
|
||||
are examples of non-governance intents.
|
||||
- You **MUST NOT** execute any non-governance intent. Defer each one to `Next Actions`.
|
||||
- You **MUST NOT** create, modify, or delete application source files or other artifacts
|
||||
unrelated to the constitution workflow.
|
||||
- If an instruction could be either constitution content or a non-governance intent, ask for
|
||||
clarification before making changes.
|
||||
- After updating the constitution, list each deferred intent in a `Next Actions` section with an
|
||||
appropriate follow-up Spec Kit command, such as `__SPECKIT_COMMAND_SPECIFY__`, but do not
|
||||
invoke it.
|
||||
- Omit `Next Actions` when there are no non-governance intents.
|
||||
|
||||
## Outline
|
||||
|
||||
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.14.1"
|
||||
version = "0.13.4"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -39,7 +39,6 @@ packages = ["src/specify_cli"]
|
||||
"templates/commands" = "specify_cli/core_pack/commands"
|
||||
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||
"scripts/python" = "specify_cli/core_pack/scripts/python"
|
||||
# Bundled extensions (installable via `specify extension add <name>`)
|
||||
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
||||
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Helpers for bounded HTTP downloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import socket
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
from typing import NoReturn, TypeVar
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
|
||||
ErrorT = TypeVar("ErrorT", bound=Exception)
|
||||
|
||||
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
READ_CHUNK_SIZE = 64 * 1024
|
||||
|
||||
# Tighter ceiling for responses that are read fully into memory and parsed as
|
||||
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
|
||||
# downloads; JSON metadata responses are far smaller, so capping them close to
|
||||
# their real size shrinks the memory-DoS surface and keeps the "too large"
|
||||
# error reachable (rather than only triggering on tens of MiB). Pass it
|
||||
# explicitly at each JSON call site so the intended bound is pinned there.
|
||||
# METADATA covers fixed-shape single-object responses (an OAuth token, one
|
||||
# release's metadata): a few KiB in practice, 1 MiB is already generous.
|
||||
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
|
||||
|
||||
|
||||
def _ip_address_without_scope(
|
||||
hostname: str,
|
||||
) -> IPv4Address | IPv6Address | None:
|
||||
"""Parse a canonical IP literal, validating an optional IPv6 zone ID."""
|
||||
if "%" in hostname:
|
||||
# Accept only the RFC 6874 ``%25<zone>`` spelling. Other escapes can
|
||||
# alter the IPv6 address when urllib unquotes the authority.
|
||||
address_text, separator, zone = hostname.partition("%25")
|
||||
if (
|
||||
not separator
|
||||
or ":" not in address_text
|
||||
or "%" in address_text
|
||||
or "%" in zone
|
||||
):
|
||||
return None
|
||||
if not zone or any(
|
||||
not (character.isascii() and (character.isalnum() or character in "._~-"))
|
||||
for character in zone
|
||||
):
|
||||
return None
|
||||
else:
|
||||
address_text = hostname
|
||||
try:
|
||||
address = ip_address(address_text)
|
||||
except ValueError:
|
||||
return None
|
||||
if "%" in hostname and not isinstance(address, IPv6Address):
|
||||
return None
|
||||
return address
|
||||
|
||||
|
||||
def _is_ip_loopback(address: IPv4Address | IPv6Address | None) -> bool:
|
||||
if address is None:
|
||||
return False
|
||||
mapped = getattr(address, "ipv4_mapped", None)
|
||||
return address.is_loopback or bool(mapped and mapped.is_loopback)
|
||||
|
||||
|
||||
def _is_ip_local_redirect_target(
|
||||
address: IPv4Address | IPv6Address | None,
|
||||
) -> bool:
|
||||
"""Treat loopback and unspecified listener aliases as local targets."""
|
||||
if address is None:
|
||||
return False
|
||||
mapped = getattr(address, "ipv4_mapped", None)
|
||||
return _is_ip_loopback(address) or address.is_unspecified or bool(
|
||||
mapped and mapped.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _parse_url(url: str) -> ParseResult | None:
|
||||
"""Parse *url*, rejecting missing hosts and malformed ports."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
# Accessing ``port`` performs urllib's range and syntax validation.
|
||||
parsed.port
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
if "%" in hostname:
|
||||
# urllib unquotes reg-name/IPv4 authorities before connecting. Reject
|
||||
# them so encoded dots, characters, ports, or brackets cannot make the
|
||||
# validated hostname differ from the effective target. The only safe
|
||||
# percent form retained is a validated bracketed IPv6 zone ID.
|
||||
if _ip_address_without_scope(hostname) is None:
|
||||
return None
|
||||
elif ":" not in hostname:
|
||||
try:
|
||||
hostname.encode("idna")
|
||||
except UnicodeError:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _is_definite_loopback_host(hostname: str) -> bool:
|
||||
"""Recognize only unambiguous hosts that may safely authorize HTTP."""
|
||||
if not hostname.isascii():
|
||||
return False
|
||||
if hostname == "localhost":
|
||||
return True
|
||||
return _is_ip_loopback(_ip_address_without_scope(hostname))
|
||||
|
||||
|
||||
def _is_potential_local_target_host(hostname: str) -> bool:
|
||||
"""Conservatively classify aliases that could reach a local listener."""
|
||||
if ":" in hostname:
|
||||
return _is_ip_local_redirect_target(_ip_address_without_scope(hostname))
|
||||
try:
|
||||
host = hostname.encode("idna").decode("ascii").lower().removesuffix(".")
|
||||
except UnicodeError:
|
||||
return False
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
return True
|
||||
|
||||
address = _ip_address_without_scope(host)
|
||||
if address is None:
|
||||
# Historical IPv4 spellings are resolver-dependent. They are never
|
||||
# trusted to authorize HTTP, but treating them as potentially local
|
||||
# prevents them from bypassing a remote-to-loopback redirect check.
|
||||
try:
|
||||
address = ip_address(socket.inet_aton(host))
|
||||
except OSError:
|
||||
return False
|
||||
return _is_ip_local_redirect_target(address)
|
||||
|
||||
|
||||
def is_loopback_url(url: str) -> bool:
|
||||
"""Return whether *url* has an unambiguous loopback host."""
|
||||
parsed = _parse_url(url)
|
||||
return parsed is not None and _is_definite_loopback_host(parsed.hostname)
|
||||
|
||||
|
||||
def _is_potential_local_target_url(url: str) -> bool:
|
||||
parsed = _parse_url(url)
|
||||
return parsed is not None and _is_potential_local_target_host(parsed.hostname)
|
||||
|
||||
|
||||
def is_https_or_localhost_http(url: str) -> bool:
|
||||
"""Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
|
||||
|
||||
Shared scheme-safety predicate used by the auth HTTP redirect handler and
|
||||
direct URL validations in CLI download flows.
|
||||
|
||||
A hostname is always required: a URL without one (e.g. ``https:///x``)
|
||||
has no real target and is rejected regardless of scheme.
|
||||
|
||||
The HTTP exception is deliberately limited to unambiguous ``localhost``
|
||||
and canonical IPv4/IPv6 loopback literals. Ambiguous numeric, Unicode, and
|
||||
unspecified-address aliases are classified defensively for redirects but
|
||||
never authorize HTTP. No DNS lookup is performed; DNS and hosts-file
|
||||
aliases require connection-level rebinding protection outside this helper.
|
||||
"""
|
||||
parsed = _parse_url(url)
|
||||
if parsed is None:
|
||||
return False
|
||||
return parsed.scheme == "https" or (
|
||||
parsed.scheme == "http" and _is_definite_loopback_host(parsed.hostname)
|
||||
)
|
||||
|
||||
|
||||
def is_safe_download_redirect(old_url: str, new_url: str) -> bool:
|
||||
"""Return whether a redirect preserves the shared download URL policy."""
|
||||
if not is_https_or_localhost_http(new_url):
|
||||
return False
|
||||
return not _is_potential_local_target_url(new_url) or is_loopback_url(old_url)
|
||||
|
||||
|
||||
def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
|
||||
raise error_type(message)
|
||||
|
||||
|
||||
def read_response_limited(
|
||||
response,
|
||||
*,
|
||||
max_bytes: int = MAX_DOWNLOAD_BYTES,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
label: str = "download",
|
||||
) -> bytes:
|
||||
"""Read at most *max_bytes* from a response object.
|
||||
|
||||
``response.read(n)`` is only guaranteed to return *up to* ``n`` bytes and may
|
||||
return fewer even when more data is pending (e.g. chunked transfer encoding),
|
||||
so a single ``read(max_bytes + 1)`` cannot enforce the bound on its own. Read
|
||||
in a loop until EOF or until one byte past the limit has been accumulated.
|
||||
|
||||
*max_bytes* is keyword-only. It defaults to the module-wide
|
||||
``MAX_DOWNLOAD_BYTES`` (50 MiB) ceiling for archive/payload downloads;
|
||||
callers with a tighter budget (e.g. small JSON responses) should pass an
|
||||
explicit value so the intended bound is pinned at the call site rather than
|
||||
tracking changes to the shared default.
|
||||
"""
|
||||
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int):
|
||||
raise TypeError("max_bytes must be an integer")
|
||||
if max_bytes < 0:
|
||||
raise ValueError("max_bytes must be non-negative")
|
||||
|
||||
output = io.BytesIO()
|
||||
total = 0
|
||||
limit = max_bytes + 1
|
||||
while total < limit:
|
||||
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
_raise(error_type, f"{label} exceeds maximum size of {max_bytes} bytes")
|
||||
output.write(chunk)
|
||||
return output.getvalue()
|
||||
@@ -100,8 +100,6 @@ def resolve_github_release_asset_api_url(
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
from specify_cli._download_security import read_response_limited
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
|
||||
@@ -160,13 +158,10 @@ def resolve_github_release_asset_api_url(
|
||||
if redirect_validator is not None:
|
||||
open_kwargs["redirect_validator"] = redirect_validator
|
||||
with open_url_fn(release_url, **open_kwargs) as response:
|
||||
release_data = json.loads(
|
||||
read_response_limited(
|
||||
response,
|
||||
max_bytes=max_metadata_bytes,
|
||||
label=f"GitHub release metadata {release_url}",
|
||||
)
|
||||
)
|
||||
raw_release_data = response.read(max_metadata_bytes + 1)
|
||||
if len(raw_release_data) > max_metadata_bytes:
|
||||
raise ValueError("GitHub release metadata exceeds size limit")
|
||||
release_data = json.loads(raw_release_data)
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
json.JSONDecodeError,
|
||||
|
||||
@@ -4,8 +4,8 @@ Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
|
||||
release tag. The ``self_app`` Typer sub-command group is co-located here so
|
||||
all version-related logic lives in one place.
|
||||
|
||||
Dependencies: stdlib + packaging + ._console + ._download_security only
|
||||
(keeping this layer thin and circular-import-safe).
|
||||
Dependencies: stdlib + packaging + ._console only (no other internal imports
|
||||
at module level, keeping this layer thin and circular-import-safe).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,7 +28,6 @@ from pathlib import Path
|
||||
import typer
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
|
||||
from ._console import console
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
@@ -120,13 +119,7 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(
|
||||
read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_JSON_METADATA_BYTES,
|
||||
label="GitHub latest release",
|
||||
).decode("utf-8")
|
||||
)
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
raise ValueError("GitHub API response missing valid tag_name")
|
||||
|
||||
@@ -8,7 +8,6 @@ import os
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
|
||||
from .base import AuthProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -18,20 +17,6 @@ if TYPE_CHECKING:
|
||||
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
|
||||
|
||||
|
||||
class _TokenResponseTooLarge(Exception):
|
||||
"""Raised when an Azure AD token response exceeds the bounded read limit."""
|
||||
|
||||
|
||||
def _extract_token(payload: object, key: str) -> str | None:
|
||||
"""Return a normalized token from a JSON object, or None for other shapes."""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
token = payload.get(key)
|
||||
if not isinstance(token, str):
|
||||
return None
|
||||
return token.strip() or None
|
||||
|
||||
|
||||
class AzureDevOpsAuth(AuthProvider):
|
||||
"""Azure DevOps authentication provider.
|
||||
|
||||
@@ -89,7 +74,8 @@ class AzureDevOpsAuth(AuthProvider):
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
payload = _json.loads(result.stdout)
|
||||
return _extract_token(payload, "accessToken")
|
||||
token = payload.get("accessToken", "").strip()
|
||||
return token or None
|
||||
except (
|
||||
OSError,
|
||||
subprocess.TimeoutExpired,
|
||||
@@ -133,37 +119,9 @@ class AzureDevOpsAuth(AuthProvider):
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
try:
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
|
||||
def reject_token_redirect(_old_url: str, new_url: str) -> None:
|
||||
# A 307/308 redirect preserves this POST body, including the
|
||||
# client_secret. Refuse every redirect so credentials cannot
|
||||
# leave the fixed Microsoft token endpoint.
|
||||
raise urllib.error.URLError(
|
||||
f"Azure AD token request must not be redirected to {new_url}"
|
||||
)
|
||||
|
||||
opener = urllib.request.build_opener(
|
||||
_StripAuthOnRedirect((), reject_token_redirect)
|
||||
)
|
||||
with opener.open(req, timeout=30) as resp: # noqa: S310
|
||||
payload = _json.loads(
|
||||
read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_JSON_METADATA_BYTES,
|
||||
error_type=_TokenResponseTooLarge,
|
||||
label="Azure DevOps token response",
|
||||
).decode("utf-8")
|
||||
)
|
||||
return _extract_token(payload, "access_token")
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
OSError,
|
||||
_json.JSONDecodeError,
|
||||
UnicodeDecodeError,
|
||||
_TokenResponseTooLarge,
|
||||
):
|
||||
# Network failure, malformed JSON, or an oversized response — fall
|
||||
# through to the next strategy. Unrelated programming errors (other
|
||||
# ValueErrors, KeyErrors) intentionally propagate so they surface.
|
||||
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
|
||||
payload = _json.loads(resp.read().decode("utf-8"))
|
||||
token = payload.get("access_token", "").strip()
|
||||
return token or None
|
||||
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
@@ -17,7 +17,6 @@ from fnmatch import fnmatch
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .._download_security import is_safe_download_redirect
|
||||
from . import get_provider
|
||||
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
|
||||
|
||||
@@ -61,23 +60,8 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
RedirectValidator = Callable[[str, str], None]
|
||||
|
||||
|
||||
def _validate_strict_redirect(old_url: str, new_url: str) -> None:
|
||||
if not is_safe_download_redirect(old_url, new_url):
|
||||
raise urllib.error.URLError(
|
||||
f"unsafe redirect to {new_url}: target must use HTTPS with a hostname, "
|
||||
"must not enter a local target from a remote host, and may use HTTP only "
|
||||
"within loopback (for example localhost, 127.0.0.1, ::1)"
|
||||
)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Redirect handler that guards every redirect it is installed for.
|
||||
|
||||
1. Run any caller-provided redirect validator.
|
||||
2. Reject redirects that are not HTTPS with a hostname. HTTP loopback is
|
||||
allowed only when the previous hop is also loopback.
|
||||
3. Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades.
|
||||
"""
|
||||
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -91,8 +75,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
try:
|
||||
new_parsed = urlparse(newurl)
|
||||
# Force urllib's syntax and range validation before following.
|
||||
new_parsed.port
|
||||
except ValueError as exc:
|
||||
# Malformed redirect target (e.g. unterminated IPv6 bracket).
|
||||
# Surface as URLError so callers' download error handling applies.
|
||||
@@ -100,7 +82,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
|
||||
if self._redirect_validator is not None:
|
||||
self._redirect_validator(req.full_url, newurl)
|
||||
_validate_strict_redirect(req.full_url, newurl)
|
||||
|
||||
original_auth = (
|
||||
req.get_header("Authorization")
|
||||
@@ -174,12 +155,6 @@ def open_url(
|
||||
*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.
|
||||
|
||||
Every attempt uses an isolated opener so a process-wide opener installed
|
||||
with ``urllib.request.install_opener`` cannot replace the redirect guard.
|
||||
Redirect scheme safety: every attempt goes through
|
||||
``_StripAuthOnRedirect``, which rejects redirects to non-HTTPS URLs except
|
||||
HTTP between loopback URLs, and rejects remote-to-local redirects.
|
||||
"""
|
||||
entries = find_entries_for_url(url, _load_config())
|
||||
|
||||
@@ -213,7 +188,7 @@ def open_url(
|
||||
|
||||
# No entry worked (or none matched) — unauthenticated fallback
|
||||
req = _make_req({})
|
||||
# No auth is attached on this path, so the handler's host list is empty:
|
||||
# here it runs redirect validation only, not auth stripping.
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
|
||||
return opener.open(req, timeout=timeout)
|
||||
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
|
||||
|
||||
@@ -40,12 +40,9 @@ def _read(project_root: Path) -> list[dict]:
|
||||
path = ensure_within(project_root, _config_path(project_root))
|
||||
if not path.exists():
|
||||
return []
|
||||
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
|
||||
# otherwise, so a non-mapping top level — a falsy ``[]``/``false``/``0``/``''``
|
||||
# or an explicit null (``load_yaml`` -> ``None``) — is caught by the isinstance
|
||||
# guard below and raised like a truthy one, staying consistent with the other
|
||||
# reader of this file (models/catalog._merge_config).
|
||||
data = load_yaml(path)
|
||||
if data is None:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {path}: expected a mapping at the top "
|
||||
|
||||
@@ -39,35 +39,17 @@ def ensure_within(root: Path, candidate: Path) -> Path:
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
"""Parse a YAML file, returning ``{}`` only for an *empty* document.
|
||||
|
||||
A non-empty document is returned exactly as parsed — including a
|
||||
non-mapping such as ``[]``, ``false``, ``0``, ``''``, or an explicit null
|
||||
(``null``/``~``) — so callers can validate the top-level shape (e.g. reject
|
||||
a non-mapping config) instead of having it silently coerced to an empty
|
||||
mapping.
|
||||
|
||||
``yaml.safe_load`` returns ``None`` for *both* an empty document and an
|
||||
explicit null scalar, so ``yaml.compose`` (which yields no node only for a
|
||||
truly empty document) is used to tell them apart: an empty document becomes
|
||||
``{}`` while an explicit ``null``/``~`` is returned as ``None`` for the
|
||||
caller to reject.
|
||||
"""
|
||||
"""Parse a YAML file, returning ``{}`` for an empty document."""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"File not found: {path}")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
try:
|
||||
has_node = yaml.compose(text) is not None
|
||||
data = yaml.safe_load(text)
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
|
||||
if data is None and not has_node:
|
||||
return {}
|
||||
return data
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
|
||||
|
||||
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
@@ -78,13 +60,7 @@ def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
yaml.safe_dump(
|
||||
data,
|
||||
handle,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
yaml.safe_dump(data, handle, sort_keys=False, default_flow_style=False)
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not write {path}: {exc}") from exc
|
||||
return path
|
||||
|
||||
@@ -152,21 +152,14 @@ class CatalogEntry:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each catalog entry must be a mapping.")
|
||||
entry_id = str(data.get("id", "")).strip()
|
||||
# `or {}` would coerce a FALSY non-mapping (0, '', False, []) to {} before
|
||||
# the isinstance guard, silently accepting a corrupt catalog entry; only
|
||||
# an absent/None value means "not present".
|
||||
requires = data.get("requires")
|
||||
if requires is None:
|
||||
requires = {}
|
||||
elif not isinstance(requires, dict):
|
||||
requires = data.get("requires") or {}
|
||||
if not isinstance(requires, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'requires' must be a "
|
||||
"mapping when present."
|
||||
)
|
||||
provides_raw = data.get("provides")
|
||||
if provides_raw is None:
|
||||
provides_raw = {}
|
||||
elif not isinstance(provides_raw, dict):
|
||||
provides_raw = data.get("provides") or {}
|
||||
if not isinstance(provides_raw, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'provides' must be a "
|
||||
"mapping when present."
|
||||
@@ -256,18 +249,8 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -
|
||||
def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None:
|
||||
if not config_path.exists():
|
||||
return
|
||||
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
|
||||
# otherwise, so a non-mapping top level (a YAML list or scalar, including
|
||||
# the falsy ``[]``/``false``/``0``/``''``) is caught here and raised —
|
||||
# matching the sibling reader commands_impl/catalog_config._read. #3623
|
||||
# aligned the inner non-list ``catalogs`` value between the two readers.
|
||||
data = load_yaml(config_path)
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {config_path}: expected a mapping at "
|
||||
f"the top level, got {type(data).__name__}."
|
||||
)
|
||||
catalogs = data.get("catalogs")
|
||||
catalogs = data.get("catalogs") if isinstance(data, dict) else None
|
||||
if catalogs is None:
|
||||
return
|
||||
if not isinstance(catalogs, list):
|
||||
|
||||
@@ -111,10 +111,8 @@ class BundleManifest:
|
||||
license=str(bundle_raw.get("license", "")).strip(),
|
||||
)
|
||||
|
||||
requires_raw = data.get("requires")
|
||||
if requires_raw is None:
|
||||
requires_raw = {}
|
||||
elif not isinstance(requires_raw, dict):
|
||||
requires_raw = data.get("requires") or {}
|
||||
if not isinstance(requires_raw, dict):
|
||||
raise BundlerError("'requires' must be a mapping when present.")
|
||||
requires = Requires(
|
||||
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
|
||||
@@ -132,10 +130,8 @@ class BundleManifest:
|
||||
if isinstance(integration_raw, dict) and integration_raw.get("id"):
|
||||
integration = IntegrationRef(id=str(integration_raw["id"]).strip())
|
||||
|
||||
provides = data.get("provides")
|
||||
if provides is None:
|
||||
provides = {}
|
||||
elif not isinstance(provides, dict):
|
||||
provides = data.get("provides") or {}
|
||||
if not isinstance(provides, dict):
|
||||
raise BundlerError("'provides' must be a mapping when present.")
|
||||
|
||||
tags_raw = data.get("tags")
|
||||
|
||||
@@ -55,13 +55,8 @@ class InstalledBundleRecord:
|
||||
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each installed-bundle record must be a mapping.")
|
||||
components_raw = data.get("contributed_components")
|
||||
if components_raw is None:
|
||||
components_raw = []
|
||||
elif not isinstance(components_raw, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to []
|
||||
# before this guard, silently accepting a corrupt record; only an
|
||||
# absent/None value means "no components".
|
||||
components_raw = data.get("contributed_components") or []
|
||||
if not isinstance(components_raw, list):
|
||||
raise BundlerError(
|
||||
"Corrupt record: 'contributed_components' must be a list."
|
||||
)
|
||||
@@ -126,13 +121,8 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(f"Corrupt records file: {path}")
|
||||
_check_schema_version(data.get("schema_version"), path=path, required=True)
|
||||
bundles = data.get("bundles")
|
||||
if bundles is None:
|
||||
bundles = []
|
||||
elif not isinstance(bundles, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before
|
||||
# this guard, silently treating a corrupt file as "no bundles"; only an
|
||||
# absent/None value means empty.
|
||||
bundles = data.get("bundles") or []
|
||||
if not isinstance(bundles, list):
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: {path} — 'bundles' must be a list."
|
||||
)
|
||||
|
||||
@@ -50,10 +50,7 @@ class InstallResult:
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
# `uninstalled` is a mutating outcome too: a `bundle update` whose new
|
||||
# manifest drops components (removing them via the refresh path) with no
|
||||
# new install/refresh must still report changed=True, not a no-op.
|
||||
return bool(self.installed or self.refreshed or self.uninstalled)
|
||||
return bool(self.installed or self.refreshed)
|
||||
|
||||
|
||||
def install_bundle(
|
||||
|
||||
@@ -142,10 +142,4 @@ def _collect_files(
|
||||
# Skip symlinked files to avoid escaping the bundle directory.
|
||||
continue
|
||||
collected.append(path)
|
||||
# Order by the canonical POSIX arcname (the same key build_bundle uses to
|
||||
# NAME each member), not by pathlib.Path comparison. Path ordering is
|
||||
# platform-dependent (Windows folds case and uses backslash separators),
|
||||
# which would lay out zip members differently across build hosts and break
|
||||
# the byte-for-byte reproducible-build guarantee even though the member
|
||||
# names are identical.
|
||||
return sorted(collected, key=lambda p: p.relative_to(bundle_dir).as_posix())
|
||||
return sorted(collected)
|
||||
|
||||
@@ -1330,20 +1330,19 @@ class ExtensionManager:
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
import yaml as _yaml
|
||||
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
# Parse on the ``---`` delimiter *line*, not any ``---``
|
||||
# substring: a description containing ``---`` would trip a
|
||||
# raw ``split("---", 2)`` and hide metadata.source, so this
|
||||
# extension's own skill would look unrelated and be left
|
||||
# orphaned. Mirrors the #3590 parse_frontmatter fix.
|
||||
fm, _ = _Registrar.parse_frontmatter(raw)
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
if source != f"extension:{extension_id}":
|
||||
continue
|
||||
except (OSError, UnicodeDecodeError, Exception):
|
||||
@@ -1387,20 +1386,19 @@ class ExtensionManager:
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
import yaml as _yaml
|
||||
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
# Parse on the ``---`` delimiter *line*, not any ``---``
|
||||
# substring: a description containing ``---`` would trip
|
||||
# a raw ``split("---", 2)`` and hide metadata.source, so
|
||||
# this extension's own skill would look unrelated and be
|
||||
# left orphaned. Mirrors the #3590 parse_frontmatter fix.
|
||||
fm, _ = _Registrar.parse_frontmatter(raw)
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
# Only remove skills explicitly created by this extension
|
||||
if source != f"extension:{extension_id}":
|
||||
continue
|
||||
|
||||
@@ -428,17 +428,10 @@ def extension_add(
|
||||
|
||||
try:
|
||||
parsed = urlparse(from_url)
|
||||
# Read .hostname inside the try: parsing a malformed authority -- or
|
||||
# accessing .hostname on one, e.g. an invalid bracketed IPv6 host like
|
||||
# "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the
|
||||
# parse and the .hostname read inside the guard surfaces a clean
|
||||
# "Invalid URL" message instead of leaking a raw traceback past the
|
||||
# CLI. Reuse the value below.
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
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("[red]Error:[/red] URL must use HTTPS for security.")
|
||||
@@ -630,22 +623,16 @@ def extension_add(
|
||||
for warning in manifest.warnings:
|
||||
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
|
||||
|
||||
selected_ai = load_init_options(project_root).get("ai")
|
||||
is_cline = selected_ai == "cline"
|
||||
is_forge = selected_ai == "forge"
|
||||
is_cline = load_init_options(project_root).get("ai") == "cline"
|
||||
|
||||
if is_cline:
|
||||
from specify_cli.integrations.cline import format_cline_command_name
|
||||
if is_forge:
|
||||
from specify_cli.integrations.forge import format_forge_command_name
|
||||
|
||||
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
||||
for cmd in manifest.commands:
|
||||
cmd_name = cmd['name']
|
||||
if is_cline:
|
||||
cmd_name = format_cline_command_name(cmd_name)
|
||||
elif is_forge:
|
||||
cmd_name = format_forge_command_name(cmd_name)
|
||||
console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}")
|
||||
|
||||
# Report agent skills registration
|
||||
|
||||
@@ -64,15 +64,8 @@ def with_integration_setting(
|
||||
elif raw_options is not None:
|
||||
current.pop("parsed_options", None)
|
||||
|
||||
# Recompute the separator from the options actually STORED on ``current``
|
||||
# after the update, not the raw ``parsed_options`` argument. When only
|
||||
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
|
||||
# None), the previously-stored ``parsed_options`` are retained above, so
|
||||
# deriving the separator from the argument (None) would drop an
|
||||
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
|
||||
# the default ".".
|
||||
current["invoke_separator"] = integration.effective_invoke_separator(
|
||||
current.get("parsed_options"), project_root
|
||||
parsed_options, project_root
|
||||
)
|
||||
settings[key] = current
|
||||
return settings
|
||||
|
||||
@@ -138,14 +138,8 @@ class ClineIntegration(MarkdownIntegration):
|
||||
content,
|
||||
)
|
||||
|
||||
def post_process_command_content(self, content: str) -> str:
|
||||
"""Apply Cline-specific transformations to command content.
|
||||
|
||||
Overrides the ``IntegrationBase`` hook of the same name so that
|
||||
``CommandRegistrar.register_commands()`` (which dispatches to
|
||||
``post_process_command_content``) applies these transforms to
|
||||
extension/preset command files too, not just core commands.
|
||||
"""
|
||||
def post_process_content(self, content: str) -> str:
|
||||
"""Apply Cline-specific transformations to command content."""
|
||||
updated = self._inject_hook_command_note(content)
|
||||
updated = self._rewrite_handoff_references(updated)
|
||||
return updated
|
||||
@@ -175,7 +169,7 @@ class ClineIntegration(MarkdownIntegration):
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
updated = self.post_process_command_content(content)
|
||||
updated = self.post_process_content(content)
|
||||
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
|
||||
@@ -13,13 +13,6 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
|
||||
|
||||
class KiroCliIntegration(MarkdownIntegration):
|
||||
key = "kiro-cli"
|
||||
# Kiro CLI keeps everything under a static, isolated agent root
|
||||
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
|
||||
# integration writes to, so it is safe to install alongside others
|
||||
# (issue #3471). IntegrationBase defaults this to False; declaring it
|
||||
# True here is the actual behavior change this integration opts into.
|
||||
# The registry's multi-install-safe contract tests enforce that
|
||||
# isolation for every integration setting this flag.
|
||||
multi_install_safe = True
|
||||
config = {
|
||||
"name": "Kiro CLI",
|
||||
@@ -34,3 +27,10 @@ class KiroCliIntegration(MarkdownIntegration):
|
||||
"args": _KIRO_ARG_FALLBACK,
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
# Kiro CLI keeps everything under a static, isolated agent root
|
||||
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
|
||||
# integration writes to, so it is safe to install alongside others
|
||||
# (issue #3471). The registry's multi-install-safe contract tests
|
||||
# enforce that isolation for every integration setting this flag.
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -27,7 +27,6 @@ class LingmaIntegration(SkillsIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
|
||||
@@ -20,7 +20,6 @@ class OmpIntegration(MarkdownIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
|
||||
@@ -16,10 +16,6 @@ import yaml
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console
|
||||
from .._download_security import (
|
||||
is_https_or_localhost_http,
|
||||
is_safe_download_redirect,
|
||||
)
|
||||
|
||||
preset_app = typer.Typer(
|
||||
name="preset",
|
||||
@@ -106,25 +102,38 @@ def preset_add(
|
||||
|
||||
elif from_url:
|
||||
# Validate URL scheme before downloading
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
try:
|
||||
_parsed = _urlparse(from_url)
|
||||
_parsed.port
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
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_safe_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 without entering a local "
|
||||
"target, or stay within loopback over HTTP"
|
||||
"redirect target must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback"
|
||||
)
|
||||
|
||||
if not is_https_or_localhost_http(from_url):
|
||||
if not _is_allowed_download_url(_parsed):
|
||||
console.print(
|
||||
"[red]Error:[/red] URL must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback."
|
||||
@@ -158,7 +167,7 @@ def preset_add(
|
||||
redirect_validator=_validate_download_redirect,
|
||||
) as response:
|
||||
final_url = response.geturl() if hasattr(response, "geturl") else from_url
|
||||
if not is_https_or_localhost_http(final_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, "
|
||||
|
||||
@@ -20,10 +20,6 @@ import yaml
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console, err_console
|
||||
from .._download_security import (
|
||||
is_https_or_localhost_http,
|
||||
is_safe_download_redirect,
|
||||
)
|
||||
from .._project import _resolve_init_dir_override
|
||||
|
||||
workflow_app = typer.Typer(
|
||||
@@ -387,12 +383,27 @@ _RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"}
|
||||
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
|
||||
"""Reject insecure redirects before they are followed."""
|
||||
import urllib.error
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if is_safe_download_redirect(old_url, new_url):
|
||||
def _is_loopback_http(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "http":
|
||||
return False
|
||||
host = parsed.hostname or ""
|
||||
if host == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if urlparse(new_url).scheme == "https":
|
||||
return
|
||||
if _is_loopback_http(old_url) and _is_loopback_http(new_url):
|
||||
return
|
||||
raise urllib.error.URLError(
|
||||
"redirect target must use HTTPS without entering a local target; "
|
||||
"loopback HTTP may only redirect from another loopback URL"
|
||||
"redirect target must use HTTPS; loopback HTTP may only redirect from loopback HTTP"
|
||||
)
|
||||
|
||||
|
||||
@@ -1568,15 +1579,24 @@ def workflow_add(
|
||||
else (source if source.startswith(("http://", "https://")) else None)
|
||||
)
|
||||
if download_url is not None:
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
try:
|
||||
urlparse(download_url).port
|
||||
parsed_src = urlparse(download_url)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(download_url)}")
|
||||
raise typer.Exit(1)
|
||||
if not is_https_or_localhost_http(download_url):
|
||||
src_host = parsed_src.hostname or ""
|
||||
src_loopback = src_host == "localhost"
|
||||
if not src_loopback:
|
||||
try:
|
||||
src_loopback = ip_address(src_host).is_loopback
|
||||
except ValueError:
|
||||
# Host is not an IP literal (e.g., a DNS name); keep default non-loopback.
|
||||
pass
|
||||
if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
|
||||
console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -1627,7 +1647,16 @@ def workflow_add(
|
||||
redirect_validator=_reject_insecure_download_redirect,
|
||||
) as resp:
|
||||
final_url = resp.geturl()
|
||||
if not is_https_or_localhost_http(final_url):
|
||||
final_parsed = urlparse(final_url)
|
||||
final_host = final_parsed.hostname or ""
|
||||
final_lb = final_host == "localhost"
|
||||
if not final_lb:
|
||||
try:
|
||||
final_lb = ip_address(final_host).is_loopback
|
||||
except ValueError:
|
||||
# Redirect host is not an IP literal; keep loopback as determined above.
|
||||
pass
|
||||
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
|
||||
console.print(
|
||||
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
|
||||
)
|
||||
@@ -1759,17 +1788,27 @@ def _install_workflow_from_catalog(
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate URL scheme (HTTPS required, HTTP allowed for localhost only)
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
parsed_url = urlparse(workflow_url)
|
||||
parsed_url.port
|
||||
url_host = parsed_url.hostname or ""
|
||||
except ValueError:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if not is_https_or_localhost_http(workflow_url):
|
||||
is_loopback = False
|
||||
if url_host == "localhost":
|
||||
is_loopback = True
|
||||
else:
|
||||
try:
|
||||
is_loopback = ip_address(url_host).is_loopback
|
||||
except ValueError:
|
||||
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
|
||||
pass
|
||||
if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Workflow '{safe_wf_id}' has an invalid install URL. "
|
||||
"Only HTTPS URLs are allowed, except HTTP for localhost/loopback."
|
||||
@@ -1823,7 +1862,16 @@ def _install_workflow_from_catalog(
|
||||
) as response:
|
||||
# Validate final URL after redirects
|
||||
final_url = response.geturl()
|
||||
if not is_https_or_localhost_http(final_url):
|
||||
final_parsed = urlparse(final_url)
|
||||
final_host = final_parsed.hostname or ""
|
||||
final_loopback = final_host == "localhost"
|
||||
if not final_loopback:
|
||||
try:
|
||||
final_loopback = ip_address(final_host).is_loopback
|
||||
except ValueError:
|
||||
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
|
||||
pass
|
||||
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback):
|
||||
_safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
|
||||
@@ -2376,15 +2424,7 @@ def workflow_info(
|
||||
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
|
||||
for step in definition.steps:
|
||||
stype = step.get("type", "command")
|
||||
# Escape the literal bracket (\[) so Rich renders `[<type>]`
|
||||
# instead of parsing it as a style tag named after the step
|
||||
# type (which it silently swallows); escape id/type too, as
|
||||
# the sibling workflow_list does. Mirrors the `\[disabled]`
|
||||
# precedent above.
|
||||
console.print(
|
||||
f" → {_escape_markup(str(step.get('id', '?')))} "
|
||||
f"\\[{_escape_markup(str(stype))}]"
|
||||
)
|
||||
console.print(f" → {step.get('id', '?')} [{stype}]")
|
||||
return
|
||||
|
||||
# Try catalog
|
||||
@@ -2654,17 +2694,28 @@ def workflow_step_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _safe_fetch(url: str) -> bytes:
|
||||
if not is_https_or_localhost_http(url):
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}")
|
||||
if not parsed.hostname:
|
||||
raise ValueError(f"Refusing to fetch from URL with no hostname: {url}")
|
||||
with _open_url(
|
||||
url, timeout=30, redirect_validator=_reject_insecure_download_redirect
|
||||
) as resp:
|
||||
final_url = resp.geturl()
|
||||
if not is_https_or_localhost_http(final_url):
|
||||
final_parsed = urlparse(final_url)
|
||||
final_is_localhost = final_parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if final_parsed.scheme != "https" and not (
|
||||
final_parsed.scheme == "http" and final_is_localhost
|
||||
):
|
||||
raise ValueError(f"Redirect to non-HTTPS URL: {final_url}")
|
||||
if not final_parsed.hostname:
|
||||
raise ValueError(f"Redirect to URL with no hostname: {final_url}")
|
||||
return _read_response_within_limit(resp)
|
||||
|
||||
_validate_step_id_or_exit(step_id)
|
||||
|
||||
@@ -392,14 +392,8 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
|
||||
)
|
||||
return _filter_from_json(value)
|
||||
|
||||
# Parse filter name and argument. Use fullmatch (not match) so trailing
|
||||
# tokens after the closing paren — e.g. a comparison/boolean operator that
|
||||
# binds looser than the pipe, as in ``count | default(0) > 5`` — are not
|
||||
# silently discarded but fall through to the "unsupported form" ValueError
|
||||
# below, mirroring the strict trailing-token handling of the from_json
|
||||
# branch above. The greedy ``.+`` still handles literal ``)`` and ``|``
|
||||
# inside quoted args.
|
||||
filter_match = re.fullmatch(r"(\w+)\((.+)\)", filter_expr)
|
||||
# Parse filter name and argument
|
||||
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
|
||||
if filter_match:
|
||||
fname = filter_match.group(1)
|
||||
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
|
||||
|
||||
@@ -292,21 +292,9 @@ def _traverse_and_apply(
|
||||
cases[case_key] = _traverse_and_apply(case_steps, edits_by_anchor, sources)
|
||||
result.append(step)
|
||||
|
||||
# Insert after: higher-priority overlays land closer to the anchor
|
||||
# (reversed merge order), but a single overlay's own inserts must keep
|
||||
# their declared order — mirroring the forward insert_before loop above.
|
||||
# Reversing the whole flat list would also flip an overlay's own edits,
|
||||
# so group contiguous same-layer edits and reverse the GROUP order only.
|
||||
after_groups: list[list[tuple[OverlayLayer, OverlayEdit]]] = []
|
||||
for layer, edit in edits:
|
||||
if edit.operation != "insert_after":
|
||||
continue
|
||||
if after_groups and after_groups[-1][0][0] is layer:
|
||||
after_groups[-1].append((layer, edit))
|
||||
else:
|
||||
after_groups.append([(layer, edit)])
|
||||
for group in reversed(after_groups):
|
||||
for layer, edit in group:
|
||||
# Insert after (highest priority closest to anchor — reversed merge order).
|
||||
for layer, edit in reversed(edits):
|
||||
if edit.operation == "insert_after":
|
||||
new_step = copy.deepcopy(edit.step)
|
||||
_record_sources_recursively(new_step, layer.source, sources)
|
||||
result.append(new_step)
|
||||
|
||||
@@ -26,7 +26,7 @@ class GateStep(StepBase):
|
||||
later with ``specify workflow resume``.
|
||||
|
||||
The user's choice is stored in ``output.choice``. ``on_reject``
|
||||
controls abort / skip / retry behaviour.
|
||||
controls abort / skip behaviour.
|
||||
"""
|
||||
|
||||
type_key = "gate"
|
||||
|
||||
@@ -59,7 +59,7 @@ class InitStep(StepBase):
|
||||
Extra options for the integration (e.g. ``"--skills"`` or
|
||||
``"--commands-dir .myagent/cmds"``).
|
||||
``script``
|
||||
Script type, ``sh``, ``ps``, or ``py``.
|
||||
Script type, ``sh`` or ``ps``.
|
||||
``force``
|
||||
Merge/overwrite without confirmation when the directory is not
|
||||
empty.
|
||||
|
||||
@@ -14,25 +14,6 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Scope Guard
|
||||
|
||||
This command's own work is limited to updating the project constitution and propagating
|
||||
constitution-driven changes to the dependent artifacts identified in this command.
|
||||
|
||||
- Classify every part of the user input as either constitution content or a separate,
|
||||
non-governance intent.
|
||||
- If the input includes feature implementation, code generation, refactoring, building, or
|
||||
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
|
||||
- You **MUST NOT** create, modify, or delete application source files, feature routes,
|
||||
components, tests, deployment files, or other artifacts unrelated to the constitution
|
||||
workflow and its required propagation.
|
||||
- If it is unclear whether an instruction is constitution content, ask for clarification before
|
||||
making changes.
|
||||
- After completing the constitution update, include a `Next Actions` section for each deferred
|
||||
intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
|
||||
as `__SPECKIT_COMMAND_SPECIFY__`, without invoking it.
|
||||
- If there are no non-governance intents, omit the `Next Actions` section.
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before constitution update)**:
|
||||
@@ -123,7 +104,6 @@ Follow this execution flow:
|
||||
- New version and bump rationale.
|
||||
- Any files flagged for manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
- A `Next Actions` section for any deferred non-governance intents.
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
|
||||
@@ -139,9 +139,9 @@ Given that feature description, do this:
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
|
||||
@@ -68,44 +68,10 @@ def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"- a\n- b\n", # truthy list
|
||||
"42\n", # truthy scalar
|
||||
"[]\n", # falsy list
|
||||
"false\n", # falsy bool
|
||||
"0\n", # falsy int
|
||||
"''\n", # falsy empty string
|
||||
"null\n", # explicit null scalar (safe_load -> None, but a real node)
|
||||
"~\n", # explicit null scalar (alt spelling)
|
||||
],
|
||||
)
|
||||
def test_toplevel_non_mapping_raises(tmp_path: Path, body: str):
|
||||
"""A top-level non-mapping bundle-catalogs.yml (list/scalar/null) must raise,
|
||||
matching the sibling reader (catalog_config._read) — not silently fall back
|
||||
to the built-in default stack. This includes FALSY non-mappings ([], false,
|
||||
0, '') and an explicit null (null/~); the shared load_yaml would coerce those
|
||||
to {} and hide them, so it distinguishes them from a truly empty document."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"catalogs:\n", # present key, null value
|
||||
"catalogs: []\n", # present key, empty list
|
||||
"", # truly empty document
|
||||
"# only a comment\n", # comment-only == empty document
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
|
||||
def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
|
||||
"""An empty document, comment-only file, or absent/empty-list ``catalogs:``
|
||||
is valid: it contributes no project sources and falls back to the built-in
|
||||
default stack (must not be confused with an explicit top-level null)."""
|
||||
"""An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes
|
||||
no project sources and falls back to the built-in default stack."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
|
||||
# Does not raise; still yields the built-in defaults.
|
||||
@@ -241,17 +207,3 @@ def test_catalog_entry_rejects_non_mapping_provides():
|
||||
data["provides"] = "extensions"
|
||||
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["requires", "provides"])
|
||||
@pytest.mark.parametrize("bad", [[], "", 0, False])
|
||||
def test_catalog_entry_rejects_falsy_non_mapping(field, bad):
|
||||
# `or {}` coerced a FALSY non-mapping ([], '', 0, False) to {} before the
|
||||
# isinstance guard, silently accepting a corrupt entry; only absent/None
|
||||
# means "not present". Mirrors the manifest requires/provides guard.
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data[field] = bad
|
||||
with pytest.raises(BundlerError, match=f"'{field}' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
@@ -134,34 +134,3 @@ def test_string_integration_rejected_not_silently_dropped():
|
||||
data["integration"] = "copilot"
|
||||
with pytest.raises(BundlerError, match="'integration' must be a mapping when present"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [[], "", 0, False, "extensions"])
|
||||
def test_non_mapping_provides_rejected_including_falsy(bad):
|
||||
# `data.get("provides") or {}` coerced a FALSY non-mapping ([], '', 0, False)
|
||||
# to {} before the type check, so a malformed manifest passed validation as
|
||||
# a bundle that provides nothing. Only an absent/None value means "empty".
|
||||
data = valid_manifest_dict()
|
||||
data["provides"] = bad
|
||||
with pytest.raises(BundlerError, match="'provides' must be a mapping when present"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [[], "", 0, False, "speckit>=0.1"])
|
||||
def test_non_mapping_requires_rejected_including_falsy(bad):
|
||||
# Same falsy-coercion hole for `requires`.
|
||||
data = valid_manifest_dict()
|
||||
data["requires"] = bad
|
||||
with pytest.raises(BundlerError, match="'requires' must be a mapping when present"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_absent_provides_and_requires_do_not_raise_mapping_error():
|
||||
# Absent (None) optional mappings default to empty and must NOT trigger the
|
||||
# "must be a mapping when present" guard — that is reserved for present
|
||||
# non-mappings. (Structural completeness, e.g. requires.speckit_version, is
|
||||
# a separate concern checked by structural_errors().)
|
||||
data = valid_manifest_dict()
|
||||
data.pop("provides", None)
|
||||
data.pop("requires", None)
|
||||
BundleManifest.from_dict(data) # does not raise BundlerError
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Contract tests for the script variants bundled into the wheel's core_pack.
|
||||
|
||||
``specify init --script <type>`` installs from ``specify_cli/core_pack/scripts/``
|
||||
when the CLI runs from a wheel. Any script variant that lives in the repository
|
||||
must therefore be force-included at build time, otherwise the generated
|
||||
commands reference scripts the released package never ships (#3665).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
def _force_include() -> dict[str, str]:
|
||||
with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file:
|
||||
pyproject = tomllib.load(pyproject_file)
|
||||
return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"]
|
||||
|
||||
|
||||
def test_every_script_variant_is_bundled_into_core_pack():
|
||||
force_include = _force_include()
|
||||
variants = sorted(
|
||||
path.name for path in (REPO_ROOT / "scripts").iterdir() if path.is_dir()
|
||||
)
|
||||
|
||||
assert variants, "expected at least one script variant under scripts/"
|
||||
for variant in variants:
|
||||
assert force_include.get(f"scripts/{variant}") == (
|
||||
f"specify_cli/core_pack/scripts/{variant}"
|
||||
), f"scripts/{variant} is missing from the wheel force-include list"
|
||||
|
||||
|
||||
def test_python_script_variant_is_bundled():
|
||||
# Explicit regression guard for #3665: `--script py` shipped skills that
|
||||
# invoked python3 .specify/scripts/python/*.py while the wheel bundled
|
||||
# only the bash and PowerShell variants.
|
||||
assert _force_include()["scripts/python"] == "specify_cli/core_pack/scripts/python"
|
||||
@@ -1167,295 +1167,6 @@ class TestAutoCommitBash:
|
||||
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestAutoCommitBashCommitStyle:
|
||||
"""Tests for the `commit_style: conventional` option (issue #3390)."""
|
||||
|
||||
def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
|
||||
"""Omitting commit_style preserves the fixed/static message behavior."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
|
||||
"""commit_style: fixed (explicit) still uses the configured static message,
|
||||
not just the absent-key default."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: this should be ignored"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
assert "this should be ignored" not in log.stdout
|
||||
|
||||
def test_conventional_message_file_used(self, tmp_path: Path):
|
||||
"""--message-file reads the generated message from a file instead of argv,
|
||||
avoiding shell interpolation of agent-controlled content."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
# Write the message file inside the worktree (as an agent invoking
|
||||
# this from a working directory tool naturally would) to exercise
|
||||
# the exclusion-from-staging behavior below.
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add $(dangerous) `injection` test" in log.stdout
|
||||
|
||||
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
|
||||
"""--message-file written inside the worktree must never be staged or
|
||||
committed itself, and must be removed once its content is consumed."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: real change\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert not msg_file.exists()
|
||||
show = subprocess.run(
|
||||
["git", "show", "--stat", "--oneline", "HEAD"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "new-file.txt" in show.stdout
|
||||
assert "commit-msg.txt" not in show.stdout
|
||||
|
||||
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
|
||||
"""If the message file is the only 'change' in the worktree (no real
|
||||
edits), auto-commit must still report no changes rather than
|
||||
committing the transport file by itself."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
# Baseline-commit the scaffolding (and config) so the tree is
|
||||
# genuinely clean before introducing the message file — otherwise
|
||||
# the untracked scaffold files would mask whether the message file
|
||||
# alone is enough to (incorrectly) trigger a commit.
|
||||
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "baseline"],
|
||||
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: no real changes\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "No changes to commit" in result.stderr
|
||||
assert not msg_file.exists()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "baseline" in log.stdout
|
||||
|
||||
def test_message_file_missing_fails(self, tmp_path: Path):
|
||||
"""--message-file pointing at a nonexistent file fails clearly."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(missing)
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "not found" in result.stderr.lower()
|
||||
|
||||
def test_conventional_uses_generated_message(self, tmp_path: Path):
|
||||
"""commit_style: conventional uses the generated_message argument as the commit message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_without_generated_message_fails(self, tmp_path: Path):
|
||||
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode != 0
|
||||
assert "conventional" in result.stderr.lower()
|
||||
|
||||
# No commit should have been made, and the fixed message must not be used.
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
|
||||
"""No pending changes short-circuits before the missing-message failure."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
subprocess.run(["git", "add", "."], cwd=project, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "No changes" in result.stderr
|
||||
|
||||
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
|
||||
"""commit_style value with a trailing YAML inline comment is still recognized."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional # team standard\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
|
||||
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
|
||||
instead of silently mis-parsing or crashing."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventonal\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "unknown commit_style" in result.stderr.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_duplicate_commit_style_lines_use_first_match(self, tmp_path: Path):
|
||||
"""A config with multiple `commit_style:` lines (e.g. from a bad merge) uses only
|
||||
the first match instead of concatenating values into an unrecognized style."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "unknown commit_style" not in result.stderr.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestAutoCommitPowerShell:
|
||||
def test_disabled_by_default(self, tmp_path: Path):
|
||||
@@ -1516,271 +1227,6 @@ class TestAutoCommitPowerShell:
|
||||
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestAutoCommitPowerShellCommitStyle:
|
||||
"""Tests for the `commit_style: conventional` option (issue #3390)."""
|
||||
|
||||
def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
|
||||
"""Omitting commit_style preserves the fixed/static message behavior."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
|
||||
"""commit_style: fixed (explicit) still uses the configured static message,
|
||||
not just the absent-key default."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: this should be ignored"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
assert "this should be ignored" not in log.stdout
|
||||
|
||||
def test_conventional_message_file_used(self, tmp_path: Path):
|
||||
"""-MessageFile reads the generated message from a file instead of argv,
|
||||
avoiding shell interpolation of agent-controlled content."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add $(dangerous) `injection` test" in log.stdout
|
||||
|
||||
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
|
||||
"""-MessageFile written inside the worktree must never be staged or
|
||||
committed itself, and must be removed once its content is consumed."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: real change\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert not msg_file.exists()
|
||||
show = subprocess.run(
|
||||
["git", "show", "--stat", "--oneline", "HEAD"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "new-file.txt" in show.stdout
|
||||
assert "commit-msg.txt" not in show.stdout
|
||||
|
||||
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
|
||||
"""If the message file is the only 'change' in the worktree (no real
|
||||
edits), auto-commit must still report no changes rather than
|
||||
committing the transport file by itself."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
# Baseline-commit the scaffolding (and config) so the tree is
|
||||
# genuinely clean before introducing the message file — otherwise
|
||||
# the untracked scaffold files would mask whether the message file
|
||||
# alone is enough to (incorrectly) trigger a commit.
|
||||
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "baseline"],
|
||||
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: no real changes\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "No changes to commit" in (result.stdout + result.stderr)
|
||||
assert not msg_file.exists()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "baseline" in log.stdout
|
||||
|
||||
def test_message_file_missing_fails(self, tmp_path: Path):
|
||||
"""-MessageFile pointing at a nonexistent file fails clearly."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(missing)
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "not found" in (result.stdout + result.stderr).lower()
|
||||
|
||||
def test_conventional_uses_generated_message(self, tmp_path: Path):
|
||||
"""commit_style: conventional uses the generated_message argument as the commit message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_without_generated_message_fails(self, tmp_path: Path):
|
||||
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode != 0
|
||||
# Write-Warning output placement (stdout vs. stderr) is not deterministic
|
||||
# across pwsh versions/platforms, so check the combined stream like the
|
||||
# other pwsh tests above (e.g. test_not_a_repo_still_detected_with_autocrlf).
|
||||
combined = result.stdout + result.stderr
|
||||
assert "conventional" in combined.lower()
|
||||
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
|
||||
"""No pending changes short-circuits before the missing-message failure."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
subprocess.run(["git", "add", "."], cwd=project, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
combined = result.stdout + result.stderr
|
||||
assert "No changes" in combined
|
||||
|
||||
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
|
||||
"""commit_style value with a trailing YAML inline comment is still recognized."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional # team standard\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
|
||||
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
|
||||
instead of silently mis-parsing or crashing."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventonal\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
combined = (result.stdout or "") + (result.stderr or "")
|
||||
assert "unknown commit_style" in combined.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
|
||||
# ── auto-commit.ps1 CRLF warning tests (issue #2253) ────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -515,32 +515,6 @@ class TestAutoCommitParity:
|
||||
assert p.stderr.strip() == b.stderr.strip()
|
||||
assert self._last_message(bash_proj) == self._last_message(py_proj) == "spec done"
|
||||
|
||||
def test_custom_message_with_trailing_whitespace_after_quote(self, tmp_path: Path):
|
||||
"""Trailing whitespace after a closing quote must not leave the quote
|
||||
dangling in the commit message. A raw close-quote strip anchored to
|
||||
end-of-string skips the quote when spaces follow it (``spec done" ``);
|
||||
trimming first (matching the PowerShell twin) yields a clean message and
|
||||
keeps bash/python in parity."""
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
config = (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "spec done" \n' # trailing spaces after the closing quote
|
||||
)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, config)
|
||||
self._dirty(proj)
|
||||
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
|
||||
p = _run_py("auto-commit", py_proj, "after_specify")
|
||||
_assert_parity(b, p)
|
||||
assert (
|
||||
self._last_message(bash_proj)
|
||||
== self._last_message(py_proj)
|
||||
== "spec done"
|
||||
)
|
||||
|
||||
def test_default_true_applies_to_unlisted_event(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
|
||||
@@ -1,46 +1,15 @@
|
||||
"""HTTP test helpers shared by CLI tests."""
|
||||
"""HTTP test helpers shared by version-related CLI tests."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import urllib.request
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
"""Build a urlopen context-manager mock whose read returns JSON."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(body).read
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def route_opener_open_through_urlopen(monkeypatch):
|
||||
"""Route build_opener().open through urllib.request.urlopen.
|
||||
|
||||
``open_url(...)`` fetches via ``build_opener(...).open()``, which bypasses
|
||||
``urllib.request.urlopen`` — and with it the urlopen patches these test
|
||||
modules are built on.
|
||||
Delegating ``open()`` to urlopen at call time keeps those patches
|
||||
effective; the redirect handler's own behavior is covered by
|
||||
``TestRedirectStripping`` in test_authentication.py.
|
||||
|
||||
Import this fixture into a test module to activate it there.
|
||||
"""
|
||||
|
||||
class _UrlopenDelegatingOpener:
|
||||
def open(self, req, data=None, timeout=None):
|
||||
if data is None:
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
return urllib.request.urlopen(req, data=data, timeout=timeout)
|
||||
|
||||
monkeypatch.setattr(
|
||||
urllib.request,
|
||||
"build_opener",
|
||||
lambda *handlers: _UrlopenDelegatingOpener(),
|
||||
)
|
||||
|
||||
@@ -491,16 +491,3 @@ def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
|
||||
assert ("extensions", "ext-b") not in {
|
||||
(c.kind, c.id) for c in rec.contributed_components
|
||||
}
|
||||
|
||||
|
||||
def test_install_result_changed_reports_uninstalled():
|
||||
# A `bundle update` that only DROPS components (new manifest reduces
|
||||
# provides) populates uninstalled with nothing installed/refreshed; that is
|
||||
# still a mutating change, so `changed` must be True — not a false no-op.
|
||||
from specify_cli.bundler.services.installer import InstallResult
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
|
||||
result = InstallResult(bundle_id="x")
|
||||
assert result.changed is False # empty == no change
|
||||
result.uninstalled.append(ComponentRef(kind="presets", id="p1"))
|
||||
assert result.changed is True
|
||||
|
||||
@@ -6,8 +6,6 @@ import os
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401
|
||||
|
||||
from specify_cli.integrations.catalog import (
|
||||
IntegrationCatalog,
|
||||
IntegrationCatalogEntry,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for LingmaIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
@@ -10,9 +8,3 @@ class TestLingmaIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".lingma/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".lingma/skills"
|
||||
|
||||
def test_multi_install_safe(self):
|
||||
# Lingma writes only to its isolated, static root .lingma/skills,
|
||||
# disjoint from every other integration, so it must be co-install safe
|
||||
# (mirrors trae/zcode and the kiro-cli #3471 precedent).
|
||||
assert get_integration(self.KEY).multi_install_safe is True
|
||||
|
||||
@@ -11,12 +11,6 @@ class TestOmpIntegration(MarkdownIntegrationTests):
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".omp/commands"
|
||||
|
||||
def test_multi_install_safe(self):
|
||||
# Omp writes only to its isolated, static root .omp/commands, disjoint
|
||||
# from every other integration, so it must be co-install safe (mirrors
|
||||
# qwen/shai/qodercli and the kiro-cli #3471 precedent).
|
||||
assert get_integration(self.KEY).multi_install_safe is True
|
||||
|
||||
def test_build_exec_args_uses_omp_json_mode(self):
|
||||
i = get_integration(self.KEY)
|
||||
|
||||
|
||||
@@ -84,31 +84,3 @@ def test_write_integration_json_strips_integration_key(tmp_path):
|
||||
assert state["integration"] == "claude"
|
||||
assert state["default_integration"] == "claude"
|
||||
assert state["installed_integrations"] == ["claude"]
|
||||
|
||||
|
||||
def test_with_integration_setting_recomputes_separator_from_retained_options():
|
||||
"""Updating only script_type must not drop an options-dependent separator.
|
||||
|
||||
Copilot resolves the command-ref separator to '-' when '--skills' options
|
||||
are stored and '.' otherwise. A second call that changes only script_type
|
||||
(parsed_options=None, raw_options=None) retains the stored parsed_options,
|
||||
so invoke_separator must stay '-', not be recomputed from the None argument.
|
||||
"""
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integration_runtime import with_integration_setting
|
||||
|
||||
copilot = get_integration("copilot")
|
||||
|
||||
settings = with_integration_setting(
|
||||
{}, "copilot", copilot, parsed_options={"skills": True}
|
||||
)
|
||||
assert settings["copilot"]["invoke_separator"] == "-"
|
||||
|
||||
settings2 = with_integration_setting(
|
||||
{"integration_settings": settings}, "copilot", copilot, script_type="ps"
|
||||
)
|
||||
# parsed_options are retained (only script_type changed) ...
|
||||
assert settings2["copilot"]["parsed_options"] == {"skills": True}
|
||||
assert settings2["copilot"]["script"] == "ps"
|
||||
# ... so the separator must reflect them, not the (None) argument.
|
||||
assert settings2["copilot"]["invoke_separator"] == "-"
|
||||
|
||||
@@ -18,7 +18,7 @@ from specify_cli._version import (
|
||||
_verify_upgrade,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import mock_urlopen_response, route_opener_open_through_urlopen
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
__all__ = (
|
||||
"SENTINEL_GH_TOKEN",
|
||||
@@ -31,7 +31,6 @@ __all__ = (
|
||||
"_verify_upgrade",
|
||||
"mock_urlopen_response",
|
||||
"requires_posix",
|
||||
"route_opener_open_through_urlopen",
|
||||
"runner",
|
||||
"strip_ansi",
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ Covers:
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -516,23 +515,6 @@ class TestAzureDevOpsAuth:
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=boom):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
@pytest.mark.parametrize("payload", [[], {"accessToken": None}, {"accessToken": 123}])
|
||||
def test_resolve_token_azure_cli_unexpected_json_shape_returns_none(
|
||||
self, payload
|
||||
):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock(returncode=0, stdout=json.dumps(payload))
|
||||
|
||||
with patch(
|
||||
"specify_cli.authentication.azure_devops.subprocess.run",
|
||||
return_value=result,
|
||||
):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_success(self, monkeypatch):
|
||||
"""azure-ad acquires token via OAuth2 client credentials."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -542,15 +524,10 @@ class TestAzureDevOpsAuth:
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.side_effect = io.BytesIO(b'{"access_token": "ad-acquired-token"}').read
|
||||
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
# The token request goes through a strict-redirect opener (so a 307/308
|
||||
# cannot forward the client_secret body to a non-HTTPS host), not bare
|
||||
# urlopen; patch the opener it builds.
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.return_value = mock_resp
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
|
||||
@@ -565,123 +542,14 @@ class TestAzureDevOpsAuth:
|
||||
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None on network errors."""
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.URLError("connection refused")
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "reason"),
|
||||
[(307, "Temporary Redirect"), (308, "Permanent Redirect")],
|
||||
)
|
||||
def test_resolve_token_azure_ad_rejects_https_redirect(
|
||||
self, monkeypatch, status, reason
|
||||
):
|
||||
"""The client-secret POST must never be redirected to another host."""
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.request import Request
|
||||
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.URLError("stop after setup")
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener) as build_opener:
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
redirect_handler = build_opener.call_args.args[0]
|
||||
request = Request(
|
||||
"https://login.microsoftonline.com/tid/oauth2/v2.0/token",
|
||||
data=b"grant_type=client_credentials&client_secret=secret-value",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert request.get_method() == "POST"
|
||||
assert b"client_secret=secret-value" in request.data
|
||||
with pytest.raises(urllib.error.URLError, match="must not be redirected"):
|
||||
redirect_handler.redirect_request(
|
||||
request,
|
||||
io.BytesIO(b""),
|
||||
status,
|
||||
reason,
|
||||
{},
|
||||
"https://evil.example/token",
|
||||
)
|
||||
|
||||
def test_resolve_token_azure_ad_oversized_response_returns_none(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""Oversized token metadata is rejected before JSON parsing."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.side_effect = io.BytesIO(
|
||||
b"x" * (MAX_JSON_METADATA_BYTES + 1)
|
||||
).read
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.return_value = mock_resp
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener), patch(
|
||||
"specify_cli.authentication.azure_devops._json.loads",
|
||||
side_effect=AssertionError("oversized body must not be parsed"),
|
||||
):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
@pytest.mark.parametrize("payload", [[], {"access_token": None}, {"access_token": 123}])
|
||||
def test_resolve_token_azure_ad_unexpected_json_shape_returns_none(
|
||||
self, monkeypatch, payload
|
||||
):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.return_value = mock_resp
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_invalid_utf8_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None when the token response is not valid UTF-8."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.side_effect = io.BytesIO(b"\xff").read
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.return_value = mock_resp
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("connection refused")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
|
||||
@@ -747,15 +615,13 @@ class TestAuthenticatedHttp:
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
def fake_open(req, timeout=None):
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = fake_open
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://example.com/file.json")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
@@ -764,15 +630,13 @@ class TestAuthenticatedHttp:
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
captured = {}
|
||||
def fake_open(req, timeout=None):
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = fake_open
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
@@ -794,7 +658,8 @@ class TestAuthenticatedHttp:
|
||||
return resp
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = fake_side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
|
||||
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert call_count == 2
|
||||
|
||||
@@ -835,23 +700,21 @@ class TestAuthenticatedHttpNegative:
|
||||
|
||||
def test_urlerror_propagates(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.URLError("refused")
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("refused")):
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
def test_timeout_propagates(self, monkeypatch):
|
||||
import socket
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = socket.timeout("timed out")
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=socket.timeout("timed out")):
|
||||
with pytest.raises(socket.timeout):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
@@ -957,18 +820,17 @@ 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_rejected(self):
|
||||
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
|
||||
import urllib.error
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
|
||||
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"http://github.com/org/repo")
|
||||
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
|
||||
@@ -1026,177 +888,6 @@ class TestRedirectStripping:
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://[::1/asset")
|
||||
|
||||
def test_redirect_rejects_https_downgrade(self):
|
||||
"""HTTPS downloads must not follow redirects to non-local HTTP URLs."""
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
import urllib.error
|
||||
handler = _StripAuthOnRedirect(("example.com",))
|
||||
req = Request("https://example.com/archive.zip")
|
||||
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"http://evil.example.com/archive.zip")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
"http://127.0.0.1/internal",
|
||||
"https://localhost/internal",
|
||||
"https://localhost./internal",
|
||||
"https://service.localhost/internal",
|
||||
"https://service.localhost./internal",
|
||||
"https://127.0.0.2/internal",
|
||||
"https://127.1/internal",
|
||||
"https://2130706433/internal",
|
||||
"https://0x7f000001/internal",
|
||||
"https://017700000001/internal",
|
||||
"https://0177.0.0.1/internal",
|
||||
"https://[::1]/internal",
|
||||
"https://[::1%25lo0]/internal",
|
||||
"https://[::ffff:127.0.0.1]/internal",
|
||||
"https://127%2e0%2e0%2e1/internal",
|
||||
"https://%31%32%37.0.0.1/internal",
|
||||
"https://127%2E1/internal",
|
||||
"https://local%68ost/internal",
|
||||
"https://[::ffff:127%2e0.0.1]/internal",
|
||||
"https://[::ffff:7f00%3a1]/internal",
|
||||
"https://[::ffff%3a127.0.0.1]/internal",
|
||||
"https://ℓocalhost/internal",
|
||||
"https://localhost/internal",
|
||||
"https://127。0。0。1/internal",
|
||||
"https://0.0.0.0/internal",
|
||||
"https://0/internal",
|
||||
"https://00.00.00.00/internal",
|
||||
"https://[::]/internal",
|
||||
"https://[::ffff:0.0.0.0]/internal",
|
||||
],
|
||||
)
|
||||
def test_redirect_rejects_remote_to_loopback(self, target):
|
||||
"""A remote response must not redirect a download into loopback."""
|
||||
import io
|
||||
import urllib.error
|
||||
from urllib.request import Request
|
||||
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
|
||||
handler = _StripAuthOnRedirect(())
|
||||
request = Request("https://example.com/archive.zip")
|
||||
|
||||
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
|
||||
handler.redirect_request(
|
||||
request,
|
||||
io.BytesIO(b""),
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
target,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "target"),
|
||||
[
|
||||
(
|
||||
"http://localhost:8000/archive.zip",
|
||||
"http://127.0.0.1:8001/archive.zip",
|
||||
),
|
||||
(
|
||||
"http://127.0.0.2:8000/archive.zip",
|
||||
"http://127.255.255.254:8001/archive.zip",
|
||||
),
|
||||
(
|
||||
"https://[0:0:0:0:0:0:0:1]/archive.zip",
|
||||
"http://[::1]:8001/archive.zip",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_redirect_allows_loopback_to_http_loopback(self, source, target):
|
||||
"""Local development may continue redirecting between loopback URLs."""
|
||||
import io
|
||||
from urllib.request import Request
|
||||
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
|
||||
handler = _StripAuthOnRedirect(())
|
||||
request = Request(source)
|
||||
redirected = handler.redirect_request(
|
||||
request,
|
||||
io.BytesIO(b""),
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
target,
|
||||
)
|
||||
|
||||
assert redirected is not None
|
||||
|
||||
def test_multi_hop_remote_to_loopback_chain_is_rejected_at_first_hop(self):
|
||||
import io
|
||||
import urllib.error
|
||||
from urllib.request import Request
|
||||
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
|
||||
handler = _StripAuthOnRedirect(())
|
||||
request = Request("https://example.com/archive.zip")
|
||||
|
||||
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
|
||||
handler.redirect_request(
|
||||
request,
|
||||
io.BytesIO(b""),
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://localhost:4443/hop",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
"https://example.com:notaport/archive.zip",
|
||||
"https://example.com:+443/archive.zip",
|
||||
"https://example.com:65536/archive.zip",
|
||||
],
|
||||
)
|
||||
def test_malformed_redirect_port_raises_urlerror(self, target):
|
||||
import io
|
||||
import urllib.error
|
||||
from urllib.request import Request
|
||||
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
|
||||
handler = _StripAuthOnRedirect(())
|
||||
request = Request("https://example.com/archive.zip")
|
||||
|
||||
with pytest.raises(urllib.error.URLError, match="malformed redirect URL"):
|
||||
handler.redirect_request(
|
||||
request,
|
||||
io.BytesIO(b""),
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
target,
|
||||
)
|
||||
|
||||
def test_strict_redirect_error_describes_target_and_allowed_localhost(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
import urllib.error
|
||||
|
||||
handler = _StripAuthOnRedirect(("example.com",))
|
||||
req = Request("https://example.com/archive.zip")
|
||||
|
||||
with pytest.raises(urllib.error.URLError) as exc_info:
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"http://evil.example.com/archive.zip")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "http://evil.example.com/archive.zip" in error_message
|
||||
assert "localhost" in error_message
|
||||
assert "127.0.0.1" in error_message
|
||||
assert "::1" in error_message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
@@ -1216,7 +907,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
captured["request"] = req
|
||||
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(body).read
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
@@ -1236,25 +927,20 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
# The unauthenticated path uses the strict redirect opener too.
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") is None
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
"""Tests for bounded HTTP download helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli._download_security import (
|
||||
is_https_or_localhost_http,
|
||||
is_loopback_url,
|
||||
read_response_limited,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, allowed",
|
||||
[
|
||||
("https://example.com/preset.zip", True),
|
||||
("http://localhost:8000/preset.zip", True),
|
||||
("http://127.0.0.1/preset.zip", True),
|
||||
("http://127.0.0.2/preset.zip", True),
|
||||
("http://127.255.255.254/preset.zip", True),
|
||||
("http://[::1]/preset.zip", True),
|
||||
("http://[0:0:0:0:0:0:0:1]/preset.zip", True),
|
||||
("http://[::ffff:127.0.0.2]/preset.zip", True),
|
||||
("http://[::1%25lo0]/preset.zip", True),
|
||||
# Non-loopback HTTP is rejected.
|
||||
("http://example.com/preset.zip", False),
|
||||
("http://192.0.2.1/preset.zip", False),
|
||||
("http://[fe80::1]/preset.zip", False),
|
||||
("http://[fe80::1%25lo0]/preset.zip", False),
|
||||
("http://0.0.0.0/preset.zip", False),
|
||||
("http://0/preset.zip", False),
|
||||
("http://[::]/preset.zip", False),
|
||||
("http://[::ffff:0.0.0.0]/preset.zip", False),
|
||||
# Ambiguous/platform-dependent spellings may never authorize HTTP.
|
||||
("http://127.1/preset.zip", False),
|
||||
("http://2130706433/preset.zip", False),
|
||||
("http://0x7f000001/preset.zip", False),
|
||||
("http://017700000001/preset.zip", False),
|
||||
("http://0177.0.0.1/preset.zip", False),
|
||||
("http://00177.0.0.1/preset.zip", False),
|
||||
("http://localhost./preset.zip", False),
|
||||
("http://ℓocalhost/preset.zip", False),
|
||||
("http://127。0。0。1/preset.zip", False),
|
||||
# A hostname is always required, even for HTTPS.
|
||||
("https:///preset.zip", False),
|
||||
("https://", False),
|
||||
# Invalid ports must be rejected before urllib opens the URL.
|
||||
("https://example.com:notaport/preset.zip", False),
|
||||
("https://example.com:+443/preset.zip", False),
|
||||
("https://example.com:65536/preset.zip", False),
|
||||
# urllib decodes escapes in the authority before connecting; reject
|
||||
# encoded reg-names so validation and connection cannot disagree.
|
||||
("https://127%2e0%2e0%2e1/preset.zip", False),
|
||||
("https://%31%32%37.0.0.1/preset.zip", False),
|
||||
("https://local%68ost/preset.zip", False),
|
||||
("https://example.com%3a443/preset.zip", False),
|
||||
("https://[::1%lo0]/preset.zip", False),
|
||||
("https://[::ffff:127%2e0.0.1]/preset.zip", False),
|
||||
("https://[::ffff:7f00%3a1]/preset.zip", False),
|
||||
("https://[::ffff%3a127.0.0.1]/preset.zip", False),
|
||||
],
|
||||
)
|
||||
def test_is_https_or_localhost_http(url, allowed):
|
||||
assert is_https_or_localhost_http(url) is allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://localhost/internal",
|
||||
"https://127.0.0.2/internal",
|
||||
"https://[::1]/internal",
|
||||
"https://[::1%25lo0]/internal",
|
||||
"https://[::ffff:127.0.0.2]/internal",
|
||||
],
|
||||
)
|
||||
def test_is_loopback_url_recognizes_effective_loopback_literals(url):
|
||||
assert is_loopback_url(url) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://localhost./internal",
|
||||
"https://service.localhost/internal",
|
||||
"https://service.localhost./internal",
|
||||
"https://127.1/internal",
|
||||
"https://2130706433/internal",
|
||||
"https://0x7f000001/internal",
|
||||
"https://017700000001/internal",
|
||||
"https://0177.0.0.1/internal",
|
||||
"https://ℓocalhost/internal",
|
||||
"https://127。0。0。1/internal",
|
||||
"https://127%2e0%2e0%2e1/internal",
|
||||
"https://0.0.0.0/internal",
|
||||
"https://0/internal",
|
||||
"https://00.00.00.00/internal",
|
||||
"https://[::]/internal",
|
||||
"https://[::ffff:0.0.0.0]/internal",
|
||||
],
|
||||
)
|
||||
def test_is_loopback_url_does_not_authorize_ambiguous_spellings(url):
|
||||
assert is_loopback_url(url) is False
|
||||
|
||||
|
||||
class _Response:
|
||||
"""Faithful stream stand-in: read() advances a cursor and returns b"" at EOF."""
|
||||
|
||||
def __init__(self, data: bytes, *, chunk: int | None = None):
|
||||
self.data = data
|
||||
self.pos = 0
|
||||
# When set, never return more than *chunk* bytes per call even if more is
|
||||
# requested - simulates short reads (e.g. chunked transfer encoding).
|
||||
self.chunk = chunk
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if size < 0:
|
||||
size = len(self.data) - self.pos
|
||||
if self.chunk is not None:
|
||||
size = min(size, self.chunk)
|
||||
out = self.data[self.pos : self.pos + size]
|
||||
self.pos += len(out)
|
||||
return out
|
||||
|
||||
|
||||
class _RecordingResponse(_Response):
|
||||
def __init__(self, data: bytes, *, chunk: int | None = None):
|
||||
super().__init__(data, chunk=chunk)
|
||||
self.requested_sizes: list[int] = []
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
self.requested_sizes.append(size)
|
||||
return super().read(size)
|
||||
|
||||
|
||||
class _TrackedChunk(bytearray):
|
||||
pass
|
||||
|
||||
|
||||
class _OneByteResponse:
|
||||
"""Return distinct weak-referenceable chunks to detect retained fragments."""
|
||||
|
||||
def __init__(self, count: int):
|
||||
self.remaining = count
|
||||
self.refs: list[weakref.ReferenceType[_TrackedChunk]] = []
|
||||
self.peak_live = 0
|
||||
|
||||
def read(self, _size: int = -1) -> bytes | _TrackedChunk:
|
||||
if self.remaining == 0:
|
||||
return b""
|
||||
self.remaining -= 1
|
||||
chunk = _TrackedChunk(b"x")
|
||||
self.refs.append(weakref.ref(chunk))
|
||||
self.peak_live = max(
|
||||
self.peak_live,
|
||||
sum(ref() is not None for ref in self.refs),
|
||||
)
|
||||
return chunk
|
||||
|
||||
|
||||
def test_read_response_limited_rejects_oversized_download():
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
read_response_limited(_Response(b"abcde"), max_bytes=4)
|
||||
|
||||
|
||||
def test_read_response_limited_returns_full_body_within_limit():
|
||||
assert read_response_limited(_Response(b"abcde"), max_bytes=10) == b"abcde"
|
||||
|
||||
|
||||
def test_read_response_limited_enforces_bound_under_short_reads():
|
||||
# A server that streams more than max_bytes total while every read() returns
|
||||
# fewer bytes than requested (chunked encoding) must still be rejected - a
|
||||
# single read(max_bytes + 1) could be fooled, the accumulating loop cannot.
|
||||
response = _Response(b"x" * 100, chunk=8)
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
read_response_limited(response, max_bytes=16)
|
||||
|
||||
|
||||
def test_read_response_limited_does_not_retain_short_read_fragments():
|
||||
response = _OneByteResponse(64)
|
||||
|
||||
assert read_response_limited(response, max_bytes=64) == b"x" * 64
|
||||
assert response.peak_live <= 2
|
||||
|
||||
|
||||
def test_read_response_limited_caps_underlying_reads_at_64_kib():
|
||||
response = _RecordingResponse(b"x" * (64 * 1024 + 1))
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
read_response_limited(response, max_bytes=64 * 1024)
|
||||
|
||||
assert max(response.requested_sizes) <= 64 * 1024
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "1", 1.5, True])
|
||||
def test_read_response_limited_rejects_non_integer_limits(value):
|
||||
with pytest.raises(TypeError, match="integer"):
|
||||
read_response_limited(_Response(b""), max_bytes=value)
|
||||
|
||||
|
||||
def test_read_response_limited_rejects_negative_limit_without_reading():
|
||||
response = _RecordingResponse(b"")
|
||||
|
||||
with pytest.raises(ValueError, match="non-negative"):
|
||||
read_response_limited(response, max_bytes=-1)
|
||||
|
||||
assert response.requested_sizes == []
|
||||
|
||||
|
||||
def test_read_response_limited_allows_empty_response_at_zero_limit():
|
||||
assert read_response_limited(_Response(b""), max_bytes=0) == b""
|
||||
|
||||
|
||||
class _CustomLimitError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def test_read_response_limited_rejects_first_byte_at_zero_limit():
|
||||
with pytest.raises(_CustomLimitError, match="exceeds maximum size"):
|
||||
read_response_limited(
|
||||
_Response(b"x"),
|
||||
max_bytes=0,
|
||||
error_type=_CustomLimitError,
|
||||
)
|
||||
@@ -163,58 +163,6 @@ def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Pa
|
||||
return ext_dir
|
||||
|
||||
|
||||
def _create_dashed_description_extension_dir(
|
||||
temp_dir: Path, ext_id: str = "dash-ext"
|
||||
) -> Path:
|
||||
"""Create an extension whose command description contains a ``---`` run.
|
||||
|
||||
A ``---`` inside the description survives into the generated SKILL.md
|
||||
frontmatter and exercises the delimiter-line parsing used when reading
|
||||
metadata.source back during removal (regression guard for the
|
||||
split("---", 2) substring bug, mirroring #3590).
|
||||
"""
|
||||
ext_dir = temp_dir / ext_id
|
||||
ext_dir.mkdir()
|
||||
description = "Separate sections with --- markers"
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": ext_id,
|
||||
"name": "Dashed Extension",
|
||||
"version": "1.0.0",
|
||||
"description": description,
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": f"speckit.{ext_id}.hello",
|
||||
"file": "commands/hello.md",
|
||||
"description": description,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(manifest_data, f, allow_unicode=True)
|
||||
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
(commands_dir / "hello.md").write_text(
|
||||
"---\n"
|
||||
f'description: "{description}"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Hello\n"
|
||||
"\n"
|
||||
"Body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
|
||||
def _can_create_symlink(temp_dir: Path) -> bool:
|
||||
"""Return True when the current platform/user can create file symlinks."""
|
||||
target = temp_dir / "symlink-target.txt"
|
||||
@@ -1710,65 +1658,6 @@ class TestExtensionSkillUnregistration:
|
||||
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||
|
||||
def test_skills_removed_when_description_contains_dashes(
|
||||
self, skills_project, temp_dir
|
||||
):
|
||||
"""A ``---`` in the command description must not orphan the skill dir.
|
||||
|
||||
The removal safety check reads metadata.source back from the generated
|
||||
SKILL.md. A raw ``split("---", 2)`` stopped at the ``---`` embedded in
|
||||
the description, so metadata.source parsed empty, the skill looked
|
||||
unrelated, and its directory was left behind. Regression guard for the
|
||||
delimiter-line fix (mirrors #3590).
|
||||
"""
|
||||
project_dir, skills_dir = skills_project
|
||||
ext_dir = _create_dashed_description_extension_dir(temp_dir)
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_dir = skills_dir / "speckit-dash-ext-hello"
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
assert skill_md.exists()
|
||||
# The dashed description must have survived into the frontmatter.
|
||||
assert "--- markers" in skill_md.read_text(encoding="utf-8")
|
||||
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
|
||||
# The extension's own skill must be recognised and removed, not orphaned.
|
||||
assert not skill_dir.exists()
|
||||
|
||||
def test_skills_removed_with_dashes_via_fallback_scan(
|
||||
self, skills_project, temp_dir
|
||||
):
|
||||
"""Same ``---`` guard, but exercised through the fallback scan branch.
|
||||
|
||||
The fast path resolves the skills dir from init-options; the fallback
|
||||
branch scans every candidate agent dir when that resolution returns
|
||||
None, and it re-reads metadata.source with an independently duplicated
|
||||
parser. Deleting init-options.json after install forces removal down
|
||||
the fallback path so a substring-split regression there is caught too.
|
||||
"""
|
||||
project_dir, skills_dir = skills_project
|
||||
ext_dir = _create_dashed_description_extension_dir(temp_dir)
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_dir = skills_dir / "speckit-dash-ext-hello"
|
||||
assert (skill_dir / "SKILL.md").exists()
|
||||
|
||||
# Drop init-options so _get_skills_dir() returns None and removal takes
|
||||
# the fallback directory-scan branch instead of the fast path.
|
||||
(project_dir / ".specify" / "init-options.json").unlink()
|
||||
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
assert not skill_dir.exists()
|
||||
|
||||
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
|
||||
"""Non-extension skills should not be affected by extension removal."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
@@ -9,7 +9,6 @@ Tests cover:
|
||||
- Catalog stack (multi-catalog support)
|
||||
"""
|
||||
|
||||
import io
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
@@ -23,7 +22,6 @@ from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401
|
||||
from specify_cli import extensions as _ext_module
|
||||
from specify_cli.extensions import (
|
||||
CatalogEntry,
|
||||
@@ -4980,7 +4978,7 @@ class TestExtensionCatalog:
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
release_response = MagicMock()
|
||||
release_response.read.side_effect = io.BytesIO(json.dumps(
|
||||
release_response.read.return_value = json.dumps(
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
@@ -4989,12 +4987,12 @@ class TestExtensionCatalog:
|
||||
}
|
||||
]
|
||||
}
|
||||
).encode()).read
|
||||
).encode()
|
||||
release_response.__enter__ = lambda s: s
|
||||
release_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
asset_response = MagicMock()
|
||||
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
|
||||
asset_response.read.return_value = zip_bytes
|
||||
asset_response.__enter__ = lambda s: s
|
||||
asset_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
@@ -6607,80 +6605,6 @@ class TestExtensionAddCLI:
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Invalid URL" in plain
|
||||
|
||||
def test_add_from_bracketed_non_ip_url_exits_cleanly(self, tmp_path):
|
||||
"""A bracketed-but-invalid IPv6 host must produce a clean error, not a
|
||||
ValueError traceback. "https://[not-an-ip]/ext.zip" is a malformed
|
||||
authority that raises ValueError during URL validation; the try/except
|
||||
guard around parsing and the .hostname read must turn that into a clean
|
||||
"Invalid URL" message.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://[not-an-ip]/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Invalid URL" in plain
|
||||
|
||||
def test_add_from_url_lazy_hostname_valueerror_exits_cleanly(self, tmp_path, monkeypatch):
|
||||
"""Synthetic defensive coverage: monkeypatch urlparse() to return an
|
||||
object whose .hostname raises ValueError lazily. This does not reproduce
|
||||
any specific CPython behavior -- it just exercises the case where the
|
||||
ValueError surfaces on the .hostname read rather than at parse time, so a
|
||||
raw ValueError would leak if .hostname were read outside the try/except.
|
||||
"""
|
||||
import urllib.parse
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
real_urlparse = urllib.parse.urlparse
|
||||
|
||||
class _LazyHostnameRaiser:
|
||||
def __init__(self, parsed):
|
||||
self._parsed = parsed
|
||||
|
||||
@property
|
||||
def hostname(self):
|
||||
raise ValueError("simulated lazy IPv6 hostname failure")
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._parsed, name)
|
||||
|
||||
def _fake_urlparse(url, *args, **kwargs):
|
||||
return _LazyHostnameRaiser(real_urlparse(url, *args, **kwargs))
|
||||
|
||||
monkeypatch.setattr(urllib.parse, "urlparse", _fake_urlparse)
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Invalid URL" in strip_ansi(result.output)
|
||||
|
||||
def test_add_status_escapes_extension_markup(self, tmp_path):
|
||||
"""User-controlled extension names must not be parsed as Rich markup."""
|
||||
from rich.markup import escape as escape_markup
|
||||
@@ -8837,10 +8761,10 @@ def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, mo
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(json.dumps({
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "ext.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}]
|
||||
}).encode()).read
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
monkeypatch.setattr(catalog, "_open_url", fake_open)
|
||||
@@ -9084,34 +9008,3 @@ class TestConfigManagerCrossExtensionEnvLeak:
|
||||
# Must not raise; must fall back to the "no siblings" path.
|
||||
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
|
||||
assert cfg == {"url": "v"}
|
||||
|
||||
|
||||
def test_forge_extension_install_listing_hyphenates_command_names(
|
||||
extension_dir, project_dir
|
||||
):
|
||||
"""The post-install 'Provided commands' listing must show hyphenated
|
||||
/speckit-<name> command names for a Forge project (Forge registers
|
||||
hyphenated names), mirroring the existing Cline handling."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.write_text(json.dumps({"ai": "forge", "script": "sh"}))
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_dir)
|
||||
result = CliRunner().invoke(
|
||||
app, ["extension", "add", str(extension_dir), "--dev"]
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Forge registers hyphenated command names, so the summary must match.
|
||||
assert "speckit-test-ext-hello" in result.output
|
||||
assert "speckit.test-ext.hello" not in result.output
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"""Tests for GitHub-authenticated HTTP request helpers."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.request import Request
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli._github_http import (
|
||||
GITHUB_HOSTS,
|
||||
build_github_request,
|
||||
resolve_github_release_asset_api_url,
|
||||
)
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
|
||||
|
||||
class TestBuildGitHubRequest:
|
||||
@@ -94,7 +90,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
@contextmanager
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(json.dumps(release_json).encode()).read
|
||||
resp.read.return_value = json.dumps(release_json).encode()
|
||||
yield resp
|
||||
return fake_open
|
||||
|
||||
@@ -202,7 +198,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
|
||||
resp.read.return_value = json.dumps({"assets": []}).encode()
|
||||
yield resp
|
||||
|
||||
resolve_github_release_asset_api_url(
|
||||
@@ -221,7 +217,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
|
||||
resp.read.return_value = json.dumps({"assets": []}).encode()
|
||||
yield resp
|
||||
|
||||
resolve_github_release_asset_api_url(
|
||||
@@ -264,7 +260,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
def recording_open(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(b"{}").read
|
||||
resp.read.return_value = b"{}"
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
@@ -303,7 +299,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
def recording_open(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(b"{}").read
|
||||
resp.read.return_value = b"{}"
|
||||
yield resp
|
||||
|
||||
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
@@ -321,7 +317,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
|
||||
resp.read.return_value = json.dumps({"assets": []}).encode()
|
||||
yield resp
|
||||
|
||||
resolve_github_release_asset_api_url(
|
||||
@@ -348,10 +344,10 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(json.dumps({
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "pack.zip",
|
||||
"url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
|
||||
}).encode()).read
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
@@ -361,43 +357,3 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
)
|
||||
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
|
||||
|
||||
|
||||
class TestGitHubRedirectAuth:
|
||||
"""Tests for GitHub-owned redirect auth handling."""
|
||||
|
||||
def test_multi_hop_github_redirect_preserves_unredirected_auth(self):
|
||||
"""Auth survives a multi-hop redirect chain within GitHub hosts."""
|
||||
handler = _StripAuthOnRedirect(tuple(GITHUB_HOSTS))
|
||||
req1 = Request(
|
||||
"https://github.com/org/repo",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
|
||||
req2 = handler.redirect_request(
|
||||
req1,
|
||||
io.BytesIO(b""),
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://codeload.github.com/org/repo/zip",
|
||||
)
|
||||
assert req2 is not None
|
||||
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get(
|
||||
"Authorization"
|
||||
)
|
||||
assert auth2 == "Bearer tok"
|
||||
|
||||
req3 = handler.redirect_request(
|
||||
req2,
|
||||
io.BytesIO(b""),
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://raw.githubusercontent.com/org/repo/main/file",
|
||||
)
|
||||
assert req3 is not None
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get(
|
||||
"Authorization"
|
||||
)
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
@@ -243,34 +243,3 @@ class TestRegressionPlainTemplate:
|
||||
assert output_file.exists(), f"Output file missing for {agent}"
|
||||
content = output_file.read_text(encoding="utf-8")
|
||||
assert body_text.strip() in content, f"Body text missing in {agent} output"
|
||||
|
||||
|
||||
class TestClineRealPostProcess:
|
||||
"""Cline's real command-content transforms (hook-command note + handoff
|
||||
dot->hyphen rewrite) must run for extension/preset commands registered via
|
||||
CommandRegistrar. This exercises the REAL method (not a monkeypatched
|
||||
marker), so it fails if Cline's override does not match the base hook name
|
||||
the registrar dispatches to (post_process_command_content)."""
|
||||
|
||||
def test_cline_transforms_applied_via_registrar(
|
||||
self, tmp_path, registrar, ext_dir
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
body = (
|
||||
"- For each executable hook, output the following:\n"
|
||||
"agent: speckit.foo\n"
|
||||
)
|
||||
_write_cmd(cmd_dir, body=body)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands("cline", commands, "test-ext", ext, tmp_path)
|
||||
|
||||
outputs = list((tmp_path / ".clinerules" / "workflows").rglob("*.md"))
|
||||
assert outputs, "no cline command file was written"
|
||||
content = outputs[0].read_text(encoding="utf-8")
|
||||
|
||||
# _inject_hook_command_note fired (its note text contains "replace dots")
|
||||
assert "replace dots" in content
|
||||
# _rewrite_handoff_references rewrote the dotted agent handoff
|
||||
assert "agent: speckit-foo" in content
|
||||
assert "agent: speckit.foo" not in content
|
||||
|
||||
@@ -2303,7 +2303,7 @@ class TestPresetCatalog:
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
release_response = MagicMock()
|
||||
release_response.read.side_effect = io.BytesIO(json.dumps(
|
||||
release_response.read.return_value = json.dumps(
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
@@ -2312,12 +2312,12 @@ class TestPresetCatalog:
|
||||
}
|
||||
]
|
||||
}
|
||||
).encode()).read
|
||||
).encode()
|
||||
release_response.__enter__ = lambda s: s
|
||||
release_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
asset_response = MagicMock()
|
||||
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
|
||||
asset_response.read.return_value = zip_bytes
|
||||
asset_response.__enter__ = lambda s: s
|
||||
asset_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
@@ -5381,9 +5381,6 @@ class TestPresetEnableDisable:
|
||||
|
||||
|
||||
LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean"
|
||||
CORE_CONSTITUTION_COMMAND = (
|
||||
Path(__file__).parent.parent / "templates" / "commands" / "constitution.md"
|
||||
)
|
||||
|
||||
LEAN_COMMAND_NAMES = [
|
||||
"speckit.specify",
|
||||
@@ -5394,31 +5391,6 @@ LEAN_COMMAND_NAMES = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command_path",
|
||||
[
|
||||
CORE_CONSTITUTION_COMMAND,
|
||||
LEAN_PRESET_DIR / "commands" / "speckit.constitution.md",
|
||||
],
|
||||
ids=["core", "lean"],
|
||||
)
|
||||
def test_constitution_commands_guard_against_non_governance_work(command_path):
|
||||
"""Constitution commands defer non-governance work instead of executing it."""
|
||||
content = command_path.read_text()
|
||||
lower_content = content.lower()
|
||||
normalized_content = " ".join(lower_content.split())
|
||||
|
||||
assert "## Scope Guard" in content
|
||||
assert "**MUST NOT**" in content
|
||||
assert "Classify every part" in content
|
||||
assert "application source files" in content
|
||||
assert "non-governance intent" in content
|
||||
assert "`Next Actions`" in content
|
||||
assert "__SPECKIT_COMMAND_SPECIFY__" in content
|
||||
assert "omit" in lower_content
|
||||
assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content
|
||||
|
||||
|
||||
class TestLeanPreset:
|
||||
"""Tests for the lean preset that ships with the repo."""
|
||||
|
||||
@@ -5605,58 +5577,6 @@ class TestBundledPresetLocator:
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir):
|
||||
"""A bracketed-but-invalid IPv6 host in --from must exit cleanly.
|
||||
|
||||
"https://[not-an-ip]/preset.zip" is a malformed authority that raises
|
||||
ValueError during URL validation; the try/except guard around parsing
|
||||
and the .hostname read must turn that into a clean "Invalid URL" message.
|
||||
"""
|
||||
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://[not-an-ip]/preset.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir):
|
||||
"""An out-of-range port raises ValueError lazily on .port access.
|
||||
|
||||
The up-front guard reads ``_parsed.port`` (urllib validates the port
|
||||
range/syntax there) inside its try/except, so "https://example.com:99999/
|
||||
preset.zip" must produce a clean "Invalid URL" message rather than
|
||||
leaking a raw ValueError traceback past the CLI.
|
||||
"""
|
||||
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://example.com:99999/preset.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Invalid URL" in strip_ansi(result.output)
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir):
|
||||
"""A catalog download_url with a bracketed non-IP host must render cleanly.
|
||||
|
||||
@@ -7538,10 +7458,10 @@ def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monke
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(json.dumps({
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "pack.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}]
|
||||
}).encode()).read
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
monkeypatch.setattr(catalog, "_open_url", fake_open)
|
||||
|
||||
@@ -13,7 +13,6 @@ import specify_cli
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
|
||||
_InstallMethod,
|
||||
_assemble_installer_argv,
|
||||
_completed_process,
|
||||
|
||||
@@ -7,7 +7,6 @@ from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
|
||||
_completed_process,
|
||||
mock_urlopen_response,
|
||||
requires_posix,
|
||||
|
||||
@@ -6,7 +6,6 @@ from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
mock_urlopen_response,
|
||||
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ import specify_cli
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
|
||||
SENTINEL_GH_TOKEN,
|
||||
SENTINEL_GITHUB_TOKEN,
|
||||
_InstallMethod,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Regression tests for top-level step numbering in specify.md."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
SPECIFY_TEMPLATE = REPO_ROOT / "templates" / "commands" / "specify.md"
|
||||
MAIN_LIST_START = "Given that feature description, do this:"
|
||||
MAIN_LIST_END = "## Mandatory Post-Execution Hooks"
|
||||
|
||||
|
||||
def _main_execution_ordinals(text: str) -> list[int]:
|
||||
"""Extract top-level ordinals from the main execution flow."""
|
||||
_, start, execution_flow = text.partition(MAIN_LIST_START)
|
||||
execution_flow, end, _ = execution_flow.partition(MAIN_LIST_END)
|
||||
if not start or not end:
|
||||
return []
|
||||
|
||||
return [
|
||||
int(match.group(1))
|
||||
for line in execution_flow.splitlines()
|
||||
if (match := re.match(r"^(\d+)\. ", line))
|
||||
]
|
||||
|
||||
|
||||
def test_main_execution_list_has_no_duplicate_ordinals():
|
||||
"""The main execution list must not reuse a step number."""
|
||||
ordinals = _main_execution_ordinals(SPECIFY_TEMPLATE.read_text(encoding="utf-8"))
|
||||
duplicates = {ordinal for ordinal in ordinals if ordinals.count(ordinal) > 1}
|
||||
|
||||
assert not duplicates, f"Duplicate top-level ordinals found: {sorted(duplicates)}"
|
||||
|
||||
|
||||
def test_main_execution_list_is_sequential():
|
||||
"""The main execution list must run from 1 through N without gaps."""
|
||||
ordinals = _main_execution_ordinals(SPECIFY_TEMPLATE.read_text(encoding="utf-8"))
|
||||
|
||||
assert ordinals, "Could not find the main execution list in specify.md"
|
||||
assert ordinals == list(range(1, 9))
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
Network isolation contract (SC-004 / FR-014): every test that exercises
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound
|
||||
urllib path so no real call reaches api.github.com. Production always uses an
|
||||
isolated `build_opener`; this module's autouse fixture routes its `open()` back
|
||||
through the locally mocked `urlopen`. Tests for non-network `self upgrade`
|
||||
behavior should keep that contract explicit with local mocks. Run this module
|
||||
under `pytest-socket` (if installed) with `--disable-socket` as an extra safety
|
||||
net.
|
||||
urllib path it expects (`urlopen` for unauthenticated requests, `build_opener`
|
||||
for authenticated requests) so no real outbound call ever reaches api.github.com.
|
||||
Tests for non-network `self upgrade` behavior should keep that contract explicit
|
||||
with local mocks. Run this module under `pytest-socket` (if installed) with
|
||||
`--disable-socket` as an extra safety net.
|
||||
"""
|
||||
|
||||
import urllib.error
|
||||
@@ -18,7 +17,6 @@ import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli._download_security import read_response_limited as _real_read_response_limited
|
||||
from specify_cli._version import (
|
||||
_fetch_latest_release_tag,
|
||||
_get_installed_version,
|
||||
@@ -26,10 +24,7 @@ from specify_cli._version import (
|
||||
_normalize_tag,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import (
|
||||
mock_urlopen_response,
|
||||
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
|
||||
)
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -240,46 +235,6 @@ class TestFailureCategorization:
|
||||
_fetch_latest_release_tag()
|
||||
|
||||
|
||||
class TestBoundedRead:
|
||||
"""Regression test for the read_response_limited hardening.
|
||||
|
||||
A future refactor could silently revert `_fetch_latest_release_tag` to
|
||||
`resp.read()` (the unbounded form) — this test pins the contract that
|
||||
the response body is read through ``read_response_limited`` with a
|
||||
bounded ``max_bytes``.
|
||||
"""
|
||||
|
||||
def test_response_body_is_bounded(self):
|
||||
recorded: dict[str, int | str] = {}
|
||||
|
||||
def _spy(response, *, max_bytes: int, label: str, **kwargs):
|
||||
# max_bytes and label are keyword-only with no defaults: if the
|
||||
# caller forgets to pass either, the call raises TypeError here
|
||||
# (instead of recording a misleading None).
|
||||
recorded["max_bytes"] = max_bytes
|
||||
recorded["label"] = label
|
||||
# Forward to the real implementation so the function under test
|
||||
# still gets a parseable body.
|
||||
return _real_read_response_limited(
|
||||
response, max_bytes=max_bytes, label=label, **kwargs
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "v9.9.9"}),
|
||||
), patch("specify_cli._version.read_response_limited", side_effect=_spy):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
|
||||
assert tag == "v9.9.9"
|
||||
assert reason is None
|
||||
# The cap (1 MiB) is a deliberate ceiling for the GitHub release
|
||||
# JSON — keep it explicit so a future refactor that drops the
|
||||
# `max_bytes=` argument fails this test instead of regressing
|
||||
# silently to the default.
|
||||
assert recorded["max_bytes"] == 1024 * 1024
|
||||
assert recorded["label"] == "GitHub latest release"
|
||||
|
||||
|
||||
_FAILURE_CASES = [
|
||||
("offline or timeout", urllib.error.URLError("down")),
|
||||
(_RATE_LIMITED_REASON, _http_error(403)),
|
||||
|
||||
@@ -686,28 +686,6 @@ class TestExpressions:
|
||||
):
|
||||
evaluate_expression("{{ inputs.tags | map }}", ctx)
|
||||
|
||||
def test_filter_call_with_trailing_tokens_fails_loudly(self):
|
||||
# A trailing operator/token after a filter's closing paren must not be
|
||||
# silently discarded (the parser used an unanchored regex). It must
|
||||
# fall through to the "unsupported form" ValueError, like the from_json
|
||||
# branch's strict trailing-token handling.
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
# A comparison after a filter (binds looser than the pipe) was dropped,
|
||||
# so `default('7') > '5'` silently returned '7'.
|
||||
with pytest.raises(ValueError, match="unsupported form"):
|
||||
evaluate_expression(
|
||||
"{{ inputs.missing | default('7') > '5' }}", StepContext(inputs={})
|
||||
)
|
||||
# Trailing garbage after a valid filter call.
|
||||
with pytest.raises(ValueError, match="unsupported form"):
|
||||
evaluate_expression(
|
||||
"{{ inputs.tags | join(',') extra }}",
|
||||
StepContext(inputs={"tags": ["a", "b"]}),
|
||||
)
|
||||
|
||||
def test_chained_filters_apply_left_to_right(self):
|
||||
# Filters chain: each filter's result feeds the next. `map` yields a
|
||||
# list and `join` is the only filter that renders a list to a string,
|
||||
@@ -1957,14 +1935,6 @@ def _force_gate_stdin(monkeypatch, *, tty: bool):
|
||||
class TestInitStep:
|
||||
"""Test the init step type."""
|
||||
|
||||
def test_docstring_lists_every_valid_script_type(self):
|
||||
# The `script` field docstring must not contradict the step's own
|
||||
# VALID_SCRIPT_TYPES (which includes 'py'); validate() accepts all three.
|
||||
from specify_cli.workflows.steps.init import InitStep, VALID_SCRIPT_TYPES
|
||||
|
||||
for script_type in VALID_SCRIPT_TYPES:
|
||||
assert f"``{script_type}``" in InitStep.__doc__
|
||||
|
||||
def test_builds_here_argv_and_bootstraps(self, tmp_path):
|
||||
from specify_cli.workflows.steps.init import InitStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
@@ -2130,15 +2100,6 @@ class TestInitStep:
|
||||
class TestGateStep:
|
||||
"""Test the gate step type."""
|
||||
|
||||
def test_docstring_lists_every_on_reject_behaviour(self):
|
||||
# The docstring must not contradict validate()/execute(): on_reject
|
||||
# accepts 'abort', 'skip', AND 'retry' (execute() has a dedicated
|
||||
# retry -> PAUSED branch), but the summary omitted 'retry'.
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
|
||||
for behaviour in ("abort", "skip", "retry"):
|
||||
assert behaviour in GateStep.__doc__
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _non_tty_stdin_by_default(self, monkeypatch):
|
||||
# Default every gate test to a non-TTY stdin so none can drop into
|
||||
@@ -7882,37 +7843,6 @@ class TestWorkflowAddCaseInsensitiveSuffix:
|
||||
assert "installed" in result.output
|
||||
|
||||
|
||||
class TestWorkflowInfoStepGraph:
|
||||
"""`workflow info` must render each step as `→ <id> [<type>]` with LITERAL
|
||||
brackets. Rich parses an unescaped `[<type>]` as a style tag and silently
|
||||
swallows it, so the step type would vanish from the output."""
|
||||
|
||||
def test_step_type_rendered_in_literal_brackets(self, temp_dir, monkeypatch):
|
||||
import types
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
|
||||
fake = types.SimpleNamespace(
|
||||
name="My WF", id="my-wf", version="1.0.0", author="", description="",
|
||||
default_integration=None, inputs={},
|
||||
steps=[{"id": "step-one", "type": "gate"}],
|
||||
)
|
||||
monkeypatch.setattr(WorkflowEngine, "load_workflow", lambda self, wid: fake)
|
||||
monkeypatch.chdir(temp_dir)
|
||||
|
||||
result = CliRunner().invoke(app, ["workflow", "info", "my-wf"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "step-one" in result.output
|
||||
# The step type must survive as a literal bracketed token, not be eaten
|
||||
# by Rich as an unknown style tag.
|
||||
assert "[gate]" in result.output
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
@@ -8728,15 +8658,18 @@ steps:
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/42"
|
||||
|
||||
def read(self, size=-1):
|
||||
if size < 0:
|
||||
size = len(self._data) - self._pos
|
||||
out = self._data[self._pos : self._pos + size]
|
||||
self._pos += len(out)
|
||||
return out
|
||||
def read(self, amt=None):
|
||||
if not hasattr(self, "_pos"):
|
||||
self._pos = 0
|
||||
if amt is None:
|
||||
chunk = self._data[self._pos :]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + amt]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
@@ -8796,15 +8729,18 @@ steps:
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/42"
|
||||
|
||||
def read(self, size=-1):
|
||||
if size < 0:
|
||||
size = len(self._data) - self._pos
|
||||
out = self._data[self._pos : self._pos + size]
|
||||
self._pos += len(out)
|
||||
return out
|
||||
def read(self, amt=None):
|
||||
if not hasattr(self, "_pos"):
|
||||
self._pos = 0
|
||||
if amt is None:
|
||||
chunk = self._data[self._pos :]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + amt]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
@@ -8844,15 +8780,18 @@ steps:
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/55"
|
||||
|
||||
def read(self, size=-1):
|
||||
if size < 0:
|
||||
size = len(self._data) - self._pos
|
||||
out = self._data[self._pos : self._pos + size]
|
||||
self._pos += len(out)
|
||||
return out
|
||||
def read(self, amt=None):
|
||||
if not hasattr(self, "_pos"):
|
||||
self._pos = 0
|
||||
if amt is None:
|
||||
chunk = self._data[self._pos :]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + amt]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
@@ -8934,15 +8873,18 @@ steps:
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"
|
||||
|
||||
def read(self, size=-1):
|
||||
if size < 0:
|
||||
size = len(self._data) - self._pos
|
||||
out = self._data[self._pos : self._pos + size]
|
||||
self._pos += len(out)
|
||||
return out
|
||||
def read(self, amt=None):
|
||||
if not hasattr(self, "_pos"):
|
||||
self._pos = 0
|
||||
if amt is None:
|
||||
chunk = self._data[self._pos :]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + amt]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
@@ -8994,15 +8936,18 @@ steps:
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"
|
||||
|
||||
def read(self, size=-1):
|
||||
if size < 0:
|
||||
size = len(self._data) - self._pos
|
||||
out = self._data[self._pos : self._pos + size]
|
||||
self._pos += len(out)
|
||||
return out
|
||||
def read(self, amt=None):
|
||||
if not hasattr(self, "_pos"):
|
||||
self._pos = 0
|
||||
if amt is None:
|
||||
chunk = self._data[self._pos :]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + amt]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
@@ -11806,10 +11751,6 @@ steps:
|
||||
_reject_insecure_download_redirect(
|
||||
"https://example.com/wf.yml", "http://localhost:8000/wf.yml"
|
||||
)
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
_reject_insecure_download_redirect(
|
||||
"https://example.com/wf.yml", "https://127.0.0.2/wf.yml"
|
||||
)
|
||||
# Allowed: HTTPS anywhere, or loopback HTTP that stays on loopback HTTP.
|
||||
_reject_insecure_download_redirect(
|
||||
"https://example.com/wf.yml", "https://cdn.example.com/wf.yml"
|
||||
@@ -11820,9 +11761,6 @@ steps:
|
||||
_reject_insecure_download_redirect(
|
||||
"http://127.0.0.1/source.yml", "http://127.0.0.1/wf.yml"
|
||||
)
|
||||
_reject_insecure_download_redirect(
|
||||
"http://127.0.0.2/source.yml", "http://127.255.255.254/wf.yml"
|
||||
)
|
||||
|
||||
def test_add_from_url_passes_redirect_validator(self, project_dir, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -154,20 +154,6 @@ def test_read_rejects_non_mapping_top_level(tmp_path: Path):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n", "null\n", "~\n"])
|
||||
def test_read_rejects_falsy_non_mapping_top_level(tmp_path: Path, body: str):
|
||||
# A FALSY non-mapping top level ([], false, 0, '') OR an explicit null
|
||||
# (null/~) must raise like a truthy one. safe_load coerces these to
|
||||
# None/{}, so load_yaml distinguishes them from a truly empty document —
|
||||
# staying consistent with models/catalog._merge_config.
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(body, encoding="utf-8")
|
||||
|
||||
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_unknown_schema_version(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
|
||||
@@ -73,23 +73,6 @@ def test_build_is_deterministic(tmp_path: Path):
|
||||
assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes()
|
||||
|
||||
|
||||
def test_member_order_is_platform_independent(tmp_path: Path):
|
||||
# Members must be laid out in canonical POSIX-arcname order (the same key
|
||||
# build_bundle uses to NAME them), not pathlib.Path order — which folds case
|
||||
# on Windows and would otherwise reorder members across build hosts, breaking
|
||||
# the byte-for-byte reproducibility guarantee. Mixed-case names make the
|
||||
# difference observable: Path order on Windows groups differently than the
|
||||
# canonical string sort.
|
||||
bundle = _make_bundle(
|
||||
tmp_path / "b",
|
||||
extra_files={"Zeta.txt": "z", "apple.txt": "a", "Foo.txt": "f", "bar.txt": "b"},
|
||||
)
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
def test_output_dir_inside_bundle_excludes_prior_artifacts(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
out_dir = bundle / "dist"
|
||||
|
||||
@@ -45,27 +45,6 @@ def test_load_missing_file_returns_empty(tmp_path: Path):
|
||||
assert load_records(tmp_path) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, False, "", {}])
|
||||
def test_load_records_rejects_falsy_non_list_bundles(tmp_path: Path, bad):
|
||||
# `data.get("bundles") or []` coerced a FALSY non-list (0, '', False, {})
|
||||
# to [] before the isinstance guard, silently treating a corrupt records
|
||||
# file as "no bundles". Only an absent/None value means empty.
|
||||
(tmp_path / ".specify").mkdir()
|
||||
records_path(tmp_path).write_text(
|
||||
json.dumps({"schema_version": "1.0", "bundles": bad}), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="'bundles' must be a list"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, False, "", {}])
|
||||
def test_from_dict_rejects_falsy_non_list_contributed_components(bad):
|
||||
# Same falsy-coercion hole for a record's 'contributed_components'.
|
||||
data = {"bundle_id": "a", "version": "1.0.0", "contributed_components": bad}
|
||||
with pytest.raises(BundlerError, match="'contributed_components' must be a list"):
|
||||
InstalledBundleRecord.from_dict(data)
|
||||
|
||||
|
||||
def test_corrupt_priority_raises_actionable_error(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
rec = _record("a", [("presets", "p1")])
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Unit tests for the bundler YAML I/O helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.bundler.lib.yamlio import dump_yaml, load_yaml
|
||||
|
||||
|
||||
def test_dump_yaml_preserves_unicode(tmp_path: Path):
|
||||
# dump_yaml must write literal UTF-8, not \xNN / \uXXXX escapes, so bundle
|
||||
# config stays human-readable — matching _utils.dump_frontmatter and the
|
||||
# extensions/presets config writers (all allow_unicode=True).
|
||||
path = tmp_path / "f.yml"
|
||||
data = {"note": "café-münchen", "url": "https://例え.example"}
|
||||
dump_yaml(path, data)
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
assert "café-münchen" in raw
|
||||
assert "例え" in raw
|
||||
assert "\\x" not in raw and "\\u" not in raw
|
||||
|
||||
|
||||
def test_dump_yaml_round_trips_unicode(tmp_path: Path):
|
||||
path = tmp_path / "f.yml"
|
||||
data = {"note": "café", "city": "münchen"}
|
||||
dump_yaml(path, data)
|
||||
assert load_yaml(path) == data
|
||||
@@ -162,23 +162,6 @@ class TestMergeSteps:
|
||||
ComposedStep("low-step", "project:low"),
|
||||
]
|
||||
|
||||
def test_merge_steps_multiple_insert_after_same_overlay_preserves_order(self):
|
||||
# Two insert_after edits from ONE overlay on the same anchor must keep
|
||||
# their declared order (a, x, y, b) — mirroring insert_before. The old
|
||||
# reversed(edits) over the flat list flipped them to (a, y, x, b).
|
||||
base = [_step("a"), _step("b")]
|
||||
overlay = Overlay(
|
||||
id="ov1",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[
|
||||
OverlayEdit("insert_after", "a", _step("x")),
|
||||
OverlayEdit("insert_after", "a", _step("y")),
|
||||
],
|
||||
)
|
||||
steps, _ = merge_steps(base, [_layer(overlay, "project:ov1")])
|
||||
assert [s["id"] for s in steps] == ["a", "x", "y", "b"]
|
||||
|
||||
def test_merge_steps_replace_wins_over_insert(self):
|
||||
"""Overlays apply to the original tree only; targeting an overlay-introduced step raises."""
|
||||
base = [_step("a")]
|
||||
|
||||
Reference in New Issue
Block a user