mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284e9a6a80 | ||
|
|
4fed84a08d | ||
|
|
459f483f57 | ||
|
|
fd101d531e | ||
|
|
a7f6fe8dd4 | ||
|
|
f065e27478 | ||
|
|
2fb18c73cb | ||
|
|
5409670c13 | ||
|
|
a4aa4f6701 | ||
|
|
7a99c4a230 | ||
|
|
fbc59d278e | ||
|
|
c1722a425e | ||
|
|
6688b447b7 | ||
|
|
fb076a38b8 | ||
|
|
1e84ee2713 | ||
|
|
353851e966 | ||
|
|
ad601e5d52 | ||
|
|
77ebd5fcea | ||
|
|
faeb956664 | ||
|
|
91839fba50 | ||
|
|
ab82571999 | ||
|
|
99a3b7ccab | ||
|
|
e742b8010a | ||
|
|
73093954e2 | ||
|
|
d83b8d1188 | ||
|
|
d7b6626218 | ||
|
|
2537be8144 | ||
|
|
6ab0c1dac1 | ||
|
|
d956ab722b | ||
|
|
e48f134c3b | ||
|
|
654793b659 |
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -76,6 +76,7 @@ body:
|
||||
- Gemini CLI
|
||||
- GitHub Copilot
|
||||
- Goose
|
||||
- Grok Build
|
||||
- Hermes Agent
|
||||
- IBM Bob
|
||||
- Junie
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -70,6 +70,7 @@ body:
|
||||
- Gemini CLI
|
||||
- GitHub Copilot
|
||||
- Goose
|
||||
- Grok Build
|
||||
- Hermes Agent
|
||||
- IBM Bob
|
||||
- Junie
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -53,9 +53,10 @@ docs/dev
|
||||
|
||||
# The following directories/file are intentionally ignored so that they are not accidentally
|
||||
# committed to the repository. They contain the scaffolding `specify init --integration copilot`
|
||||
# does and they are meant for dogfooding Spec Kit during its own feature development.
|
||||
# (or other agents) does and they are meant for dogfooding Spec Kit during its own feature development.
|
||||
.github/agents/
|
||||
.github/prompts/
|
||||
.github/copilot-instructions.md
|
||||
.grok/
|
||||
.specify/
|
||||
specs/
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -2,6 +2,67 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.17] - 2026-07-16
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(extensions): resolve __SPECKIT_COMMAND tokens in auto-registered skills (#3544)
|
||||
- fix(workflows): fail if/switch steps on non-list branch instead of crashing (#3515)
|
||||
- feat(integrations): add Grok Build skills-based integration (#3535)
|
||||
- fix(extensions/git): reject negative -Number in create-new-feature-branch.ps1 (#3538)
|
||||
- test: cover preset constitution seeding through init CLI (#3297)
|
||||
- fix(integration): preserve ai_skills on `use` for skills-mode Copilot (#3550) (#3551)
|
||||
- [extension] Add Figma Starter extension to community catalog (#3547)
|
||||
- [extension] Add Spec-Kit BDD extension to community catalog (#3548)
|
||||
- [extension] Update Quality Gates (Enforcement Layer) extension to v0.3.2 (#3542)
|
||||
- chore: release 0.12.16, begin 0.12.17.dev0 development (#3549)
|
||||
|
||||
## [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
|
||||
|
||||
@@ -51,6 +51,12 @@ Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md
|
||||
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,9 +51,10 @@ 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) |
|
||||
| Figma Starter | Turns a Figma section's screens into per-screen spec.md files, an app-level user-stories.md, and a build-order.md, then hands off to /speckit.specify | `integration` | Read+Write | [spec-kit-figma-starter](https://github.com/wavemaker/spec-kit-figma-starter) |
|
||||
| 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) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
@@ -84,12 +85,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,7 +101,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) |
|
||||
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit across agent hooks, git checks, and CI pipelines with one policy file and one verify entrypoint for identical results at every boundary. | `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) |
|
||||
@@ -132,6 +135,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| Spec-Kit BDD | ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
|
||||
@@ -11,7 +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. | 10 templates, 2 commands | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-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) |
|
||||
|
||||
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,9 +11,14 @@
|
||||
## 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:
|
||||
|
||||
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`):
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
|
||||
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
||||
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
|
||||
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||
@@ -249,7 +250,11 @@ Spec Kit tracks one default integration in `.specify/integration.json` with `def
|
||||
|
||||
### Which integrations are multi-install safe?
|
||||
|
||||
An integration is multi-install safe when it uses isolated agent directories, a dedicated context file that does not collide with another safe integration, stable command invocation settings, and a separate install manifest. Shared Spec Kit templates remain aligned to the single default integration.
|
||||
An integration is multi-install safe when it uses a static, unique agent root and command directory, stable command invocation settings, and a separate install manifest whose managed files do not overlap another safe integration. Registry tests enforce those path and manifest invariants. Shared Spec Kit templates remain aligned to the single default integration.
|
||||
|
||||
The Isolation column below lists paths Spec Kit manages for that integration (skills/commands roots and any integration-owned rule files). It is not a full inventory of every file an agent may read.
|
||||
|
||||
**Agent-context defaults are separate.** The optional agent-context extension maps each integration to a default context file in `extensions/agent-context/agent-context-defaults.json`. Those defaults are independent of multi-install safety: several agents may share a root file such as `AGENTS.md` when the extension is enabled. Multi-install safety does not require a unique context file per safe integration.
|
||||
|
||||
The currently declared multi-install safe integrations are:
|
||||
|
||||
@@ -263,6 +268,7 @@ The currently declared multi-install safe integrations are:
|
||||
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
|
||||
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
|
||||
| `gemini` | `.gemini/commands`, `GEMINI.md` |
|
||||
| `grok` | `.grok/skills` |
|
||||
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
|
||||
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
|
||||
| `qodercli` | `.qoder/commands`, `QODER.md` |
|
||||
@@ -272,7 +278,7 @@ The currently declared multi-install safe integrations are:
|
||||
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
|
||||
| `zcode` | `.zcode/skills`, `ZCODE.md` |
|
||||
|
||||
Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
|
||||
Integrations that share a command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
|
||||
|
||||
### What happens to my changes when I uninstall or switch?
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"gemini": "GEMINI.md",
|
||||
"generic": "AGENTS.md",
|
||||
"goose": "AGENTS.md",
|
||||
"grok": "AGENTS.md",
|
||||
"hermes": "AGENTS.md",
|
||||
"junie": ".junie/AGENTS.md",
|
||||
"kilocode": ".kilocode/rules/specify-rules.md",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-15T14:10:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -395,6 +395,66 @@
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-03-03T00:00:00Z"
|
||||
},
|
||||
"bdd": {
|
||||
"name": "Spec-Kit BDD",
|
||||
"id": "bdd",
|
||||
"description": "ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage.",
|
||||
"author": "RSginer",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/RSginer/spec-kit-bdd/archive/refs/tags/v1.0.2.zip",
|
||||
"repository": "https://github.com/RSginer/spec-kit-bdd",
|
||||
"homepage": "https://github.com/RSginer/spec-kit-bdd",
|
||||
"documentation": "https://github.com/RSginer/spec-kit-bdd/blob/main/docs/usage.md",
|
||||
"changelog": "https://github.com/RSginer/spec-kit-bdd/releases",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "pytest-bdd",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "behave",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "@cucumber/cucumber",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "cucumber",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "io.cucumber",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "SpecFlow",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"bdd",
|
||||
"gherkin",
|
||||
"atdd",
|
||||
"acceptance-testing",
|
||||
"tdd"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-15T00:00:00Z",
|
||||
"updated_at": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"blueprint": {
|
||||
"name": "Blueprint",
|
||||
"id": "blueprint",
|
||||
@@ -809,8 +869,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 +895,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 +1166,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 +1184,14 @@
|
||||
"name": "node",
|
||||
"version": ">=18.0.0",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "npx",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "specify",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1145,7 +1213,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",
|
||||
@@ -1287,6 +1355,43 @@
|
||||
"created_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z"
|
||||
},
|
||||
"figma-starter": {
|
||||
"name": "Figma Starter",
|
||||
"id": "figma-starter",
|
||||
"description": "Turns a Figma section's screens into per-screen spec.md files, an app-level user-stories.md, and a build-order.md, then hands off to /speckit.specify.",
|
||||
"author": "WaveMaker",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/wavemaker/spec-kit-figma-starter/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/wavemaker/spec-kit-figma-starter",
|
||||
"homepage": "https://github.com/wavemaker/spec-kit-figma-starter",
|
||||
"documentation": "https://github.com/wavemaker/spec-kit-figma-starter/blob/main/README.md",
|
||||
"changelog": "https://github.com/wavemaker/spec-kit-figma-starter/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "integration",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{ "name": "python3", "version": ">=3.8", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"figma",
|
||||
"design",
|
||||
"design-to-spec",
|
||||
"ui",
|
||||
"frontend"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-15T00:00:00Z",
|
||||
"updated_at": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"fix-findings": {
|
||||
"name": "Fix Findings",
|
||||
"id": "fix-findings",
|
||||
@@ -1430,13 +1535,14 @@
|
||||
"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).",
|
||||
"description": "Deterministic quality enforcement for Spec Kit across agent hooks, git checks, and CI pipelines with one policy file and one verify entrypoint for identical results at every boundary.",
|
||||
"author": "schwichtgit",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.1.0/gates-0.1.0.zip",
|
||||
"version": "0.3.2",
|
||||
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.3.2/gates-0.3.2.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",
|
||||
"changelog": "https://github.com/schwichtgit/spec-gates/releases",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
@@ -1449,7 +1555,7 @@
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
@@ -1462,8 +1568,8 @@
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
"commands": 8,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
@@ -1476,7 +1582,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-09T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
"updated_at": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration 1",
|
||||
@@ -2458,6 +2564,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",
|
||||
@@ -2627,6 +2775,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",
|
||||
|
||||
@@ -41,6 +41,16 @@ if ($Help) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# -Number is [long], so PowerShell binds "-5" as -5 rather than rejecting it
|
||||
# the way the bash/Python twins do (`^[0-9]+$`). A negative value would format
|
||||
# via '{0:000}' to e.g. "-005" and produce a branch name starting with "-",
|
||||
# which git refuses (refs cannot begin with a dash). Reject it here, before the
|
||||
# description check, matching the bash twin's parse-time validation order.
|
||||
if ($Number -lt 0) {
|
||||
Write-Error 'Error: --number must be a non-negative integer'
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
exit 1
|
||||
|
||||
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())
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-23T00:00:00Z",
|
||||
"updated_at": "2026-07-15T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"claude": {
|
||||
@@ -282,6 +282,15 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli"]
|
||||
},
|
||||
"grok": {
|
||||
"id": "grok",
|
||||
"name": "Grok Build",
|
||||
"version": "1.0.0",
|
||||
"description": "xAI Grok Build CLI skills-based integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills", "xai"]
|
||||
},
|
||||
"hermes": {
|
||||
"id": "hermes",
|
||||
"name": "Hermes Agent",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-13T00: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": {
|
||||
@@ -134,20 +134,21 @@
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"version": "0.1.1",
|
||||
"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.1.zip",
|
||||
"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/main/README.md",
|
||||
"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": 10,
|
||||
"commands": 2
|
||||
"templates": 12,
|
||||
"commands": 2,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"autonomous",
|
||||
@@ -157,7 +158,7 @@
|
||||
"retrospective"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
"updated_at": "2026-07-14T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.14.dev0"
|
||||
version = "0.12.17"
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"})
|
||||
|
||||
# Agents that always render /speckit-<name>, regardless of ai_skills.
|
||||
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "trae", "zed"})
|
||||
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"})
|
||||
|
||||
# Agents that render /speckit-<name> only when ai_skills is enabled.
|
||||
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
|
||||
|
||||
@@ -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:
|
||||
@@ -476,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:
|
||||
@@ -605,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
|
||||
@@ -689,6 +701,7 @@ def register(app: typer.Typer) -> None:
|
||||
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
||||
devin_skill_mode = selected_ai == "devin"
|
||||
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
|
||||
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
|
||||
cline_skill_mode = selected_ai == "cline"
|
||||
native_skill_mode = (
|
||||
codex_skill_mode
|
||||
@@ -701,6 +714,7 @@ def register(app: typer.Typer) -> None:
|
||||
or copilot_skill_mode
|
||||
or devin_skill_mode
|
||||
or zed_skill_mode
|
||||
or grok_skill_mode
|
||||
)
|
||||
|
||||
if codex_skill_mode:
|
||||
@@ -733,6 +747,11 @@ def register(app: typer.Typer) -> None:
|
||||
f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
if grok_skill_mode:
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
from .._invocation_style import (
|
||||
|
||||
@@ -1004,6 +1004,7 @@ class ExtensionManager:
|
||||
from .. import load_init_options
|
||||
from ..agents import CommandRegistrar
|
||||
from ..integrations import get_integration
|
||||
from ..integrations.base import IntegrationBase
|
||||
|
||||
written: List[str] = []
|
||||
opts = load_init_options(self.project_root)
|
||||
@@ -1015,6 +1016,30 @@ class ExtensionManager:
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
integration = get_integration(selected_ai)
|
||||
ai_skills_enabled = is_ai_skills_enabled(opts)
|
||||
|
||||
def _resolve_command_ref_tokens(body: str) -> str:
|
||||
"""Resolve explicit command-ref tokens with the active skill style."""
|
||||
|
||||
def _replacement(match: re.Match[str]) -> str:
|
||||
command_name = "speckit." + match.group(1).lower().replace("_", ".")
|
||||
if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
|
||||
return "$" + command_name.replace("speckit.", "speckit-").replace(
|
||||
".", "-"
|
||||
)
|
||||
if is_slash_skills_agent(selected_ai, ai_skills_enabled):
|
||||
return "/" + command_name.replace("speckit.", "speckit-").replace(
|
||||
".", "-"
|
||||
)
|
||||
if integration is not None:
|
||||
return integration.build_command_invocation(command_name)
|
||||
return IntegrationBase.resolve_command_refs(
|
||||
match.group(0), agent_config.get("invoke_separator", ".")
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body
|
||||
)
|
||||
|
||||
for cmd_info in manifest.commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
@@ -1086,6 +1111,7 @@ class ExtensionManager:
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
|
||||
)
|
||||
body = _resolve_command_ref_tokens(body)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
description = original_desc or f"Extension command: {cmd_name}"
|
||||
@@ -2737,6 +2763,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.
|
||||
|
||||
@@ -2756,15 +2812,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
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ def _register_builtins() -> None:
|
||||
from .gemini import GeminiIntegration
|
||||
from .generic import GenericIntegration
|
||||
from .goose import GooseIntegration
|
||||
from .grok import GrokIntegration
|
||||
from .hermes import HermesIntegration
|
||||
from .junie import JunieIntegration
|
||||
from .kilocode import KilocodeIntegration
|
||||
@@ -99,6 +100,7 @@ def _register_builtins() -> None:
|
||||
_register(GeminiIntegration())
|
||||
_register(GenericIntegration())
|
||||
_register(GooseIntegration())
|
||||
_register(GrokIntegration())
|
||||
_register(HermesIntegration())
|
||||
_register(JunieIntegration())
|
||||
_register(KilocodeIntegration())
|
||||
|
||||
@@ -260,6 +260,7 @@ def _update_init_options_for_integration(
|
||||
project_root: Path,
|
||||
integration: Any,
|
||||
script_type: str | None = None,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Update init-options.json to reflect *integration* as the active one.
|
||||
|
||||
@@ -278,7 +279,17 @@ def _update_init_options_for_integration(
|
||||
opts["speckit_version"] = _get_speckit_version()
|
||||
if script_type:
|
||||
opts["script"] = script_type
|
||||
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
|
||||
# Skills mode is either intrinsic (SkillsIntegration), set on the instance
|
||||
# during setup() (_skills_mode), or requested via parsed options (e.g.
|
||||
# Copilot's --skills, persisted as parsed_options["skills"]). The latter is
|
||||
# the only signal available on the `use` path, where no setup() runs and a
|
||||
# fresh integration instance has _skills_mode == False (issue #3550).
|
||||
skills_mode = (
|
||||
isinstance(integration, SkillsIntegration)
|
||||
or getattr(integration, "_skills_mode", False)
|
||||
or bool((parsed_options or {}).get("skills"))
|
||||
)
|
||||
if skills_mode:
|
||||
opts["ai_skills"] = True
|
||||
else:
|
||||
opts.pop("ai_skills", None)
|
||||
@@ -334,7 +345,9 @@ def _set_default_integration(
|
||||
) from exc
|
||||
|
||||
_write_integration_json(project_root, key, installed_keys, settings)
|
||||
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
|
||||
_update_init_options_for_integration(
|
||||
project_root, integration, script_type=resolved_script, parsed_options=parsed_options
|
||||
)
|
||||
|
||||
|
||||
def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
|
||||
|
||||
@@ -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
|
||||
|
||||
60
src/specify_cli/integrations/grok/__init__.py
Normal file
60
src/specify_cli/integrations/grok/__init__.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Grok Build integration — skills-based agent.
|
||||
|
||||
Grok Build discovers project skills from ``.grok/skills/speckit-<name>/SKILL.md``
|
||||
(and also scans ``.agents/skills/``). Spec Kit installs into the native
|
||||
``.grok/skills`` tree so skills take highest local priority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import SkillsIntegration
|
||||
|
||||
|
||||
class GrokIntegration(SkillsIntegration):
|
||||
"""Integration for xAI Grok Build CLI."""
|
||||
|
||||
key = "grok"
|
||||
config = {
|
||||
"name": "Grok Build",
|
||||
"folder": ".grok/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://docs.x.ai/build/overview",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".grok/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
"""Build CLI arguments for non-interactive ``grok`` execution.
|
||||
|
||||
Mandatory headless flag:
|
||||
|
||||
* ``--always-approve`` — auto-approve tool executions so workflow
|
||||
dispatch and ``dispatch_command()`` are not blocked at permission
|
||||
gates (same role as Cursor's ``--force`` / Copilot's ``--yolo``).
|
||||
"""
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [
|
||||
self._resolve_executable(),
|
||||
"-p",
|
||||
prompt,
|
||||
"--always-approve",
|
||||
]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
args.extend(["--output-format", "json"])
|
||||
return args
|
||||
@@ -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(
|
||||
@@ -1629,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,
|
||||
@@ -1710,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"
|
||||
@@ -1717,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):
|
||||
@@ -1763,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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -22,10 +22,33 @@ class IfThenStep(StepBase):
|
||||
result = evaluate_condition(condition, context)
|
||||
|
||||
if result:
|
||||
branch_name = "then"
|
||||
branch = config.get("then", [])
|
||||
else:
|
||||
branch_name = "else"
|
||||
branch = config.get("else", [])
|
||||
|
||||
# The engine does not auto-validate step config (see
|
||||
# ``WorkflowEngine.load_workflow``), and it feeds ``next_steps`` straight
|
||||
# into ``_execute_steps`` which iterates them as step mappings. A
|
||||
# non-list branch (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 branch; fail this
|
||||
# step loudly on an unvalidated run instead, mirroring the switch/fan-out
|
||||
# steps. A missing ``else`` defaults to ``[]`` and stays valid.
|
||||
if branch is None and branch_name == "else":
|
||||
branch = []
|
||||
elif not isinstance(branch, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output={"condition_result": result},
|
||||
error=(
|
||||
f"If step {config.get('id', '?')!r}: {branch_name!r} must be "
|
||||
f"a list of steps, got {type(branch).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
return StepResult(
|
||||
status=StepStatus.COMPLETED,
|
||||
output={"condition_result": result},
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
@@ -40,6 +41,13 @@ class ShellStep(StepBase):
|
||||
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
|
||||
# control commands; catalog-installed workflows should be reviewed
|
||||
@@ -51,6 +59,7 @@ class ShellStep(StepBase):
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
)
|
||||
output = {
|
||||
|
||||
@@ -42,6 +42,10 @@ class SwitchStep(StepBase):
|
||||
)
|
||||
for case_key, case_steps in cases.items():
|
||||
if str(case_key) == str_value:
|
||||
if not isinstance(case_steps, list):
|
||||
return self._non_list_branch_failure(
|
||||
config, f"case {str(case_key)!r}", case_steps, value
|
||||
)
|
||||
return StepResult(
|
||||
status=StepStatus.COMPLETED,
|
||||
output={"matched_case": str(case_key), "expression_value": value},
|
||||
@@ -50,12 +54,41 @@ class SwitchStep(StepBase):
|
||||
|
||||
# Default fallback
|
||||
default_steps = config.get("default", [])
|
||||
if default_steps is None:
|
||||
default_steps = []
|
||||
elif not isinstance(default_steps, list):
|
||||
return self._non_list_branch_failure(
|
||||
config, "'default'", default_steps, value
|
||||
)
|
||||
return StepResult(
|
||||
status=StepStatus.COMPLETED,
|
||||
output={"matched_case": "__default__", "expression_value": value},
|
||||
next_steps=default_steps,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _non_list_branch_failure(
|
||||
config: dict[str, Any], branch_label: str, branch: Any, value: Any
|
||||
) -> StepResult:
|
||||
"""Fail the step for a non-list branch instead of crashing the run.
|
||||
|
||||
``validate`` rejects a non-list case/default branch, but the engine does
|
||||
not auto-validate and feeds ``next_steps`` straight into
|
||||
``_execute_steps``, which iterates them as step mappings. A non-list
|
||||
branch would be iterated element-wise (a dict yields its keys, a str its
|
||||
characters) and crash the whole run with AttributeError on ``.get()``.
|
||||
Fail this step loudly on an unvalidated run instead, mirroring the
|
||||
non-mapping ``cases`` guard above.
|
||||
"""
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output={"matched_case": None, "expression_value": value},
|
||||
error=(
|
||||
f"Switch step {config.get('id', '?')!r}: {branch_label} must be "
|
||||
f"a list of steps, got {type(branch).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
def validate(self, config: dict[str, Any]) -> list[str]:
|
||||
errors = super().validate(config)
|
||||
if "expression" not in config:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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*."""
|
||||
|
||||
@@ -653,6 +653,19 @@ class TestCreateFeatureBash:
|
||||
assert data["BRANCH_NAME"] == "000-zero"
|
||||
assert data["FEATURE_NUM"] == "000"
|
||||
|
||||
def test_negative_number_rejected(self, tmp_path: Path):
|
||||
"""A negative --number is rejected. Pins the canonical behavior the
|
||||
PowerShell twin must mirror; a negative value would otherwise format to
|
||||
e.g. '-005' and produce a branch name starting with '-', which git
|
||||
refuses (refs cannot begin with a dash)."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--number", "-5", "--short-name", "neg", "Negative feature",
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "--number must be a non-negative integer" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestCreateFeaturePowerShell:
|
||||
@@ -974,6 +987,21 @@ class TestCreateFeaturePowerShell:
|
||||
assert data["BRANCH_NAME"] == "000-zero"
|
||||
assert data["FEATURE_NUM"] == "000"
|
||||
|
||||
def test_negative_number_rejected(self, tmp_path: Path):
|
||||
"""A negative -Number is rejected, matching the bash/Python twins'
|
||||
'--number must be a non-negative integer'. Regression guard: -Number is
|
||||
[long], so PowerShell binds '-5' as -5 rather than rejecting it the way
|
||||
the twins' `^[0-9]+$` check does; the value would then format via
|
||||
'{0:000}' to '-005' and yield a branch name starting with '-', which
|
||||
git refuses (refs cannot begin with a dash)."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-Number", "-5", "-ShortName", "neg", "Negative feature",
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "--number must be a non-negative integer" in result.stderr
|
||||
|
||||
|
||||
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -224,6 +224,66 @@ class TestInitIntegrationFlag:
|
||||
assert "Continuing without the optional preset" in normalized
|
||||
assert "Project ready" in normalized
|
||||
|
||||
def test_init_with_local_preset_seeds_manifest_constitution(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
monkeypatch.setattr(
|
||||
PresetManager,
|
||||
"_seed_constitution_from_preset",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
preset_dir = tmp_path / "constitution-preset"
|
||||
(preset_dir / "organization").mkdir(parents=True)
|
||||
preset_content = "# Ratified Organization Constitution\n"
|
||||
(preset_dir / "organization" / "ratified.md").write_text(preset_content)
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.safe_dump({
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "constitution-preset",
|
||||
"name": "Constitution Preset",
|
||||
"version": "1.0.0",
|
||||
"description": "Provides a ratified constitution",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "organization/ratified.md",
|
||||
"strategy": "replace",
|
||||
}]
|
||||
},
|
||||
})
|
||||
)
|
||||
project = tmp_path / "init-with-preset"
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
str(project),
|
||||
"--integration",
|
||||
"copilot",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
"--preset",
|
||||
str(preset_dir),
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (
|
||||
project / ".specify" / "memory" / "constitution.md"
|
||||
).read_text() == preset_content
|
||||
|
||||
def test_integration_claude_here_preserves_preexisting_commands(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
@@ -314,6 +374,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
187
tests/integrations/test_integration_grok.py
Normal file
187
tests/integrations/test_integration_grok.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Tests for GrokIntegration."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestGrokIntegration(SkillsIntegrationTests):
|
||||
KEY = "grok"
|
||||
FOLDER = ".grok/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".grok/skills"
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
"""Not applicable — Grok Build is always skills-based."""
|
||||
pytest.skip("Grok Build is always skills-based and does not expose a --skills option")
|
||||
|
||||
def test_options_do_not_include_skills_flag(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i is not None
|
||||
opts = i.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 0
|
||||
|
||||
def test_requires_cli_is_true(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i is not None
|
||||
assert i.config["requires_cli"] is True
|
||||
assert i.config["name"] == "Grok Build"
|
||||
assert i.multi_install_safe is True
|
||||
|
||||
|
||||
class TestGrokInitFlow:
|
||||
"""--integration grok creates expected files."""
|
||||
|
||||
def test_integration_grok_creates_skills(self, tmp_path):
|
||||
"""--integration grok should create skills in .grok/skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
str(target),
|
||||
"--integration",
|
||||
"grok",
|
||||
"--ignore-agent-tools",
|
||||
"--script",
|
||||
"sh",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --integration grok failed: {result.output}"
|
||||
assert (target / ".grok" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (target / ".grok" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
|
||||
"""Core skills must not carry a context-file placeholder."""
|
||||
target = tmp_path / "test-proj"
|
||||
target.mkdir()
|
||||
|
||||
integration = get_integration("grok")
|
||||
manifest = IntegrationManifest("grok", target)
|
||||
integration.setup(target, manifest, script_type="sh")
|
||||
|
||||
plan_skill = target / ".grok" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
content = plan_skill.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_build_exec_args_uses_headless_prompt_flag(self):
|
||||
integration = get_integration("grok")
|
||||
args = integration.build_exec_args("hello", model="grok-build", output_json=True)
|
||||
assert args is not None
|
||||
assert args[0] == "grok" or args[0].endswith("/grok")
|
||||
assert "-p" in args
|
||||
assert "hello" in args
|
||||
assert "--always-approve" in args
|
||||
assert "--model" in args
|
||||
assert "grok-build" in args
|
||||
assert "--output-format" in args
|
||||
assert "json" in args
|
||||
|
||||
|
||||
class TestGrokNextSteps:
|
||||
"""CLI output tests for Grok next-steps display."""
|
||||
|
||||
def test_init_next_steps_show_grok_skill_guidance(self, tmp_path):
|
||||
"""init --integration grok should guide users to .grok/skills and /speckit-*."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "grok-next-steps"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
str(target),
|
||||
"--integration",
|
||||
"grok",
|
||||
"--ignore-agent-tools",
|
||||
"--script",
|
||||
"sh",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --integration grok failed: {result.output}"
|
||||
assert "Start Grok Build" in result.output, (
|
||||
f"Expected Grok start guidance in next steps but got:\n{result.output}"
|
||||
)
|
||||
assert ".grok/skills" in result.output, (
|
||||
f"Expected .grok/skills install path in next steps but got:\n{result.output}"
|
||||
)
|
||||
assert "/speckit-plan" in result.output, (
|
||||
f"Expected /speckit-plan in next steps but got:\n{result.output}"
|
||||
)
|
||||
assert "/speckit.plan" not in result.output, (
|
||||
f"Should not show /speckit.plan for Grok skills mode:\n{result.output}"
|
||||
)
|
||||
|
||||
|
||||
class TestGrokInitOptions:
|
||||
"""Init-options persistence for always-skills Grok."""
|
||||
|
||||
def test_init_persists_ai_skills_for_grok(self, tmp_path, monkeypatch):
|
||||
"""specify init --integration grok must persist ai_skills: true,
|
||||
so HookExecutor renders slash-skill invocations without manual
|
||||
init-options manipulation.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.extensions import HookExecutor
|
||||
|
||||
project = tmp_path / "grok-init-test"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
"grok",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
opts_path = project / ".specify" / "init-options.json"
|
||||
assert opts_path.exists()
|
||||
opts = json.loads(opts_path.read_text(encoding="utf-8"))
|
||||
assert opts.get("ai") == "grok"
|
||||
assert opts.get("ai_skills") is True, (
|
||||
f"init must persist ai_skills=true for Grok, got: {opts.get('ai_skills')}"
|
||||
)
|
||||
|
||||
hook_executor = HookExecutor(project)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_plan",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.plan",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "Executing: `/speckit-plan`" in message, (
|
||||
"Hook rendering must produce /speckit-plan for Grok without hint injection"
|
||||
)
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
|
||||
@@ -1566,6 +1566,43 @@ class TestIntegrationUse:
|
||||
assert opts["integration"] == "codex"
|
||||
assert opts["ai"] == "codex"
|
||||
|
||||
def test_use_preserves_copilot_skills_mode(self, tmp_path):
|
||||
"""`use` on a skills-mode Copilot keeps ``ai_skills`` (issue #3550).
|
||||
|
||||
Re-selecting the same skills-mode Copilot must not drop ``ai_skills``
|
||||
from init-options.json nor regenerate extension commands in the legacy
|
||||
``.agent.md``/``.prompt.md`` layout.
|
||||
"""
|
||||
project = _init_project(tmp_path, "copilot", integration_options="--skills")
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts.get("ai_skills") is True, "precondition: init recorded skills mode"
|
||||
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
|
||||
# Simulate a fresh process: `use` in real life runs in its own process
|
||||
# where the registry's Copilot instance has _skills_mode == False (it is
|
||||
# only set during setup()). In-process test invocations otherwise reuse
|
||||
# the singleton left in skills mode by init, masking the bug (#3550).
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
get_integration("copilot")._skills_mode = False
|
||||
|
||||
result = _run_in_project(project, ["integration", "use", "copilot"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts.get("ai_skills") is True, "ai_skills must survive `use copilot`"
|
||||
|
||||
# No legacy command-layout files should be regenerated for the
|
||||
# skills-mode agent.
|
||||
assert not (project / ".github" / "agents" / "speckit.git.feature.agent.md").exists()
|
||||
assert not (project / ".github" / "prompts" / "speckit.git.feature.prompt.md").exists()
|
||||
assert (
|
||||
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
||||
).exists()
|
||||
|
||||
def test_use_requires_installed_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
|
||||
@@ -141,6 +141,8 @@ class TestSlashSkillsSets:
|
||||
# ALWAYS_SLASH_AGENTS — unconditional on ai_skills
|
||||
("devin", True, "/speckit-plan"),
|
||||
("devin", False, "/speckit-plan"),
|
||||
("grok", True, "/speckit-plan"),
|
||||
("grok", False, "/speckit-plan"),
|
||||
("trae", True, "/speckit-plan"),
|
||||
("trae", False, "/speckit-plan"),
|
||||
("zed", True, "/speckit-plan"),
|
||||
|
||||
@@ -25,6 +25,7 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"gemini",
|
||||
"copilot",
|
||||
"goose",
|
||||
"grok",
|
||||
"hermes",
|
||||
"bob",
|
||||
"junie",
|
||||
|
||||
@@ -970,6 +970,113 @@ class TestExtensionSkillRegistration:
|
||||
assert "Read agents/control" not in content
|
||||
assert "and knowledge-base/" not in content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ai", "expected_invocation"),
|
||||
[
|
||||
("claude", "/speckit-plan"),
|
||||
("copilot", "/speckit-plan"),
|
||||
("codex", "$speckit-plan"),
|
||||
("kimi", "/skill:speckit-plan"),
|
||||
("zcode", "$speckit-plan"),
|
||||
],
|
||||
)
|
||||
def test_skill_registration_resolves_command_ref_tokens(
|
||||
self, project_dir, temp_dir, ai, expected_invocation
|
||||
):
|
||||
"""Auto-registered skills should resolve explicit command ref tokens."""
|
||||
_create_init_options(project_dir, ai=ai, ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai=ai)
|
||||
|
||||
ext_dir = temp_dir / "command-ref-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "command-ref-ext",
|
||||
"name": "Command Ref Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.command-ref-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 / "commands" / "run.md").write_text(
|
||||
"---\n"
|
||||
"description: Run command\n"
|
||||
"---\n\n"
|
||||
"Use __SPECKIT_COMMAND_PLAN__ before proceeding.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-command-ref-ext-run" / "SKILL.md").read_text()
|
||||
assert "__SPECKIT_COMMAND_PLAN__" not in content
|
||||
assert expected_invocation in content
|
||||
|
||||
def test_skill_registration_does_not_rewrite_literal_speckit_text(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Auto-registered skills should leave literal speckit text untouched."""
|
||||
_create_init_options(project_dir, ai="codex", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="codex")
|
||||
|
||||
ext_dir = temp_dir / "literal-ref-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "literal-ref-ext",
|
||||
"name": "Literal Ref Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.literal-ref-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 / "commands" / "run.md").write_text(
|
||||
"---\n"
|
||||
"description: Run command\n"
|
||||
"---\n\n"
|
||||
"Literal slash form: /speckit.foo.bar\n"
|
||||
"Literal skill form: /speckit-plan\n"
|
||||
"Literal bare form: speckit.foo.bar\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text()
|
||||
assert "/speckit.foo.bar" in content
|
||||
assert "/speckit-plan" in content
|
||||
assert "speckit.foo.bar" in content
|
||||
assert "/speckit-foo-bar" not in content
|
||||
assert "$speckit-plan" 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
|
||||
|
||||
@@ -7896,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 = []
|
||||
|
||||
@@ -2777,6 +2777,40 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1
|
||||
return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version)
|
||||
|
||||
|
||||
def _make_convention_constitution_preset(temp_dir: Path) -> Path:
|
||||
"""Create a preset whose constitution is found by convention, not its manifest."""
|
||||
preset_dir = temp_dir / "convention-constitution"
|
||||
(preset_dir / "templates").mkdir(parents=True)
|
||||
(preset_dir / "templates" / "constitution-template.md").write_text(
|
||||
"# Convention Constitution\n"
|
||||
)
|
||||
(preset_dir / "templates" / "spec-template.md").write_text("# Spec\n")
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "convention-constitution",
|
||||
"name": "Convention Constitution",
|
||||
"version": "1.0.0",
|
||||
"description": "Convention-based constitution for testing",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return preset_dir
|
||||
|
||||
|
||||
class TestSelfTestPreset:
|
||||
"""Tests using the self-test preset that ships with the repo.
|
||||
|
||||
@@ -2884,6 +2918,24 @@ class TestSelfTestPreset:
|
||||
assert result is not None
|
||||
assert result["source"] == "core"
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.read_text() == "# Core constitution-template\n"
|
||||
|
||||
def test_self_test_removal_preserves_edited_constitution(self, project_dir):
|
||||
"""Removing a preset does not overwrite an edited generated constitution."""
|
||||
templates_dir = project_dir / ".specify" / "templates"
|
||||
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
edited = memory.read_text() + "\n## Authored amendment\n"
|
||||
memory.write_text(edited)
|
||||
|
||||
manager.remove("self-test")
|
||||
|
||||
assert memory.read_text() == edited
|
||||
|
||||
def test_self_test_not_in_catalog(self):
|
||||
"""Verify the self-test preset is NOT in the catalog (it's local-only)."""
|
||||
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
|
||||
@@ -2958,6 +3010,432 @@ class TestSelfTestPreset:
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert metadata["registered_commands"] == {}
|
||||
|
||||
def test_self_test_seeds_constitution_when_memory_absent(self, project_dir):
|
||||
"""Installing a preset seeds memory/constitution.md from its template."""
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.exists(), "constitution.md was not seeded from the preset"
|
||||
assert "preset:self-test" in memory.read_text(), (
|
||||
"constitution.md was not seeded from the self-test preset template"
|
||||
)
|
||||
|
||||
def test_self_test_reseeds_exact_core_constitution(self, project_dir):
|
||||
"""An unchanged core constitution is re-seeded from the preset template."""
|
||||
resolver = PresetResolver(project_dir)
|
||||
bundled_core = resolver._find_bundled_core(
|
||||
"constitution-template", "template", ".md"
|
||||
)
|
||||
assert bundled_core is not None
|
||||
core = bundled_core.read_bytes()
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory.write_bytes(core)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
content = memory.read_text()
|
||||
assert "preset:self-test" in content, "placeholder constitution was not re-seeded"
|
||||
assert "[PROJECT_NAME]" not in content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provenance_content",
|
||||
[
|
||||
'{"sha256": "does-not-match", "source": "old-preset"}\n',
|
||||
"{not valid json",
|
||||
],
|
||||
ids=["hash-mismatch", "malformed"],
|
||||
)
|
||||
def test_self_test_preserves_core_content_with_existing_invalid_provenance(
|
||||
self, project_dir, provenance_content
|
||||
):
|
||||
"""A present invalid sidecar disables legacy core-template migration."""
|
||||
resolver = PresetResolver(project_dir)
|
||||
bundled_core = resolver._find_bundled_core(
|
||||
"constitution-template", "template", ".md"
|
||||
)
|
||||
assert bundled_core is not None
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory.write_bytes(bundled_core.read_bytes())
|
||||
(memory.parent / ".constitution-template.json").write_text(
|
||||
provenance_content
|
||||
)
|
||||
original = memory.read_bytes()
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert memory.read_bytes() == original
|
||||
|
||||
def test_self_test_preserves_mutable_project_core_copy(self, project_dir):
|
||||
"""A project template copy does not establish generated provenance."""
|
||||
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
|
||||
project_template = (
|
||||
project_dir / ".specify" / "templates" / "constitution-template.md"
|
||||
)
|
||||
project_template.write_text(authored)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory.write_text(authored)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert memory.read_text() == authored
|
||||
assert not (memory.parent / ".constitution-template.json").exists()
|
||||
|
||||
def test_core_prefixed_preset_does_not_establish_generated_provenance(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""A preset ID beginning with core is not an immutable core source."""
|
||||
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory.write_text(authored)
|
||||
|
||||
preset_dir = temp_dir / "core-company"
|
||||
(preset_dir / "templates").mkdir(parents=True)
|
||||
(preset_dir / "templates" / "constitution-template.md").write_text(authored)
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "core-company",
|
||||
"name": "Core Company",
|
||||
"version": "1.0.0",
|
||||
"description": "Company constitution preset",
|
||||
"author": "Test Author",
|
||||
"repository": "https://github.com/test/core-company",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "templates/constitution-template.md",
|
||||
"description": "Company constitution",
|
||||
"replaces": "constitution-template",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
PresetManager(project_dir).install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
assert memory.read_text() == authored
|
||||
assert not (memory.parent / ".constitution-template.json").exists()
|
||||
|
||||
def test_self_test_preserves_authored_constitution_with_placeholder(
|
||||
self, project_dir
|
||||
):
|
||||
"""A placeholder mention does not establish generated provenance."""
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
authored = "# Acme Constitution\n\nGuidance for [PROJECT_NAME].\n"
|
||||
memory.write_text(authored)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert memory.read_text() == authored
|
||||
|
||||
def test_self_test_preserves_authored_constitution(self, project_dir):
|
||||
"""An authored (placeholder-free) constitution is never overwritten."""
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n"
|
||||
memory.write_text(authored)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert memory.read_text() == authored, "authored constitution was overwritten"
|
||||
|
||||
def test_self_test_override_resolves_constitution_template(self, project_dir):
|
||||
"""The preset override of constitution-template resolves to the preset file."""
|
||||
templates_dir = project_dir / ".specify" / "templates"
|
||||
(templates_dir / "constitution-template.md").write_text("# Core constitution\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("constitution-template", "template")
|
||||
assert result is not None
|
||||
assert "preset:self-test" in result.read_text()
|
||||
|
||||
def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir):
|
||||
"""Seeding memory composes wrap constitution-template layers."""
|
||||
templates_dir = project_dir / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
(templates_dir / "constitution-template.md").write_text(
|
||||
"# Core Constitution\n\n## Core Principle\n"
|
||||
)
|
||||
|
||||
preset_dir = temp_dir / "constitution-wrap"
|
||||
(preset_dir / "templates").mkdir(parents=True)
|
||||
(preset_dir / "templates" / "constitution-template.md").write_text(
|
||||
"# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n"
|
||||
)
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "constitution-wrap",
|
||||
"name": "Constitution Wrap",
|
||||
"version": "1.0.0",
|
||||
"description": "Wrap constitution template for testing",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "templates/constitution-template.md",
|
||||
"strategy": "wrap",
|
||||
"description": "Wrapped constitution template",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
content = memory.read_text()
|
||||
assert "{CORE_TEMPLATE}" not in content
|
||||
assert "# Wrapper Constitution" in content
|
||||
assert "## Core Principle" in content
|
||||
|
||||
def test_constitution_follows_priority_when_winning_preset_removed(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""An unchanged generated constitution follows priority and fallback layers."""
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
preset_dir = temp_dir / "higher-priority"
|
||||
(preset_dir / "templates").mkdir(parents=True)
|
||||
(preset_dir / "templates" / "constitution-template.md").write_text(
|
||||
"# Higher Priority Constitution\n"
|
||||
)
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "higher-priority",
|
||||
"name": "Higher Priority",
|
||||
"version": "1.0.0",
|
||||
"description": "Higher-priority constitution",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "templates/constitution-template.md",
|
||||
"strategy": "replace",
|
||||
"description": "Higher-priority constitution",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
manager.install_from_directory(preset_dir, "0.1.5", priority=1)
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.read_text() == "# Higher Priority Constitution\n"
|
||||
|
||||
manager.remove("higher-priority")
|
||||
|
||||
assert "preset:self-test" in memory.read_text()
|
||||
|
||||
def test_convention_constitution_removal_restores_remaining_layer(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Removing a convention layer rematerializes the remaining resolver layer."""
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(
|
||||
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
|
||||
)
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.read_text() == "# Convention Constitution\n"
|
||||
|
||||
manager.remove("convention-constitution")
|
||||
|
||||
assert "preset:self-test" in memory.read_text()
|
||||
|
||||
def test_convention_constitution_removal_preserves_edited_content(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Removing a convention layer does not overwrite edited generated content."""
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
templates_dir = project_dir / ".specify" / "templates"
|
||||
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
_make_convention_constitution_preset(temp_dir), "0.1.5"
|
||||
)
|
||||
ensure_constitution_from_template(project_dir)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
edited = memory.read_text() + "\n## Authored amendment\n"
|
||||
memory.write_text(edited)
|
||||
|
||||
manager.remove("convention-constitution")
|
||||
|
||||
assert memory.read_text() == edited
|
||||
|
||||
def test_custom_constitution_removal_recovers_with_invalid_manifest(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Provenance triggers fallback when a custom-path manifest is invalid."""
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
preset_dir = temp_dir / "custom-constitution"
|
||||
(preset_dir / "policy").mkdir(parents=True)
|
||||
(preset_dir / "policy" / "charter.md").write_text("# Custom Constitution\n")
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "custom-constitution",
|
||||
"name": "Custom Constitution",
|
||||
"version": "1.0.0",
|
||||
"description": "Custom-path constitution for testing",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "policy/charter.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
manager.install_from_directory(preset_dir, "0.1.5", priority=1)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.read_text() == "# Custom Constitution\n"
|
||||
|
||||
installed_manifest = (
|
||||
project_dir
|
||||
/ ".specify"
|
||||
/ "presets"
|
||||
/ "custom-constitution"
|
||||
/ "preset.yml"
|
||||
)
|
||||
installed_manifest.write_text("invalid: [")
|
||||
|
||||
manager.remove("custom-constitution")
|
||||
|
||||
assert "preset:self-test" in memory.read_text()
|
||||
|
||||
def test_constitution_seed_rejects_symlinked_memory_directory(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Preset installation cannot seed through a symlinked memory directory."""
|
||||
outside = temp_dir / "outside"
|
||||
outside.mkdir()
|
||||
try:
|
||||
(project_dir / ".specify" / "memory").symlink_to(
|
||||
outside, target_is_directory=True
|
||||
)
|
||||
except OSError:
|
||||
pytest.skip("symlinks are unavailable")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
with pytest.warns(UserWarning, match="symlinked"):
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert manager.registry.is_installed("self-test")
|
||||
assert not (outside / "constitution.md").exists()
|
||||
|
||||
def test_constitution_seed_rejects_dangling_destination_symlink(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Preset installation cannot seed through a dangling destination symlink."""
|
||||
memory = project_dir / ".specify" / "memory"
|
||||
memory.mkdir(parents=True)
|
||||
outside = temp_dir / "outside-constitution.md"
|
||||
try:
|
||||
(memory / "constitution.md").symlink_to(outside)
|
||||
except OSError:
|
||||
pytest.skip("symlinks are unavailable")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
with pytest.warns(UserWarning, match="symlinked"):
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert manager.registry.is_installed("self-test")
|
||||
assert not outside.exists()
|
||||
|
||||
def test_constitution_materialization_error_is_nonfatal(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""An invalid wrap warns without reporting an uninstalled preset."""
|
||||
preset_dir = temp_dir / "invalid-wrap"
|
||||
(preset_dir / "templates").mkdir(parents=True)
|
||||
(preset_dir / "templates" / "constitution-template.md").write_text(
|
||||
"# Missing core placeholder\n"
|
||||
)
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "invalid-wrap",
|
||||
"name": "Invalid Wrap",
|
||||
"version": "1.0.0",
|
||||
"description": "Invalid wrapping constitution",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "templates/constitution-template.md",
|
||||
"strategy": "wrap",
|
||||
"description": "Invalid wrap",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
with pytest.warns(UserWarning, match="Failed to seed constitution"):
|
||||
manifest = manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
assert manifest.id == "invalid-wrap"
|
||||
assert manager.registry.is_installed("invalid-wrap")
|
||||
|
||||
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
|
||||
"""Test that extension command overrides are skipped if the extension isn't installed."""
|
||||
claude_dir = project_dir / ".claude" / "skills"
|
||||
@@ -4308,6 +4786,31 @@ class TestPresetSetPriority:
|
||||
manager2 = PresetManager(project_dir)
|
||||
assert manager2.registry.get("test-pack")["priority"] == 5
|
||||
|
||||
def test_set_priority_reconciles_generated_constitution(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Changing priority rematerializes an unchanged generated constitution."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(
|
||||
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=20
|
||||
)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert "preset:self-test" in memory.read_text()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["preset", "set-priority", "convention-constitution", "1"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert memory.read_text() == "# Convention Constitution\n"
|
||||
|
||||
def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
|
||||
"""Test set-priority with same value shows already set message."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -4528,6 +5031,61 @@ class TestPresetEnableDisable:
|
||||
manager2 = PresetManager(project_dir)
|
||||
assert manager2.registry.get("test-pack")["enabled"] is True
|
||||
|
||||
def test_enable_disable_reconciles_generated_constitution(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Enable and disable rematerialize the winning constitution layer."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(
|
||||
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
|
||||
)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.read_text() == "# Convention Constitution\n"
|
||||
runner = CliRunner()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
disabled = runner.invoke(
|
||||
app, ["preset", "disable", "convention-constitution"]
|
||||
)
|
||||
|
||||
assert disabled.exit_code == 0, disabled.output
|
||||
assert "preset:self-test" in memory.read_text()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
enabled = runner.invoke(
|
||||
app, ["preset", "enable", "convention-constitution"]
|
||||
)
|
||||
|
||||
assert enabled.exit_code == 0, enabled.output
|
||||
assert memory.read_text() == "# Convention Constitution\n"
|
||||
|
||||
def test_stack_changes_do_not_create_missing_constitution(
|
||||
self, project_dir, pack_dir
|
||||
):
|
||||
"""Stack changes for non-providers do not seed a missing constitution."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5")
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
runner = CliRunner()
|
||||
|
||||
for args in (
|
||||
["preset", "set-priority", "test-pack", "5"],
|
||||
["preset", "disable", "test-pack"],
|
||||
["preset", "enable", "test-pack"],
|
||||
):
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, args)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not memory.exists()
|
||||
|
||||
def test_disable_already_disabled(self, project_dir, pack_dir):
|
||||
"""Test disable on already disabled preset shows warning."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -5016,26 +5574,13 @@ class TestPresetAddFromUrlResolution:
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
return io.BytesIO(json.dumps({
|
||||
"assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(zip_bytes)
|
||||
return io.BytesIO(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
@@ -5074,22 +5619,9 @@ class TestPresetAddFromUrlResolution:
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
return FakeResponse(zip_bytes)
|
||||
return io.BytesIO(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
@@ -5131,26 +5663,13 @@ class TestPresetAddFromUrlResolution:
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
return io.BytesIO(json.dumps({
|
||||
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(zip_bytes)
|
||||
return io.BytesIO(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
@@ -6717,3 +7236,130 @@ def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monke
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]
|
||||
|
||||
|
||||
# ===== ensure_constitution_from_template resolver-awareness =====
|
||||
|
||||
|
||||
class TestEnsureConstitutionResolverAware:
|
||||
"""`ensure_constitution_from_template` must resolve through PresetResolver.
|
||||
|
||||
The constitution is the only template materialized to a live file rather
|
||||
than resolved on demand. These tests pin the regression from issue #3272:
|
||||
a preset-provided ``constitution-template`` must seed memory, while the
|
||||
core template is used when no preset overrides it.
|
||||
"""
|
||||
|
||||
def _core_constitution(self, project_dir):
|
||||
templates_dir = project_dir / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
(templates_dir / "constitution-template.md").write_text(
|
||||
"# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n"
|
||||
)
|
||||
|
||||
def _wrap_constitution_preset(self, temp_dir):
|
||||
preset_dir = temp_dir / "ensure-wrap-preset"
|
||||
(preset_dir / "templates").mkdir(parents=True)
|
||||
(preset_dir / "templates" / "constitution-template.md").write_text(
|
||||
"# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n"
|
||||
)
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ensure-wrap",
|
||||
"name": "Ensure Wrap",
|
||||
"version": "1.0.0",
|
||||
"description": "Wrap strategy for ensure() coverage",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "templates/constitution-template.md",
|
||||
"strategy": "wrap",
|
||||
"description": "Wrapped constitution",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return preset_dir
|
||||
|
||||
def test_seeds_from_core_when_no_preset(self, project_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.exists()
|
||||
assert "[PROJECT_NAME]" in memory.read_text()
|
||||
assert (memory.parent / ".constitution-template.json").exists()
|
||||
|
||||
def test_seeds_from_preset_when_installed(self, project_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
# Remove the memory file seeded during install to test ensure() in
|
||||
# isolation; it must re-seed from the preset, not the core template.
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.unlink()
|
||||
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
assert memory.exists()
|
||||
content = memory.read_text()
|
||||
assert "preset:self-test" in content
|
||||
assert "[PROJECT_NAME]" not in content
|
||||
|
||||
def test_preserves_existing_memory(self, project_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
authored = "# Acme Constitution\nAuthored.\n"
|
||||
memory.write_text(authored)
|
||||
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
assert memory.read_text() == authored
|
||||
|
||||
def test_preserves_edited_generated_memory(self, project_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
ensure_constitution_from_template(project_dir)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
authored = memory.read_text() + "\nAuthored amendment.\n"
|
||||
memory.write_text(authored)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert memory.read_text() == authored
|
||||
|
||||
def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5")
|
||||
|
||||
# Ensure we validate ensure() behavior directly.
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.unlink()
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
content = memory.read_text()
|
||||
assert "{CORE_TEMPLATE}" not in content
|
||||
assert "# Ensure Wrapper" in content
|
||||
assert "[PROJECT_NAME]" in content
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -348,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.
|
||||
@@ -365,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
|
||||
@@ -445,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