mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cd8953696 | ||
|
|
c1722a425e | ||
|
|
6688b447b7 | ||
|
|
fb076a38b8 | ||
|
|
1e84ee2713 | ||
|
|
353851e966 | ||
|
|
ad601e5d52 | ||
|
|
77ebd5fcea | ||
|
|
faeb956664 | ||
|
|
91839fba50 | ||
|
|
ab82571999 | ||
|
|
99a3b7ccab | ||
|
|
e742b8010a | ||
|
|
73093954e2 | ||
|
|
d83b8d1188 | ||
|
|
d7b6626218 | ||
|
|
2537be8144 | ||
|
|
6ab0c1dac1 | ||
|
|
d956ab722b | ||
|
|
e48f134c3b | ||
|
|
654793b659 | ||
|
|
a8d3038ece | ||
|
|
5f59a5b238 | ||
|
|
3c9aa1f81b | ||
|
|
52c1acf8ba | ||
|
|
fc1a3fd76c | ||
|
|
993083405e | ||
|
|
801ff888ff | ||
|
|
c05a626cbc | ||
|
|
0acb5c6461 | ||
|
|
a965413a24 | ||
|
|
8cb0889f4a | ||
|
|
e590cd8007 | ||
|
|
e649bbdc44 | ||
|
|
6664cf813c | ||
|
|
3b7d95a408 | ||
|
|
086929e546 | ||
|
|
32952c94f4 | ||
|
|
82c078bb3a | ||
|
|
86d769b47c | ||
|
|
55c66125f0 | ||
|
|
7ff4522cf3 |
76
CHANGELOG.md
76
CHANGELOG.md
@@ -2,6 +2,82 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.16] - 2026-07-15
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): raise a clear error, not a cryptic crash, on non-string filter args (#3522)
|
||||
- feat(workflows): expose workflow source directory to steps (#3469)
|
||||
- fix(workflows): fan-out max_concurrency .inf falls back to sequential, not crash (#3521)
|
||||
- Update Coding Standards Drift Control extension to v0.4.0 (#3540)
|
||||
- fix(presets): seed constitution from preset constitution-template (#3272) (#3276)
|
||||
- docs: add PyPI as second supported install route (#3425) (#3516)
|
||||
- fix(workflows): fail while/do-while steps on non-list steps instead of crashing (#3519)
|
||||
- Add PatchWarden Evidence Pack extension to community catalog (#3514)
|
||||
- feat(extensions): port git extension scripts to Python (#3400)
|
||||
- chore: release 0.12.15, begin 0.12.16.dev0 development (#3513)
|
||||
|
||||
## [0.12.15] - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Autonomous Run Governance preset to v0.1.4 (#3511)
|
||||
- fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
|
||||
- fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)
|
||||
- fix: add trailing newline to init-options.json output (#3509)
|
||||
- feat(workflows): align workflow CLI with extension command surface (#3419)
|
||||
- fix(extensions): stop env-var config leaking across prefix-colliding extension IDs (#3497)
|
||||
- fix(integrations): escape control characters in goose recipe YAML renderer (#3384)
|
||||
- [extension] Update DocGuard — CDD Enforcement extension to v0.32.0 (#3489)
|
||||
- [extension] Add Multi-Repo Branch Sync extension to community catalog (#3411)
|
||||
- chore: release 0.12.14, begin 0.12.15.dev0 development (#3506)
|
||||
|
||||
## [0.12.14] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- [extension] Add Spec Kit Memory extension to community catalog (#3455)
|
||||
- Add Test-First Governance preset to community catalog (#3504)
|
||||
- Add Autonomous Run Governance preset to community catalog (#3501)
|
||||
- fix(workflows): validate command step input/options are mappings (#3262)
|
||||
- fix(presets): resolve() honors manifest-declared file: for installed presets (#3351)
|
||||
- fix(init): don't block on confirmation for 'init --here' without a TTY (#3236)
|
||||
- [extension] Add Quality Gates (Enforcement Layer) extension to community catalog (#3431)
|
||||
- fix(integrations): exit cleanly on unbalanced quote in --integration-options (#3457) (#3466)
|
||||
- fix(integrations): declare kiro-cli multi-install safe (#3471) (#3485)
|
||||
- fix(workflows): fail fan-in step on non-list wait_for instead of crashing (#3482)
|
||||
- chore: release 0.12.13, begin 0.12.14.dev0 development (#3498)
|
||||
|
||||
## [0.12.13] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
|
||||
- Cleanup agent-file-template.md (#2579)
|
||||
- fix: mark Kiro integration as multi-install safe (#3472)
|
||||
- fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
|
||||
- fix(templates): point constitution sync checklist at installed command files (#3418)
|
||||
- feat(workflows): make shell step timeout configurable (#3327) (#3328)
|
||||
- docs: clarify that release tags keep the leading v prefix (#3463)
|
||||
- fix(workflows): don't crash on membership test against a non-iterable (#3448)
|
||||
- fix(workflows): if-step validate accepts falsy non-list else (#3264)
|
||||
- chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
|
||||
|
||||
## [0.12.12] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(extensions): set-priority repairs corrupted boolean priority (#3268)
|
||||
- fix(presets): set-priority repairs corrupted boolean priority (#3269)
|
||||
- fix(workflows): engine loop cap ignores bool max_iterations (#3270)
|
||||
- docs(bundles): document --integration on 'bundle update' (#3271)
|
||||
- fix(workflows): harden catalog.py against mis-shaped registry & non-string fields (#3375)
|
||||
- Add Verify Review Ship extension to community catalog (#3450)
|
||||
- fix(bundle): reject file:// / local download_url — catalog URLs are HTTPS-only (#3344)
|
||||
- fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
|
||||
- docs: document copilot skills mode (--skills) and markdown deprecation (#3313)
|
||||
- chore: release 0.12.11, begin 0.12.12.dev0 development (#3460)
|
||||
|
||||
## [0.12.11] - 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -45,12 +45,18 @@ Spec-Driven Development **flips the script** on traditional software development
|
||||
|
||||
### 1. Install Specify CLI
|
||||
|
||||
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
Prefer installing from PyPI? The `specify-cli` package is also published there:
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli
|
||||
```
|
||||
|
||||
See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting.
|
||||
|
||||
### 2. Initialize a project
|
||||
|
||||
@@ -51,7 +51,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
|
||||
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| DocGuard — CDD Enforcement | Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
@@ -84,12 +84,14 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MemoryLint | Evidence-driven instruction drift checker: audits agent memory files for boundary, reality, conflict, and redundancy drift. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
|
||||
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
| PatchWarden Evidence Pack | Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage. | `process` | Read+Write | [spec-kit-patchwarden](https://github.com/jiezeng2004-design/spec-kit-patchwarden) |
|
||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
@@ -98,6 +100,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity). | `process` | Read+Write | [spec-gates](https://github.com/schwichtgit/spec-gates) |
|
||||
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
@@ -119,6 +122,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
|
||||
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
|
||||
| Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) |
|
||||
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
|
||||
|
||||
@@ -11,6 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| 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, 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 autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 12 templates, 2 commands, 2 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) |
|
||||
@@ -28,6 +29,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| 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) |
|
||||
| Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) |
|
||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ If you want to try Spec Kit without installing it permanently, use `uvx` to run
|
||||
# Create a new project (latest from main)
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
||||
|
||||
# Or target a specific release (replace vX.Y.Z with a tag from Releases)
|
||||
# Or target a specific release (replace vX.Y.Z with a tag from Releases;
|
||||
# keep the leading v, e.g. v0.12.11 not 0.12.11)
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||
|
||||
# Initialize in the current directory
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||
|
||||
```bash
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the
|
||||
# latest tag, keeping the leading v, e.g. v0.12.11 not 0.12.11)
|
||||
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
|
||||
# Or install latest from main (may include unreleased changes)
|
||||
|
||||
83
docs/install/pypi.md
Normal file
83
docs/install/pypi.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Installing from PyPI
|
||||
|
||||
Spec Kit is published to PyPI as [`specify-cli`](https://pypi.org/project/specify-cli/), maintained by the Spec Kit maintainers. Installing from PyPI is the second supported install route alongside installing from the [GitHub source](../installation.md#install-from-source--persistent-installation-recommended). Use whichever fits your workflow — both provide the same `specify` CLI.
|
||||
|
||||
> [!NOTE]
|
||||
> The PyPI release version tracks the GitHub release tags (for example, PyPI `0.12.11` corresponds to the `v0.12.11` tag). `specify version` is only a local version/runtime sanity check — it reports the installed version but not where the `specify` executable came from, so it cannot distinguish a PyPI install from a Git install. To confirm the install source, inspect the source metadata your package manager records: `pipx list --json` reports the exact install specification for each tool, and for uv/pip installs you can check the package's [PEP 610](https://peps.python.org/pep-0610/) `direct_url.json` inside its `*.dist-info` directory (a Git or URL install records the repository/archive URL there, while a plain PyPI index install does not create that file). Note that `pip show specify-cli` only prints package metadata and will not see uv/pipx-managed environments from the host interpreter.
|
||||
|
||||
## Install Specify CLI
|
||||
|
||||
Use whichever Python tool you already have:
|
||||
|
||||
```bash
|
||||
# Using uv (recommended)
|
||||
uv tool install specify-cli
|
||||
|
||||
# Or using pipx
|
||||
pipx install specify-cli
|
||||
|
||||
# Or using pip
|
||||
pip install specify-cli
|
||||
```
|
||||
|
||||
### Install a specific release
|
||||
|
||||
Pin an exact version for reproducible installs (check [PyPI](https://pypi.org/project/specify-cli/#history) or [Releases](https://github.com/github/spec-kit/releases) for available versions):
|
||||
|
||||
```bash
|
||||
# Using uv
|
||||
uv tool install specify-cli==0.12.11
|
||||
|
||||
# Or using pipx
|
||||
pipx install specify-cli==0.12.11
|
||||
|
||||
# Or using pip
|
||||
pip install specify-cli==0.12.11
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
specify version
|
||||
```
|
||||
|
||||
## Initialize a project
|
||||
|
||||
```bash
|
||||
specify init <PROJECT_NAME> --integration copilot
|
||||
```
|
||||
|
||||
## Upgrade
|
||||
|
||||
Upgrade by reinstalling the package through the same tool you used for the original install. If you originally pinned a version, note that `uv tool upgrade` preserves that pin; to move to the newest PyPI release, use an unpinned install command so you do not keep the existing version pin:
|
||||
|
||||
```bash
|
||||
# Using uv
|
||||
uv tool install --force specify-cli
|
||||
|
||||
# Or using pipx
|
||||
pipx install --force specify-cli
|
||||
|
||||
# Or using pip
|
||||
pip install --upgrade specify-cli
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `specify self upgrade` currently rebuilds `uv tool` and `pipx` installs from the GitHub source release URL rather than preserving a PyPI-based installation. If you want to stay on the PyPI route, use the package-manager commands above. A plain `pip install specify-cli` is treated as an unmanaged install — upgrade it with `pip install --upgrade specify-cli`. See the [Upgrade Guide](../upgrade.md) for details.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
# Using uv
|
||||
uv tool uninstall specify-cli
|
||||
|
||||
# Or using pipx
|
||||
pipx uninstall specify-cli
|
||||
|
||||
# Or using pip
|
||||
pip uninstall specify-cli
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
Head to the [Quick Start](../quickstart.md) to initialize your first project.
|
||||
@@ -11,11 +11,16 @@
|
||||
## Installation
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
|
||||
> Spec Kit is distributed through two official channels, both published and maintained by the Spec Kit maintainers: the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository (source installs) and the [`specify-cli`](https://pypi.org/project/specify-cli/) package on [PyPI](https://pypi.org/project/specify-cli/). Either route is supported for normal installs — use the commands shown below. After installing, run `specify version` as a local version/runtime sanity check. It confirms that the `specify` command is available and reports its version, but it does not prove whether the executable came from PyPI or GitHub. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
|
||||
|
||||
### Persistent Installation (Recommended)
|
||||
Spec Kit supports two install routes:
|
||||
|
||||
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
1. **Install from source (GitHub)** — the recommended route, pinned to a release tag.
|
||||
2. **Install from PyPI** — install the published `specify-cli` package with your usual Python tooling.
|
||||
|
||||
### Install from Source — Persistent Installation (Recommended)
|
||||
|
||||
Install once and use everywhere. Replace `vX.Y.Z` with a release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
|
||||
|
||||
> [!NOTE]
|
||||
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).
|
||||
@@ -30,12 +35,30 @@ Then initialize a project:
|
||||
specify init <PROJECT_NAME> --integration copilot
|
||||
```
|
||||
|
||||
### Install from PyPI
|
||||
|
||||
Spec Kit is also published to PyPI as [`specify-cli`](https://pypi.org/project/specify-cli/), so you can install it with your preferred Python package manager without referencing the Git URL:
|
||||
|
||||
```bash
|
||||
# Using uv (recommended)
|
||||
uv tool install specify-cli
|
||||
|
||||
# Or using pipx
|
||||
pipx install specify-cli
|
||||
|
||||
# Or using pip
|
||||
pip install specify-cli
|
||||
```
|
||||
|
||||
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade.
|
||||
|
||||
### One-time Usage
|
||||
|
||||
Run directly without installing — see the [One-time usage (uvx)](install/one-time.md) guide.
|
||||
|
||||
### Alternative Package Managers
|
||||
|
||||
- **PyPI** — see the [PyPI installation guide](install/pypi.md)
|
||||
- **pipx** — see the [pipx installation guide](install/pipx.md)
|
||||
- **Enterprise / Air-Gapped** — see the [air-gapped installation guide](install/air-gapped.md)
|
||||
|
||||
@@ -81,13 +104,13 @@ specify init <project_name> --integration claude --ignore-agent-tools
|
||||
|
||||
## Verification
|
||||
|
||||
After installation, run the following command to confirm the correct version is installed:
|
||||
After installation, run the following command as a local version/runtime check:
|
||||
|
||||
```bash
|
||||
specify version
|
||||
```
|
||||
|
||||
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
|
||||
This confirms that the `specify` command is available and reporting the expected version. It does not prove whether that executable came from PyPI or GitHub.
|
||||
|
||||
**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md).
|
||||
|
||||
|
||||
@@ -86,8 +86,30 @@ Lists workflows installed in the current project.
|
||||
specify workflow add <source>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| `--dev` | Install from a local workflow YAML file or directory |
|
||||
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
|
||||
|
||||
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
|
||||
|
||||
## Update Workflows
|
||||
|
||||
```bash
|
||||
specify workflow update [workflow_id]
|
||||
```
|
||||
|
||||
Updates one installed catalog workflow — or all of them when no ID is given — to the latest catalog version. Prompts for confirmation and keeps the installed copy if a download or validation fails.
|
||||
|
||||
## Enable or Disable a Workflow
|
||||
|
||||
```bash
|
||||
specify workflow enable <workflow_id>
|
||||
specify workflow disable <workflow_id>
|
||||
```
|
||||
|
||||
Disabled workflows stay installed and listed (marked `[disabled]`) but refuse to run until re-enabled.
|
||||
|
||||
## Remove a Workflow
|
||||
|
||||
```bash
|
||||
@@ -102,9 +124,10 @@ Removes an installed workflow from the project.
|
||||
specify workflow search [query]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------- | --------------- |
|
||||
| `--tag` | Filter by tag |
|
||||
| Option | Description |
|
||||
| ---------- | ----------------- |
|
||||
| `--tag` | Filter by tag |
|
||||
| `--author` | Filter by author |
|
||||
|
||||
Searches all active catalogs for workflows matching the query.
|
||||
|
||||
@@ -282,6 +305,8 @@ Steps can reference inputs and previous step outputs using `{{ expression }}` sy
|
||||
| `inputs.spec` | Workflow input values |
|
||||
| `steps.specify.output.file` | Output from a previous step |
|
||||
| `item` | Current item in a fan-out iteration |
|
||||
| `context.run_id` | Current workflow run ID |
|
||||
| `context.workflow_dir` | Resolved absolute path to the workflow source directory. Empty string for string-loaded workflows. |
|
||||
|
||||
Available filters: `default`, `join`, `contains`, `map`, `from_json`.
|
||||
|
||||
@@ -293,6 +318,14 @@ args: "{{ inputs.spec }}"
|
||||
message: "{{ status | default('pending') }}"
|
||||
```
|
||||
|
||||
## Shell Step Environment Variables
|
||||
|
||||
Shell steps automatically receive the following environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
| -------- | ----------- |
|
||||
| `SPECKIT_WORKFLOW_DIR` | Resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path. |
|
||||
|
||||
## Input Types
|
||||
|
||||
| Type | Coercion |
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
href: upgrade.md
|
||||
- name: Install uv
|
||||
href: install/uv.md
|
||||
- name: Install from PyPI
|
||||
href: install/pypi.md
|
||||
- name: Install with pipx
|
||||
href: install/pipx.md
|
||||
- name: One-time Usage (uvx)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-15T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -809,8 +809,8 @@
|
||||
"id": "coding-standards-drift-control",
|
||||
"description": "Generate coding-standards drift reports and remediation tasks for active Spec Kit features",
|
||||
"author": "Igor Benicio de Mesquita",
|
||||
"version": "0.3.1",
|
||||
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.3.1.zip",
|
||||
"version": "0.4.0",
|
||||
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.4.0.zip",
|
||||
"repository": "https://github.com/benizzio/spec-kit-coding-standards-drift-control",
|
||||
"homepage": "https://github.com/benizzio/spec-kit-coding-standards-drift-control",
|
||||
"documentation": "https://github.com/benizzio/spec-kit-coding-standards-drift-control#readme",
|
||||
@@ -835,7 +835,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-11T00:00:00Z",
|
||||
"updated_at": "2026-06-11T00:00:00Z"
|
||||
"updated_at": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"companion": {
|
||||
"name": "SpecKit Companion",
|
||||
@@ -1106,10 +1106,10 @@
|
||||
"docguard": {
|
||||
"name": "DocGuard — CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
|
||||
"description": "Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js.",
|
||||
"author": "raccioly",
|
||||
"version": "0.30.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
|
||||
"version": "0.32.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.32.0/spec-kit-docguard-v0.32.0.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
@@ -1124,6 +1124,14 @@
|
||||
"name": "node",
|
||||
"version": ">=18.0.0",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "npx",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "specify",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1145,7 +1153,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
@@ -1427,6 +1435,57 @@
|
||||
"created_at": "2026-05-06T00:00:00Z",
|
||||
"updated_at": "2026-05-06T00:00:00Z"
|
||||
},
|
||||
"gates": {
|
||||
"name": "Quality Gates (Enforcement Layer)",
|
||||
"id": "gates",
|
||||
"description": "Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity).",
|
||||
"author": "schwichtgit",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.1.0/gates-0.1.0.zip",
|
||||
"repository": "https://github.com/schwichtgit/spec-gates",
|
||||
"homepage": "https://github.com/schwichtgit/spec-gates",
|
||||
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "jq",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "shellcheck",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"enforcement",
|
||||
"hooks",
|
||||
"ci",
|
||||
"governance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-09T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration 1",
|
||||
"id": "github-issues",
|
||||
@@ -2217,6 +2276,42 @@
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"memory": {
|
||||
"name": "Spec Kit Memory",
|
||||
"id": "memory",
|
||||
"description": "Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows.",
|
||||
"author": "Andrey Zaytsev",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/zaytsevand/spec-kit-memory/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/zaytsevand/spec-kit-memory",
|
||||
"homepage": "https://github.com/zaytsevand/spec-kit-memory",
|
||||
"documentation": "https://github.com/zaytsevand/spec-kit-memory/blob/main/README.md",
|
||||
"changelog": "",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{ "name": "memsearch", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
"recall",
|
||||
"research",
|
||||
"memsearch"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
},
|
||||
"memory-loader": {
|
||||
"name": "Memory Loader",
|
||||
"id": "memory-loader",
|
||||
@@ -2371,6 +2466,48 @@
|
||||
"created_at": "2026-05-04T02:51:52Z",
|
||||
"updated_at": "2026-06-18T00:00:00Z"
|
||||
},
|
||||
"multi-repo-sync": {
|
||||
"name": "Multi-Repo Branch Sync",
|
||||
"id": "multi-repo-sync",
|
||||
"description": "Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks",
|
||||
"author": "Fyloss",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/fyloss/spec-kit-multi-repo-sync/releases/download/v1.0.0/spec-kit-multi-repo-sync.zip",
|
||||
"sha256": "12a5c7392145b4424b20715aaa3d8b6a8218c143dea596873e344146c1a76ba0",
|
||||
"repository": "https://github.com/fyloss/spec-kit-multi-repo-sync",
|
||||
"homepage": "https://github.com/fyloss/spec-kit-multi-repo-sync",
|
||||
"documentation": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/README.md",
|
||||
"changelog": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"version": ">=2.31",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"git",
|
||||
"branching",
|
||||
"multi-repo",
|
||||
"submodules",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"multi-sites": {
|
||||
"name": "Multi-Sites Spec Kit",
|
||||
"id": "multi-sites",
|
||||
@@ -2540,6 +2677,46 @@
|
||||
"created_at": "2026-04-24T14:00:00Z",
|
||||
"updated_at": "2026-04-24T14:00:00Z"
|
||||
},
|
||||
"patchwarden-evidence": {
|
||||
"name": "PatchWarden Evidence Pack",
|
||||
"id": "patchwarden-evidence",
|
||||
"description": "Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage.",
|
||||
"author": "Zengjie",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/jiezeng2004-design/spec-kit-patchwarden",
|
||||
"homepage": "https://github.com/jiezeng2004-design/spec-kit-patchwarden",
|
||||
"documentation": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/blob/main/README.md",
|
||||
"changelog": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "patchwarden",
|
||||
"version": ">=1.5.1",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"verification",
|
||||
"evidence",
|
||||
"traceability",
|
||||
"security"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-14T00:00:00Z",
|
||||
"updated_at": "2026-07-14T00:00:00Z"
|
||||
},
|
||||
"plan-review-gate": {
|
||||
"name": "Plan Review Gate",
|
||||
"id": "plan-review-gate",
|
||||
|
||||
187
extensions/git/scripts/python/auto_commit.py
Normal file
187
extensions/git/scripts/python/auto_commit.py
Normal file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Git extension: auto_commit.py
|
||||
|
||||
Automatically commit changes after a Spec Kit command completes.
|
||||
Python port of ``auto-commit.sh`` / ``auto-commit.ps1``.
|
||||
Checks per-command config keys in git-config.yml before committing.
|
||||
|
||||
Usage: auto_commit.py <event_name>
|
||||
e.g.: auto_commit.py after_specify
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _find_project_root(start: Path) -> Path | None:
|
||||
current = start
|
||||
while True:
|
||||
if (current / ".specify").is_dir() or (current / ".git").exists():
|
||||
return current
|
||||
if current.parent == current:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _value_after_colon(line: str) -> str:
|
||||
return re.sub(r"^[^:]*:\s*", "", line)
|
||||
|
||||
|
||||
def _strip_quotes(value: str) -> str:
|
||||
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
|
||||
value = re.sub(r"^[\"']", "", value)
|
||||
return re.sub(r"[\"']*$", "", value)
|
||||
|
||||
|
||||
def _parse_auto_commit_config(
|
||||
config_file: Path, event_name: str
|
||||
) -> tuple[bool, str]:
|
||||
"""Parse the auto_commit section for this event, mirroring the bash line parser.
|
||||
|
||||
Returns (enabled, commit_msg). Looks for auto_commit.<event_name>.enabled
|
||||
and .message, with auto_commit.default as fallback.
|
||||
"""
|
||||
enabled = False
|
||||
commit_msg = ""
|
||||
default_enabled = False
|
||||
in_auto_commit = False
|
||||
in_event = False
|
||||
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# Unreadable or non-UTF-8 config is treated like a missing one:
|
||||
# auto-commit stays disabled instead of crashing with a traceback.
|
||||
return False, ""
|
||||
for record in content.splitlines(keepends=True):
|
||||
if not record.endswith("\n"):
|
||||
break
|
||||
line = record[:-1]
|
||||
if line.startswith("auto_commit:"):
|
||||
in_auto_commit = True
|
||||
in_event = False
|
||||
continue
|
||||
|
||||
# Exit auto_commit section on next top-level key
|
||||
if in_auto_commit and re.match(r"^[a-z]", line):
|
||||
break
|
||||
|
||||
if not in_auto_commit:
|
||||
continue
|
||||
|
||||
if re.match(r"^\s+default:\s", line):
|
||||
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
|
||||
if value == "true":
|
||||
default_enabled = True
|
||||
|
||||
if re.match(rf"^\s+{re.escape(event_name)}:", line):
|
||||
in_event = True
|
||||
continue
|
||||
|
||||
if in_event:
|
||||
# Exit on next sibling key (same indent level as event name)
|
||||
if re.match(r"^\s{2}[a-z]", line) and not re.match(r"^\s{4}", line):
|
||||
in_event = False
|
||||
continue
|
||||
if re.search(r"\s+enabled:", line):
|
||||
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
|
||||
if value == "true":
|
||||
enabled = True
|
||||
elif value == "false":
|
||||
enabled = False
|
||||
if re.search(r"\s+message:", line):
|
||||
commit_msg = _strip_quotes(_value_after_colon(line))
|
||||
|
||||
# If event-specific key not found, use default — but only if the event
|
||||
# section didn't exist at all (an explicit false must win).
|
||||
if not enabled and default_enabled:
|
||||
if not re.search(rf"^\s*{re.escape(event_name)}:", content, re.MULTILINE):
|
||||
enabled = True
|
||||
|
||||
return enabled, commit_msg
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
event_name = argv[0] if argv else ""
|
||||
if not event_name:
|
||||
print(f"Usage: {Path(sys.argv[0]).name} <event_name>", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
repo_root = _find_project_root(script_dir) or Path.cwd()
|
||||
|
||||
if shutil.which("git") is None:
|
||||
print("[specify] Warning: Git not found; skipped auto-commit", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
probe = subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
print(
|
||||
"[specify] Warning: Not a Git repository; skipped auto-commit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
|
||||
if not config_file.is_file():
|
||||
# No config file — auto-commit disabled by default
|
||||
return 0
|
||||
|
||||
enabled, commit_msg = _parse_auto_commit_config(config_file, event_name)
|
||||
if not enabled:
|
||||
return 0
|
||||
|
||||
# Check if there are changes to commit
|
||||
def _quiet(*args: str) -> bool:
|
||||
return (
|
||||
subprocess.run(
|
||||
["git", *args], cwd=repo_root, capture_output=True, text=True
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
|
||||
untracked = subprocess.run(
|
||||
["git", "ls-files", "--others", "--exclude-standard"],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
if _quiet("diff", "--quiet", "HEAD") and _quiet("diff", "--cached", "--quiet") and not untracked:
|
||||
print(f"[specify] No changes to commit after {event_name}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
# e.g., after_specify -> specify, before_plan -> plan
|
||||
command_name = re.sub(r"^(after_|before_)", "", event_name)
|
||||
phase = "before" if event_name.startswith("before_") else "after"
|
||||
|
||||
if not commit_msg:
|
||||
commit_msg = f"[Spec Kit] Auto-commit {phase} {command_name}"
|
||||
|
||||
steps = [
|
||||
(["git", "add", "."], "git add"),
|
||||
(["git", "commit", "-q", "-m", commit_msg], "git commit"),
|
||||
]
|
||||
for cmd, label in steps:
|
||||
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"[OK] Changes committed {phase} {command_name}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
634
extensions/git/scripts/python/create_new_feature_branch.py
Normal file
634
extensions/git/scripts/python/create_new_feature_branch.py
Normal file
@@ -0,0 +1,634 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Git extension: create_new_feature_branch.py
|
||||
|
||||
Creates a git feature branch only. The feature directory and spec file are
|
||||
created by the core create-new-feature script. Python port of
|
||||
``create-new-feature-branch.sh`` / ``create-new-feature-branch.ps1``.
|
||||
|
||||
Loads the core Python helpers from the project's installed scripts when
|
||||
available, falling back to the minimal git helpers next to this script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
MAX_BRANCH_LENGTH = 244 # GitHub enforces a 244-byte limit on branch names
|
||||
|
||||
USAGE = (
|
||||
"Usage: create_new_feature_branch.py [--json] [--dry-run] "
|
||||
"[--allow-existing-branch] [--short-name <name>] [--number N] "
|
||||
"[--timestamp] <feature_description>"
|
||||
)
|
||||
|
||||
HELP_TEXT = f"""{USAGE}
|
||||
|
||||
Options:
|
||||
--json Output in JSON format
|
||||
--dry-run Compute branch name without creating the branch
|
||||
--allow-existing-branch Switch to branch if it already exists instead of failing
|
||||
--short-name <name> Provide a custom short name (2-4 words) for the branch
|
||||
--number N Specify branch number manually (overrides auto-detection)
|
||||
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
|
||||
--help, -h Show this help message
|
||||
|
||||
Environment variables:
|
||||
GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation
|
||||
|
||||
Configuration:
|
||||
branch_template Optional git-config.yml template with {{author}}, {{app}}, {{number}}, {{slug}}
|
||||
branch_prefix Optional shorthand namespace expanded before {{number}}-{{slug}}
|
||||
|
||||
Examples:
|
||||
create_new_feature_branch.py 'Add user authentication system' --short-name 'user-auth'
|
||||
create_new_feature_branch.py 'Implement OAuth2 integration for API' --number 5
|
||||
create_new_feature_branch.py --timestamp --short-name 'user-auth' 'Add user authentication'
|
||||
GIT_BRANCH_NAME=my-branch create_new_feature_branch.py 'feature description'
|
||||
"""
|
||||
|
||||
STOP_WORDS = frozenset(
|
||||
"i a an the to for of in on at by with from is are was were be been being "
|
||||
"have has had do does did will would should could can may might must shall "
|
||||
"this that these those my your our their want need add get set".split()
|
||||
)
|
||||
|
||||
|
||||
def _err(message: str) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def _persist_hint(var_name: str, value: str) -> str:
|
||||
"""Shell-appropriate guidance for persisting an env var in the caller's shell."""
|
||||
if os.name == "nt":
|
||||
escaped_value = value.replace("'", "''")
|
||||
return f"$env:{var_name} = '{escaped_value}'"
|
||||
escaped_value = re.sub(r"([^\w@%+=:,./-])", r"\\\1", value)
|
||||
return f"export {var_name}={escaped_value}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Args:
|
||||
json_mode: bool = False
|
||||
dry_run: bool = False
|
||||
allow_existing: bool = False
|
||||
short_name: str = ""
|
||||
branch_number: str = ""
|
||||
use_timestamp: bool = False
|
||||
description_parts: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> Args:
|
||||
args = Args()
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--json":
|
||||
args.json_mode = True
|
||||
elif arg == "--dry-run":
|
||||
args.dry_run = True
|
||||
elif arg == "--allow-existing-branch":
|
||||
args.allow_existing = True
|
||||
elif arg == "--short-name":
|
||||
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
||||
_err("Error: --short-name requires a value")
|
||||
raise SystemExit(1)
|
||||
i += 1
|
||||
args.short_name = argv[i]
|
||||
elif arg == "--number":
|
||||
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
||||
_err("Error: --number requires a value")
|
||||
raise SystemExit(1)
|
||||
i += 1
|
||||
args.branch_number = argv[i]
|
||||
if not re.fullmatch(r"[0-9]+", args.branch_number):
|
||||
_err("Error: --number must be a non-negative integer")
|
||||
raise SystemExit(1)
|
||||
elif arg == "--timestamp":
|
||||
args.use_timestamp = True
|
||||
elif arg in ("--help", "-h"):
|
||||
print(HELP_TEXT)
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
args.description_parts.append(arg)
|
||||
i += 1
|
||||
return args
|
||||
|
||||
|
||||
# ── Core helpers loading ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _find_project_root(start: Path) -> Path | None:
|
||||
current = start
|
||||
while True:
|
||||
if (current / ".specify").is_dir() or (current / ".git").exists():
|
||||
return current
|
||||
if current.parent == current:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _load_core_common(project_root: Path | None):
|
||||
"""Load the core common.py from the project's installed scripts.
|
||||
|
||||
Search locations in priority order, mirroring the bash script:
|
||||
1. .specify/scripts/python/common.py (installed project)
|
||||
2. scripts/python/common.py (source checkout fallback)
|
||||
Returns the loaded module or None.
|
||||
"""
|
||||
if project_root is None:
|
||||
return None
|
||||
for relative in (".specify/scripts/python/common.py", "scripts/python/common.py"):
|
||||
candidate = project_root / relative
|
||||
if candidate.is_file():
|
||||
spec = importlib.util.spec_from_file_location("speckit_core_common", candidate)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
return None
|
||||
|
||||
|
||||
def _local_has_git(repo_root: Path) -> bool:
|
||||
git_marker = repo_root / ".git"
|
||||
if not (git_marker.is_dir() or git_marker.is_file()):
|
||||
return False
|
||||
if shutil.which("git") is None:
|
||||
return False
|
||||
return (
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
# ── Numbering ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_highest_from_specs(specs_dir: Path) -> int:
|
||||
highest = 0
|
||||
if specs_dir.is_dir():
|
||||
for entry in specs_dir.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if re.match(r"^[0-9]{3,}-", name) and not re.match(
|
||||
r"^[0-9]{8}-[0-9]{6}-", name
|
||||
):
|
||||
number = int(re.match(r"^[0-9]+", name).group(0))
|
||||
highest = max(highest, number)
|
||||
return highest
|
||||
|
||||
|
||||
def _extract_highest_number(names: list[str], scope_prefix: str) -> int:
|
||||
"""Extract the highest sequential feature number from a list of ref names."""
|
||||
highest = 0
|
||||
for name in names:
|
||||
if not name:
|
||||
continue
|
||||
if scope_prefix:
|
||||
if not name.startswith(scope_prefix):
|
||||
continue
|
||||
name = name[len(scope_prefix) :]
|
||||
name = name.rsplit("/", 1)[-1]
|
||||
if (
|
||||
re.match(r"^[0-9]{3,}-", name)
|
||||
and not re.match(r"^[0-9]{8}-[0-9]{6}-", name)
|
||||
and not re.match(r"^[0-9]{7}-[0-9]{6}-", name)
|
||||
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", name)
|
||||
):
|
||||
match = re.match(r"^([0-9]{3,})-", name)
|
||||
number = int(match.group(1)) if match else 0
|
||||
highest = max(highest, number)
|
||||
return highest
|
||||
|
||||
|
||||
def _git_lines(repo_root: Path, *args: str, env_extra: dict | None = None) -> list[str]:
|
||||
if shutil.which("git") is None:
|
||||
return []
|
||||
env = {**os.environ, **(env_extra or {})}
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=repo_root, capture_output=True, text=True, env=env
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return result.stdout.splitlines()
|
||||
|
||||
|
||||
def get_highest_from_branches(repo_root: Path, scope_prefix: str) -> int:
|
||||
names = []
|
||||
for line in _git_lines(repo_root, "branch", "-a"):
|
||||
line = re.sub(r"^[+*]\s+", "", line)
|
||||
line = line.lstrip()
|
||||
line = re.sub(r"^remotes/[^/]*/", "", line)
|
||||
names.append(line)
|
||||
return _extract_highest_number(names, scope_prefix)
|
||||
|
||||
|
||||
def get_highest_from_remote_refs(repo_root: Path, scope_prefix: str) -> int:
|
||||
"""Highest number from remote branches without fetching (side-effect-free)."""
|
||||
highest = 0
|
||||
for remote in _git_lines(repo_root, "remote"):
|
||||
refs = _git_lines(
|
||||
repo_root,
|
||||
"ls-remote",
|
||||
"--heads",
|
||||
remote,
|
||||
env_extra={"GIT_TERMINAL_PROMPT": "0"},
|
||||
)
|
||||
names = [re.sub(r".*refs/heads/", "", ref) for ref in refs]
|
||||
highest = max(highest, _extract_highest_number(names, scope_prefix))
|
||||
return highest
|
||||
|
||||
|
||||
def check_existing_branches(
|
||||
repo_root: Path, specs_dir: Path, skip_fetch: bool, scope_prefix: str
|
||||
) -> int:
|
||||
"""Check existing branches and return the next available number."""
|
||||
if skip_fetch:
|
||||
highest_branch = max(
|
||||
get_highest_from_remote_refs(repo_root, scope_prefix),
|
||||
get_highest_from_branches(repo_root, scope_prefix),
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
["git", "fetch", "--all", "--prune"],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
highest_branch = get_highest_from_branches(repo_root, scope_prefix)
|
||||
|
||||
return max(highest_branch, get_highest_from_specs(specs_dir)) + 1
|
||||
|
||||
|
||||
# ── Branch naming ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def clean_branch_name(name: str) -> str:
|
||||
name = re.sub(r"[^a-z0-9]", "-", name.lower())
|
||||
name = re.sub(r"-+", "-", name)
|
||||
return name.strip("-")
|
||||
|
||||
|
||||
def generate_branch_name(description: str) -> str:
|
||||
"""Generate a branch suffix from the description with stop word filtering."""
|
||||
clean_name = re.sub(r"[^a-z0-9]", " ", description.lower())
|
||||
|
||||
meaningful_words = []
|
||||
for word in clean_name.split():
|
||||
if word in STOP_WORDS:
|
||||
continue
|
||||
if len(word) >= 3:
|
||||
meaningful_words.append(word)
|
||||
# Keep short words only when they appear uppercased in the original
|
||||
# description (acronyms like "API" or "DB").
|
||||
elif re.search(rf"\b{re.escape(word.upper())}\b", description):
|
||||
meaningful_words.append(word)
|
||||
|
||||
if meaningful_words:
|
||||
max_words = 4 if len(meaningful_words) == 4 else 3
|
||||
return "-".join(meaningful_words[:max_words])
|
||||
|
||||
cleaned = clean_branch_name(description)
|
||||
return "-".join([part for part in cleaned.split("-") if part][:3])
|
||||
|
||||
|
||||
def branch_token(value: str, fallback: str) -> str:
|
||||
cleaned = clean_branch_name(value)
|
||||
return cleaned if cleaned else fallback
|
||||
|
||||
|
||||
def get_author_token(repo_root: Path) -> str:
|
||||
author = ""
|
||||
if shutil.which("git") is not None:
|
||||
lines = _git_lines(repo_root, "config", "user.name")
|
||||
author = lines[0] if lines else ""
|
||||
if not author:
|
||||
lines = _git_lines(repo_root, "config", "user.email")
|
||||
email = lines[0] if lines else ""
|
||||
author = email.split("@")[0]
|
||||
if not author:
|
||||
author = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
|
||||
return branch_token(author, "unknown")
|
||||
|
||||
|
||||
def get_app_token(repo_root: Path) -> str:
|
||||
return branch_token(repo_root.name, "app")
|
||||
|
||||
|
||||
def read_git_config_value(config_file: Path, key: str) -> str:
|
||||
if not config_file.is_file():
|
||||
return ""
|
||||
try:
|
||||
lines = config_file.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
for line in lines:
|
||||
if re.match(rf"^\s*{re.escape(key)}:", line):
|
||||
value = re.sub(rf"^\s*{re.escape(key)}:\s*", "", line)
|
||||
value = re.sub(r"\s+#.*$", "", value)
|
||||
value = value.strip()
|
||||
value = re.sub(r'^"|"$', "", value)
|
||||
value = re.sub(r"^'|'$", "", value)
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_branch_template(config_file: Path) -> str:
|
||||
template = read_git_config_value(config_file, "branch_template")
|
||||
if template:
|
||||
return template
|
||||
|
||||
prefix = read_git_config_value(config_file, "branch_prefix")
|
||||
if not prefix:
|
||||
return ""
|
||||
if prefix.endswith("/"):
|
||||
return f"{prefix}{{number}}-{{slug}}"
|
||||
return f"{prefix}/{{number}}-{{slug}}"
|
||||
|
||||
|
||||
def validate_branch_template(template: str) -> None:
|
||||
if not template:
|
||||
return
|
||||
if "{number}" not in template:
|
||||
_err(
|
||||
"Error: branch_template must include the {number} token so generated "
|
||||
"branches remain valid feature branches."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
slug_index = template.find("{slug}")
|
||||
if slug_index != -1 and "{number}" in template[slug_index:]:
|
||||
_err(
|
||||
"Error: branch_template must not place {slug} before {number}; "
|
||||
"use {slug} only in the final feature segment."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
feature_segment = template.rsplit("/", 1)[-1]
|
||||
if not feature_segment.startswith("{number}-"):
|
||||
_err(
|
||||
"Error: branch_template must put {number}- at the start of the final "
|
||||
"path segment so generated branches remain valid feature branches."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def render_branch_template(
|
||||
template: str, feature_num: str, branch_suffix: str, author_token: str, app_token: str
|
||||
) -> str:
|
||||
rendered = template
|
||||
rendered = rendered.replace("{author}", author_token)
|
||||
rendered = rendered.replace("{app}", app_token)
|
||||
rendered = rendered.replace("{number}", feature_num)
|
||||
rendered = rendered.replace("{slug}", branch_suffix)
|
||||
return rendered
|
||||
|
||||
|
||||
def extract_feature_num_from_branch(branch_name: str) -> str:
|
||||
feature_segment = branch_name.rsplit("/", 1)[-1]
|
||||
match = re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment)
|
||||
if match:
|
||||
return match.group(0).rstrip("-")
|
||||
match = re.match(r"^[0-9]+-", feature_segment)
|
||||
if match:
|
||||
return match.group(0).rstrip("-")
|
||||
return branch_name
|
||||
|
||||
|
||||
def _byte_length(value: str) -> int:
|
||||
return len(value.encode("utf-8"))
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
|
||||
feature_description = " ".join(args.description_parts)
|
||||
if not feature_description:
|
||||
_err(USAGE)
|
||||
return 1
|
||||
feature_description = feature_description.strip()
|
||||
if not feature_description:
|
||||
_err("Error: Feature description cannot be empty or contain only whitespace")
|
||||
return 1
|
||||
|
||||
project_root = _find_project_root(SCRIPT_DIR)
|
||||
core = _load_core_common(project_root)
|
||||
|
||||
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If the
|
||||
# core helpers were not found, refuse rather than silently falling back to
|
||||
# the wrong root.
|
||||
if os.environ.get("SPECIFY_INIT_DIR") and (
|
||||
core is None or not hasattr(core, "resolve_specify_init_dir")
|
||||
):
|
||||
_err(
|
||||
"Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts "
|
||||
"(common.py with resolve_specify_init_dir), which were not found."
|
||||
)
|
||||
return 1
|
||||
|
||||
if core is not None and hasattr(core, "get_repo_root"):
|
||||
# Pass script path so cwd-outside-repo callers land on the same
|
||||
# fallback the bash twin does. Older cores don't accept the kwarg —
|
||||
# fall back to the no-arg call for compatibility.
|
||||
try:
|
||||
repo_root = core.get_repo_root(script_file=Path(__file__))
|
||||
except TypeError:
|
||||
repo_root = core.get_repo_root()
|
||||
else:
|
||||
toplevel = _git_lines(Path.cwd(), "rev-parse", "--show-toplevel")
|
||||
if toplevel:
|
||||
repo_root = Path(toplevel[0])
|
||||
elif project_root is not None:
|
||||
repo_root = project_root
|
||||
else:
|
||||
_err("Error: Could not determine repository root.")
|
||||
return 1
|
||||
repo_root = Path(repo_root)
|
||||
|
||||
has_git_repo = _local_has_git(repo_root)
|
||||
|
||||
specs_dir = repo_root / "specs"
|
||||
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
|
||||
|
||||
author_token = get_author_token(repo_root)
|
||||
app_token = get_app_token(repo_root)
|
||||
branch_template = resolve_branch_template(config_file)
|
||||
validate_branch_template(branch_template)
|
||||
|
||||
def build_branch_name(feature_num: str, branch_suffix: str) -> str:
|
||||
if branch_template:
|
||||
return render_branch_template(
|
||||
branch_template, feature_num, branch_suffix, author_token, app_token
|
||||
)
|
||||
return f"{feature_num}-{branch_suffix}"
|
||||
|
||||
branch_number = args.branch_number
|
||||
|
||||
# Check for GIT_BRANCH_NAME env var override (exact name, no prefix/suffix)
|
||||
env_branch_name = os.environ.get("GIT_BRANCH_NAME", "")
|
||||
if env_branch_name:
|
||||
branch_name = env_branch_name
|
||||
feature_num = extract_feature_num_from_branch(branch_name)
|
||||
branch_suffix = branch_name
|
||||
else:
|
||||
if args.short_name:
|
||||
branch_suffix = clean_branch_name(args.short_name)
|
||||
else:
|
||||
branch_suffix = generate_branch_name(feature_description)
|
||||
|
||||
if args.use_timestamp and branch_number:
|
||||
_err("[specify] Warning: --number is ignored when --timestamp is used")
|
||||
branch_number = ""
|
||||
|
||||
if args.use_timestamp:
|
||||
feature_num = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
branch_name = build_branch_name(feature_num, branch_suffix)
|
||||
else:
|
||||
scope_prefix = ""
|
||||
if branch_template:
|
||||
prefix_template = branch_template.split("{number}")[0]
|
||||
scope_prefix = render_branch_template(
|
||||
prefix_template, "", branch_suffix, author_token, app_token
|
||||
)
|
||||
if not branch_number:
|
||||
if args.dry_run and has_git_repo:
|
||||
branch_number = check_existing_branches(
|
||||
repo_root, specs_dir, True, scope_prefix
|
||||
)
|
||||
elif args.dry_run:
|
||||
branch_number = get_highest_from_specs(specs_dir) + 1
|
||||
elif has_git_repo:
|
||||
branch_number = check_existing_branches(
|
||||
repo_root, specs_dir, False, scope_prefix
|
||||
)
|
||||
else:
|
||||
branch_number = get_highest_from_specs(specs_dir) + 1
|
||||
|
||||
feature_num = f"{int(branch_number):03d}"
|
||||
branch_name = build_branch_name(feature_num, branch_suffix)
|
||||
|
||||
branch_byte_len = _byte_length(branch_name)
|
||||
if env_branch_name and branch_byte_len > MAX_BRANCH_LENGTH:
|
||||
_err(
|
||||
"Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. "
|
||||
f"Provided value is {branch_byte_len} bytes."
|
||||
)
|
||||
return 1
|
||||
if branch_byte_len > MAX_BRANCH_LENGTH:
|
||||
original_branch_name = branch_name
|
||||
truncated_suffix = branch_suffix
|
||||
while _byte_length(branch_name) > MAX_BRANCH_LENGTH and truncated_suffix:
|
||||
truncated_suffix = truncated_suffix[:-1]
|
||||
truncated_suffix = truncated_suffix.rstrip("-")
|
||||
branch_name = build_branch_name(feature_num, truncated_suffix)
|
||||
if _byte_length(branch_name) > MAX_BRANCH_LENGTH:
|
||||
_err("Error: Branch template prefix exceeds GitHub's 244-byte branch name limit.")
|
||||
return 1
|
||||
|
||||
_err("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
|
||||
_err(
|
||||
f"[specify] Original: {original_branch_name} "
|
||||
f"({_byte_length(original_branch_name)} bytes)"
|
||||
)
|
||||
_err(f"[specify] Truncated to: {branch_name} ({_byte_length(branch_name)} bytes)")
|
||||
|
||||
if not args.dry_run:
|
||||
if has_git_repo:
|
||||
create = subprocess.run(
|
||||
["git", "checkout", "-q", "-b", branch_name],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if create.returncode != 0:
|
||||
current_branch_lines = _git_lines(
|
||||
repo_root, "rev-parse", "--abbrev-ref", "HEAD"
|
||||
)
|
||||
current_branch = current_branch_lines[0] if current_branch_lines else ""
|
||||
branch_exists = bool(
|
||||
_git_lines(repo_root, "branch", "--list", branch_name)
|
||||
)
|
||||
if branch_exists:
|
||||
if args.allow_existing:
|
||||
if current_branch != branch_name:
|
||||
switch = subprocess.run(
|
||||
["git", "checkout", "-q", branch_name],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if switch.returncode != 0:
|
||||
_err(
|
||||
f"Error: Failed to switch to existing branch '{branch_name}'. "
|
||||
"Please resolve any local changes or conflicts and try again."
|
||||
)
|
||||
if switch.stderr.strip():
|
||||
_err(switch.stderr.strip())
|
||||
return 1
|
||||
elif args.use_timestamp:
|
||||
_err(
|
||||
f"Error: Branch '{branch_name}' already exists. Rerun to get "
|
||||
"a new timestamp or use a different --short-name."
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
_err(
|
||||
f"Error: Branch '{branch_name}' already exists. Please use a "
|
||||
"different feature name or specify a different number with --number."
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
_err(f"Error: Failed to create git branch '{branch_name}'.")
|
||||
if create.stderr.strip():
|
||||
_err(create.stderr.strip())
|
||||
else:
|
||||
_err("Please check your git configuration and try again.")
|
||||
return 1
|
||||
else:
|
||||
_err(
|
||||
"[specify] Warning: Git repository not detected; skipped branch "
|
||||
f"creation for {branch_name}"
|
||||
)
|
||||
|
||||
_err(f"# To persist: {_persist_hint('SPECIFY_FEATURE', branch_name)}")
|
||||
|
||||
if args.json_mode:
|
||||
payload: dict[str, object] = {
|
||||
"BRANCH_NAME": branch_name,
|
||||
"FEATURE_NUM": feature_num,
|
||||
}
|
||||
if args.dry_run:
|
||||
payload["DRY_RUN"] = True
|
||||
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
||||
else:
|
||||
print(f"BRANCH_NAME: {branch_name}")
|
||||
print(f"FEATURE_NUM: {feature_num}")
|
||||
if not args.dry_run:
|
||||
print(
|
||||
"# To persist in your shell: "
|
||||
f"{_persist_hint('SPECIFY_FEATURE', branch_name)}"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
81
extensions/git/scripts/python/git_common.py
Normal file
81
extensions/git/scripts/python/git_common.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Git-specific common helpers for the git extension.
|
||||
|
||||
Python port of ``git-common.sh`` / ``git-common.ps1`` — contains only
|
||||
git-specific branch validation and detection logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def has_git(repo_root: Path | None = None) -> bool:
|
||||
"""Check if we have git available at the repo root."""
|
||||
root = Path(repo_root) if repo_root is not None else Path.cwd()
|
||||
git_marker = root / ".git"
|
||||
if not (git_marker.is_dir() or git_marker.is_file()):
|
||||
return False
|
||||
if shutil.which("git") is None:
|
||||
return False
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def effective_branch_name(raw: str) -> str:
|
||||
"""Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
|
||||
Only when the full name is exactly two slash-free segments; otherwise
|
||||
returns the raw name.
|
||||
"""
|
||||
match = re.fullmatch(r"([^/]+)/([^/]+)", raw)
|
||||
if match:
|
||||
return match.group(2)
|
||||
return raw
|
||||
|
||||
|
||||
def check_feature_branch(raw: str, has_git_repo: bool) -> bool:
|
||||
"""Validate that a branch name matches the expected feature branch pattern.
|
||||
|
||||
Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*)
|
||||
formats, either at the start of the branch or after path-style namespace
|
||||
prefixes. Logic aligned with the bash/PowerShell twins.
|
||||
"""
|
||||
if not has_git_repo:
|
||||
print(
|
||||
"[specify] Warning: Git repository not detected; skipped branch validation",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return True
|
||||
|
||||
branch = effective_branch_name(raw)
|
||||
feature_segment = branch.rsplit("/", 1)[-1]
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps:
|
||||
# 7-or-8 digit date + 6-digit time with no trailing slug.
|
||||
is_sequential = bool(
|
||||
re.match(r"^[0-9]{3,}-", feature_segment)
|
||||
and not re.match(r"^[0-9]{7}-[0-9]{6}-", feature_segment)
|
||||
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", feature_segment)
|
||||
)
|
||||
is_timestamp = bool(re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment))
|
||||
|
||||
if not is_sequential and not is_timestamp:
|
||||
print(f"ERROR: Not on a feature branch. Current branch: {raw}", file=sys.stderr)
|
||||
print(
|
||||
"Feature branches should be named like: 001-feature-name, "
|
||||
"1234-feature-name, 20260319-143022-feature-name, or "
|
||||
"<prefix>/001-feature-name",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
89
extensions/git/scripts/python/initialize_repo.py
Normal file
89
extensions/git/scripts/python/initialize_repo.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Git extension: initialize_repo.py
|
||||
|
||||
Initialize a Git repository with an initial commit.
|
||||
Python port of ``initialize-repo.sh`` / ``initialize-repo.ps1``.
|
||||
Customizable — replace this script to add .gitignore templates,
|
||||
default branch config, git-flow, LFS, signing, etc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _find_project_root(start: Path) -> Path | None:
|
||||
current = start
|
||||
while True:
|
||||
if (current / ".specify").is_dir() or (current / ".git").exists():
|
||||
return current
|
||||
if current.parent == current:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _read_commit_message(repo_root: Path) -> str:
|
||||
"""Read init_commit_message from git-config.yml, mirroring the bash sed pipeline."""
|
||||
default = "[Spec Kit] Initial commit"
|
||||
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
|
||||
if not config_file.is_file():
|
||||
return default
|
||||
try:
|
||||
lines = config_file.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return default
|
||||
for line in lines:
|
||||
if line.startswith("init_commit_message:"):
|
||||
value = re.sub(r"^init_commit_message:\s*", "", line)
|
||||
value = re.sub(r"^[\"']", "", value)
|
||||
value = re.sub(r"[\"']*$", "", value)
|
||||
if value:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def main() -> int:
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
repo_root = _find_project_root(script_dir) or Path.cwd()
|
||||
|
||||
commit_msg = _read_commit_message(repo_root)
|
||||
|
||||
if shutil.which("git") is None:
|
||||
print(
|
||||
"[specify] Warning: Git not found; skipped repository initialization",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
probe = subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
print("[specify] Git repository already initialized; skipping", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
steps = [
|
||||
(["git", "init", "-q"], "git init"),
|
||||
(["git", "add", "."], "git add"),
|
||||
(["git", "commit", "--allow-empty", "-q", "-m", commit_msg], "git commit"),
|
||||
]
|
||||
for cmd, label in steps:
|
||||
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("[OK] Git repository initialized", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -158,8 +158,7 @@ presets/
|
||||
├── plan-template.md
|
||||
├── tasks-template.md
|
||||
├── checklist-template.md
|
||||
├── constitution-template.md
|
||||
└── agent-file-template.md
|
||||
└── constitution-template.md
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-30T00:00:00Z",
|
||||
"updated_at": "2026-07-14T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -131,6 +131,35 @@
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"version": "0.1.4",
|
||||
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.4.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.1.4/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 12,
|
||||
"commands": 2,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"autonomous",
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"retrospective"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-14T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
"id": "canon-core",
|
||||
@@ -618,6 +647,34 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"test-first-governance": {
|
||||
"name": "Test-First Governance",
|
||||
"id": "test-first-governance",
|
||||
"version": "1.3.0",
|
||||
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
|
||||
"author": "Zoltán Katona, PhD",
|
||||
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
|
||||
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"documentation": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.11"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"commands": 8
|
||||
},
|
||||
"tags": [
|
||||
"tdd",
|
||||
"bdd",
|
||||
"atdd",
|
||||
"quality-gates",
|
||||
"traceability"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"toc-navigation": {
|
||||
"name": "Table of Contents Navigation",
|
||||
"id": "toc-navigation",
|
||||
|
||||
@@ -44,12 +44,6 @@ provides:
|
||||
description: "Self-test constitution template"
|
||||
replaces: "constitution-template"
|
||||
|
||||
- type: "template"
|
||||
name: "agent-file-template"
|
||||
file: "templates/agent-file-template.md"
|
||||
description: "Self-test agent file template"
|
||||
replaces: "agent-file-template"
|
||||
|
||||
- type: "command"
|
||||
name: "speckit.specify"
|
||||
file: "commands/speckit.specify.md"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Agent File (Self-Test Preset)
|
||||
|
||||
<!-- preset:self-test -->
|
||||
|
||||
> This template is provided by the self-test preset.
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
Follow these guidelines when working on this project.
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.12.dev0"
|
||||
version = "0.12.16"
|
||||
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"
|
||||
|
||||
@@ -24,6 +24,7 @@ GITHUB_HOSTS = frozenset({
|
||||
"api.github.com",
|
||||
"codeload.github.com",
|
||||
})
|
||||
_MAX_RELEASE_METADATA_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def build_github_request(url: str) -> urllib.request.Request:
|
||||
@@ -68,6 +69,8 @@ def resolve_github_release_asset_api_url(
|
||||
open_url_fn: Callable,
|
||||
timeout: int = 60,
|
||||
github_hosts: tuple[str, ...] = (),
|
||||
redirect_validator: Callable[[str, str], None] | None = None,
|
||||
max_metadata_bytes: int = _MAX_RELEASE_METADATA_BYTES,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
|
||||
|
||||
@@ -91,6 +94,8 @@ def resolve_github_release_asset_api_url(
|
||||
authenticated release-metadata lookup.
|
||||
timeout: Per-request timeout in seconds.
|
||||
github_hosts: Host patterns to treat as GitHub Enterprise Server.
|
||||
redirect_validator: Optional policy applied to metadata redirects.
|
||||
max_metadata_bytes: Maximum release-metadata response size.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
@@ -149,13 +154,33 @@ def resolve_github_release_asset_api_url(
|
||||
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
|
||||
|
||||
try:
|
||||
with open_url_fn(release_url, timeout=timeout) as response:
|
||||
release_data = json.loads(response.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError):
|
||||
open_kwargs = {"timeout": timeout}
|
||||
if redirect_validator is not None:
|
||||
open_kwargs["redirect_validator"] = redirect_validator
|
||||
with open_url_fn(release_url, **open_kwargs) as response:
|
||||
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,
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
return None
|
||||
|
||||
for asset in release_data.get("assets", []):
|
||||
if asset.get("name") == asset_name and asset.get("url"):
|
||||
if not isinstance(release_data, dict):
|
||||
return None
|
||||
assets = release_data.get("assets", [])
|
||||
if not isinstance(assets, list):
|
||||
return None
|
||||
for asset in assets:
|
||||
if (
|
||||
isinstance(asset, dict)
|
||||
and asset.get("name") == asset_name
|
||||
and asset.get("url")
|
||||
):
|
||||
return str(asset["url"])
|
||||
|
||||
return None
|
||||
|
||||
@@ -14,7 +14,7 @@ def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||
dest = project_path / INIT_OPTIONS_FILE
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@@ -213,6 +213,52 @@ class CommandRegistrar:
|
||||
".specify.specify/", ".specify/"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def rewrite_extension_paths(
|
||||
text: str, extension_id: str, extension_dir: Path
|
||||
) -> str:
|
||||
"""Rewrite extension-relative paths to their installed locations.
|
||||
|
||||
Extension command bodies reference bundled files relative to the
|
||||
extension root (e.g. ``agents/control/commander.md``). After install
|
||||
those files live under ``.specify/extensions/<id>/``, so bare
|
||||
references would resolve against the workspace root and never be
|
||||
found (#2101).
|
||||
|
||||
Only directories that actually exist inside *extension_dir* are
|
||||
rewritten, keeping the behaviour conservative and avoiding false
|
||||
positives on prose. ``commands`` (slash-command sources), ``specs``
|
||||
(user project artifacts) and dot-directories are never rewritten.
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
|
||||
skip = {"commands", ".git", "specs"}
|
||||
try:
|
||||
subdirs = [
|
||||
entry.name
|
||||
for entry in extension_dir.iterdir()
|
||||
if entry.is_dir()
|
||||
and entry.name not in skip
|
||||
and not entry.name.startswith(".")
|
||||
]
|
||||
except OSError:
|
||||
return text
|
||||
|
||||
for subdir in subdirs:
|
||||
# Only rewrite relative references (subdir/... or ./subdir/...);
|
||||
# absolute paths like /subdir/... keep their meaning. Use a
|
||||
# callable replacement: subdir/extension_id come from the
|
||||
# filesystem and could contain backslashes or "\1"-like
|
||||
# sequences, which would corrupt a string replacement template.
|
||||
replacement = f".specify/extensions/{extension_id}/{subdir}/"
|
||||
text = re.sub(
|
||||
r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/",
|
||||
lambda m: m.group(1) + replacement,
|
||||
text,
|
||||
)
|
||||
return text
|
||||
|
||||
def render_markdown_command(
|
||||
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
|
||||
) -> str:
|
||||
@@ -639,6 +685,9 @@ class CommandRegistrar:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
frontmatter.pop("strategy", None)
|
||||
|
||||
if extension_id:
|
||||
body = self.rewrite_extension_paths(body, extension_id, source_root)
|
||||
|
||||
frontmatter = self._adjust_script_paths(
|
||||
frontmatter, extension_id=extension_id
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
@@ -87,17 +89,63 @@ def loads_json(text: str, *, origin: str = "<string>") -> Any:
|
||||
|
||||
|
||||
def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
"""Write *data* as pretty JSON to *path* (optionally confined to *within*)."""
|
||||
"""Atomically write pretty JSON to *path* (optionally confined to *within*)."""
|
||||
path = Path(path)
|
||||
if within is not None:
|
||||
path = ensure_within(within, path)
|
||||
fd = -1
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
temp_path = Path(temp_name)
|
||||
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2, sort_keys=False)
|
||||
handle.write("\n")
|
||||
|
||||
try:
|
||||
if path.exists():
|
||||
existing = path.stat(follow_symlinks=False)
|
||||
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchmod"):
|
||||
os.fchmod(fd, stat.S_IMODE(existing.st_mode))
|
||||
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchown"):
|
||||
try:
|
||||
os.fchown(fd, existing.st_uid, existing.st_gid)
|
||||
except PermissionError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
staged = os.stat(temp_path, follow_symlinks=False)
|
||||
opened = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(staged.st_mode)
|
||||
or staged.st_dev != opened.st_dev
|
||||
or staged.st_ino != opened.st_ino
|
||||
):
|
||||
raise OSError("staged JSON file changed before commit")
|
||||
|
||||
os.close(fd)
|
||||
fd = -1
|
||||
os.replace(temp_path, path)
|
||||
temp_path = None
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not write {path}: {exc}") from exc
|
||||
finally:
|
||||
if fd >= 0:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
if temp_path is not None:
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
|
||||
@@ -187,19 +187,41 @@ def remove_bundle(
|
||||
|
||||
still_needed = components_still_needed(records, exclude_bundle_id=bundle_id)
|
||||
result = InstallResult(bundle_id=bundle_id)
|
||||
remove_attempted = False
|
||||
|
||||
for component in target.contributed_components:
|
||||
key = (component.kind, component.id)
|
||||
if key in still_needed:
|
||||
result.skipped.append(component)
|
||||
continue
|
||||
if installer.is_installed(project_root, component):
|
||||
installer.remove(project_root, component)
|
||||
result.uninstalled.append(component)
|
||||
try:
|
||||
for component in target.contributed_components:
|
||||
key = (component.kind, component.id)
|
||||
if key in still_needed:
|
||||
result.skipped.append(component)
|
||||
continue
|
||||
if installer.is_installed(project_root, component):
|
||||
remove_attempted = True
|
||||
installer.remove(project_root, component)
|
||||
result.uninstalled.append(component)
|
||||
save_records(project_root, remove_record(records, bundle_id))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if result.uninstalled:
|
||||
detail = (
|
||||
f"{len(result.uninstalled)} component(s) were already removed "
|
||||
"before this failure; the bundle record was left unchanged, "
|
||||
"so the project may be partially uninstalled."
|
||||
)
|
||||
elif remove_attempted:
|
||||
detail = (
|
||||
"No components were removed, but the failing component may "
|
||||
"have made partial changes before raising, so the project "
|
||||
"may be partially uninstalled."
|
||||
)
|
||||
else:
|
||||
result.skipped.append(component)
|
||||
detail = (
|
||||
"No components were removed and no removal was attempted; "
|
||||
"the bundle record was left unchanged."
|
||||
)
|
||||
raise BundlerError(
|
||||
f"Failed to remove bundle '{bundle_id}': {exc}. {detail}"
|
||||
) from exc
|
||||
|
||||
save_records(project_root, remove_record(records, bundle_id))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -33,11 +33,17 @@ def _stdin_is_interactive() -> bool:
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
"""Copy constitution template to memory if it doesn't exist."""
|
||||
"""Materialize the resolved constitution template to memory if missing.
|
||||
|
||||
Resolution walks the full priority stack (project overrides → installed
|
||||
presets → extensions → core) via :class:`PresetResolver`, so a preset that
|
||||
ships a ``constitution-template`` (e.g. ``strategy: replace`` with a ratified
|
||||
constitution) can seed the memory file. When nothing overrides it, the
|
||||
resolver falls through to the core template.
|
||||
"""
|
||||
from ..presets import _materialize_constitution_template
|
||||
|
||||
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
|
||||
template_constitution = (
|
||||
project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
)
|
||||
|
||||
if memory_constitution.exists():
|
||||
if tracker:
|
||||
@@ -45,18 +51,21 @@ def ensure_constitution_from_template(
|
||||
tracker.skip("constitution", "existing file preserved")
|
||||
return
|
||||
|
||||
if not template_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", "template not found")
|
||||
return
|
||||
|
||||
try:
|
||||
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(template_constitution, memory_constitution)
|
||||
materialization = _materialize_constitution_template(
|
||||
project_path, memory_constitution
|
||||
)
|
||||
if materialization is None:
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", "template not found")
|
||||
return
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.complete("constitution", "copied from template")
|
||||
if materialization == "copied":
|
||||
tracker.complete("constitution", "copied from template")
|
||||
else:
|
||||
tracker.complete("constitution", "composed from template")
|
||||
else:
|
||||
console.print("[cyan]Initialized constitution from template[/cyan]")
|
||||
except Exception as e:
|
||||
@@ -220,16 +229,45 @@ def register(app: typer.Typer) -> None:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)"
|
||||
)
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
if force:
|
||||
# Proceeding: the merge/overwrite warning is accurate here.
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
|
||||
)
|
||||
else:
|
||||
response = typer.confirm("Do you want to continue?")
|
||||
if not response:
|
||||
# Fold the merge risk into the confirmation prompt rather than
|
||||
# printing it unconditionally first: on the EOF/no-input path
|
||||
# below the command exits without changing anything, so a
|
||||
# standalone "will be merged" line would mislead. Interactive
|
||||
# users still see the risk as part of the question.
|
||||
#
|
||||
# Call typer.confirm normally so piped y/n is honored — e.g.
|
||||
# `echo y | specify init --here` keeps reaching the
|
||||
# non-destructive preserve-merge path.
|
||||
try:
|
||||
proceed = typer.confirm(
|
||||
"Template files will be merged with existing content "
|
||||
"and may overwrite existing files. Do you want to continue?"
|
||||
)
|
||||
except (typer.Abort, EOFError):
|
||||
# typer.confirm raises Abort for BOTH an interactive Ctrl+C
|
||||
# and an EOF on closed/empty stdin. Distinguish them: a real
|
||||
# TTY cancellation is a normal exit (0, "cancelled"), while a
|
||||
# missing-input EOF (non-interactive) becomes an actionable
|
||||
# error pointing at --force.
|
||||
if _stdin_is_interactive():
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0) from None
|
||||
console.print(
|
||||
"[red]Error:[/red] Current directory is not empty and no "
|
||||
"confirmation input is available. Re-run with "
|
||||
"[bold]--force[/bold] to merge into it."
|
||||
)
|
||||
raise typer.Exit(1) from None
|
||||
if not proceed:
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
@@ -447,8 +485,6 @@ def register(app: typer.Typer) -> None:
|
||||
"shared-infra", f"scripts ({selected_script}) + templates"
|
||||
)
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
try:
|
||||
bundled_wf = _locate_bundled_workflow("speckit")
|
||||
if bundled_wf:
|
||||
@@ -576,6 +612,11 @@ def register(app: typer.Typer) -> None:
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
|
||||
# Seed the constitution AFTER preset installation so that a
|
||||
# preset-provided constitution-template (resolved via the
|
||||
# priority stack) wins over the core template.
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except (typer.Exit, SystemExit):
|
||||
raise
|
||||
|
||||
@@ -1078,6 +1078,11 @@ class ExtensionManager:
|
||||
frontmatter = registrar._adjust_script_paths(
|
||||
frontmatter, extension_id=manifest.id
|
||||
)
|
||||
# Mirror the register_commands() rewrite (#2101): resolve
|
||||
# extension-relative subdir references (agents/, knowledge-base/,
|
||||
# etc.) to their installed .specify/extensions/<id>/ location
|
||||
# before the generic placeholder/path resolution below.
|
||||
body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir)
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
|
||||
)
|
||||
@@ -2732,6 +2737,36 @@ class ConfigManager:
|
||||
config_file = self.extension_dir / "local-config.yml"
|
||||
return self._load_yaml_config(config_file)
|
||||
|
||||
def _sibling_extension_ids(self) -> list[str]:
|
||||
"""Return IDs of other extensions installed alongside this one.
|
||||
|
||||
Sourced from ``ExtensionRegistry`` (``.specify/extensions/.registry``)
|
||||
rather than a directory scan: ``ExtensionManager.remove(...,
|
||||
keep_config=True)`` deliberately preserves the extension directory
|
||||
while dropping the registry entry, so a directory scan would treat
|
||||
that config-only leftover as an installed sibling and keep silently
|
||||
absorbing its ``SPECKIT_<sibling>_*`` env vars into no one. The
|
||||
registry is the source of truth for "installed".
|
||||
|
||||
Returns an empty list if the registry is missing or corrupted
|
||||
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
|
||||
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
|
||||
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
|
||||
opens the file in text mode and only handles ``JSONDecodeError`` /
|
||||
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
|
||||
otherwise surface a ``UnicodeDecodeError`` here and break *every*
|
||||
config read instead of degrading gracefully.
|
||||
|
||||
Used by ``_get_env_config`` to detect env vars whose remainder claims
|
||||
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
|
||||
owned by ``git-hooks`` when it is co-installed with ``git``).
|
||||
"""
|
||||
extensions_dir = self.project_root / ".specify" / "extensions"
|
||||
try:
|
||||
return list(ExtensionRegistry(extensions_dir).keys())
|
||||
except (OSError, UnicodeError):
|
||||
return []
|
||||
|
||||
def _get_env_config(self) -> Dict[str, Any]:
|
||||
"""Get configuration from environment variables.
|
||||
|
||||
@@ -2751,15 +2786,49 @@ class ConfigManager:
|
||||
ext_id_upper = self.extension_id.replace("-", "_").upper()
|
||||
prefix = f"SPECKIT_{ext_id_upper}_"
|
||||
|
||||
# Cross-extension prefix collision: because ``_`` doubles as both the
|
||||
# separator between the extension ID and the config path *and* the
|
||||
# substitute for ``-`` inside an extension ID, an env var like
|
||||
# ``SPECKIT_GIT_HOOKS_URL`` begins with *both* the ``SPECKIT_GIT_``
|
||||
# prefix of the ``git`` extension and the ``SPECKIT_GIT_HOOKS_`` prefix
|
||||
# of a co-installed ``git-hooks`` extension. It logically belongs to
|
||||
# the extension whose normalized ID is the longer, more specific match
|
||||
# — otherwise config intended for one extension silently surfaces
|
||||
# inside another and can drive hooks that only inspect
|
||||
# ``config.<field> is set``. Build the list of sibling-owned
|
||||
# remainder-prefixes here so a later env var can be skipped if it
|
||||
# matches one.
|
||||
sibling_prefixes: list[str] = []
|
||||
for sibling_id in self._sibling_extension_ids():
|
||||
if sibling_id == self.extension_id:
|
||||
continue
|
||||
sib_upper = sibling_id.replace("-", "_").upper()
|
||||
# A sibling collides only when its normalized ID *extends* our own
|
||||
# (i.e. starts with ``<US>_``). ``git`` vs ``not-git`` is not a
|
||||
# collision; ``git`` vs ``git-hooks`` is.
|
||||
if sib_upper.startswith(ext_id_upper + "_"):
|
||||
# The portion of the env-var *remainder* the sibling claims,
|
||||
# including the trailing ``_`` so a shorter ID that shares a
|
||||
# non-boundary prefix cannot false-positive (e.g. sibling
|
||||
# ``hook`` would not eat env vars under key ``hooks``).
|
||||
sibling_prefixes.append(sib_upper[len(ext_id_upper) + 1 :] + "_")
|
||||
|
||||
for key, value in os.environ.items():
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
|
||||
remainder = key[len(prefix) :]
|
||||
# Skip when a longer sibling ID claims this var — see the block
|
||||
# above. Keeps ``SPECKIT_GIT_HOOKS_URL`` out of the ``git``
|
||||
# extension's config when ``git-hooks`` is co-installed.
|
||||
if any(remainder.startswith(sp) for sp in sibling_prefixes):
|
||||
continue
|
||||
|
||||
# Remove prefix and split into parts. Drop empty components from a
|
||||
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
|
||||
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
|
||||
# entry under an empty key.
|
||||
config_path = [p for p in key[len(prefix) :].lower().split("_") if p]
|
||||
config_path = [p for p in remainder.lower().split("_") if p]
|
||||
if not config_path:
|
||||
continue
|
||||
|
||||
|
||||
@@ -190,7 +190,15 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
|
||||
"""
|
||||
import shlex
|
||||
parsed: dict[str, Any] = {}
|
||||
tokens = shlex.split(raw_options)
|
||||
try:
|
||||
tokens = shlex.split(raw_options)
|
||||
except ValueError as exc:
|
||||
# An unbalanced quote (e.g. --integration-options='--commands-dir "foo')
|
||||
# makes shlex raise "No closing quotation". Translate it into the same
|
||||
# clean exit-1 UX as every other bad-input path below rather than
|
||||
# letting a raw traceback escape.
|
||||
console.print(f"[red]Error:[/red] Could not parse integration options: {exc}.")
|
||||
raise typer.Exit(1)
|
||||
declared_options = list(integration.options())
|
||||
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
|
||||
allowed = ", ".join(sorted(opt.name for opt in declared_options))
|
||||
|
||||
@@ -1122,6 +1122,17 @@ class TomlIntegration(IntegrationBase):
|
||||
# YamlIntegration — YAML-format agents (Goose)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Characters a YAML literal block scalar cannot carry: C0 controls other
|
||||
# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the
|
||||
# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF.
|
||||
# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1
|
||||
# treats it as a line break, which corrupts the block scalar's structure
|
||||
# just the same, so all three are included.
|
||||
_YAML_BLOCK_SCALAR_UNSAFE = re.compile(
|
||||
r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]"
|
||||
)
|
||||
|
||||
|
||||
class YamlIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use YAML recipe format.
|
||||
|
||||
@@ -1227,9 +1238,9 @@ class YamlIntegration(IntegrationBase):
|
||||
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
|
||||
"""Render a YAML recipe file from title, description, and body.
|
||||
|
||||
Produces a Goose-compatible recipe with a literal block scalar
|
||||
for the prompt content. Uses ``yaml.safe_dump()`` for the
|
||||
header fields to ensure proper escaping.
|
||||
Produces a Goose-compatible recipe with a literal block scalar for
|
||||
normal prompt content, or an escaped quoted scalar when control
|
||||
characters require it. Uses ``yaml.safe_dump()`` for the header fields.
|
||||
"""
|
||||
header = cls._build_yaml_header(title, description)
|
||||
|
||||
@@ -1240,6 +1251,23 @@ class YamlIntegration(IntegrationBase):
|
||||
default_flow_style=False,
|
||||
).strip()
|
||||
|
||||
# YAML forbids C0 control characters (except tab and newline) and
|
||||
# DEL in every scalar form, and a bare CR acts as a line break
|
||||
# inside a block scalar. A literal block scalar emits such bytes
|
||||
# verbatim, producing a recipe the YAML parser rejects, so fall
|
||||
# back to an escaped double-quoted scalar for those bodies.
|
||||
if _YAML_BLOCK_SCALAR_UNSAFE.search(body):
|
||||
prompt_yaml = yaml.safe_dump(
|
||||
{"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize
|
||||
).strip()
|
||||
lines = [
|
||||
header_yaml,
|
||||
prompt_yaml,
|
||||
"",
|
||||
f"# Source: {source_id}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
# Indent the body for YAML block scalar. Use an explicit indentation
|
||||
# indicator ("|2") rather than a bare "|": YAML infers a plain block
|
||||
# scalar's indentation from its first non-empty line, so a body whose
|
||||
|
||||
@@ -13,6 +13,7 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
|
||||
|
||||
class KiroCliIntegration(MarkdownIntegration):
|
||||
key = "kiro-cli"
|
||||
multi_install_safe = True
|
||||
config = {
|
||||
"name": "Kiro CLI",
|
||||
"folder": ".kiro/",
|
||||
@@ -26,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
|
||||
|
||||
@@ -31,7 +31,117 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priorit
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from ..integrations.base import IntegrationBase
|
||||
from .._utils import dump_frontmatter, version_satisfies
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
from ..shared_infra import (
|
||||
_ensure_safe_shared_destination,
|
||||
_ensure_safe_shared_directory,
|
||||
_write_shared_bytes,
|
||||
_write_shared_text,
|
||||
verify_archive_sha256,
|
||||
)
|
||||
|
||||
|
||||
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
|
||||
|
||||
|
||||
def _content_sha256(content: bytes) -> str:
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
|
||||
def _constitution_is_generated(
|
||||
project_root: Path,
|
||||
memory_constitution: Path,
|
||||
resolver: "PresetResolver",
|
||||
) -> bool:
|
||||
"""Return whether the live constitution is an unchanged generated file."""
|
||||
_ensure_safe_shared_destination(project_root, memory_constitution)
|
||||
content = memory_constitution.read_bytes()
|
||||
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
|
||||
_ensure_safe_shared_destination(project_root, provenance)
|
||||
|
||||
if provenance.exists():
|
||||
try:
|
||||
metadata = json.loads(provenance.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return False
|
||||
return (
|
||||
isinstance(metadata, dict)
|
||||
and metadata.get("sha256") == _content_sha256(content)
|
||||
)
|
||||
|
||||
# Older projects have no provenance sidecar. Only the immutable bundled or
|
||||
# source-checkout core template is safe to treat as generated.
|
||||
core = resolver._find_bundled_core(
|
||||
"constitution-template", "template", ".md"
|
||||
)
|
||||
return core is not None and core.read_bytes() == content
|
||||
|
||||
|
||||
def _constitution_provenance_matches_preset(
|
||||
project_root: Path,
|
||||
memory_constitution: Path,
|
||||
pack_id: str,
|
||||
pack_version: str,
|
||||
) -> bool:
|
||||
"""Return whether provenance identifies a preset as the materialized source."""
|
||||
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
|
||||
if not provenance.parent.exists():
|
||||
return False
|
||||
_ensure_safe_shared_destination(project_root, provenance)
|
||||
if not provenance.exists():
|
||||
return False
|
||||
try:
|
||||
metadata = json.loads(provenance.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return False
|
||||
return (
|
||||
isinstance(metadata, dict)
|
||||
and metadata.get("source") == f"{pack_id} v{pack_version}"
|
||||
)
|
||||
|
||||
|
||||
def _materialize_constitution_template(
|
||||
project_root: Path,
|
||||
memory_constitution: Path,
|
||||
) -> str | None:
|
||||
"""Materialize constitution-template content into memory/constitution.md.
|
||||
|
||||
Returns:
|
||||
"copied" when the winning layer is ``replace`` and the source file is
|
||||
copied verbatim; "composed" when a composing strategy is materialized
|
||||
via ``resolve_content``; ``None`` when no constitution template resolves.
|
||||
"""
|
||||
resolver = PresetResolver(project_root)
|
||||
layers = resolver.collect_all_layers("constitution-template", "template")
|
||||
if not layers:
|
||||
return None
|
||||
|
||||
top_layer = layers[0]
|
||||
if top_layer["strategy"] == "replace":
|
||||
content = top_layer["path"].read_bytes()
|
||||
result = "copied"
|
||||
else:
|
||||
composed_content = resolver.resolve_content("constitution-template", "template")
|
||||
if composed_content is None:
|
||||
return None
|
||||
content = composed_content.encode("utf-8")
|
||||
result = "composed"
|
||||
|
||||
_ensure_safe_shared_directory(project_root, memory_constitution.parent)
|
||||
_write_shared_bytes(project_root, memory_constitution, content)
|
||||
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
|
||||
_write_shared_text(
|
||||
project_root,
|
||||
provenance,
|
||||
json.dumps(
|
||||
{
|
||||
"sha256": _content_sha256(content),
|
||||
"source": top_layer["source"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _substitute_core_template(
|
||||
@@ -778,6 +888,7 @@ class PresetManager:
|
||||
matching_cmds, ext_id, ext_dir,
|
||||
self.project_root,
|
||||
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
|
||||
extension_id=ext_id,
|
||||
)
|
||||
registered = True
|
||||
except Exception:
|
||||
@@ -1199,6 +1310,8 @@ class PresetManager:
|
||||
"command_name": cmd_name,
|
||||
"source_file": source_file,
|
||||
"source": f"extension:{manifest.id}",
|
||||
"extension_id": manifest.id,
|
||||
"extension_dir": ext_root,
|
||||
}
|
||||
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
||||
restore_index.setdefault(modern_skill_name, restore_info)
|
||||
@@ -1463,6 +1576,17 @@ class PresetManager:
|
||||
if extension_restore:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
# Mirror the register-time rewrite (#2101): resolve
|
||||
# extension-relative subdir references (agents/,
|
||||
# knowledge-base/, etc.) to their installed location before
|
||||
# the generic placeholder resolution below, otherwise
|
||||
# restoring after a preset override removal would leave
|
||||
# bare, unresolvable paths in the skill body.
|
||||
body = registrar.rewrite_extension_paths(
|
||||
body,
|
||||
extension_restore["extension_id"],
|
||||
extension_restore["extension_dir"],
|
||||
)
|
||||
if isinstance(selected_ai, str):
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
@@ -1615,8 +1739,73 @@ class PresetManager:
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Seed/re-seed memory/constitution.md from a preset-provided
|
||||
# constitution-template. The constitution is the only template that is
|
||||
# materialized to a live file rather than resolved on demand, so a
|
||||
# preset that ships one (e.g. strategy: replace with a ratified
|
||||
# constitution) must be propagated here. Guard against clobbering an
|
||||
# already-authored constitution by only replacing a file whose recorded
|
||||
# hash (or exact legacy core-template content) proves it was generated.
|
||||
self._seed_constitution_from_preset(manifest, dest_dir)
|
||||
|
||||
return manifest
|
||||
|
||||
def _seed_constitution_from_preset(
|
||||
self, manifest: PresetManifest, preset_dir: Path
|
||||
) -> None:
|
||||
"""Seed memory/constitution.md from a preset constitution-template.
|
||||
|
||||
Only runs when the preset declares a ``type: template`` entry named
|
||||
``constitution-template`` or provides one at a convention path, and the
|
||||
live memory file is either missing or is an unchanged generated file.
|
||||
Authored constitutions are never overwritten.
|
||||
"""
|
||||
provides_constitution = any(
|
||||
t.get("type") == "template" and t.get("name") == "constitution-template"
|
||||
for t in manifest.templates
|
||||
) or any(
|
||||
(preset_dir / relative_path).is_file()
|
||||
for relative_path in (
|
||||
"templates/constitution-template.md",
|
||||
"constitution-template.md",
|
||||
)
|
||||
)
|
||||
if not provides_constitution:
|
||||
return
|
||||
|
||||
self.reconcile_constitution(
|
||||
f"Failed to seed constitution from preset {manifest.id}",
|
||||
create_if_missing=True,
|
||||
)
|
||||
|
||||
def reconcile_constitution(
|
||||
self, failure_context: str, *, create_if_missing: bool = False
|
||||
) -> None:
|
||||
"""Reconcile generated constitution content without failing a persisted change."""
|
||||
try:
|
||||
self._reconcile_constitution(create_if_missing=create_if_missing)
|
||||
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"{failure_context}: {exc}.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
|
||||
"""Materialize the winning constitution layer when the live file is generated."""
|
||||
memory_constitution = (
|
||||
self.project_root / ".specify" / "memory" / "constitution.md"
|
||||
)
|
||||
if not memory_constitution.exists() and not create_if_missing:
|
||||
return
|
||||
resolver = PresetResolver(self.project_root)
|
||||
if memory_constitution.exists() and not _constitution_is_generated(
|
||||
self.project_root, memory_constitution, resolver
|
||||
):
|
||||
return
|
||||
_materialize_constitution_template(self.project_root, memory_constitution)
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
@@ -1696,6 +1885,25 @@ class PresetManager:
|
||||
# Also include aliases from the manifest as a safety net for registries
|
||||
# populated by older versions that may not track aliases.
|
||||
removed_cmd_names = set()
|
||||
removed_constitution = any(
|
||||
path.exists()
|
||||
for path in (
|
||||
pack_dir / "templates" / "constitution-template.md",
|
||||
pack_dir / "constitution-template.md",
|
||||
)
|
||||
)
|
||||
if metadata and isinstance(metadata.get("version"), str):
|
||||
memory_constitution = (
|
||||
self.project_root / ".specify" / "memory" / "constitution.md"
|
||||
)
|
||||
removed_constitution = removed_constitution or (
|
||||
_constitution_provenance_matches_preset(
|
||||
self.project_root,
|
||||
memory_constitution,
|
||||
pack_id,
|
||||
metadata["version"],
|
||||
)
|
||||
)
|
||||
for cmd_names in registered_commands.values():
|
||||
removed_cmd_names.update(cmd_names)
|
||||
manifest_path = pack_dir / "preset.yml"
|
||||
@@ -1703,6 +1911,11 @@ class PresetManager:
|
||||
try:
|
||||
manifest = PresetManifest(manifest_path)
|
||||
for tmpl in manifest.templates:
|
||||
if (
|
||||
tmpl.get("type") == "template"
|
||||
and tmpl.get("name") == "constitution-template"
|
||||
):
|
||||
removed_constitution = True
|
||||
if tmpl.get("type") == "command":
|
||||
for alias in tmpl.get("aliases", []):
|
||||
if isinstance(alias, str):
|
||||
@@ -1749,6 +1962,18 @@ class PresetManager:
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if removed_constitution:
|
||||
try:
|
||||
self._reconcile_constitution()
|
||||
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"Post-removal constitution reconciliation failed for {pack_id}: "
|
||||
f"{exc}. The live constitution may be stale.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def list_installed(self) -> List[Dict[str, Any]]:
|
||||
@@ -2574,6 +2799,39 @@ class PresetResolver:
|
||||
self._manifest_cache[key] = None
|
||||
return self._manifest_cache[key]
|
||||
|
||||
def _manifest_declared_template(
|
||||
self, pack_dir: Path, template_name: str, template_type: str
|
||||
) -> tuple[dict | None, Path | None]:
|
||||
"""Resolve a preset's manifest-declared template entry and usable file.
|
||||
|
||||
Returns ``(entry, candidate)``:
|
||||
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
|
||||
the manifest is absent or does not list this ``(name, type)``.
|
||||
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
|
||||
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
|
||||
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
|
||||
|
||||
The manifest is authoritative: when it declares a template (``entry`` is
|
||||
not ``None``) but the file is unusable (``candidate`` is ``None``),
|
||||
callers must NOT fall back to the convention lookup — that would mask a
|
||||
typo or pick up an undeclared file. Shared by ``resolve()`` and
|
||||
``collect_all_layers()`` so their manifest-first resolution cannot
|
||||
silently diverge again (the divergence this fix addressed).
|
||||
"""
|
||||
manifest = self._get_manifest(pack_dir)
|
||||
if not manifest:
|
||||
return None, None
|
||||
for tmpl in manifest.templates:
|
||||
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
|
||||
file_path = tmpl.get("file")
|
||||
if file_path:
|
||||
manifest_candidate = pack_dir / file_path
|
||||
return tmpl, (
|
||||
manifest_candidate if manifest_candidate.is_file() else None
|
||||
)
|
||||
return tmpl, None
|
||||
return None, None
|
||||
|
||||
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
||||
"""Build unified list of registered and unregistered extensions sorted by priority.
|
||||
|
||||
@@ -2676,6 +2934,27 @@ class PresetResolver:
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
for pack_id, _metadata in registry.list_by_priority():
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# The preset manifest is authoritative: if it declares this
|
||||
# template with an explicit ``file:``, resolve to that path —
|
||||
# and do NOT fall back to convention when it's missing, to
|
||||
# avoid masking typos or picking up an undeclared file. Only
|
||||
# when the manifest is absent or doesn't list this template do
|
||||
# we use the convention-based subdir lookup. Mirrors
|
||||
# collect_all_layers()/resolve_content() so resolve() and
|
||||
# resolve_with_source() agree with them instead of returning
|
||||
# the core template (or a stray convention file).
|
||||
entry, manifest_candidate = self._manifest_declared_template(
|
||||
pack_dir, template_name, template_type
|
||||
)
|
||||
if manifest_candidate is not None:
|
||||
return manifest_candidate
|
||||
if entry is not None:
|
||||
# Manifest declares this template but the file is missing,
|
||||
# non-file (e.g. a directory), or an empty/falsey ``file``
|
||||
# value. The manifest is authoritative, so skip this pack's
|
||||
# convention fallback rather than mask a typo — mirrors
|
||||
# collect_all_layers().
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = pack_dir / subdir / f"{template_name}{ext}"
|
||||
@@ -2943,31 +3222,22 @@ class PresetResolver:
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# Read strategy and manifest file path from preset manifest
|
||||
strategy = "replace"
|
||||
manifest_file_path = None
|
||||
manifest_has_strategy = False
|
||||
manifest_found_entry = False
|
||||
manifest = self._get_manifest(pack_dir)
|
||||
if manifest:
|
||||
for tmpl in manifest.templates:
|
||||
if (tmpl.get("name") == template_name
|
||||
and tmpl.get("type") == template_type):
|
||||
strategy = tmpl.get("strategy", "replace")
|
||||
manifest_has_strategy = "strategy" in tmpl
|
||||
manifest_file_path = tmpl.get("file")
|
||||
manifest_found_entry = True
|
||||
break
|
||||
# Use manifest file path if specified, otherwise convention-based
|
||||
# lookup — but only when the manifest doesn't exist or doesn't
|
||||
# list this template, so preset.yml stays authoritative.
|
||||
entry, manifest_candidate = self._manifest_declared_template(
|
||||
pack_dir, template_name, template_type
|
||||
)
|
||||
if entry is not None:
|
||||
strategy = entry.get("strategy", "replace")
|
||||
manifest_has_strategy = "strategy" in entry
|
||||
# Use the manifest's declared file when it's a usable regular file;
|
||||
# only fall back to convention-based lookup when the manifest
|
||||
# doesn't list this template at all, so preset.yml stays
|
||||
# authoritative (a declared-but-unusable file skips convention —
|
||||
# parity with resolve()).
|
||||
candidate = None
|
||||
if manifest_file_path:
|
||||
manifest_candidate = pack_dir / manifest_file_path
|
||||
if manifest_candidate.exists():
|
||||
candidate = manifest_candidate
|
||||
# Explicit file path that doesn't exist: skip convention
|
||||
# fallback to avoid masking typos or picking up unintended files.
|
||||
elif not manifest_found_entry:
|
||||
# Manifest doesn't list this template — check convention paths
|
||||
if manifest_candidate is not None:
|
||||
candidate = manifest_candidate
|
||||
elif entry is None:
|
||||
candidate = _find_in_subdirs(pack_dir)
|
||||
if candidate:
|
||||
# Legacy fallback: if manifest doesn't explicitly declare a
|
||||
@@ -3038,6 +3308,8 @@ class PresetResolver:
|
||||
"path": candidate,
|
||||
"source": source,
|
||||
"strategy": "replace",
|
||||
"extension_id": ext_id,
|
||||
"extension_dir": ext_dir,
|
||||
})
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
@@ -3157,10 +3429,32 @@ class PresetResolver:
|
||||
if not layers:
|
||||
return None
|
||||
|
||||
def _read_layer_content(layer: Dict[str, Any]) -> str:
|
||||
"""Read a layer's raw text, rewriting extension-relative subdir
|
||||
references (agents/, knowledge-base/, etc.) to their installed
|
||||
location when the layer is extension-provided (#2101).
|
||||
|
||||
Extension layers are always inserted with strategy "replace"
|
||||
(see collect_all_layers), so a layer only ever needs this
|
||||
rewrite when it wins outright above or serves as the
|
||||
composition base below — never as a mid-stack composing
|
||||
(append/prepend/wrap) layer.
|
||||
"""
|
||||
text = layer["path"].read_text(encoding="utf-8")
|
||||
extension_id = layer.get("extension_id")
|
||||
extension_dir = layer.get("extension_dir")
|
||||
if extension_id and extension_dir:
|
||||
from ..agents import CommandRegistrar
|
||||
|
||||
text = CommandRegistrar.rewrite_extension_paths(
|
||||
text, extension_id, extension_dir
|
||||
)
|
||||
return text
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if layers[0]["strategy"] == "replace":
|
||||
return layers[0]["path"].read_text(encoding="utf-8")
|
||||
return _read_layer_content(layers[0])
|
||||
|
||||
# Composition: build content bottom-up from the effective base.
|
||||
# The base is the nearest replace layer scanning from highest priority
|
||||
@@ -3183,7 +3477,7 @@ class PresetResolver:
|
||||
|
||||
# Convert to reversed_layers index
|
||||
base_reversed_idx = len(layers) - 1 - base_layer_idx
|
||||
content = layers[base_layer_idx]["path"].read_text(encoding="utf-8")
|
||||
content = _read_layer_content(layers[base_layer_idx])
|
||||
# Compose only the layers above the base (higher priority = lower index in layers,
|
||||
# higher index in reversed_layers). Process bottom-up from base+1.
|
||||
start_idx = base_reversed_idx + 1
|
||||
|
||||
@@ -484,6 +484,9 @@ def preset_set_priority(
|
||||
|
||||
# Update priority
|
||||
manager.registry.update(preset_id, {"priority": priority})
|
||||
manager.reconcile_constitution(
|
||||
f"Failed to reconcile constitution after changing priority for preset {preset_id}"
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}")
|
||||
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
||||
@@ -517,6 +520,9 @@ def preset_enable(
|
||||
|
||||
# Enable the preset
|
||||
manager.registry.update(preset_id, {"enabled": True})
|
||||
manager.reconcile_constitution(
|
||||
f"Failed to reconcile constitution after enabling preset {preset_id}"
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
|
||||
console.print("\nTemplates from this preset will now be included in resolution.")
|
||||
@@ -551,6 +557,9 @@ def preset_disable(
|
||||
|
||||
# Disable the preset
|
||||
manager.registry.update(preset_id, {"enabled": False})
|
||||
manager.reconcile_constitution(
|
||||
f"Failed to reconcile constitution after disabling preset {preset_id}"
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
|
||||
console.print("\nTemplates from this preset will be skipped during resolution.")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,9 @@ class StepContext:
|
||||
#: Current run ID.
|
||||
run_id: str | None = None
|
||||
|
||||
#: Source directory of the workflow definition file.
|
||||
workflow_dir: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
|
||||
@@ -13,6 +13,8 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -71,48 +73,180 @@ class WorkflowRegistry:
|
||||
self.registry_path = self.workflows_dir / self.REGISTRY_FILE
|
||||
self.data = self._load()
|
||||
|
||||
def _has_symlinked_parent(self) -> bool:
|
||||
"""Return True if any directory under .specify/workflows is a symlink."""
|
||||
current = self.project_root
|
||||
for part in (".specify", "workflows"):
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
"""Load registry from disk or create default."""
|
||||
default_registry: dict[str, Any] = {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"workflows": {},
|
||||
}
|
||||
# Defense-in-depth: refuse to read through symlinked parents or a
|
||||
# symlinked registry file. Unlike StepRegistry (read-only best-effort
|
||||
# elsewhere), a fabricated empty registry here is not safe: read-only
|
||||
# callers (notably the bundler's remove path) query is_installed()
|
||||
# before ever writing, and would otherwise conclude an installed
|
||||
# workflow is absent, skip removing it, then delete the bundle
|
||||
# record -- leaving the workflow untracked but still on disk. Fail
|
||||
# closed here just like the unreadable-file case below.
|
||||
if self._has_symlinked_parent() or self.registry_path.is_symlink():
|
||||
raise OSError(
|
||||
f"Refusing to read workflow registry at {self.registry_path}: "
|
||||
"a parent directory or the registry file itself is a symlink"
|
||||
)
|
||||
if self.registry_path.exists():
|
||||
try:
|
||||
with open(self.registry_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Validate shape: must be a dict with a dict "workflows" field,
|
||||
# otherwise every method that indexes data["workflows"] crashes.
|
||||
# Mirrors StepRegistry._load.
|
||||
if not isinstance(data, dict):
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
if not isinstance(data.get("workflows"), dict):
|
||||
data["workflows"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
|
||||
# Corrupted registry file — reset to default
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
except OSError as exc:
|
||||
# The real data may still be intact on disk. Fail closed at
|
||||
# construction rather than fabricating an empty registry that
|
||||
# a read-only caller could mistake for "nothing installed."
|
||||
raise OSError(
|
||||
f"Failed to read workflow registry at {self.registry_path}: {exc}"
|
||||
) from exc
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
ValueError,
|
||||
UnicodeError,
|
||||
) as exc:
|
||||
raise OSError(
|
||||
f"Workflow registry at {self.registry_path} is corrupted: "
|
||||
f"{exc}"
|
||||
) from exc
|
||||
# Validate shape: must be a dict with a dict "workflows" field.
|
||||
if not isinstance(data, dict):
|
||||
raise OSError(
|
||||
f"Workflow registry at {self.registry_path} is corrupted: "
|
||||
"top-level value must be an object"
|
||||
)
|
||||
if not isinstance(data.get("workflows"), dict):
|
||||
raise OSError(
|
||||
f"Workflow registry at {self.registry_path} is corrupted: "
|
||||
"'workflows' must be an object"
|
||||
)
|
||||
return data
|
||||
return default_registry
|
||||
|
||||
def save(self) -> None:
|
||||
"""Persist registry to disk."""
|
||||
"""Persist registry to disk atomically."""
|
||||
# Refuse to write through symlinked parents (mirrors StepRegistry.save
|
||||
# and the CLI-level _reject_unsafe_dir guard).
|
||||
if self._has_symlinked_parent() or self.registry_path.is_symlink():
|
||||
raise OSError(
|
||||
"Refusing to write workflow registry through a symlinked path."
|
||||
)
|
||||
self.workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
# Unique, exclusive temp then replace: a failed dump cannot truncate
|
||||
# the registry, a pre-created symlink cannot redirect the write, and
|
||||
# concurrent CLI processes cannot collide on the same temp path.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(self.registry_path.parent),
|
||||
prefix=f".{self.registry_path.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
try:
|
||||
# Write through a duplicate so the exclusive mkstemp descriptor
|
||||
# stays open for fd-based metadata updates and inode verification.
|
||||
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
# mkstemp creates the temp file at 0600. A pre-existing registry
|
||||
# may be shared more permissively (e.g. 0640/0644); preserve its
|
||||
# mode across the replace so a save doesn't silently lock other
|
||||
# project users out. A brand-new registry has no prior mode to
|
||||
# preserve, so mkstemp's secure 0600 default stands. Mirrors
|
||||
# _utils.py's atomic_write_json (best-effort; data safety over
|
||||
# metadata preservation).
|
||||
try:
|
||||
if self.registry_path.exists():
|
||||
existing_stat = self.registry_path.stat(
|
||||
follow_symlinks=False
|
||||
)
|
||||
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
|
||||
os, "fchmod"
|
||||
):
|
||||
os.fchmod(fd, stat.S_IMODE(existing_stat.st_mode))
|
||||
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
|
||||
os, "fchown"
|
||||
):
|
||||
try:
|
||||
os.fchown(
|
||||
fd, existing_stat.st_uid, existing_stat.st_gid
|
||||
)
|
||||
except PermissionError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
staged_stat = os.stat(tmp, follow_symlinks=False)
|
||||
open_stat = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(staged_stat.st_mode)
|
||||
or staged_stat.st_dev != open_stat.st_dev
|
||||
or staged_stat.st_ino != open_stat.st_ino
|
||||
):
|
||||
raise OSError(
|
||||
"Refusing to replace workflow registry: "
|
||||
"staged file changed before commit"
|
||||
)
|
||||
os.close(fd)
|
||||
fd = -1
|
||||
os.replace(tmp, self.registry_path)
|
||||
except BaseException:
|
||||
if fd >= 0:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
|
||||
"""Add or update an installed workflow entry."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
existing = self.data["workflows"].get(workflow_id, {})
|
||||
raw_existing = self.data["workflows"].get(workflow_id)
|
||||
had_entry = workflow_id in self.data["workflows"]
|
||||
# Corrupted-but-parseable registries may hold non-dict entries.
|
||||
existing = raw_existing if isinstance(raw_existing, dict) else {}
|
||||
metadata["installed_at"] = existing.get(
|
||||
"installed_at", datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
self.data["workflows"][workflow_id] = metadata
|
||||
self.save()
|
||||
try:
|
||||
self.save()
|
||||
except (OSError, TypeError, ValueError):
|
||||
# Roll back the in-memory mutation so a later successful save
|
||||
# cannot persist metadata for a write that failed.
|
||||
if had_entry:
|
||||
self.data["workflows"][workflow_id] = raw_existing
|
||||
else:
|
||||
del self.data["workflows"][workflow_id]
|
||||
raise
|
||||
|
||||
def remove(self, workflow_id: str) -> bool:
|
||||
"""Remove an installed workflow entry. Returns True if found."""
|
||||
if workflow_id in self.data["workflows"]:
|
||||
removed_entry = self.data["workflows"][workflow_id]
|
||||
del self.data["workflows"][workflow_id]
|
||||
self.save()
|
||||
try:
|
||||
self.save()
|
||||
except (OSError, TypeError, ValueError):
|
||||
# Roll back the in-memory deletion so a save failure can't
|
||||
# desync this instance from the untouched file on disk,
|
||||
# mirroring add()'s rollback-on-save-failure.
|
||||
self.data["workflows"][workflow_id] = removed_entry
|
||||
raise
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -165,8 +299,20 @@ class WorkflowCatalog:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. an unterminated IPv6 bracket
|
||||
# "https://[::1") makes urlparse / hostname access raise ValueError.
|
||||
# This validator's contract is to raise WorkflowValidationError for a
|
||||
# bad URL, so surface that rather than leaking a raw ValueError past the
|
||||
# command handler (which only catches WorkflowValidationError). Mirrors
|
||||
# specify_cli.catalogs (#3435).
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise WorkflowValidationError(
|
||||
f"Catalog URL is malformed: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
@@ -174,7 +320,7 @@ class WorkflowCatalog:
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
if not hostname:
|
||||
raise WorkflowValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
@@ -340,15 +486,26 @@ class WorkflowCatalog:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_catalog_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. "https://[::1") makes urlparse /
|
||||
# hostname access raise ValueError; treat it as a refused fetch
|
||||
# rather than leaking a raw ValueError (this also validates the
|
||||
# post-redirect resp.geturl(), so a hostile redirect target cannot
|
||||
# crash the fetch either).
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from malformed URL: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
|
||||
)
|
||||
if not parsed.hostname:
|
||||
if not hostname:
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from URL with no hostname: {url}"
|
||||
)
|
||||
@@ -435,6 +592,7 @@ class WorkflowCatalog:
|
||||
self,
|
||||
query: str | None = None,
|
||||
tag: str | None = None,
|
||||
author: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search workflows across all configured catalogs."""
|
||||
merged = self._get_merged_workflows()
|
||||
@@ -459,6 +617,10 @@ class WorkflowCatalog:
|
||||
normalized_tags = [t.lower() for t in tags if isinstance(t, str)]
|
||||
if tag.lower() not in normalized_tags:
|
||||
continue
|
||||
if author:
|
||||
wf_author = wf_data.get("author", "")
|
||||
if not isinstance(wf_author, str) or wf_author.lower() != author.lower():
|
||||
continue
|
||||
results.append(wf_data)
|
||||
return results
|
||||
|
||||
@@ -782,8 +944,20 @@ class StepCatalog:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. an unterminated IPv6 bracket
|
||||
# "https://[::1") makes urlparse / hostname access raise ValueError.
|
||||
# This validator's contract is to raise StepValidationError for a bad
|
||||
# URL, so surface that rather than leaking a raw ValueError past the
|
||||
# command handler (which only catches StepValidationError). Mirrors
|
||||
# specify_cli.catalogs (#3435).
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise StepValidationError(
|
||||
f"Catalog URL is malformed: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
@@ -791,7 +965,7 @@ class StepCatalog:
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
if not hostname:
|
||||
raise StepValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
@@ -957,15 +1131,26 @@ class StepCatalog:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. "https://[::1") makes urlparse /
|
||||
# hostname access raise ValueError; treat it as a refused fetch
|
||||
# rather than leaking a raw ValueError (this also validates the
|
||||
# post-redirect resp.geturl(), so a hostile redirect target cannot
|
||||
# crash the fetch either).
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from malformed URL: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
|
||||
)
|
||||
if not parsed.hostname:
|
||||
if not hostname:
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from URL with no hostname: {url}"
|
||||
)
|
||||
|
||||
@@ -150,7 +150,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"'workflow.id' must be a string, got "
|
||||
f"{type(definition.id).__name__} ({definition.id!r})."
|
||||
)
|
||||
elif not _ID_PATTERN.match(definition.id):
|
||||
elif not _ID_PATTERN.fullmatch(definition.id):
|
||||
errors.append(
|
||||
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
|
||||
f"with hyphens."
|
||||
@@ -172,7 +172,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"{type(definition.version).__name__} ({definition.version!r}) — "
|
||||
f'quote it in YAML (version: "1.0.0").'
|
||||
)
|
||||
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
|
||||
elif not re.fullmatch(r"\d+\.\d+\.\d+", definition.version):
|
||||
errors.append(
|
||||
f"Workflow version {definition.version!r} is not valid "
|
||||
f"semantic versioning (expected X.Y.Z)."
|
||||
@@ -416,18 +416,57 @@ class RunState:
|
||||
ID into a path so a malicious value cannot probe or read files
|
||||
outside ``.specify/workflows/runs/<run_id>/``.
|
||||
"""
|
||||
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
|
||||
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.fullmatch(run_id):
|
||||
raise ValueError(
|
||||
f"Invalid run_id {run_id!r}: must be alphanumeric with "
|
||||
"hyphens/underscores only (and must start with an "
|
||||
"alphanumeric character)."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_installed_origin(
|
||||
installed_workflow_id: str | None,
|
||||
installed_registry_root: str | None,
|
||||
) -> None:
|
||||
"""Validate persisted installed-workflow ownership metadata."""
|
||||
if installed_workflow_id is not None:
|
||||
if not isinstance(installed_workflow_id, str):
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_workflow_id' must be a "
|
||||
f"string or null, got {type(installed_workflow_id).__name__}"
|
||||
)
|
||||
if not _ID_PATTERN.fullmatch(installed_workflow_id):
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_workflow_id' must be a "
|
||||
"lowercase alphanumeric workflow ID with hyphens"
|
||||
)
|
||||
if installed_registry_root is not None:
|
||||
if not isinstance(installed_registry_root, str):
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_registry_root' must be a "
|
||||
f"string or null, got {type(installed_registry_root).__name__}"
|
||||
)
|
||||
if not installed_registry_root or not Path(
|
||||
installed_registry_root
|
||||
).is_absolute():
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_registry_root' must be "
|
||||
"an absolute path or null"
|
||||
)
|
||||
if installed_workflow_id is None:
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_registry_root' requires "
|
||||
"'installed_workflow_id'"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_id: str | None = None,
|
||||
workflow_id: str = "",
|
||||
project_root: Path | None = None,
|
||||
installed_workflow_id: str | None = None,
|
||||
installed_registry_root: str | None = None,
|
||||
installed_origin_tracked: bool = True,
|
||||
) -> None:
|
||||
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
|
||||
# string is *not* the same as "omitted" and must be validated like
|
||||
@@ -439,8 +478,22 @@ class RunState:
|
||||
else:
|
||||
self.run_id = run_id
|
||||
self._validate_run_id(self.run_id)
|
||||
self._validate_installed_origin(
|
||||
installed_workflow_id, installed_registry_root
|
||||
)
|
||||
self.workflow_id = workflow_id
|
||||
self.project_root = project_root or Path(".")
|
||||
# Identifies the installed workflow (if any) this run was started
|
||||
# from, and the project root that owns its registry — set by
|
||||
# execute() when the source was resolved to an installed ID (see
|
||||
# workflow_run's ownership mapping). None for a direct/non-installed
|
||||
# YAML source. ``installed_origin_tracked`` distinguishes those
|
||||
# explicit None values from legacy state files that predate both
|
||||
# fields, allowing the CLI to conservatively infer same-project
|
||||
# registry ownership before resuming.
|
||||
self.installed_workflow_id = installed_workflow_id
|
||||
self.installed_registry_root = installed_registry_root
|
||||
self.installed_origin_tracked = installed_origin_tracked
|
||||
self.status = RunStatus.CREATED
|
||||
self.current_step_index = 0
|
||||
self.current_step_id: str | None = None
|
||||
@@ -455,6 +508,7 @@ class RunState:
|
||||
# append_log is never called while _lock is held, the two never nest.
|
||||
self._log_lock = threading.Lock()
|
||||
self.inputs: dict[str, Any] = {}
|
||||
self.workflow_dir: str | None = None
|
||||
self.created_at = datetime.now(timezone.utc).isoformat()
|
||||
self.updated_at = self.created_at
|
||||
self.log_entries: list[dict[str, Any]] = []
|
||||
@@ -503,10 +557,13 @@ class RunState:
|
||||
state_data = {
|
||||
"run_id": self.run_id,
|
||||
"workflow_id": self.workflow_id,
|
||||
"installed_workflow_id": self.installed_workflow_id,
|
||||
"installed_registry_root": self.installed_registry_root,
|
||||
"status": self.status.value,
|
||||
"current_step_index": self.current_step_index,
|
||||
"current_step_id": self.current_step_id,
|
||||
"step_results": self.step_results,
|
||||
"workflow_dir": self.workflow_dir,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
@@ -554,16 +611,52 @@ class RunState:
|
||||
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
state_data = json.load(f)
|
||||
if not isinstance(state_data, dict):
|
||||
raise ValueError("Invalid run state: expected a JSON object")
|
||||
missing_fields = [
|
||||
field
|
||||
for field in ("run_id", "workflow_id", "status")
|
||||
if field not in state_data
|
||||
]
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
"Invalid run state: missing required field(s): "
|
||||
+ ", ".join(missing_fields)
|
||||
)
|
||||
|
||||
workflow_id = state_data["workflow_id"]
|
||||
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
|
||||
workflow_id
|
||||
):
|
||||
raise ValueError(
|
||||
"Invalid run state: 'workflow_id' must be a lowercase "
|
||||
"alphanumeric workflow ID with hyphens"
|
||||
)
|
||||
|
||||
has_installed_workflow_id = "installed_workflow_id" in state_data
|
||||
has_installed_registry_root = "installed_registry_root" in state_data
|
||||
if has_installed_workflow_id != has_installed_registry_root:
|
||||
raise ValueError(
|
||||
"Invalid run state: installed workflow origin fields must "
|
||||
"either both be present or both be absent"
|
||||
)
|
||||
|
||||
installed_workflow_id = state_data.get("installed_workflow_id")
|
||||
installed_registry_root = state_data.get("installed_registry_root")
|
||||
|
||||
state = cls(
|
||||
run_id=state_data["run_id"],
|
||||
workflow_id=state_data["workflow_id"],
|
||||
workflow_id=workflow_id,
|
||||
project_root=project_root,
|
||||
installed_workflow_id=installed_workflow_id,
|
||||
installed_registry_root=installed_registry_root,
|
||||
installed_origin_tracked=has_installed_workflow_id,
|
||||
)
|
||||
state.status = RunStatus(state_data["status"])
|
||||
state.current_step_index = state_data.get("current_step_index", 0)
|
||||
state.current_step_id = state_data.get("current_step_id")
|
||||
state.step_results = state_data.get("step_results", {})
|
||||
state.workflow_dir = state_data.get("workflow_dir")
|
||||
state.created_at = state_data.get("created_at", "")
|
||||
state.updated_at = state_data.get("updated_at", "")
|
||||
|
||||
@@ -571,7 +664,16 @@ class RunState:
|
||||
if inputs_path.exists():
|
||||
with open(inputs_path, encoding="utf-8") as f:
|
||||
inputs_data = json.load(f)
|
||||
state.inputs = inputs_data.get("inputs", {})
|
||||
if not isinstance(inputs_data, dict):
|
||||
raise ValueError(
|
||||
"Invalid run inputs: expected a JSON object"
|
||||
)
|
||||
inputs = inputs_data.get("inputs", {})
|
||||
if not isinstance(inputs, dict):
|
||||
raise ValueError(
|
||||
"Invalid run inputs: 'inputs' must be a JSON object"
|
||||
)
|
||||
state.inputs = inputs
|
||||
|
||||
return state
|
||||
|
||||
@@ -654,6 +756,8 @@ class WorkflowEngine:
|
||||
definition: WorkflowDefinition,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
run_id: str | None = None,
|
||||
installed_workflow_id: str | None = None,
|
||||
installed_registry_root: Path | None = None,
|
||||
) -> RunState:
|
||||
"""Execute a workflow definition.
|
||||
|
||||
@@ -665,6 +769,12 @@ class WorkflowEngine:
|
||||
User-provided input values.
|
||||
run_id:
|
||||
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
|
||||
installed_workflow_id, installed_registry_root:
|
||||
When the run was started from an installed workflow (as opposed
|
||||
to a direct/non-installed YAML source), identifies it and its
|
||||
owning registry root so a later ``resume`` can re-check the
|
||||
registry's current disabled state before continuing — see
|
||||
``workflow_resume``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -682,6 +792,12 @@ class WorkflowEngine:
|
||||
run_id=effective_run_id,
|
||||
workflow_id=definition.id,
|
||||
project_root=self.project_root,
|
||||
installed_workflow_id=installed_workflow_id,
|
||||
installed_registry_root=(
|
||||
str(installed_registry_root)
|
||||
if installed_registry_root is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
# Persist a copy of the workflow definition so resume can
|
||||
@@ -697,6 +813,12 @@ class WorkflowEngine:
|
||||
# Resolve inputs
|
||||
resolved_inputs = self._resolve_inputs(definition, inputs or {})
|
||||
state.inputs = resolved_inputs
|
||||
workflow_dir = (
|
||||
str(definition.source_path.resolve().parent)
|
||||
if definition.source_path is not None
|
||||
else None
|
||||
)
|
||||
state.workflow_dir = workflow_dir
|
||||
state.status = RunStatus.RUNNING
|
||||
state.save()
|
||||
|
||||
@@ -707,6 +829,7 @@ class WorkflowEngine:
|
||||
default_options=definition.default_options,
|
||||
project_root=str(self.project_root),
|
||||
run_id=state.run_id,
|
||||
workflow_dir=workflow_dir,
|
||||
)
|
||||
|
||||
# Execute steps
|
||||
@@ -772,6 +895,7 @@ class WorkflowEngine:
|
||||
default_options=definition.default_options,
|
||||
project_root=str(self.project_root),
|
||||
run_id=state.run_id,
|
||||
workflow_dir=state.workflow_dir,
|
||||
)
|
||||
|
||||
from . import STEP_REGISTRY
|
||||
@@ -1084,9 +1208,9 @@ class WorkflowEngine:
|
||||
already flipped), so the prefix never drops the actual halting item.
|
||||
|
||||
``max_concurrency`` is coerced with ``int()``; a value that cannot be
|
||||
coerced (``None``, a non-numeric string, …) or that coerces to <= 1 runs
|
||||
sequentially, while a numeric string like ``"4"`` or a float like ``4.0``
|
||||
is honored.
|
||||
coerced (``None``, a non-numeric string, ``.inf``/``.nan``, …) or that
|
||||
coerces to <= 1 runs sequentially, while a numeric string like ``"4"`` or
|
||||
a float like ``4.0`` is honored.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
@@ -1094,7 +1218,9 @@ class WorkflowEngine:
|
||||
halting = (RunStatus.PAUSED, RunStatus.FAILED, RunStatus.ABORTED)
|
||||
try:
|
||||
workers = max(1, int(max_concurrency))
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a YAML ``max_concurrency: .inf``
|
||||
# would otherwise crash the whole run instead of falling back.
|
||||
workers = 1
|
||||
# Never spin up more workers than there is work — bounds a user-controlled
|
||||
# max_concurrency from over-allocating threads.
|
||||
|
||||
@@ -35,14 +35,38 @@ def _filter_default(value: Any, default_value: Any = "") -> Any:
|
||||
|
||||
|
||||
def _filter_join(value: Any, separator: str = ", ") -> str:
|
||||
"""Join a list into a string with *separator*."""
|
||||
"""Join a list into a string with *separator*.
|
||||
|
||||
Raises ``ValueError`` when *separator* is not a string. Without the guard a
|
||||
non-string separator (an authoring mistake like ``| join(5)``) reaches
|
||||
``str.join`` and raises a cryptic ``AttributeError: 'int' object has no
|
||||
attribute 'join'`` that escapes the evaluator and crashes the whole run,
|
||||
since the engine wraps neither expression evaluation nor ``execute`` in a
|
||||
try/except. Mirrors the strict argument handling in ``from_json``.
|
||||
"""
|
||||
if not isinstance(separator, str):
|
||||
raise ValueError(
|
||||
f"join: expected a string separator, got {type(separator).__name__}"
|
||||
)
|
||||
if isinstance(value, list):
|
||||
return separator.join(str(v) for v in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _filter_map(value: Any, attr: str) -> list[Any]:
|
||||
"""Map a list of dicts to a specific attribute."""
|
||||
"""Map a list of dicts to a specific attribute.
|
||||
|
||||
Raises ``ValueError`` when *attr* is not a string. Without the guard a
|
||||
non-string attribute (an authoring mistake like ``| map(5)``) reaches
|
||||
``attr.split(".")`` and raises a cryptic ``AttributeError: 'int' object has
|
||||
no attribute 'split'`` that escapes the evaluator and crashes the whole run,
|
||||
since the engine wraps neither expression evaluation nor ``execute`` in a
|
||||
try/except. Mirrors the strict argument handling in ``from_json``.
|
||||
"""
|
||||
if not isinstance(attr, str):
|
||||
raise ValueError(
|
||||
f"map: expected a string attribute name, got {type(attr).__name__}"
|
||||
)
|
||||
if isinstance(value, list):
|
||||
result = []
|
||||
for item in value:
|
||||
@@ -63,9 +87,25 @@ def _filter_map(value: Any, attr: str) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
def _filter_contains(value: Any, substring: str) -> bool:
|
||||
"""Check if a string or list contains *substring*."""
|
||||
def _filter_contains(value: Any, substring: Any) -> bool:
|
||||
"""Check if a string or list contains *substring*.
|
||||
|
||||
For a string *value*, *substring* must itself be a string: ``x in y`` on a
|
||||
string requires a string left operand, so a non-string argument (an
|
||||
authoring mistake like ``| contains(5)``) would otherwise raise a cryptic
|
||||
``TypeError`` that escapes the evaluator and crashes the whole run, since
|
||||
the engine wraps neither expression evaluation nor ``execute`` in a
|
||||
try/except. Raise a ``ValueError`` naming the problem instead, mirroring the
|
||||
strict argument handling in ``from_json``. For a list *value*, membership of
|
||||
any element type is legitimate (``5 in [1, 2, 5]``), so that branch is left
|
||||
unguarded.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if not isinstance(substring, str):
|
||||
raise ValueError(
|
||||
"contains: expected a string argument when the value is a "
|
||||
f"string, got {type(substring).__name__}"
|
||||
)
|
||||
return substring in value
|
||||
if isinstance(value, list):
|
||||
return substring in value
|
||||
@@ -142,7 +182,8 @@ def _build_namespace(context: Any) -> dict[str, Any]:
|
||||
# runs use an 8-character uuid4 hex; operator-supplied ids may be
|
||||
# any alphanumeric string with hyphens or underscores.
|
||||
run_id = getattr(context, "run_id", None) or ""
|
||||
ns["context"] = {"run_id": run_id}
|
||||
workflow_dir = getattr(context, "workflow_dir", None) or ""
|
||||
ns["context"] = {"run_id": run_id, "workflow_dir": workflow_dir}
|
||||
return ns
|
||||
|
||||
|
||||
@@ -464,9 +505,9 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
if op == "<=":
|
||||
return _safe_compare(left, right, "<=")
|
||||
if op == " in ":
|
||||
return left in right if right is not None else False
|
||||
return _safe_membership(left, right, negate=False)
|
||||
if op == " not in ":
|
||||
return left not in right if right is not None else True
|
||||
return _safe_membership(left, right, negate=True)
|
||||
|
||||
# Numeric literal
|
||||
try:
|
||||
@@ -511,6 +552,26 @@ def _coerce_number(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _safe_membership(left: Any, right: Any, *, negate: bool) -> bool:
|
||||
"""Safely evaluate ``left in right`` (or ``not in``) without crashing.
|
||||
|
||||
``left in right`` raises ``TypeError`` whenever the operands don't support
|
||||
membership testing — most commonly a non-iterable right operand (``None``,
|
||||
an int, a bool), but also cases like an unhashable ``left`` against a set.
|
||||
In every such case the membership relation is undefined, so treat it as
|
||||
``False`` (``not in`` as ``True``) rather than leaking the error out of the
|
||||
evaluator and crashing the whole workflow. Mirrors the graceful
|
||||
``TypeError`` handling in ``_safe_compare`` for the ordering operators, and
|
||||
generalizes the previous ``right is not None`` guard to any operand pair
|
||||
that can't be membership-tested.
|
||||
"""
|
||||
try:
|
||||
contained = left in right
|
||||
except TypeError:
|
||||
contained = False
|
||||
return not contained if negate else contained
|
||||
|
||||
|
||||
def _safe_compare(left: Any, right: Any, op: str) -> bool:
|
||||
"""Compare two values for ordering, coercing numeric strings when possible.
|
||||
|
||||
|
||||
@@ -31,6 +31,20 @@ class CommandStep(StepBase):
|
||||
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
|
||||
command = config.get("command", "")
|
||||
input_data = config.get("input", {})
|
||||
# validate() rejects a non-mapping input, but the engine does not
|
||||
# auto-validate before execute(); a workflow that skipped validation can
|
||||
# still reach here. Fail the step with the same contract error rather
|
||||
# than silently coercing to {} and dispatching with empty args — that
|
||||
# would change the command's meaning, hide the config error, and report
|
||||
# COMPLETED, defeating the per-step FAILED / continue_on_error behavior.
|
||||
if not isinstance(input_data, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'input' must be a "
|
||||
f"mapping, got {type(input_data).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Resolve expressions in input
|
||||
resolved_input: dict[str, Any] = {}
|
||||
@@ -50,8 +64,18 @@ class CommandStep(StepBase):
|
||||
# Merge options (workflow defaults ← step overrides)
|
||||
options = dict(context.default_options)
|
||||
step_options = config.get("options", {})
|
||||
if step_options:
|
||||
options.update(step_options)
|
||||
# Same rationale as 'input': a malformed options fails the step rather
|
||||
# than being silently ignored (which would let an invalid step run and
|
||||
# apparently complete).
|
||||
if not isinstance(step_options, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a "
|
||||
f"mapping, got {type(step_options).__name__}."
|
||||
),
|
||||
)
|
||||
options.update(step_options)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
args_str = str(resolved_input.get("args", ""))
|
||||
@@ -155,4 +179,16 @@ class CommandStep(StepBase):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r} is missing 'command' field."
|
||||
)
|
||||
# execute() iterates input.items() and options.update(step_options); a
|
||||
# non-mapping here would raise at run time. Validate the shape like the
|
||||
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
|
||||
# crashed on.
|
||||
if "input" in config and not isinstance(config["input"], dict):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'input' must be a mapping."
|
||||
)
|
||||
if "options" in config and not isinstance(config["options"], dict):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -27,6 +27,30 @@ class DoWhileStep(StepBase):
|
||||
nested_steps = config.get("steps", [])
|
||||
condition = config.get("condition", "false")
|
||||
|
||||
# The engine does not auto-validate step config (see
|
||||
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
|
||||
# into ``_execute_steps``, which iterates them as step mappings. A
|
||||
# non-list ``steps`` (a single mapping or scalar authoring mistake)
|
||||
# would otherwise be iterated element-wise — a dict yields its string
|
||||
# keys, a str its characters — and crash the whole run with
|
||||
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
|
||||
# ``steps``; fail this step loudly on an unvalidated run instead,
|
||||
# mirroring the if/switch/fan-out steps. The body always runs on the
|
||||
# first call, so unlike the while step this guard is unconditional.
|
||||
if not isinstance(nested_steps, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output={
|
||||
"condition": condition,
|
||||
"max_iterations": max_iterations,
|
||||
"loop_type": "do-while",
|
||||
},
|
||||
error=(
|
||||
f"Do-while step {config.get('id', '?')!r}: 'steps' must be "
|
||||
f"a list of steps, got {type(nested_steps).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Always execute body at least once; the engine layer evaluates
|
||||
# `condition` after each iteration to decide whether to loop.
|
||||
return StepResult(
|
||||
|
||||
@@ -24,6 +24,24 @@ class FanInStep(StepBase):
|
||||
if not isinstance(output_config, dict):
|
||||
output_config = {}
|
||||
|
||||
# The engine does not auto-validate step config, so an unvalidated run
|
||||
# with a non-list ``wait_for`` reaches here raw. Iterating it then
|
||||
# either crashes the whole run (a scalar like an int or None raises
|
||||
# TypeError) or, worse, silently iterates a string's characters and
|
||||
# yields a bogus join of empty results with a COMPLETED status — the
|
||||
# exact "silent empty result + COMPLETED" wiring bug the engine's
|
||||
# fan-in validation guards against. Fail this step loudly instead,
|
||||
# mirroring the fan-out step's non-list ``items`` handling.
|
||||
if not isinstance(wait_for, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' must be "
|
||||
f"a list of step IDs, got {type(wait_for).__name__}."
|
||||
),
|
||||
output={"results": []},
|
||||
)
|
||||
|
||||
# Collect results from referenced steps
|
||||
results = []
|
||||
for step_id in wait_for:
|
||||
|
||||
@@ -47,8 +47,8 @@ class IfThenStep(StepBase):
|
||||
errors.append(
|
||||
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
|
||||
)
|
||||
else_branch = config.get("else", [])
|
||||
if else_branch and not isinstance(else_branch, list):
|
||||
else_branch = config.get("else")
|
||||
if else_branch is not None and not isinstance(else_branch, list):
|
||||
errors.append(
|
||||
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
@@ -25,14 +27,26 @@ class ShellStep(StepBase):
|
||||
run_cmd = str(run_cmd)
|
||||
|
||||
cwd = context.project_root or "."
|
||||
# Defensive: the engine does not auto-validate step config, so an
|
||||
# invalid ``timeout`` (string, None, ...) would otherwise raise a
|
||||
# TypeError from subprocess.run() and crash the whole run. Mirror
|
||||
# the engine's handling of unvalidated ``continue_on_error`` by
|
||||
# only honoring well-formed values and falling back to the default.
|
||||
# Per-step execution timeout in seconds; defaults to 300 for backward
|
||||
# compatibility. The engine does not auto-validate step config, so
|
||||
# validate here as well — a caller that skips WorkflowEngine.validate()
|
||||
# must fail the step cleanly rather than crash subprocess.run() with a
|
||||
# TypeError (or silently coerce ``timeout: true`` to a 1s duration,
|
||||
# since bool is an int subclass).
|
||||
timeout = config.get("timeout", 300)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
|
||||
timeout = 300
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=timeout_error,
|
||||
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
|
||||
)
|
||||
|
||||
env = {**os.environ}
|
||||
if context.workflow_dir:
|
||||
env["SPECKIT_WORKFLOW_DIR"] = context.workflow_dir
|
||||
else:
|
||||
env.pop("SPECKIT_WORKFLOW_DIR", None)
|
||||
|
||||
# NOTE: shell=True is required to support pipes, redirects, and
|
||||
# multi-command expressions in workflow YAML. Workflow authors
|
||||
@@ -45,6 +59,7 @@ class ShellStep(StepBase):
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
)
|
||||
output = {
|
||||
@@ -92,6 +107,32 @@ class ShellStep(StepBase):
|
||||
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _timeout_error(config: dict[str, Any]) -> str | None:
|
||||
"""Return an error message if ``config['timeout']`` is invalid, else None.
|
||||
|
||||
Shared by execute() and validate() so both paths reject the same
|
||||
values with the same message. An absent ``timeout`` is valid (the
|
||||
default is used). bool is a subclass of int, but ``timeout: true`` is a
|
||||
config error rather than a duration, so it is rejected explicitly.
|
||||
Non-finite floats (YAML ``.inf``/``.nan``) pass a plain ``> 0`` check
|
||||
but would raise in subprocess.run(), so they are rejected too.
|
||||
"""
|
||||
if "timeout" not in config:
|
||||
return None
|
||||
timeout = config["timeout"]
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout)
|
||||
or timeout <= 0
|
||||
):
|
||||
return (
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive number of seconds, got {timeout!r}."
|
||||
)
|
||||
return None
|
||||
|
||||
def validate(self, config: dict[str, Any]) -> list[str]:
|
||||
errors = super().validate(config)
|
||||
if "run" not in config:
|
||||
@@ -114,16 +155,7 @@ class ShellStep(StepBase):
|
||||
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
|
||||
f"be 'json' when present, got {output_format!r}."
|
||||
)
|
||||
if "timeout" in config:
|
||||
timeout = config["timeout"]
|
||||
# bool is an int subclass, so reject it explicitly.
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, int)
|
||||
or timeout <= 0
|
||||
):
|
||||
errors.append(
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive integer (seconds) when present, got {timeout!r}."
|
||||
)
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
errors.append(timeout_error)
|
||||
return errors
|
||||
|
||||
@@ -26,6 +26,20 @@ class SwitchStep(StepBase):
|
||||
str_value = str(value) if value is not None else ""
|
||||
|
||||
cases = config.get("cases", {})
|
||||
if not isinstance(cases, dict):
|
||||
# The engine does not auto-validate step config, so an unvalidated
|
||||
# run with a non-mapping ``cases`` (a list/scalar authoring mistake)
|
||||
# would otherwise raise AttributeError from ``.items()`` below and
|
||||
# crash the whole run. Fail this step loudly instead, mirroring the
|
||||
# fan-out step's non-list ``items`` handling.
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Switch step {config.get('id', '?')!r}: 'cases' must be a "
|
||||
f"mapping, got {type(cases).__name__}."
|
||||
),
|
||||
output={"matched_case": None, "expression_value": value},
|
||||
)
|
||||
for case_key, case_steps in cases.items():
|
||||
if str(case_key) == str_value:
|
||||
return StepResult(
|
||||
|
||||
@@ -26,6 +26,32 @@ class WhileStep(StepBase):
|
||||
nested_steps = config.get("steps", [])
|
||||
|
||||
result = evaluate_condition(condition, context)
|
||||
|
||||
# The engine does not auto-validate step config (see
|
||||
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
|
||||
# into ``_execute_steps``, which iterates them as step mappings. A
|
||||
# non-list ``steps`` (a single mapping or scalar authoring mistake)
|
||||
# would otherwise be iterated element-wise — a dict yields its string
|
||||
# keys, a str its characters — and crash the whole run with
|
||||
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
|
||||
# ``steps``; fail this step loudly on an unvalidated run instead,
|
||||
# mirroring the if/switch/fan-out steps. The guard fires only when the
|
||||
# body would actually be dispatched (condition truthy). The condition is
|
||||
# still evaluated first, so its result is surfaced for downstream context.
|
||||
if result and not isinstance(nested_steps, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output={
|
||||
"condition_result": True,
|
||||
"max_iterations": max_iterations,
|
||||
"loop_type": "while",
|
||||
},
|
||||
error=(
|
||||
f"While step {config.get('id', '?')!r}: 'steps' must be a "
|
||||
f"list of steps, got {type(nested_steps).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
if result:
|
||||
return StepResult(
|
||||
status=StepStatus.COMPLETED,
|
||||
|
||||
@@ -81,7 +81,7 @@ Follow this execution flow:
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
|
||||
@@ -6,6 +6,7 @@ contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -63,6 +64,42 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch
|
||||
assert "Spec Kit project" in result.output
|
||||
|
||||
|
||||
def test_remove_reports_clean_error_when_primitive_raises_raw_exception(
|
||||
project: Path,
|
||||
):
|
||||
"""A raw exception from a primitive installer (e.g. an OSError from an
|
||||
unreadable workflow registry surfacing through _WorkflowKindManager's
|
||||
fail-closed construction) must not propagate uncaught through
|
||||
`specify bundle remove` -- the command only catches BundlerError, so
|
||||
without a conversion at the remove_bundle boundary this would exit
|
||||
with an unhandled exception and empty/raw output instead of a clean,
|
||||
actionable message, and no removal side effects should occur either."""
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
|
||||
from specify_cli.bundler.services.installer import install_bundle
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import FakeInstaller
|
||||
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
install_bundle(project, plan, FakeInstaller(), manifest=manifest)
|
||||
|
||||
def boom(self, project_root, component):
|
||||
raise OSError("workflow registry unreadable")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom)
|
||||
result = runner.invoke(app, ["bundle", "remove", "demo-bundle"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert result.output.strip() != ""
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_fail_writes_error_to_stderr_not_stdout(capsys):
|
||||
"""_fail must write to stderr, not stdout: every bundle command routes errors
|
||||
through it, and under --json the error would otherwise corrupt the JSON payload
|
||||
@@ -432,25 +469,16 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
|
||||
# ===== Private GitHub release asset URL resolution =====
|
||||
|
||||
|
||||
class FakeBundleResponse:
|
||||
class FakeBundleResponse(io.BytesIO):
|
||||
"""Minimal context-manager response stub for open_url fakes."""
|
||||
|
||||
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
|
||||
self._data = data
|
||||
super().__init__(data)
|
||||
self._url = url
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
|
||||
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
|
||||
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""
|
||||
|
||||
647
tests/extensions/git/test_git_extension_python_parity.py
Normal file
647
tests/extensions/git/test_git_extension_python_parity.py
Normal file
@@ -0,0 +1,647 @@
|
||||
"""
|
||||
Parity tests for the Python port of the git extension scripts (extensions/git/scripts/python/).
|
||||
|
||||
Each test runs the bash script and its Python twin in identical twin projects
|
||||
and asserts matching output, exit codes, and resulting git state.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import runpy
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
from tests.extensions.git.test_git_extension import (
|
||||
_GIT_ENV,
|
||||
_init_git,
|
||||
_run_bash,
|
||||
_setup_project,
|
||||
_write_config,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
EXT_PY = PROJECT_ROOT / "extensions" / "git" / "scripts" / "python"
|
||||
CORE_COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
|
||||
|
||||
PY_SCRIPTS = {
|
||||
"create-new-feature-branch": "create_new_feature_branch.py",
|
||||
"initialize-repo": "initialize_repo.py",
|
||||
"auto-commit": "auto_commit.py",
|
||||
}
|
||||
|
||||
|
||||
def _setup_py_project(tmp_path: Path, *, git: bool = True) -> Path:
|
||||
"""Twin of _setup_project that also installs the Python scripts."""
|
||||
project = _setup_project(tmp_path, git=git)
|
||||
|
||||
py_core = project / ".specify" / "scripts" / "python"
|
||||
py_core.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(CORE_COMMON_PY, py_core / "common.py")
|
||||
|
||||
ext_py = project / ".specify" / "extensions" / "git" / "scripts" / "python"
|
||||
ext_py.mkdir(parents=True, exist_ok=True)
|
||||
for f in EXT_PY.iterdir():
|
||||
if f.suffix == ".py":
|
||||
shutil.copy(f, ext_py / f.name)
|
||||
return project
|
||||
|
||||
|
||||
def _run_py(
|
||||
script_name: str,
|
||||
cwd: Path,
|
||||
*args: str,
|
||||
env_extra: dict | None = None,
|
||||
run_cwd: Path | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run an extension Python script.
|
||||
|
||||
``run_cwd`` overrides the working directory while the script path is
|
||||
still resolved against ``cwd``, for tests that invoke a project's script
|
||||
from outside that project.
|
||||
"""
|
||||
script = (
|
||||
cwd / ".specify" / "extensions" / "git" / "scripts" / "python" / PY_SCRIPTS[script_name]
|
||||
)
|
||||
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), *args],
|
||||
cwd=run_cwd or cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _twin_projects(tmp_path: Path, *, git: bool = True) -> tuple[Path, Path]:
|
||||
"""Two identically named projects so {app} tokens match."""
|
||||
bash_proj = _setup_py_project(tmp_path / "bash" / "proj", git=git)
|
||||
py_proj = _setup_py_project(tmp_path / "py" / "proj", git=git)
|
||||
return bash_proj, py_proj
|
||||
|
||||
|
||||
def _assert_parity(
|
||||
bash_result: subprocess.CompletedProcess,
|
||||
py_result: subprocess.CompletedProcess,
|
||||
*,
|
||||
stdout: bool = True,
|
||||
stderr: bool = True,
|
||||
) -> None:
|
||||
assert py_result.returncode == bash_result.returncode, (
|
||||
f"exit codes diverge: bash={bash_result.returncode} py={py_result.returncode}\n"
|
||||
f"bash stderr: {bash_result.stderr}\npy stderr: {py_result.stderr}"
|
||||
)
|
||||
if stdout:
|
||||
assert py_result.stdout == bash_result.stdout
|
||||
if stderr:
|
||||
py_stderr = py_result.stderr
|
||||
bash_stderr = bash_result.stderr
|
||||
if os.name == "nt":
|
||||
py_stderr = _without_persist_hint(py_stderr)
|
||||
bash_stderr = _without_persist_hint(bash_stderr)
|
||||
assert py_stderr == bash_stderr
|
||||
|
||||
|
||||
def _without_persist_hint(stderr: str) -> str:
|
||||
return "".join(
|
||||
line
|
||||
for line in stderr.splitlines(keepends=True)
|
||||
if not line.startswith("# To persist: ")
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestCreateFeatureBranchParity:
|
||||
def test_sequential_branch_json(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "Add user authentication")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "Add user authentication")
|
||||
_assert_parity(b, p)
|
||||
data = json.loads(p.stdout)
|
||||
assert data == {"BRANCH_NAME": "001-user-authentication", "FEATURE_NUM": "001"}
|
||||
branch = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=py_proj,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert branch == "001-user-authentication"
|
||||
|
||||
def test_slug_generation_stop_words_and_acronyms(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
description = "I want to add DB caching for the API layer"
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", description)
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", description)
|
||||
_assert_parity(b, p)
|
||||
|
||||
def test_short_name_cleaning(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
# Single separator runs only: the bash twin's collapse step
|
||||
# (sed 's/-\+/-/g') is a GNU-ism that BSD sed treats literally.
|
||||
b = _run_bash(
|
||||
"create-new-feature-branch.sh", bash_proj,
|
||||
"--json", "--dry-run", "--short-name", "User_Auth!", "desc",
|
||||
)
|
||||
p = _run_py(
|
||||
"create-new-feature-branch", py_proj,
|
||||
"--json", "--dry-run", "--short-name", "User_Auth!", "desc",
|
||||
)
|
||||
_assert_parity(b, p)
|
||||
assert json.loads(p.stdout)["BRANCH_NAME"] == "001-user-auth"
|
||||
|
||||
def test_numbering_from_specs_and_branches(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
(proj / "specs" / "007-existing").mkdir(parents=True)
|
||||
(proj / "specs" / "20260101-120000-timestamped").mkdir(parents=True)
|
||||
subprocess.run(["git", "branch", "012-in-branch"], cwd=proj, check=True)
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "next feature")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "next feature")
|
||||
_assert_parity(b, p)
|
||||
assert json.loads(p.stdout)["FEATURE_NUM"] == "013"
|
||||
|
||||
def test_explicit_number(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--number", "42", "some feature")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "--number", "42", "some feature")
|
||||
_assert_parity(b, p)
|
||||
assert json.loads(p.stdout)["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_timestamp_mode_format(self, tmp_path: Path):
|
||||
_, py_proj = _twin_projects(tmp_path)
|
||||
p = _run_py(
|
||||
"create-new-feature-branch", py_proj,
|
||||
"--json", "--timestamp", "--short-name", "user-auth", "desc",
|
||||
)
|
||||
assert p.returncode == 0
|
||||
data = json.loads(p.stdout)
|
||||
assert re.fullmatch(r"[0-9]{8}-[0-9]{6}", data["FEATURE_NUM"])
|
||||
assert data["BRANCH_NAME"] == f"{data['FEATURE_NUM']}-user-auth"
|
||||
|
||||
def test_timestamp_with_number_warns(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
b = _run_bash(
|
||||
"create-new-feature-branch.sh", bash_proj,
|
||||
"--json", "--dry-run", "--timestamp", "--number", "5", "desc word",
|
||||
)
|
||||
p = _run_py(
|
||||
"create-new-feature-branch", py_proj,
|
||||
"--json", "--dry-run", "--timestamp", "--number", "5", "desc word",
|
||||
)
|
||||
assert p.returncode == b.returncode == 0
|
||||
warning = "[specify] Warning: --number is ignored when --timestamp is used"
|
||||
assert warning in b.stderr
|
||||
assert warning in p.stderr
|
||||
|
||||
def test_branch_template_author_app(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "new payment flow")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "new payment flow")
|
||||
_assert_parity(b, p)
|
||||
assert json.loads(p.stdout)["BRANCH_NAME"] == "test-user/proj/001-new-payment-flow"
|
||||
|
||||
def test_branch_prefix_shorthand(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, "branch_prefix: feat\n")
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "new payment flow")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "new payment flow")
|
||||
_assert_parity(b, p)
|
||||
assert json.loads(p.stdout)["BRANCH_NAME"] == "feat/001-new-payment-flow"
|
||||
|
||||
def test_template_scopes_existing_branch_numbers(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, 'branch_template: "{author}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "test-user/008-scoped"], cwd=proj, check=True)
|
||||
subprocess.run(["git", "branch", "other-user/030-unscoped"], cwd=proj, check=True)
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "next thing")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "next thing")
|
||||
_assert_parity(b, p)
|
||||
assert json.loads(p.stdout)["FEATURE_NUM"] == "009"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"template",
|
||||
[
|
||||
'branch_template: "feat/{slug}"\n',
|
||||
'branch_template: "{slug}/{number}-x"\n',
|
||||
'branch_template: "{number}/{slug}-x"\n',
|
||||
],
|
||||
)
|
||||
def test_invalid_template_rejected(self, tmp_path: Path, template: str):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, template)
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "desc word")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "desc word")
|
||||
assert b.returncode == p.returncode == 1
|
||||
assert p.stderr.strip() == b.stderr.strip()
|
||||
|
||||
def test_git_branch_name_override(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
env = {"GIT_BRANCH_NAME": "team/042-exact-name"}
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "desc word", env_extra=env)
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "desc word", env_extra=env)
|
||||
_assert_parity(b, p)
|
||||
assert json.loads(p.stdout) == {"BRANCH_NAME": "team/042-exact-name", "FEATURE_NUM": "042"}
|
||||
|
||||
def test_git_branch_name_override_persist_hint_matches_bash(
|
||||
self, tmp_path: Path
|
||||
):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
env = {"GIT_BRANCH_NAME": "feature/$value's-\"quoted\""}
|
||||
b = _run_bash(
|
||||
"create-new-feature-branch.sh",
|
||||
bash_proj,
|
||||
"--json",
|
||||
"desc word",
|
||||
env_extra=env,
|
||||
)
|
||||
p = _run_py(
|
||||
"create-new-feature-branch",
|
||||
py_proj,
|
||||
"--json",
|
||||
"desc word",
|
||||
env_extra=env,
|
||||
)
|
||||
_assert_parity(b, p)
|
||||
|
||||
def test_long_branch_name_truncated_to_244_bytes(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
long_name = "-".join(["word"] * 60)
|
||||
b = _run_bash(
|
||||
"create-new-feature-branch.sh", bash_proj,
|
||||
"--json", "--dry-run", "--short-name", long_name, "desc",
|
||||
)
|
||||
p = _run_py(
|
||||
"create-new-feature-branch", py_proj,
|
||||
"--json", "--dry-run", "--short-name", long_name, "desc",
|
||||
)
|
||||
_assert_parity(b, p)
|
||||
assert len(json.loads(p.stdout)["BRANCH_NAME"].encode()) <= 244
|
||||
|
||||
def test_existing_branch_errors_without_flag(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
subprocess.run(["git", "branch", "001-user-auth"], cwd=proj, check=True)
|
||||
args = ("--json", "--number", "1", "--short-name", "user-auth", "desc")
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, *args)
|
||||
p = _run_py("create-new-feature-branch", py_proj, *args)
|
||||
assert b.returncode == p.returncode == 1
|
||||
assert p.stderr.strip() == b.stderr.strip()
|
||||
|
||||
def test_existing_branch_switches_with_allow_flag(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
subprocess.run(["git", "branch", "001-user-auth"], cwd=proj, check=True)
|
||||
args = ("--json", "--number", "1", "--short-name", "user-auth", "--allow-existing-branch", "desc")
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, *args)
|
||||
p = _run_py("create-new-feature-branch", py_proj, *args)
|
||||
_assert_parity(b, p)
|
||||
for proj in (bash_proj, py_proj):
|
||||
branch = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=proj,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert branch == "001-user-auth"
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "offline feature")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", "offline feature")
|
||||
_assert_parity(b, p)
|
||||
assert "skipped branch creation" in p.stderr
|
||||
|
||||
def test_missing_git_executable_gracefully_degrades(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
module = runpy.run_path(str(EXT_PY / "create_new_feature_branch.py"))
|
||||
monkeypatch.setenv("PATH", "")
|
||||
|
||||
assert module["_git_lines"](tmp_path, "status") == []
|
||||
|
||||
def test_windows_persist_hint_quotes_branch_name(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
module = runpy.run_path(str(EXT_PY / "create_new_feature_branch.py"))
|
||||
monkeypatch.setattr(module["os"], "name", "nt")
|
||||
|
||||
result = module["_persist_hint"](
|
||||
"GIT_BRANCH_NAME", "feature/$value's-\"quoted\""
|
||||
)
|
||||
|
||||
assert (
|
||||
result
|
||||
== "$env:GIT_BRANCH_NAME = 'feature/$value''s-\"quoted\"'"
|
||||
)
|
||||
|
||||
def test_shell_specific_persist_hint_can_be_ignored_for_parity(self):
|
||||
bash_stderr = (
|
||||
"[specify] Warning\n"
|
||||
"# To persist: export SPECIFY_FEATURE=feature/name\n"
|
||||
)
|
||||
windows_stderr = (
|
||||
"[specify] Warning\n"
|
||||
"# To persist: $env:SPECIFY_FEATURE = 'feature/name'\n"
|
||||
)
|
||||
|
||||
assert _without_persist_hint(bash_stderr) == _without_persist_hint(
|
||||
windows_stderr
|
||||
)
|
||||
|
||||
def test_assert_parity_ignores_windows_persist_hint(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(os, "name", "nt")
|
||||
bash_result = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr=(
|
||||
"[specify] Warning\n"
|
||||
"# To persist: export SPECIFY_FEATURE=feature/name\n"
|
||||
)
|
||||
)
|
||||
py_result = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr=(
|
||||
"[specify] Warning\n"
|
||||
"# To persist: $env:SPECIFY_FEATURE = 'feature/name'\n"
|
||||
)
|
||||
)
|
||||
|
||||
_assert_parity(bash_result, py_result)
|
||||
|
||||
def test_empty_description_errors(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", " ")
|
||||
p = _run_py("create-new-feature-branch", py_proj, "--json", " ")
|
||||
assert b.returncode == p.returncode == 1
|
||||
assert p.stderr.strip() == b.stderr.strip()
|
||||
assert "cannot be empty or contain only whitespace" in p.stderr
|
||||
|
||||
def test_specify_init_dir_resolves_target_project(self, tmp_path: Path):
|
||||
# The script is installed under host_proj, so script_file-based
|
||||
# discovery (and cwd-based discovery, since we run from elsewhere)
|
||||
# would resolve host_proj, not target_proj. host_proj has no specs
|
||||
# (next number 001); target_proj already has 007-existing (next
|
||||
# number 008). Only honoring SPECIFY_INIT_DIR produces 008, so this
|
||||
# proves the env var -- not script location or cwd -- controls
|
||||
# resolution.
|
||||
host_proj = _setup_py_project(tmp_path / "host")
|
||||
target_proj = _setup_py_project(tmp_path / "target")
|
||||
(target_proj / "specs" / "007-existing").mkdir(parents=True)
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
p = _run_py(
|
||||
"create-new-feature-branch", host_proj,
|
||||
"--json", "--dry-run", "init dir feature",
|
||||
env_extra={"SPECIFY_INIT_DIR": str(target_proj)},
|
||||
run_cwd=elsewhere,
|
||||
)
|
||||
assert p.returncode == 0
|
||||
assert json.loads(p.stdout)["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
|
||||
_, py_proj = _twin_projects(tmp_path)
|
||||
(
|
||||
py_proj / ".specify" / "scripts" / "python" / "common.py"
|
||||
).unlink()
|
||||
p = _run_py(
|
||||
"create-new-feature-branch", py_proj,
|
||||
"--json", "desc word",
|
||||
env_extra={"SPECIFY_INIT_DIR": str(py_proj)},
|
||||
)
|
||||
assert p.returncode == 1
|
||||
assert "SPECIFY_INIT_DIR requires updated Spec Kit core scripts" in p.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestInitializeRepoParity:
|
||||
def test_initializes_repo_with_default_message(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
|
||||
b = _run_bash("initialize-repo.sh", bash_proj)
|
||||
p = _run_py("initialize-repo", py_proj)
|
||||
_assert_parity(b, p)
|
||||
assert p.stderr.strip() == b.stderr.strip()
|
||||
for proj in (bash_proj, py_proj):
|
||||
message = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s"],
|
||||
cwd=proj,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert message == "[Spec Kit] Initial commit"
|
||||
|
||||
def test_custom_commit_message(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, 'init_commit_message: "Custom initial commit"\n')
|
||||
b = _run_bash("initialize-repo.sh", bash_proj)
|
||||
p = _run_py("initialize-repo", py_proj)
|
||||
_assert_parity(b, p)
|
||||
for proj in (bash_proj, py_proj):
|
||||
message = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s"],
|
||||
cwd=proj,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert message == "Custom initial commit"
|
||||
|
||||
def test_skips_existing_repo(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
b = _run_bash("initialize-repo.sh", bash_proj)
|
||||
p = _run_py("initialize-repo", py_proj)
|
||||
_assert_parity(b, p)
|
||||
assert p.stderr.strip() == b.stderr.strip()
|
||||
assert "already initialized" in p.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestAutoCommitParity:
|
||||
def _dirty(self, proj: Path) -> None:
|
||||
(proj / "change.txt").write_text("dirty\n", encoding="utf-8")
|
||||
|
||||
def _last_message(self, proj: Path) -> str:
|
||||
return subprocess.run(
|
||||
["git", "log", "-1", "--format=%s"],
|
||||
cwd=proj,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
def test_disabled_by_default(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, "auto_commit:\n after_specify:\n enabled: false\n")
|
||||
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(py_proj) == "seed"
|
||||
|
||||
def test_ignores_unterminated_final_config_line(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
config = "auto_commit:\n after_specify:\n enabled: true"
|
||||
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(py_proj) == "seed"
|
||||
|
||||
def test_enabled_per_command_with_custom_message(self, tmp_path: Path):
|
||||
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'
|
||||
)
|
||||
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 p.stderr.strip() == b.stderr.strip()
|
||||
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):
|
||||
_write_config(proj, "auto_commit:\n default: true\n")
|
||||
self._dirty(proj)
|
||||
b = _run_bash("auto-commit.sh", bash_proj, "after_plan")
|
||||
p = _run_py("auto-commit", py_proj, "after_plan")
|
||||
_assert_parity(b, p)
|
||||
expected = "[Spec Kit] Auto-commit after plan"
|
||||
assert self._last_message(bash_proj) == self._last_message(py_proj) == expected
|
||||
|
||||
def test_explicit_false_beats_default_true(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
config = "auto_commit:\n default: true\n after_specify:\n enabled: false\n"
|
||||
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(py_proj) == "seed"
|
||||
|
||||
def test_before_event_message(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, "auto_commit:\n before_plan:\n enabled: true\n")
|
||||
self._dirty(proj)
|
||||
b = _run_bash("auto-commit.sh", bash_proj, "before_plan")
|
||||
p = _run_py("auto-commit", py_proj, "before_plan")
|
||||
_assert_parity(b, p)
|
||||
expected = "[Spec Kit] Auto-commit before plan"
|
||||
assert self._last_message(bash_proj) == self._last_message(py_proj) == expected
|
||||
|
||||
def test_no_changes_skips(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
_write_config(proj, "auto_commit:\n after_specify:\n enabled: true\n")
|
||||
subprocess.run(["git", "add", "-A"], cwd=proj, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "clean"],
|
||||
cwd=proj,
|
||||
check=True,
|
||||
env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
|
||||
p = _run_py("auto-commit", py_proj, "after_specify")
|
||||
_assert_parity(b, p)
|
||||
assert p.stderr.strip() == b.stderr.strip()
|
||||
assert "No changes to commit" in p.stderr
|
||||
|
||||
def test_no_config_file_skips(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
for proj in (bash_proj, py_proj):
|
||||
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(py_proj) == "seed"
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions")
|
||||
def test_unreadable_config_skips_auto_commit(self, tmp_path: Path):
|
||||
"""An unreadable config behaves like a missing one: no traceback, no commit."""
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip("root bypasses file permissions")
|
||||
proj = _setup_py_project(tmp_path / "proj")
|
||||
config = _write_config(
|
||||
proj, "auto_commit:\n after_specify:\n enabled: true\n"
|
||||
)
|
||||
self._dirty(proj)
|
||||
config.chmod(0o000)
|
||||
try:
|
||||
p = _run_py("auto-commit", proj, "after_specify")
|
||||
finally:
|
||||
config.chmod(0o644)
|
||||
assert p.returncode == 0
|
||||
assert "Traceback" not in p.stderr
|
||||
assert self._last_message(proj) == "seed"
|
||||
|
||||
def test_missing_event_argument_errors(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path)
|
||||
b = _run_bash("auto-commit.sh", bash_proj)
|
||||
p = _run_py("auto-commit", py_proj)
|
||||
assert b.returncode == p.returncode == 1
|
||||
|
||||
def test_not_a_repo_skips(self, tmp_path: Path):
|
||||
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
|
||||
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
|
||||
p = _run_py("auto-commit", py_proj, "after_specify")
|
||||
_assert_parity(b, p)
|
||||
assert "Not a Git repository" in p.stderr
|
||||
|
||||
|
||||
class TestGitCommonPython:
|
||||
"""Unit tests for git_common.py (imported directly)."""
|
||||
|
||||
@pytest.fixture()
|
||||
def git_common(self):
|
||||
sys.path.insert(0, str(EXT_PY))
|
||||
try:
|
||||
import git_common
|
||||
|
||||
yield git_common
|
||||
finally:
|
||||
sys.path.remove(str(EXT_PY))
|
||||
sys.modules.pop("git_common", None)
|
||||
|
||||
def test_has_git(self, git_common, tmp_path: Path):
|
||||
assert git_common.has_git(tmp_path) is False
|
||||
_init_git(tmp_path)
|
||||
assert git_common.has_git(tmp_path) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("branch", "expected"),
|
||||
[
|
||||
("001-feature-name", True),
|
||||
("1234-feature-name", True),
|
||||
("20260319-143022-feature-name", True),
|
||||
("feat/004-name", True),
|
||||
("main", False),
|
||||
("2026031-143022", False),
|
||||
("20260319-143022", False),
|
||||
("2026031-143022-slug", False),
|
||||
],
|
||||
)
|
||||
def test_check_feature_branch(self, git_common, branch: str, expected: bool):
|
||||
assert git_common.check_feature_branch(branch, True) is expected
|
||||
|
||||
def test_check_feature_branch_no_git_warns_but_passes(self, git_common, capsys):
|
||||
assert git_common.check_feature_branch("main", False) is True
|
||||
assert "skipped branch validation" in capsys.readouterr().err
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
from specify_cli.bundler.models.records import load_records, records_path
|
||||
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict
|
||||
@@ -97,6 +97,212 @@ def test_remove_unknown_bundle_errors(tmp_path: Path):
|
||||
remove_bundle(tmp_path, "ghost", FakeInstaller())
|
||||
|
||||
|
||||
def test_remove_converts_raw_installer_exception_to_bundler_error(tmp_path: Path):
|
||||
"""A raw exception from a primitive installer (e.g. an OSError from an
|
||||
unreadable workflow registry surfacing through _WorkflowKindManager's
|
||||
fail-closed construction) must not propagate uncaught out of
|
||||
remove_bundle: install_bundle already converts any non-BundlerError
|
||||
exception into a clean BundlerError, but remove_bundle had no such
|
||||
conversion, so the CLI's `bundle remove` (which only catches
|
||||
BundlerError) would let a raw exception through with no clean message
|
||||
and no removal side effects should occur either."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
def boom(project_root, component):
|
||||
raise OSError("workflow registry unreadable")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "is_installed", boom)
|
||||
with pytest.raises(BundlerError):
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
# No removal side effects: the bundle record must still be present.
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_partial_failure_message_reflects_partial_state(tmp_path: Path):
|
||||
"""A failure can occur after earlier components in the same bundle have
|
||||
already been removed from disk. The bundle record is left unchanged
|
||||
(save_records never runs on this path), so it still claims the bundle
|
||||
fully installed -- but the message must not claim "No changes were
|
||||
recorded" when components were, in fact, already removed."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
real_remove = installer.remove
|
||||
calls = {"n": 0}
|
||||
|
||||
def remove_then_fail(project_root, component):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return real_remove(project_root, component)
|
||||
raise OSError("disk full")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "remove", remove_then_fail)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no changes were recorded" not in message.lower()
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_bundler_error_from_installer_after_partial_removal_reports_partial_state(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""If the primitive installer itself raises BundlerError (not a raw/
|
||||
unexpected exception) after an earlier component in the same bundle was
|
||||
already removed, the surfaced message must still carry the same
|
||||
partial-removal detail as the generic-exception path -- a bare
|
||||
``except BundlerError: raise`` would re-raise the installer's original
|
||||
message verbatim with no mention that the project may now be partially
|
||||
uninstalled."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
real_remove = installer.remove
|
||||
calls = {"n": 0}
|
||||
|
||||
def remove_then_raise_bundler_error(project_root, component):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return real_remove(project_root, component)
|
||||
raise BundlerError("kind manager refused removal")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "remove", remove_then_raise_bundler_error)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no changes were recorded" not in message.lower()
|
||||
assert "kind manager refused removal" in message
|
||||
assert "partially uninstalled" in message.lower()
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_bundler_error_from_installer_with_zero_removed_reports_no_changes(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""When the installer raises BundlerError before anything was actually
|
||||
removed, the message should not misleadingly claim partial state."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
def boom(project_root, component):
|
||||
raise BundlerError("kind manager unavailable")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "is_installed", boom)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no components were removed" in message.lower()
|
||||
assert "no removal was attempted" in message.lower()
|
||||
assert "partially uninstalled" not in message.lower()
|
||||
assert "kind manager unavailable" in message
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_zero_completed_removals_still_cautions_about_partial_changes(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""`result.uninstalled` only records a component after its `remove()`
|
||||
call returns successfully. If the very first `remove()` call itself
|
||||
raises after already deleting some files, zero completed removals are
|
||||
recorded even though the project may already be partially uninstalled --
|
||||
the zero-count message must not claim "No components were removed" as
|
||||
an unqualified fact; it must caution that the failing component may
|
||||
have made partial changes before raising."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
def boom(project_root, component):
|
||||
# Simulates a remove() that deletes some files before raising --
|
||||
# from the caller's perspective this component was never recorded
|
||||
# as completed, but disk state may already be partially changed.
|
||||
raise OSError("disk full partway through removal")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "remove", boom)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no components were removed" in message.lower()
|
||||
assert "partial" in message.lower()
|
||||
assert "partially uninstalled" in message.lower()
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_record_save_failure_reports_partial_state(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
record_file = records_path(tmp_path)
|
||||
original_record = record_file.read_bytes()
|
||||
|
||||
def fail_dump(_data, handle, *_args, **_kwargs):
|
||||
handle.write('{"partial":')
|
||||
handle.flush()
|
||||
raise OSError("disk full")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(
|
||||
"specify_cli.bundler.lib.yamlio.json.dump",
|
||||
fail_dump,
|
||||
)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "disk full" in message
|
||||
assert "partially uninstalled" in message.lower()
|
||||
assert installer.installed == set()
|
||||
assert record_file.read_bytes() == original_record
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_record_save_failure_without_remove_attempt_is_not_partial(
|
||||
tmp_path: Path,
|
||||
):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
installer.installed.clear()
|
||||
|
||||
def fail_save(*_args, **_kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(
|
||||
"specify_cli.bundler.services.installer.save_records",
|
||||
fail_save,
|
||||
)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no removal was attempted" in message.lower()
|
||||
assert "partially uninstalled" not in message.lower()
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
@@ -128,7 +334,7 @@ def test_remove_counts_only_components_actually_removed(tmp_path: Path):
|
||||
|
||||
assert len(result.uninstalled) == 3
|
||||
assert (gone.kind, gone.id) not in installer.remove_calls
|
||||
assert gone in result.skipped
|
||||
assert gone not in result.skipped
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
@@ -115,6 +115,63 @@ class TestInitIntegrationFlag:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||
|
||||
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
|
||||
"""`init --here` on a non-empty directory with no confirmation input (empty
|
||||
stdin) must fail fast with guidance to use --force, instead of the bare
|
||||
'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides
|
||||
empty stdin, so typer.confirm raises Abort, which the command converts to the
|
||||
actionable error."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "nonempty-here"
|
||||
project.mkdir()
|
||||
(project / "existing.txt").write_text("keep me", encoding="utf-8")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "--force" in result.output
|
||||
# Aborted before scaffolding: the pre-existing file is untouched.
|
||||
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
def test_init_here_interactive_cancel_exits_zero(self, tmp_path, monkeypatch):
|
||||
"""An interactive Ctrl+C at the merge confirmation (typer.Abort on a TTY)
|
||||
is a normal cancellation — exit 0, "cancelled" — NOT the missing-input
|
||||
--force error, which is reserved for non-interactive EOF. Guards the
|
||||
regression where Abort was caught unconditionally and every cancel became
|
||||
an exit-1 --force error."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
import specify_cli.commands.init as init_mod
|
||||
|
||||
# Simulate an interactive terminal so the Abort is treated as a cancel.
|
||||
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
|
||||
|
||||
project = tmp_path / "cancel-here"
|
||||
project.mkdir()
|
||||
(project / "existing.txt").write_text("keep me", encoding="utf-8")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
# No input → typer.confirm raises Abort (stands in for Ctrl+C).
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "cancelled" in result.output.lower()
|
||||
assert "--force" not in result.output # not the missing-input error
|
||||
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
def test_integration_copilot_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
@@ -257,6 +314,18 @@ class TestInitIntegrationFlag:
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
|
||||
def test_shared_infra_installs_python_scripts_for_py(self, tmp_path):
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "python-scripts"
|
||||
project.mkdir()
|
||||
|
||||
_install_shared_infra(project, "py")
|
||||
|
||||
assert (
|
||||
project / ".specify" / "scripts" / "python" / "common.py"
|
||||
).exists()
|
||||
|
||||
def test_shared_infra_removes_stale_managed_script(self, tmp_path):
|
||||
"""A managed script the core no longer ships (e.g. the legacy
|
||||
update-agent-context.sh, superseded by the agent-context extension) is
|
||||
@@ -835,7 +904,8 @@ class TestInitIntegrationFlag:
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||
|
||||
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
|
||||
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
|
||||
"""E2E: confirming the merge with piped "y" (no --force) preserves
|
||||
existing shared infra files (unlike --force, which overwrites them)."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@ class MarkdownIntegrationTests:
|
||||
"spec-template.md", "tasks-template.md"]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/.constitution-template.json")
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
|
||||
@@ -399,6 +399,7 @@ class SkillsIntegrationTests:
|
||||
".specify/integration.json",
|
||||
f".specify/integrations/{self.KEY}.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
]
|
||||
# Script variant
|
||||
|
||||
@@ -517,6 +517,7 @@ class TomlIntegrationTests:
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/.constitution-template.json")
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
|
||||
@@ -201,6 +201,36 @@ class YamlIntegrationTests:
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
assert parsed["prompt"].rstrip("\n") == body
|
||||
|
||||
def test_yaml_prompt_with_control_characters_stays_valid(self):
|
||||
"""A body containing control characters must still produce parseable YAML.
|
||||
|
||||
YAML forbids C0 control characters (except tab and newline), DEL,
|
||||
C1 controls, lone surrogates and U+FFFE/U+FFFF in every scalar form,
|
||||
and YAML 1.1 treats NEL (U+0085), LS (U+2028) and PS (U+2029) as
|
||||
line breaks that corrupt a literal block scalar's structure. The
|
||||
renderer falls back to an escaped double-quoted scalar for such
|
||||
bodies."""
|
||||
for ch in (
|
||||
"\x08", "\x0c", "\x1b", "\x7f",
|
||||
"\x80", "\x84", "\x85", "\x86", "\x9f",
|
||||
"\u2028", "\u2029",
|
||||
"\ud800", "\udfff", "\ufffe", "\uffff",
|
||||
):
|
||||
body = f"before{ch}after\nsecond line"
|
||||
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
|
||||
parsed = yaml.safe_load(rendered)
|
||||
assert parsed["prompt"].rstrip("\n") == body, f"char {ch!r} round-trip"
|
||||
|
||||
def test_yaml_prompt_with_bare_carriage_return_stays_valid(self):
|
||||
"""A bare CR (not part of CRLF) must not break the generated YAML.
|
||||
|
||||
Inside a block scalar a lone \r acts as a line break, corrupting
|
||||
the document structure."""
|
||||
body = "line1\rstill line1\nline2"
|
||||
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
|
||||
parsed = yaml.safe_load(rendered)
|
||||
assert parsed["prompt"].rstrip("\n") == body
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
@@ -401,6 +431,7 @@ class YamlIntegrationTests:
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/.constitution-template.json")
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
|
||||
@@ -214,6 +214,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/.constitution-template.json")
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
|
||||
@@ -252,6 +252,7 @@ class TestCopilotIntegration:
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
@@ -313,6 +314,7 @@ class TestCopilotIntegration:
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
@@ -724,6 +726,7 @@ class TestCopilotSkillsMode:
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
# Bundled workflow
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
|
||||
@@ -286,6 +286,7 @@ class TestGenericIntegration:
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/scripts/bash/check-prerequisites.sh",
|
||||
".specify/scripts/bash/common.sh",
|
||||
@@ -342,6 +343,7 @@ class TestGenericIntegration:
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/scripts/powershell/check-prerequisites.ps1",
|
||||
".specify/scripts/powershell/common.ps1",
|
||||
|
||||
@@ -42,6 +42,9 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
|
||||
COMMANDS_SUBDIR = "prompts"
|
||||
REGISTRAR_DIR = ".kiro/prompts"
|
||||
|
||||
def test_declares_multi_install_safe(self):
|
||||
assert get_integration(self.KEY).multi_install_safe is True
|
||||
|
||||
def test_registrar_config(self):
|
||||
"""Override base assertion: kiro-cli uses a prose fallback for args
|
||||
because Kiro CLI file-based prompts do not natively substitute
|
||||
|
||||
@@ -2675,6 +2675,27 @@ class TestParseIntegrationOptionsEqualsForm:
|
||||
assert result_space["commands_dir"] == "./mydir"
|
||||
assert result_equals["commands_dir"] == "./mydir"
|
||||
|
||||
def test_unbalanced_quote_exits_cleanly(self, capsys):
|
||||
"""An unbalanced quote must exit(1) with a message, not a raw ValueError.
|
||||
|
||||
shlex.split() raises ValueError("No closing quotation") on an unbalanced
|
||||
quote; the parser must translate that into the same clean typer.Exit(1)
|
||||
UX as unknown-option / missing-value, rather than letting the traceback
|
||||
escape (issue #3457).
|
||||
"""
|
||||
import typer
|
||||
|
||||
from specify_cli.integrations._commands import _parse_integration_options
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
integration = get_integration("generic")
|
||||
assert integration is not None
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
_parse_integration_options(integration, '--commands-dir "foo')
|
||||
assert excinfo.value.exit_code == 1
|
||||
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestUninstallNoManifestClearsInitOptions:
|
||||
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
|
||||
|
||||
@@ -272,6 +272,20 @@ class TestMultiInstallSafeContracts:
|
||||
f"these files: {sorted(overlap)}"
|
||||
)
|
||||
|
||||
def test_kiro_cli_is_declared_multi_install_safe(self):
|
||||
"""kiro-cli confines itself to an isolated ``.kiro/`` root that no
|
||||
other integration touches, so it must be declared multi-install safe
|
||||
(issue #3471).
|
||||
|
||||
Before the fix, co-installing kiro-cli alongside another integration
|
||||
left ``specify integration status`` permanently in ERROR
|
||||
(``unsafe-multi-install``) with no way to acknowledge it. The
|
||||
parametrized isolation/manifest contracts above already exercise
|
||||
kiro-cli once the flag is set; this pins the declaration itself so a
|
||||
future edit cannot silently drop it and reintroduce the error.
|
||||
"""
|
||||
assert INTEGRATION_REGISTRY["kiro-cli"].multi_install_safe is True
|
||||
|
||||
|
||||
class TestCatalogParity:
|
||||
"""The discovery catalog must list every registered integration."""
|
||||
|
||||
@@ -911,6 +911,65 @@ class TestExtensionSkillRegistration:
|
||||
assert ".specify/scripts/bash/resolve-skill.sh" not in content
|
||||
assert ".specify/scripts/bash/ensure-skills.sh" not in content
|
||||
|
||||
def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, temp_dir):
|
||||
"""Auto-registered skills should resolve extension-relative subdir
|
||||
references (agents/, knowledge-base/) to their installed location,
|
||||
matching the rewrite already applied by register_commands() (#2101)."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
ext_dir = temp_dir / "path-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "path-ext",
|
||||
"name": "Path Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.path-ext.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Run command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.safe_dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "agents" / "control").mkdir(parents=True)
|
||||
(ext_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
||||
(ext_dir / "knowledge-base").mkdir()
|
||||
(ext_dir / "knowledge-base" / "agent-scores.yaml").write_text("scores: {}\n")
|
||||
(ext_dir / "templates").mkdir()
|
||||
(ext_dir / "templates" / "kill-report.md").write_text("# Kill Report\n")
|
||||
|
||||
(ext_dir / "commands" / "run.md").write_text(
|
||||
"---\n"
|
||||
"description: Run command\n"
|
||||
"---\n\n"
|
||||
"Read agents/control/commander.md and knowledge-base/agent-scores.yaml.\n"
|
||||
"Use templates/kill-report.md as the report template.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-path-ext-run" / "SKILL.md").read_text()
|
||||
assert ".specify/extensions/path-ext/agents/control/commander.md" in content
|
||||
assert ".specify/extensions/path-ext/knowledge-base/agent-scores.yaml" in content
|
||||
# extension's own templates/ dir must resolve under the extension,
|
||||
# not the project-level .specify/templates/
|
||||
assert ".specify/extensions/path-ext/templates/kill-report.md" in content
|
||||
assert "Read agents/control" not in content
|
||||
assert "and knowledge-base/" not in content
|
||||
|
||||
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
||||
"""Commands with missing source files should be skipped gracefully."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
@@ -2376,6 +2376,185 @@ Run {SCRIPT}
|
||||
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
|
||||
assert ".specify/scripts/bash/setup-plan.sh" not in content
|
||||
|
||||
@staticmethod
|
||||
def _make_subdir_extension(temp_dir, ext_id="echelon", aliases=None):
|
||||
"""Create an extension whose command body references bundled subdirs."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / ext_id
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "agents" / "control").mkdir(parents=True)
|
||||
(ext_dir / "knowledge-base").mkdir()
|
||||
(ext_dir / "templates").mkdir()
|
||||
(ext_dir / "specs" / "001-internal").mkdir(parents=True)
|
||||
|
||||
command = {
|
||||
"name": f"speckit.{ext_id}.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Run",
|
||||
}
|
||||
if aliases:
|
||||
command["aliases"] = aliases
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": ext_id,
|
||||
"name": "Echelon",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"commands": [command]},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "run.md").write_text(
|
||||
"---\ndescription: Run\n---\n\n"
|
||||
"Read agents/control/commander.md for instructions.\n"
|
||||
"Load knowledge-base/agent-scores.yaml for calibration.\n"
|
||||
"Use templates/kill-report.md as output format.\n"
|
||||
"Artifacts go to specs/001-internal/plan.md.\n"
|
||||
"See commands/run.md for the source.\n"
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
def test_codex_skill_registration_rewrites_extension_subdir_paths(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Extension-relative subdir refs must point at the installed location."""
|
||||
ext_dir = self._make_subdir_extension(temp_dir)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
content = (skills_dir / "speckit-echelon-run" / "SKILL.md").read_text()
|
||||
assert ".specify/extensions/echelon/agents/control/commander.md" in content
|
||||
assert ".specify/extensions/echelon/knowledge-base/agent-scores.yaml" in content
|
||||
assert ".specify/extensions/echelon/templates/kill-report.md" in content
|
||||
assert "Read agents/" not in content
|
||||
# specs/ refs point at the user's project artifacts, never the extension
|
||||
assert "to specs/001-internal/plan.md" in content
|
||||
assert ".specify/extensions/echelon/specs/" not in content
|
||||
# commands/ refs are slash-command sources, not runtime reads
|
||||
assert "See commands/run.md" in content
|
||||
|
||||
def test_skill_registration_rewrites_extension_subdir_paths_in_aliases(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Alias skills reuse the rewritten body."""
|
||||
ext_dir = self._make_subdir_extension(
|
||||
temp_dir, ext_id="ext-alias-paths", aliases=["speckit.ext-alias-paths.go"]
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
alias_content = (
|
||||
skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md"
|
||||
).read_text()
|
||||
assert (
|
||||
".specify/extensions/ext-alias-paths/agents/control/commander.md"
|
||||
in alias_content
|
||||
)
|
||||
assert "Read agents/" not in alias_content
|
||||
|
||||
def test_markdown_registration_rewrites_extension_subdir_paths(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Markdown-format agents get the same rewrite via the shared path."""
|
||||
ext_dir = self._make_subdir_extension(temp_dir, ext_id="ext-md-paths")
|
||||
|
||||
amp_dir = project_dir / ".agents" / "commands"
|
||||
amp_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("amp", manifest, ext_dir, project_dir)
|
||||
|
||||
content = (amp_dir / "speckit.ext-md-paths.run.md").read_text()
|
||||
assert ".specify/extensions/ext-md-paths/agents/control/commander.md" in content
|
||||
assert "Read agents/" not in content
|
||||
|
||||
def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir):
|
||||
"""Only directories present in the extension are rewritten."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
ext_dir = temp_dir / "ext-existing"
|
||||
(ext_dir / "agents").mkdir(parents=True)
|
||||
(ext_dir / ".hidden").mkdir()
|
||||
|
||||
text = (
|
||||
"Read agents/one.md then knowledge-base/two.md.\n"
|
||||
"Also ./agents/three.md but not /agents/abs.md.\n"
|
||||
"Keep .hidden/secret.md alone.\n"
|
||||
)
|
||||
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "ext-existing", ext_dir
|
||||
)
|
||||
|
||||
assert ".specify/extensions/ext-existing/agents/one.md" in rewritten
|
||||
assert "Also .specify/extensions/ext-existing/agents/three.md" in rewritten
|
||||
# absolute paths keep their meaning
|
||||
assert "not /agents/abs.md" in rewritten
|
||||
# knowledge-base/ does not exist in this extension: left untouched
|
||||
assert "then knowledge-base/two.md" in rewritten
|
||||
assert ".hidden/secret.md" in rewritten
|
||||
assert ".specify/extensions/ext-existing/.hidden/" not in rewritten
|
||||
|
||||
def test_rewrite_extension_paths_handles_regex_special_replacement_text(
|
||||
self, temp_dir
|
||||
):
|
||||
"""subdir/extension_id containing regex-replacement-special characters
|
||||
(e.g. backslash / group references) must not raise or be misinterpreted
|
||||
by re.sub's replacement template (#2101).
|
||||
|
||||
The subdir name uses brackets rather than a backslash: on Windows,
|
||||
"\\" is a path separator, so a subdir literally named "assets\\q"
|
||||
would create nested directories "assets/q" instead of a single
|
||||
directory, and iterdir() would then only discover "assets" - never
|
||||
exercising the intended replacement text. extension_id isn't used to
|
||||
create a directory, so it's free to contain a real backslash/"\\1"
|
||||
to verify the callable replacement treats it literally.
|
||||
"""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
ext_dir = temp_dir / "ext-backslash"
|
||||
weird_subdir = "assets[q]"
|
||||
(ext_dir / weird_subdir).mkdir(parents=True)
|
||||
# sanity-check the cross-platform assumption above
|
||||
assert [p.name for p in ext_dir.iterdir()] == [weird_subdir]
|
||||
|
||||
text = f"Read {weird_subdir}/file.md but not /{weird_subdir}/abs.md.\n"
|
||||
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "ext\\1", ext_dir
|
||||
)
|
||||
|
||||
assert f".specify/extensions/ext\\1/{weird_subdir}/file.md" in rewritten
|
||||
# absolute paths are still left untouched
|
||||
assert f"/{weird_subdir}/abs.md" in rewritten
|
||||
|
||||
def test_rewrite_extension_paths_missing_dir_returns_text(self, temp_dir):
|
||||
"""A missing extension directory leaves the text unchanged."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
text = "Read agents/one.md."
|
||||
assert (
|
||||
AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "gone", temp_dir / "does-not-exist"
|
||||
)
|
||||
== text
|
||||
)
|
||||
|
||||
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Copilot agent with .agent.md extension."""
|
||||
# Create .github/agents directory (Copilot project)
|
||||
@@ -7717,3 +7896,135 @@ class TestConfigManagerEnvPrefixCollision:
|
||||
cfg = cm._get_env_config()
|
||||
assert "" not in cfg
|
||||
assert cfg == {"a": {"b": "z"}}
|
||||
|
||||
|
||||
class TestConfigManagerCrossExtensionEnvLeak:
|
||||
"""Cross-extension env-var leak: a longer, co-installed sibling ID must
|
||||
own its own env vars instead of leaking them into a shorter-prefix sibling.
|
||||
|
||||
Before the fix, ``SPECKIT_GIT_HOOKS_URL`` (intended for a ``git-hooks``
|
||||
extension) also surfaced inside the ``git`` extension's config as
|
||||
``{'hooks': {'url': ...}}`` because ``SPECKIT_GIT_`` is a strict prefix of
|
||||
``SPECKIT_GIT_HOOKS_``.
|
||||
"""
|
||||
|
||||
def _install(self, project_root, ext_id):
|
||||
extensions_dir = project_root / ".specify" / "extensions"
|
||||
(extensions_dir / ext_id).mkdir(parents=True)
|
||||
# Register in the extension registry — the registry is the source of
|
||||
# truth for "installed" (a bare directory can be a config-only leftover
|
||||
# from ``ExtensionManager.remove(..., keep_config=True)``).
|
||||
ExtensionRegistry(extensions_dir).add(ext_id, {})
|
||||
|
||||
def test_sibling_owns_longer_prefix_env(self, tmp_path, monkeypatch):
|
||||
"""SPECKIT_GIT_HOOKS_URL belongs to git-hooks when co-installed with git."""
|
||||
self._install(tmp_path, "git")
|
||||
self._install(tmp_path, "git-hooks")
|
||||
monkeypatch.setenv("SPECKIT_GIT_URL", "for_git")
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
|
||||
|
||||
git_cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
gh_cfg = ConfigManager(tmp_path, "git-hooks")._get_env_config()
|
||||
|
||||
# 'git' must NOT see the git-hooks var — no cross-extension leak.
|
||||
assert git_cfg == {"url": "for_git"}
|
||||
# 'git-hooks' still receives its own var (unchanged behaviour).
|
||||
assert gh_cfg == {"url": "for_git_hooks"}
|
||||
|
||||
def test_no_sibling_installed_keeps_legacy_absorption(self, tmp_path, monkeypatch):
|
||||
"""Without a longer-prefix sibling installed, the legacy behaviour is
|
||||
preserved: ``SPECKIT_GIT_HOOKS_URL`` is absorbed as a nested key of
|
||||
the ``git`` extension. This keeps the fix strictly to the *collision*
|
||||
case and avoids surprising users who deliberately set a nested key
|
||||
via env with no sibling to disambiguate against.
|
||||
"""
|
||||
self._install(tmp_path, "git")
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
|
||||
|
||||
cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
assert cfg == {"hooks": {"url": "for_git_hooks"}}
|
||||
|
||||
def test_non_prefix_sibling_ignored(self, tmp_path, monkeypatch):
|
||||
"""A sibling whose ID does not extend our own is not a collision.
|
||||
|
||||
e.g. current='git' and sibling='not-git' — 'not-git' normalized to
|
||||
'NOT_GIT' does not start with 'GIT_', so its presence must not
|
||||
influence git's env-var interpretation.
|
||||
"""
|
||||
self._install(tmp_path, "git")
|
||||
self._install(tmp_path, "not-git")
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
|
||||
|
||||
cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
assert cfg == {"hooks": {"url": "for_git_hooks"}}
|
||||
|
||||
def test_boundary_prevents_false_positive(self, tmp_path, monkeypatch):
|
||||
"""Sibling ID 'hook' (not 'hooks') must NOT eat env keys starting
|
||||
with 'hooks'. The trailing-underscore boundary in the sibling prefix
|
||||
prevents this false positive.
|
||||
"""
|
||||
self._install(tmp_path, "git")
|
||||
self._install(tmp_path, "git-hook")
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_key_hooks")
|
||||
|
||||
# git-hook's prefix is 'HOOK_', which does not match 'HOOKS_URL',
|
||||
# so 'git' keeps the env var (single-installed semantics).
|
||||
cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
assert cfg == {"hooks": {"url": "for_git_key_hooks"}}
|
||||
|
||||
def test_missing_extensions_dir_does_not_crash(self, tmp_path, monkeypatch):
|
||||
"""A ConfigManager built against a project without ``.specify/extensions``
|
||||
(fresh project, ad-hoc test harness) must still evaluate env config
|
||||
rather than raising from the sibling scan.
|
||||
"""
|
||||
# Note: no _install call — extensions dir intentionally absent.
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
|
||||
|
||||
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
|
||||
assert cfg == {"url": "v"}
|
||||
|
||||
def test_config_only_leftover_not_treated_as_sibling(self, tmp_path, monkeypatch):
|
||||
"""A directory left behind by ``remove(..., keep_config=True)`` must
|
||||
NOT be treated as an installed sibling.
|
||||
|
||||
``ExtensionManager.remove(keep_config=True)`` preserves the extension
|
||||
directory (config files remain, dormant, for a possible reinstall) but
|
||||
removes the registry entry. The sibling scan is sourced from the
|
||||
registry, so a leftover ``git-hooks/`` directory without a registry
|
||||
entry must not silently discard ``SPECKIT_GIT_HOOKS_*`` from ``git``.
|
||||
"""
|
||||
self._install(tmp_path, "git")
|
||||
# Simulate ``remove('git-hooks', keep_config=True)``: dir present,
|
||||
# config file preserved, but no registry entry.
|
||||
gh_dir = tmp_path / ".specify" / "extensions" / "git-hooks"
|
||||
gh_dir.mkdir(parents=True)
|
||||
(gh_dir / "git-hooks-config.yml").write_text("url: leftover\n")
|
||||
# Sanity: git-hooks is NOT registered.
|
||||
registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
|
||||
assert "git-hooks" not in registry.keys()
|
||||
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git")
|
||||
|
||||
cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
# git absorbs the var (no registered sibling owns it).
|
||||
assert cfg == {"hooks": {"url": "for_git"}}
|
||||
|
||||
def test_non_utf8_registry_does_not_crash(self, tmp_path, monkeypatch):
|
||||
"""A registry file with invalid text encoding must NOT propagate
|
||||
``UnicodeDecodeError`` out of the sibling scan and abort every
|
||||
config read. ``ExtensionRegistry._load()`` catches ``JSONDecodeError``
|
||||
/ ``FileNotFoundError`` only, so ``_sibling_extension_ids`` must
|
||||
additionally swallow ``UnicodeError`` and degrade to the documented
|
||||
pre-fix behaviour.
|
||||
"""
|
||||
extensions_dir = tmp_path / ".specify" / "extensions"
|
||||
extensions_dir.mkdir(parents=True)
|
||||
# Write bytes that are not valid UTF-8 to the registry file.
|
||||
(extensions_dir / ExtensionRegistry.REGISTRY_FILE).write_bytes(
|
||||
b"\xff\xfe invalid utf-8 registry \xc3\x28"
|
||||
)
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
|
||||
|
||||
# Must not raise; must fall back to the "no siblings" path.
|
||||
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
|
||||
assert cfg == {"url": "v"}
|
||||
|
||||
@@ -152,6 +152,44 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_metadata_lookup_is_bounded_and_redirect_validated(self):
|
||||
"""Release metadata reads stay bounded and use the caller's policy."""
|
||||
captured = {}
|
||||
|
||||
class OversizedResponse:
|
||||
def read(self, amount=None):
|
||||
captured["read_amount"] = amount
|
||||
return b"x" * amount
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def redirect_validator(old_url, new_url):
|
||||
return None
|
||||
|
||||
def fake_open(
|
||||
url,
|
||||
timeout=None,
|
||||
extra_headers=None,
|
||||
redirect_validator=None,
|
||||
):
|
||||
captured["redirect_validator"] = redirect_validator
|
||||
return OversizedResponse()
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1/pack.zip",
|
||||
fake_open,
|
||||
redirect_validator=redirect_validator,
|
||||
max_metadata_bytes=8,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert captured["read_amount"] == 9
|
||||
assert captured["redirect_validator"] is redirect_validator
|
||||
|
||||
def test_tag_with_special_characters_is_url_encoded(self):
|
||||
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
|
||||
captured_urls = []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -112,8 +112,14 @@ Run a shell command and capture output:
|
||||
- id: run-tests
|
||||
type: shell
|
||||
run: "cd {{ inputs.project_dir }} && npm test"
|
||||
timeout: 1800 # Optional: max seconds before the command is killed (default 300)
|
||||
```
|
||||
|
||||
`timeout` is the maximum time in seconds the command may run before it is
|
||||
killed and the step fails; it must be a positive number and defaults to
|
||||
`300` (five minutes) when omitted. Raise it for long-running gates such as
|
||||
full builds, linter aggregators, or integration-test targets.
|
||||
|
||||
### Init Steps
|
||||
|
||||
Bootstrap a project the same way `specify init` does — scaffolding
|
||||
@@ -342,6 +348,7 @@ current run:
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `context.run_id` | The current workflow run id (the same value Spec Kit prints as `Run ID:` at the end of `workflow run`). Auto-generated runs are 8-character hex from `uuid4`; operator-supplied ids may be any alphanumeric string with hyphens or underscores. Empty string outside a run context. |
|
||||
| `context.workflow_dir` | The resolved absolute path to the directory containing the workflow source file. For file-loaded workflows this is the parent directory of the YAML file; for installed-by-ID workflows it is the absolute path to the installation directory (e.g. `<project>/.specify/workflows/<id>/`); for string-loaded workflows it is an empty string. On resume the original source directory is preserved from the first execution. |
|
||||
|
||||
```yaml
|
||||
# Stamp telemetry events with the run id for cross-system join.
|
||||
@@ -359,6 +366,11 @@ current run:
|
||||
command: speckit.specify
|
||||
input:
|
||||
args: "{{ context.run_id }}"
|
||||
|
||||
# Reference a sibling file shipped alongside the workflow definition.
|
||||
- id: apply-config
|
||||
type: shell
|
||||
run: 'cp "{{ context.workflow_dir }}/defaults.yml" ./config.yml'
|
||||
```
|
||||
|
||||
## Input Types
|
||||
@@ -439,6 +451,7 @@ specify workflow catalog remove <index>
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SPECKIT_WORKFLOW_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
|
||||
| `SPECKIT_WORKFLOW_DIR` | Set automatically for shell steps; contains the resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path (string-loaded workflows). |
|
||||
|
||||
## Configuration Files
|
||||
|
||||
|
||||
Reference in New Issue
Block a user