mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e802a7dd52 |
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, 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
|
||||
**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
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -76,7 +76,6 @@ 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,7 +70,6 @@ body:
|
||||
- Gemini CLI
|
||||
- GitHub Copilot
|
||||
- Goose
|
||||
- Grok Build
|
||||
- Hermes Agent
|
||||
- IBM Bob
|
||||
- Junie
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -53,10 +53,9 @@ 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`
|
||||
# (or other agents) does and they are meant for dogfooding Spec Kit during its own feature development.
|
||||
# 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/
|
||||
|
||||
91
CHANGELOG.md
91
CHANGELOG.md
@@ -2,97 +2,6 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
- fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
|
||||
- Cleanup agent-file-template.md (#2579)
|
||||
- fix: mark Kiro integration as multi-install safe (#3472)
|
||||
- fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
|
||||
- fix(templates): point constitution sync checklist at installed command files (#3418)
|
||||
- feat(workflows): make shell step timeout configurable (#3327) (#3328)
|
||||
- docs: clarify that release tags keep the leading v prefix (#3463)
|
||||
- fix(workflows): don't crash on membership test against a non-iterable (#3448)
|
||||
- fix(workflows): if-step validate accepts falsy non-list else (#3264)
|
||||
- chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
|
||||
|
||||
## [0.12.12] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(extensions): set-priority repairs corrupted boolean priority (#3268)
|
||||
- fix(presets): set-priority repairs corrupted boolean priority (#3269)
|
||||
- fix(workflows): engine loop cap ignores bool max_iterations (#3270)
|
||||
- docs(bundles): document --integration on 'bundle update' (#3271)
|
||||
- fix(workflows): harden catalog.py against mis-shaped registry & non-string fields (#3375)
|
||||
- Add Verify Review Ship extension to community catalog (#3450)
|
||||
- fix(bundle): reject file:// / local download_url — catalog URLs are HTTPS-only (#3344)
|
||||
- fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
|
||||
- docs: document copilot skills mode (--skills) and markdown deprecation (#3313)
|
||||
- chore: release 0.12.11, begin 0.12.12.dev0 development (#3460)
|
||||
|
||||
## [0.12.11] - 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -45,18 +45,12 @@ Spec-Driven Development **flips the script** on traditional software development
|
||||
|
||||
### 1. Install Specify CLI
|
||||
|
||||
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
|
||||
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
Prefer installing from PyPI? The `specify-cli` package is also published there:
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli
|
||||
```
|
||||
|
||||
See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting.
|
||||
|
||||
### 2. Initialize a project
|
||||
|
||||
@@ -51,10 +51,9 @@ 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 | 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -85,14 +84,12 @@ 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) |
|
||||
@@ -101,7 +98,6 @@ 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 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) |
|
||||
@@ -123,7 +119,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
|
||||
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
|
||||
| Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) |
|
||||
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
|
||||
@@ -135,7 +130,6 @@ 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) |
|
||||
@@ -154,7 +148,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
|
||||
@@ -11,7 +11,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 12 templates, 2 commands, 2 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
@@ -29,7 +28,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
| Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) |
|
||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ If you want to try Spec Kit without installing it permanently, use `uvx` to run
|
||||
# Create a new project (latest from main)
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
||||
|
||||
# Or target a specific release (replace vX.Y.Z with a tag from Releases;
|
||||
# keep the leading v, e.g. v0.12.11 not 0.12.11)
|
||||
# Or target a specific release (replace vX.Y.Z with a tag from Releases)
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||
|
||||
# Initialize in the current directory
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||
|
||||
```bash
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the
|
||||
# latest tag, keeping the leading v, e.g. v0.12.11 not 0.12.11)
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
|
||||
# Or install latest from main (may include unreleased changes)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# 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,16 +11,11 @@
|
||||
## Installation
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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.
|
||||
> 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 supports two install routes:
|
||||
### Persistent Installation (Recommended)
|
||||
|
||||
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`):
|
||||
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
|
||||
> [!NOTE]
|
||||
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).
|
||||
@@ -35,30 +30,12 @@ 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)
|
||||
|
||||
@@ -104,13 +81,13 @@ specify init <project_name> --integration claude --ignore-agent-tools
|
||||
|
||||
## Verification
|
||||
|
||||
After installation, run the following command as a local version/runtime check:
|
||||
After installation, run the following command to confirm the correct version is installed:
|
||||
|
||||
```bash
|
||||
specify version
|
||||
```
|
||||
|
||||
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.
|
||||
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
|
||||
|
||||
**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).
|
||||
|
||||
|
||||
@@ -51,11 +51,10 @@ If the current directory is not yet a Spec Kit project, `install` initializes on
|
||||
specify bundle update [<bundle_id>]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--all` | Update every installed bundle |
|
||||
| `--integration` | Override the integration used when refreshing components; applied only when the project's active integration can't be determined |
|
||||
| `--offline` | Do not access the network |
|
||||
| Option | Description |
|
||||
| ------------ | ------------------------------------ |
|
||||
| `--all` | Update every installed bundle |
|
||||
| `--offline` | Do not access the network |
|
||||
|
||||
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.
|
||||
|
||||
|
||||
@@ -18,9 +18,8 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
|
||||
| [Forge](https://forgecode.dev/) | `forge` | |
|
||||
| [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. |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
|
||||
| [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` | |
|
||||
@@ -220,7 +219,6 @@ Some integrations accept additional options via `--integration-options`:
|
||||
| ----------- | ------------------- | -------------------------------------------------------------- |
|
||||
| `generic` | `--commands-dir` | Required. Directory for command files |
|
||||
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
|
||||
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
|
||||
|
||||
Example:
|
||||
|
||||
@@ -250,11 +248,7 @@ 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 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.
|
||||
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.
|
||||
|
||||
The currently declared multi-install safe integrations are:
|
||||
|
||||
@@ -268,7 +262,6 @@ 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` |
|
||||
@@ -278,7 +271,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 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 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`.
|
||||
|
||||
### What happens to my changes when I uninstall or switch?
|
||||
|
||||
|
||||
@@ -86,30 +86,8 @@ 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
|
||||
@@ -124,10 +102,9 @@ Removes an installed workflow from the project.
|
||||
specify workflow search [query]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------- | ----------------- |
|
||||
| `--tag` | Filter by tag |
|
||||
| `--author` | Filter by author |
|
||||
| Option | Description |
|
||||
| ------- | --------------- |
|
||||
| `--tag` | Filter by tag |
|
||||
|
||||
Searches all active catalogs for workflows matching the query.
|
||||
|
||||
@@ -305,8 +282,6 @@ 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`.
|
||||
|
||||
@@ -318,14 +293,6 @@ 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,8 +13,6 @@
|
||||
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,7 +17,6 @@
|
||||
"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-15T14:10:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -395,66 +395,6 @@
|
||||
"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",
|
||||
@@ -869,8 +809,8 @@
|
||||
"id": "coding-standards-drift-control",
|
||||
"description": "Generate coding-standards drift reports and remediation tasks for active Spec Kit features",
|
||||
"author": "Igor Benicio de Mesquita",
|
||||
"version": "0.4.0",
|
||||
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.4.0.zip",
|
||||
"version": "0.3.1",
|
||||
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.3.1.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",
|
||||
@@ -895,7 +835,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-11T00:00:00Z",
|
||||
"updated_at": "2026-07-15T00:00:00Z"
|
||||
"updated_at": "2026-06-11T00:00:00Z"
|
||||
},
|
||||
"companion": {
|
||||
"name": "SpecKit Companion",
|
||||
@@ -1166,10 +1106,10 @@
|
||||
"docguard": {
|
||||
"name": "DocGuard — CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"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.",
|
||||
"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.",
|
||||
"author": "raccioly",
|
||||
"version": "0.32.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.32.0/spec-kit-docguard-v0.32.0.zip",
|
||||
"version": "0.30.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.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",
|
||||
@@ -1184,14 +1124,6 @@
|
||||
"name": "node",
|
||||
"version": ">=18.0.0",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "npx",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "specify",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1213,7 +1145,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
@@ -1355,43 +1287,6 @@
|
||||
"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",
|
||||
@@ -1532,58 +1427,6 @@
|
||||
"created_at": "2026-05-06T00:00:00Z",
|
||||
"updated_at": "2026-05-06T00:00:00Z"
|
||||
},
|
||||
"gates": {
|
||||
"name": "Quality Gates (Enforcement Layer)",
|
||||
"id": "gates",
|
||||
"description": "Deterministic quality enforcement for Spec Kit 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.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",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "jq",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "shellcheck",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 8,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"enforcement",
|
||||
"hooks",
|
||||
"ci",
|
||||
"governance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-09T00:00:00Z",
|
||||
"updated_at": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration 1",
|
||||
"id": "github-issues",
|
||||
@@ -2374,42 +2217,6 @@
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"memory": {
|
||||
"name": "Spec Kit Memory",
|
||||
"id": "memory",
|
||||
"description": "Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows.",
|
||||
"author": "Andrey Zaytsev",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/zaytsevand/spec-kit-memory/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/zaytsevand/spec-kit-memory",
|
||||
"homepage": "https://github.com/zaytsevand/spec-kit-memory",
|
||||
"documentation": "https://github.com/zaytsevand/spec-kit-memory/blob/main/README.md",
|
||||
"changelog": "",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{ "name": "memsearch", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
"recall",
|
||||
"research",
|
||||
"memsearch"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
},
|
||||
"memory-loader": {
|
||||
"name": "Memory Loader",
|
||||
"id": "memory-loader",
|
||||
@@ -2564,48 +2371,6 @@
|
||||
"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",
|
||||
@@ -2775,46 +2540,6 @@
|
||||
"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",
|
||||
@@ -4631,40 +4356,6 @@
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"verify-review-ship": {
|
||||
"name": "Verify Review Ship",
|
||||
"id": "verify-review-ship",
|
||||
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
|
||||
"author": "Carlos Eduardo Gevaerd Araujo",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
|
||||
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-only",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"review",
|
||||
"shipping",
|
||||
"workflow",
|
||||
"testing"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
"id": "verify-tasks",
|
||||
|
||||
@@ -41,16 +41,6 @@ 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
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
#!/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:]))
|
||||
@@ -1,634 +0,0 @@
|
||||
#!/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:]))
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/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-07-15T00:00:00Z",
|
||||
"updated_at": "2026-06-23T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"claude": {
|
||||
@@ -282,15 +282,6 @@
|
||||
"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",
|
||||
|
||||
@@ -158,7 +158,8 @@ presets/
|
||||
├── plan-template.md
|
||||
├── tasks-template.md
|
||||
├── checklist-template.md
|
||||
└── constitution-template.md
|
||||
├── constitution-template.md
|
||||
└── agent-file-template.md
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-14T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -131,35 +131,6 @@
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"version": "0.1.4",
|
||||
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.4.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.1.4/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 12,
|
||||
"commands": 2,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"autonomous",
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"retrospective"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-14T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
"id": "canon-core",
|
||||
@@ -647,34 +618,6 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"test-first-governance": {
|
||||
"name": "Test-First Governance",
|
||||
"id": "test-first-governance",
|
||||
"version": "1.3.0",
|
||||
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
|
||||
"author": "Zoltán Katona, PhD",
|
||||
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
|
||||
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"documentation": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.11"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"commands": 8
|
||||
},
|
||||
"tags": [
|
||||
"tdd",
|
||||
"bdd",
|
||||
"atdd",
|
||||
"quality-gates",
|
||||
"traceability"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"toc-navigation": {
|
||||
"name": "Table of Contents Navigation",
|
||||
"id": "toc-navigation",
|
||||
|
||||
@@ -44,6 +44,12 @@ provides:
|
||||
description: "Self-test constitution template"
|
||||
replaces: "constitution-template"
|
||||
|
||||
- type: "template"
|
||||
name: "agent-file-template"
|
||||
file: "templates/agent-file-template.md"
|
||||
description: "Self-test agent file template"
|
||||
replaces: "agent-file-template"
|
||||
|
||||
- type: "command"
|
||||
name: "speckit.specify"
|
||||
file: "commands/speckit.specify.md"
|
||||
|
||||
9
presets/self-test/templates/agent-file-template.md
Normal file
9
presets/self-test/templates/agent-file-template.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Agent File (Self-Test Preset)
|
||||
|
||||
<!-- preset:self-test -->
|
||||
|
||||
> This template is provided by the self-test preset.
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
Follow these guidelines when working on this project.
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.17"
|
||||
version = "0.12.11"
|
||||
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,7 +24,6 @@ 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:
|
||||
@@ -69,8 +68,6 @@ 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.
|
||||
|
||||
@@ -94,8 +91,6 @@ 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
|
||||
@@ -154,33 +149,13 @@ def resolve_github_release_asset_api_url(
|
||||
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
|
||||
|
||||
try:
|
||||
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,
|
||||
):
|
||||
with open_url_fn(release_url, timeout=timeout) as response:
|
||||
release_data = json.loads(response.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
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")
|
||||
):
|
||||
for asset in release_data.get("assets", []):
|
||||
if 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) + "\n",
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
|
||||
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", "grok", "trae", "zed"})
|
||||
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "trae", "zed"})
|
||||
|
||||
# Agents that render /speckit-<name> only when ai_skills is enabled.
|
||||
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
|
||||
|
||||
@@ -213,52 +213,6 @@ class CommandRegistrar:
|
||||
".specify.specify/", ".specify/"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def rewrite_extension_paths(
|
||||
text: str, extension_id: str, extension_dir: Path
|
||||
) -> str:
|
||||
"""Rewrite extension-relative paths to their installed locations.
|
||||
|
||||
Extension command bodies reference bundled files relative to the
|
||||
extension root (e.g. ``agents/control/commander.md``). After install
|
||||
those files live under ``.specify/extensions/<id>/``, so bare
|
||||
references would resolve against the workspace root and never be
|
||||
found (#2101).
|
||||
|
||||
Only directories that actually exist inside *extension_dir* are
|
||||
rewritten, keeping the behaviour conservative and avoiding false
|
||||
positives on prose. ``commands`` (slash-command sources), ``specs``
|
||||
(user project artifacts) and dot-directories are never rewritten.
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
|
||||
skip = {"commands", ".git", "specs"}
|
||||
try:
|
||||
subdirs = [
|
||||
entry.name
|
||||
for entry in extension_dir.iterdir()
|
||||
if entry.is_dir()
|
||||
and entry.name not in skip
|
||||
and not entry.name.startswith(".")
|
||||
]
|
||||
except OSError:
|
||||
return text
|
||||
|
||||
for subdir in subdirs:
|
||||
# Only rewrite relative references (subdir/... or ./subdir/...);
|
||||
# absolute paths like /subdir/... keep their meaning. Use a
|
||||
# callable replacement: subdir/extension_id come from the
|
||||
# filesystem and could contain backslashes or "\1"-like
|
||||
# sequences, which would corrupt a string replacement template.
|
||||
replacement = f".specify/extensions/{extension_id}/{subdir}/"
|
||||
text = re.sub(
|
||||
r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/",
|
||||
lambda m: m.group(1) + replacement,
|
||||
text,
|
||||
)
|
||||
return text
|
||||
|
||||
def render_markdown_command(
|
||||
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
|
||||
) -> str:
|
||||
@@ -685,9 +639,6 @@ class CommandRegistrar:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
frontmatter.pop("strategy", None)
|
||||
|
||||
if extension_id:
|
||||
body = self.rewrite_extension_paths(body, extension_id, source_root)
|
||||
|
||||
frontmatter = self._adjust_script_paths(
|
||||
frontmatter, extension_id=extension_id
|
||||
)
|
||||
|
||||
@@ -10,8 +10,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
@@ -89,63 +87,17 @@ def loads_json(text: str, *, origin: str = "<string>") -> Any:
|
||||
|
||||
|
||||
def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
"""Atomically write pretty JSON to *path* (optionally confined to *within*)."""
|
||||
"""Write *data* as 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)
|
||||
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:
|
||||
with path.open("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,41 +187,19 @@ def remove_bundle(
|
||||
|
||||
still_needed = components_still_needed(records, exclude_bundle_id=bundle_id)
|
||||
result = InstallResult(bundle_id=bundle_id)
|
||||
remove_attempted = False
|
||||
|
||||
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."
|
||||
)
|
||||
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)
|
||||
else:
|
||||
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
|
||||
result.skipped.append(component)
|
||||
|
||||
save_records(project_root, remove_record(records, bundle_id))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -746,16 +746,11 @@ def _resolve_manifest_path(path: Path | None) -> Path:
|
||||
def _download_manifest(resolved, *, offline: bool):
|
||||
"""Resolve a bundle's manifest from its catalog ``download_url``.
|
||||
|
||||
Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost),
|
||||
matching the extensions/presets/workflows catalog systems. Remote URLs are
|
||||
fetched with the shared authenticated, redirect-validated HTTP client, and
|
||||
only when not ``--offline``.
|
||||
|
||||
Local and ``file://`` sources are intentionally not resolved here: to
|
||||
install a bundle from disk, pass the path positionally
|
||||
(``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a
|
||||
``.zip`` artifact also works), which :func:`_local_manifest_source` handles
|
||||
before catalog resolution and which never touches ``download_url``.
|
||||
Local/``file://`` URLs always work offline and may point at a ``.zip``
|
||||
artifact, a bundle directory, or a ``bundle.yml`` (handled by
|
||||
:func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with
|
||||
the shared authenticated, redirect-validated HTTP client, and only when not
|
||||
``--offline``.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -768,35 +763,26 @@ def _download_manifest(resolved, *, offline: bool):
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
# ``file://`` URLs and bare filesystem paths (including Windows drive paths
|
||||
# like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme)
|
||||
# are not valid catalog download URLs. Catalog URLs are HTTPS-only across
|
||||
# every catalog system; installing from disk is done by passing the path
|
||||
# positionally, which never reaches URL resolution. Give an actionable
|
||||
# error rather than accepting a scheme the rest of the codebase rejects.
|
||||
# On Windows an absolute path like ``C:\bundle.yml`` parses with a
|
||||
# single-letter ``scheme``; treat it as a local file, not a URL scheme.
|
||||
if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url "
|
||||
f"({url}); catalog download URLs must be HTTPS (http for localhost) — "
|
||||
"a file:// URL, a local filesystem path, or a scheme-less value "
|
||||
"(e.g. 'example.com/bundle.zip') is not accepted. "
|
||||
"To install a bundle from disk, pass the path directly: "
|
||||
"'specify bundle install <path-to-bundle.yml | bundle-dir | .zip>'."
|
||||
)
|
||||
local = Path(parsed.path if scheme == "file" else url)
|
||||
manifest = _local_manifest_source(str(local))
|
||||
if manifest is None:
|
||||
raise BundlerError(f"Bundle manifest not found: {local}")
|
||||
return manifest
|
||||
|
||||
# Validate the scheme/host *before* the offline gate so an invalid or
|
||||
# non-HTTPS download_url reports the real problem in every mode, rather
|
||||
# than a misleading "Network access disabled" under --offline.
|
||||
# (_download_remote_manifest re-checks this, but only once network access
|
||||
# is permitted.) HTTPS-only, http allowed for localhost.
|
||||
_require_https(f"bundle '{resolved.entry.id}'", url)
|
||||
if scheme in ("http", "https"):
|
||||
if offline:
|
||||
raise BundlerError(
|
||||
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
return _download_remote_manifest(resolved.entry.id, url)
|
||||
|
||||
if offline:
|
||||
raise BundlerError(
|
||||
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
return _download_remote_manifest(resolved.entry.id, url)
|
||||
raise BundlerError(
|
||||
f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}"
|
||||
)
|
||||
|
||||
|
||||
def _require_https(label: str, url: str) -> None:
|
||||
|
||||
@@ -33,17 +33,11 @@ def _stdin_is_interactive() -> bool:
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
"""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
|
||||
|
||||
"""Copy constitution template to memory if it doesn't exist."""
|
||||
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
|
||||
template_constitution = (
|
||||
project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
)
|
||||
|
||||
if memory_constitution.exists():
|
||||
if tracker:
|
||||
@@ -51,21 +45,18 @@ def ensure_constitution_from_template(
|
||||
tracker.skip("constitution", "existing file preserved")
|
||||
return
|
||||
|
||||
try:
|
||||
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 not template_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
if materialization == "copied":
|
||||
tracker.complete("constitution", "copied from template")
|
||||
else:
|
||||
tracker.complete("constitution", "composed from template")
|
||||
tracker.error("constitution", "template not found")
|
||||
return
|
||||
|
||||
try:
|
||||
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(template_constitution, memory_constitution)
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.complete("constitution", "copied from template")
|
||||
else:
|
||||
console.print("[cyan]Initialized constitution from template[/cyan]")
|
||||
except Exception as e:
|
||||
@@ -229,45 +220,16 @@ def register(app: typer.Typer) -> None:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)"
|
||||
)
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
if force:
|
||||
# Proceeding: the merge/overwrite warning is accurate here.
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
|
||||
)
|
||||
else:
|
||||
# Fold the merge risk into the confirmation prompt rather than
|
||||
# printing it unconditionally first: on the EOF/no-input path
|
||||
# below the command exits without changing anything, so a
|
||||
# standalone "will be merged" line would mislead. Interactive
|
||||
# users still see the risk as part of the question.
|
||||
#
|
||||
# Call typer.confirm normally so piped y/n is honored — e.g.
|
||||
# `echo y | specify init --here` keeps reaching the
|
||||
# non-destructive preserve-merge path.
|
||||
try:
|
||||
proceed = typer.confirm(
|
||||
"Template files will be merged with existing content "
|
||||
"and may overwrite existing files. Do you want to continue?"
|
||||
)
|
||||
except (typer.Abort, EOFError):
|
||||
# typer.confirm raises Abort for BOTH an interactive Ctrl+C
|
||||
# and an EOF on closed/empty stdin. Distinguish them: a real
|
||||
# TTY cancellation is a normal exit (0, "cancelled"), while a
|
||||
# missing-input EOF (non-interactive) becomes an actionable
|
||||
# error pointing at --force.
|
||||
if _stdin_is_interactive():
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0) from None
|
||||
console.print(
|
||||
"[red]Error:[/red] Current directory is not empty and no "
|
||||
"confirmation input is available. Re-run with "
|
||||
"[bold]--force[/bold] to merge into it."
|
||||
)
|
||||
raise typer.Exit(1) from None
|
||||
if not proceed:
|
||||
response = typer.confirm("Do you want to continue?")
|
||||
if not response:
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
@@ -485,6 +447,8 @@ 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:
|
||||
@@ -612,11 +576,6 @@ 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
|
||||
@@ -701,7 +660,6 @@ 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
|
||||
@@ -714,7 +672,6 @@ 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:
|
||||
@@ -747,11 +704,6 @@ 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,7 +1004,6 @@ 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)
|
||||
@@ -1016,30 +1015,6 @@ 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"]
|
||||
@@ -1103,15 +1078,9 @@ class ExtensionManager:
|
||||
frontmatter = registrar._adjust_script_paths(
|
||||
frontmatter, extension_id=manifest.id
|
||||
)
|
||||
# Mirror the register_commands() rewrite (#2101): resolve
|
||||
# extension-relative subdir references (agents/, knowledge-base/,
|
||||
# etc.) to their installed .specify/extensions/<id>/ location
|
||||
# before the generic placeholder/path resolution below.
|
||||
body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir)
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
|
||||
)
|
||||
body = _resolve_command_ref_tokens(body)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
description = original_desc or f"Extension command: {cmd_name}"
|
||||
@@ -2763,36 +2732,6 @@ 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.
|
||||
|
||||
@@ -2812,70 +2751,22 @@ 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
|
||||
config_path = key[len(prefix) :].lower().split("_")
|
||||
|
||||
# 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 remainder.lower().split("_") if p]
|
||||
if not config_path:
|
||||
continue
|
||||
|
||||
# Build nested dict. Two env vars can collide on a prefix, e.g.
|
||||
# SPECKIT_X_CONNECTION=a and SPECKIT_X_CONNECTION_URL=b. Guard the
|
||||
# walk so a colliding scalar is replaced by a dict (deeper/more
|
||||
# specific vars win) instead of being indexed into — which raised
|
||||
# TypeError ('str' object does not support item assignment) — and
|
||||
# guard the leaf so a scalar processed after the nested var does
|
||||
# not clobber the nested dict. Order-independent: both insertion
|
||||
# orders yield {'connection': {'url': ...}}. Nested-wins mirrors
|
||||
# _merge_configs' dict-preserving semantics.
|
||||
# Build nested dict
|
||||
current = env_config
|
||||
for part in config_path[:-1]:
|
||||
if not isinstance(current.get(part), dict):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
# Set the final value, unless a nested dict already occupies it.
|
||||
if not isinstance(current.get(config_path[-1]), dict):
|
||||
current[config_path[-1]] = value
|
||||
# Set the final value
|
||||
current[config_path[-1]] = value
|
||||
|
||||
return env_config
|
||||
|
||||
|
||||
@@ -1566,14 +1566,7 @@ def extension_set_priority(
|
||||
raw_priority = metadata.get("priority")
|
||||
# Only skip if the stored value is already a valid int equal to requested priority
|
||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
|
||||
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
|
||||
# equals 1/0 here and is never repaired.
|
||||
if (
|
||||
isinstance(raw_priority, int)
|
||||
and not isinstance(raw_priority, bool)
|
||||
and raw_priority == priority
|
||||
):
|
||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ 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
|
||||
@@ -100,7 +99,6 @@ def _register_builtins() -> None:
|
||||
_register(GeminiIntegration())
|
||||
_register(GenericIntegration())
|
||||
_register(GooseIntegration())
|
||||
_register(GrokIntegration())
|
||||
_register(HermesIntegration())
|
||||
_register(JunieIntegration())
|
||||
_register(KilocodeIntegration())
|
||||
|
||||
@@ -190,15 +190,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
|
||||
"""
|
||||
import shlex
|
||||
parsed: dict[str, Any] = {}
|
||||
try:
|
||||
tokens = shlex.split(raw_options)
|
||||
except ValueError as exc:
|
||||
# An unbalanced quote (e.g. --integration-options='--commands-dir "foo')
|
||||
# makes shlex raise "No closing quotation". Translate it into the same
|
||||
# clean exit-1 UX as every other bad-input path below rather than
|
||||
# letting a raw traceback escape.
|
||||
console.print(f"[red]Error:[/red] Could not parse integration options: {exc}.")
|
||||
raise typer.Exit(1)
|
||||
tokens = shlex.split(raw_options)
|
||||
declared_options = list(integration.options())
|
||||
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
|
||||
allowed = ", ".join(sorted(opt.name for opt in declared_options))
|
||||
@@ -260,7 +252,6 @@ 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.
|
||||
|
||||
@@ -279,17 +270,7 @@ def _update_init_options_for_integration(
|
||||
opts["speckit_version"] = _get_speckit_version()
|
||||
if script_type:
|
||||
opts["script"] = script_type
|
||||
# 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:
|
||||
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
|
||||
opts["ai_skills"] = True
|
||||
else:
|
||||
opts.pop("ai_skills", None)
|
||||
@@ -345,9 +326,7 @@ 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, parsed_options=parsed_options
|
||||
)
|
||||
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
|
||||
|
||||
|
||||
def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
|
||||
|
||||
@@ -1122,17 +1122,6 @@ 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.
|
||||
|
||||
@@ -1238,9 +1227,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
|
||||
normal prompt content, or an escaped quoted scalar when control
|
||||
characters require it. Uses ``yaml.safe_dump()`` for the header fields.
|
||||
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.
|
||||
"""
|
||||
header = cls._build_yaml_header(title, description)
|
||||
|
||||
@@ -1251,23 +1240,6 @@ 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
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""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
|
||||
@@ -13,7 +13,6 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
|
||||
|
||||
class KiroCliIntegration(MarkdownIntegration):
|
||||
key = "kiro-cli"
|
||||
multi_install_safe = True
|
||||
config = {
|
||||
"name": "Kiro CLI",
|
||||
"folder": ".kiro/",
|
||||
@@ -27,10 +26,3 @@ class KiroCliIntegration(MarkdownIntegration):
|
||||
"args": _KIRO_ARG_FALLBACK,
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
# Kiro CLI keeps everything under a static, isolated agent root
|
||||
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
|
||||
# integration writes to, so it is safe to install alongside others
|
||||
# (issue #3471). The registry's multi-install-safe contract tests
|
||||
# enforce that isolation for every integration setting this flag.
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -31,117 +31,7 @@ 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 (
|
||||
_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
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
|
||||
|
||||
def _substitute_core_template(
|
||||
@@ -888,7 +778,6 @@ class PresetManager:
|
||||
matching_cmds, ext_id, ext_dir,
|
||||
self.project_root,
|
||||
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
|
||||
extension_id=ext_id,
|
||||
)
|
||||
registered = True
|
||||
except Exception:
|
||||
@@ -1310,8 +1199,6 @@ class PresetManager:
|
||||
"command_name": cmd_name,
|
||||
"source_file": source_file,
|
||||
"source": f"extension:{manifest.id}",
|
||||
"extension_id": manifest.id,
|
||||
"extension_dir": ext_root,
|
||||
}
|
||||
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
||||
restore_index.setdefault(modern_skill_name, restore_info)
|
||||
@@ -1576,17 +1463,6 @@ class PresetManager:
|
||||
if extension_restore:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
# Mirror the register-time rewrite (#2101): resolve
|
||||
# extension-relative subdir references (agents/,
|
||||
# knowledge-base/, etc.) to their installed location before
|
||||
# the generic placeholder resolution below, otherwise
|
||||
# restoring after a preset override removal would leave
|
||||
# bare, unresolvable paths in the skill body.
|
||||
body = registrar.rewrite_extension_paths(
|
||||
body,
|
||||
extension_restore["extension_id"],
|
||||
extension_restore["extension_dir"],
|
||||
)
|
||||
if isinstance(selected_ai, str):
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
@@ -1739,73 +1615,8 @@ 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,
|
||||
@@ -1885,25 +1696,6 @@ 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"
|
||||
@@ -1911,11 +1703,6 @@ 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):
|
||||
@@ -1962,18 +1749,6 @@ 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]]:
|
||||
@@ -2799,39 +2574,6 @@ class PresetResolver:
|
||||
self._manifest_cache[key] = None
|
||||
return self._manifest_cache[key]
|
||||
|
||||
def _manifest_declared_template(
|
||||
self, pack_dir: Path, template_name: str, template_type: str
|
||||
) -> tuple[dict | None, Path | None]:
|
||||
"""Resolve a preset's manifest-declared template entry and usable file.
|
||||
|
||||
Returns ``(entry, candidate)``:
|
||||
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
|
||||
the manifest is absent or does not list this ``(name, type)``.
|
||||
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
|
||||
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
|
||||
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
|
||||
|
||||
The manifest is authoritative: when it declares a template (``entry`` is
|
||||
not ``None``) but the file is unusable (``candidate`` is ``None``),
|
||||
callers must NOT fall back to the convention lookup — that would mask a
|
||||
typo or pick up an undeclared file. Shared by ``resolve()`` and
|
||||
``collect_all_layers()`` so their manifest-first resolution cannot
|
||||
silently diverge again (the divergence this fix addressed).
|
||||
"""
|
||||
manifest = self._get_manifest(pack_dir)
|
||||
if not manifest:
|
||||
return None, None
|
||||
for tmpl in manifest.templates:
|
||||
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
|
||||
file_path = tmpl.get("file")
|
||||
if file_path:
|
||||
manifest_candidate = pack_dir / file_path
|
||||
return tmpl, (
|
||||
manifest_candidate if manifest_candidate.is_file() else None
|
||||
)
|
||||
return tmpl, None
|
||||
return None, None
|
||||
|
||||
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
||||
"""Build unified list of registered and unregistered extensions sorted by priority.
|
||||
|
||||
@@ -2934,27 +2676,6 @@ class PresetResolver:
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
for pack_id, _metadata in registry.list_by_priority():
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# The preset manifest is authoritative: if it declares this
|
||||
# template with an explicit ``file:``, resolve to that path —
|
||||
# and do NOT fall back to convention when it's missing, to
|
||||
# avoid masking typos or picking up an undeclared file. Only
|
||||
# when the manifest is absent or doesn't list this template do
|
||||
# we use the convention-based subdir lookup. Mirrors
|
||||
# collect_all_layers()/resolve_content() so resolve() and
|
||||
# resolve_with_source() agree with them instead of returning
|
||||
# the core template (or a stray convention file).
|
||||
entry, manifest_candidate = self._manifest_declared_template(
|
||||
pack_dir, template_name, template_type
|
||||
)
|
||||
if manifest_candidate is not None:
|
||||
return manifest_candidate
|
||||
if entry is not None:
|
||||
# Manifest declares this template but the file is missing,
|
||||
# non-file (e.g. a directory), or an empty/falsey ``file``
|
||||
# value. The manifest is authoritative, so skip this pack's
|
||||
# convention fallback rather than mask a typo — mirrors
|
||||
# collect_all_layers().
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = pack_dir / subdir / f"{template_name}{ext}"
|
||||
@@ -3222,22 +2943,31 @@ class PresetResolver:
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# Read strategy and manifest file path from preset manifest
|
||||
strategy = "replace"
|
||||
manifest_file_path = None
|
||||
manifest_has_strategy = False
|
||||
entry, manifest_candidate = self._manifest_declared_template(
|
||||
pack_dir, template_name, template_type
|
||||
)
|
||||
if entry is not None:
|
||||
strategy = entry.get("strategy", "replace")
|
||||
manifest_has_strategy = "strategy" in entry
|
||||
# Use the manifest's declared file when it's a usable regular file;
|
||||
# only fall back to convention-based lookup when the manifest
|
||||
# doesn't list this template at all, so preset.yml stays
|
||||
# authoritative (a declared-but-unusable file skips convention —
|
||||
# parity with resolve()).
|
||||
manifest_found_entry = False
|
||||
manifest = self._get_manifest(pack_dir)
|
||||
if manifest:
|
||||
for tmpl in manifest.templates:
|
||||
if (tmpl.get("name") == template_name
|
||||
and tmpl.get("type") == template_type):
|
||||
strategy = tmpl.get("strategy", "replace")
|
||||
manifest_has_strategy = "strategy" in tmpl
|
||||
manifest_file_path = tmpl.get("file")
|
||||
manifest_found_entry = True
|
||||
break
|
||||
# Use manifest file path if specified, otherwise convention-based
|
||||
# lookup — but only when the manifest doesn't exist or doesn't
|
||||
# list this template, so preset.yml stays authoritative.
|
||||
candidate = None
|
||||
if manifest_candidate is not None:
|
||||
candidate = manifest_candidate
|
||||
elif entry is None:
|
||||
if manifest_file_path:
|
||||
manifest_candidate = pack_dir / manifest_file_path
|
||||
if manifest_candidate.exists():
|
||||
candidate = manifest_candidate
|
||||
# Explicit file path that doesn't exist: skip convention
|
||||
# fallback to avoid masking typos or picking up unintended files.
|
||||
elif not manifest_found_entry:
|
||||
# Manifest doesn't list this template — check convention paths
|
||||
candidate = _find_in_subdirs(pack_dir)
|
||||
if candidate:
|
||||
# Legacy fallback: if manifest doesn't explicitly declare a
|
||||
@@ -3308,8 +3038,6 @@ class PresetResolver:
|
||||
"path": candidate,
|
||||
"source": source,
|
||||
"strategy": "replace",
|
||||
"extension_id": ext_id,
|
||||
"extension_dir": ext_dir,
|
||||
})
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
@@ -3429,32 +3157,10 @@ class PresetResolver:
|
||||
if not layers:
|
||||
return None
|
||||
|
||||
def _read_layer_content(layer: Dict[str, Any]) -> str:
|
||||
"""Read a layer's raw text, rewriting extension-relative subdir
|
||||
references (agents/, knowledge-base/, etc.) to their installed
|
||||
location when the layer is extension-provided (#2101).
|
||||
|
||||
Extension layers are always inserted with strategy "replace"
|
||||
(see collect_all_layers), so a layer only ever needs this
|
||||
rewrite when it wins outright above or serves as the
|
||||
composition base below — never as a mid-stack composing
|
||||
(append/prepend/wrap) layer.
|
||||
"""
|
||||
text = layer["path"].read_text(encoding="utf-8")
|
||||
extension_id = layer.get("extension_id")
|
||||
extension_dir = layer.get("extension_dir")
|
||||
if extension_id and extension_dir:
|
||||
from ..agents import CommandRegistrar
|
||||
|
||||
text = CommandRegistrar.rewrite_extension_paths(
|
||||
text, extension_id, extension_dir
|
||||
)
|
||||
return text
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if layers[0]["strategy"] == "replace":
|
||||
return _read_layer_content(layers[0])
|
||||
return layers[0]["path"].read_text(encoding="utf-8")
|
||||
|
||||
# Composition: build content bottom-up from the effective base.
|
||||
# The base is the nearest replace layer scanning from highest priority
|
||||
@@ -3477,7 +3183,7 @@ class PresetResolver:
|
||||
|
||||
# Convert to reversed_layers index
|
||||
base_reversed_idx = len(layers) - 1 - base_layer_idx
|
||||
content = _read_layer_content(layers[base_layer_idx])
|
||||
content = layers[base_layer_idx]["path"].read_text(encoding="utf-8")
|
||||
# Compose only the layers above the base (higher priority = lower index in layers,
|
||||
# higher index in reversed_layers). Process bottom-up from base+1.
|
||||
start_idx = base_reversed_idx + 1
|
||||
|
||||
@@ -469,14 +469,7 @@ def preset_set_priority(
|
||||
raw_priority = metadata.get("priority")
|
||||
# Only skip if the stored value is already a valid int equal to requested priority
|
||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
|
||||
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
|
||||
# equals 1/0 here and is never repaired.
|
||||
if (
|
||||
isinstance(raw_priority, int)
|
||||
and not isinstance(raw_priority, bool)
|
||||
and raw_priority == priority
|
||||
):
|
||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||
console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
@@ -484,9 +477,6 @@ 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]")
|
||||
@@ -520,9 +510,6 @@ 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.")
|
||||
@@ -557,9 +544,6 @@ 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,9 +74,6 @@ 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,8 +13,6 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -73,180 +71,40 @@ 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)
|
||||
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
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Corrupted registry file — reset to default
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
|
||||
def save(self) -> None:
|
||||
"""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."
|
||||
)
|
||||
"""Persist registry to disk."""
|
||||
self.workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 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
|
||||
with open(self.registry_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
|
||||
"""Add or update an installed workflow entry."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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 {}
|
||||
existing = self.data["workflows"].get(workflow_id, {})
|
||||
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
|
||||
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
|
||||
self.save()
|
||||
|
||||
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]
|
||||
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
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -299,20 +157,8 @@ class WorkflowCatalog:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# 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")
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
@@ -320,7 +166,7 @@ class WorkflowCatalog:
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not hostname:
|
||||
if not parsed.hostname:
|
||||
raise WorkflowValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
@@ -486,26 +332,15 @@ class WorkflowCatalog:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_catalog_url(url: str) -> None:
|
||||
# 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")
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
|
||||
)
|
||||
if not hostname:
|
||||
if not parsed.hostname:
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from URL with no hostname: {url}"
|
||||
)
|
||||
@@ -592,7 +427,6 @@ 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()
|
||||
@@ -604,9 +438,9 @@ class WorkflowCatalog:
|
||||
q = query.lower()
|
||||
searchable = " ".join(
|
||||
[
|
||||
str(wf_data.get("name") or ""),
|
||||
str(wf_data.get("description") or ""),
|
||||
str(wf_data.get("id") or ""),
|
||||
wf_data.get("name", ""),
|
||||
wf_data.get("description", ""),
|
||||
wf_data.get("id", ""),
|
||||
]
|
||||
).lower()
|
||||
if q not in searchable:
|
||||
@@ -617,10 +451,6 @@ 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
|
||||
|
||||
@@ -944,20 +774,8 @@ class StepCatalog:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# 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")
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
@@ -965,7 +783,7 @@ class StepCatalog:
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not hostname:
|
||||
if not parsed.hostname:
|
||||
raise StepValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
@@ -1131,26 +949,15 @@ class StepCatalog:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_url(url: str) -> None:
|
||||
# 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")
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
|
||||
)
|
||||
if not hostname:
|
||||
if not parsed.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.fullmatch(definition.id):
|
||||
elif not _ID_PATTERN.match(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.fullmatch(r"\d+\.\d+\.\d+", definition.version):
|
||||
elif not re.match(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,57 +416,18 @@ 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.fullmatch(run_id):
|
||||
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(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
|
||||
@@ -478,22 +439,8 @@ 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
|
||||
@@ -508,7 +455,6 @@ 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]] = []
|
||||
@@ -557,13 +503,10 @@ 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,
|
||||
}
|
||||
@@ -611,52 +554,16 @@ 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=workflow_id,
|
||||
workflow_id=state_data["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", "")
|
||||
|
||||
@@ -664,16 +571,7 @@ class RunState:
|
||||
if inputs_path.exists():
|
||||
with open(inputs_path, encoding="utf-8") as f:
|
||||
inputs_data = json.load(f)
|
||||
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
|
||||
state.inputs = inputs_data.get("inputs", {})
|
||||
|
||||
return state
|
||||
|
||||
@@ -756,8 +654,6 @@ 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.
|
||||
|
||||
@@ -769,12 +665,6 @@ 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
|
||||
-------
|
||||
@@ -792,12 +682,6 @@ 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
|
||||
@@ -813,12 +697,6 @@ 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()
|
||||
|
||||
@@ -829,7 +707,6 @@ class WorkflowEngine:
|
||||
default_options=definition.default_options,
|
||||
project_root=str(self.project_root),
|
||||
run_id=state.run_id,
|
||||
workflow_dir=workflow_dir,
|
||||
)
|
||||
|
||||
# Execute steps
|
||||
@@ -895,7 +772,6 @@ 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
|
||||
@@ -1106,16 +982,7 @@ class WorkflowEngine:
|
||||
from .expressions import evaluate_condition
|
||||
|
||||
max_iters = step_config.get("max_iterations")
|
||||
# A bool is an int in Python (isinstance(True, int) is True
|
||||
# and True == 1), so a bool max_iterations would slip past
|
||||
# the int check and cap the loop at range(0)==1 iteration
|
||||
# instead of the default. Exclude bools, mirroring the
|
||||
# while/do-while validators and the continue_on_error guard.
|
||||
if (
|
||||
isinstance(max_iters, bool)
|
||||
or not isinstance(max_iters, int)
|
||||
or max_iters < 1
|
||||
):
|
||||
if not isinstance(max_iters, int) or max_iters < 1:
|
||||
max_iters = 10
|
||||
condition = step_config.get("condition", False)
|
||||
for _loop_iter in range(max_iters - 1):
|
||||
@@ -1208,9 +1075,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, ``.inf``/``.nan``, …) 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, …) 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 []
|
||||
@@ -1218,9 +1085,7 @@ class WorkflowEngine:
|
||||
halting = (RunStatus.PAUSED, RunStatus.FAILED, RunStatus.ABORTED)
|
||||
try:
|
||||
workers = max(1, int(max_concurrency))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a YAML ``max_concurrency: .inf``
|
||||
# would otherwise crash the whole run instead of falling back.
|
||||
except (TypeError, ValueError):
|
||||
workers = 1
|
||||
# Never spin up more workers than there is work — bounds a user-controlled
|
||||
# max_concurrency from over-allocating threads.
|
||||
|
||||
@@ -35,38 +35,14 @@ 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*.
|
||||
|
||||
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__}"
|
||||
)
|
||||
"""Join a list into a string with *separator*."""
|
||||
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.
|
||||
|
||||
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__}"
|
||||
)
|
||||
"""Map a list of dicts to a specific attribute."""
|
||||
if isinstance(value, list):
|
||||
result = []
|
||||
for item in value:
|
||||
@@ -87,25 +63,9 @@ def _filter_map(value: Any, attr: str) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
def _filter_contains(value: Any, substring: str) -> bool:
|
||||
"""Check if a string or list contains *substring*."""
|
||||
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
|
||||
@@ -182,8 +142,7 @@ 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 ""
|
||||
workflow_dir = getattr(context, "workflow_dir", None) or ""
|
||||
ns["context"] = {"run_id": run_id, "workflow_dir": workflow_dir}
|
||||
ns["context"] = {"run_id": run_id}
|
||||
return ns
|
||||
|
||||
|
||||
@@ -505,9 +464,9 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
if op == "<=":
|
||||
return _safe_compare(left, right, "<=")
|
||||
if op == " in ":
|
||||
return _safe_membership(left, right, negate=False)
|
||||
return left in right if right is not None else False
|
||||
if op == " not in ":
|
||||
return _safe_membership(left, right, negate=True)
|
||||
return left not in right if right is not None else True
|
||||
|
||||
# Numeric literal
|
||||
try:
|
||||
@@ -552,26 +511,6 @@ def _coerce_number(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _safe_membership(left: Any, right: Any, *, negate: bool) -> bool:
|
||||
"""Safely evaluate ``left in right`` (or ``not in``) without crashing.
|
||||
|
||||
``left in right`` raises ``TypeError`` whenever the operands don't support
|
||||
membership testing — most commonly a non-iterable right operand (``None``,
|
||||
an int, a bool), but also cases like an unhashable ``left`` against a set.
|
||||
In every such case the membership relation is undefined, so treat it as
|
||||
``False`` (``not in`` as ``True``) rather than leaking the error out of the
|
||||
evaluator and crashing the whole workflow. Mirrors the graceful
|
||||
``TypeError`` handling in ``_safe_compare`` for the ordering operators, and
|
||||
generalizes the previous ``right is not None`` guard to any operand pair
|
||||
that can't be membership-tested.
|
||||
"""
|
||||
try:
|
||||
contained = left in right
|
||||
except TypeError:
|
||||
contained = False
|
||||
return not contained if negate else contained
|
||||
|
||||
|
||||
def _safe_compare(left: Any, right: Any, op: str) -> bool:
|
||||
"""Compare two values for ordering, coercing numeric strings when possible.
|
||||
|
||||
|
||||
@@ -31,20 +31,6 @@ class CommandStep(StepBase):
|
||||
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
|
||||
command = config.get("command", "")
|
||||
input_data = config.get("input", {})
|
||||
# validate() rejects a non-mapping input, but the engine does not
|
||||
# auto-validate before execute(); a workflow that skipped validation can
|
||||
# still reach here. Fail the step with the same contract error rather
|
||||
# than silently coercing to {} and dispatching with empty args — that
|
||||
# would change the command's meaning, hide the config error, and report
|
||||
# COMPLETED, defeating the per-step FAILED / continue_on_error behavior.
|
||||
if not isinstance(input_data, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'input' must be a "
|
||||
f"mapping, got {type(input_data).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Resolve expressions in input
|
||||
resolved_input: dict[str, Any] = {}
|
||||
@@ -64,18 +50,8 @@ class CommandStep(StepBase):
|
||||
# Merge options (workflow defaults ← step overrides)
|
||||
options = dict(context.default_options)
|
||||
step_options = config.get("options", {})
|
||||
# Same rationale as 'input': a malformed options fails the step rather
|
||||
# than being silently ignored (which would let an invalid step run and
|
||||
# apparently complete).
|
||||
if not isinstance(step_options, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a "
|
||||
f"mapping, got {type(step_options).__name__}."
|
||||
),
|
||||
)
|
||||
options.update(step_options)
|
||||
if step_options:
|
||||
options.update(step_options)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
args_str = str(resolved_input.get("args", ""))
|
||||
@@ -179,16 +155,4 @@ class CommandStep(StepBase):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r} is missing 'command' field."
|
||||
)
|
||||
# execute() iterates input.items() and options.update(step_options); a
|
||||
# non-mapping here would raise at run time. Validate the shape like the
|
||||
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
|
||||
# crashed on.
|
||||
if "input" in config and not isinstance(config["input"], dict):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'input' must be a mapping."
|
||||
)
|
||||
if "options" in config and not isinstance(config["options"], dict):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -27,30 +27,6 @@ class DoWhileStep(StepBase):
|
||||
nested_steps = config.get("steps", [])
|
||||
condition = config.get("condition", "false")
|
||||
|
||||
# The engine does not auto-validate step config (see
|
||||
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
|
||||
# into ``_execute_steps``, which iterates them as step mappings. A
|
||||
# non-list ``steps`` (a single mapping or scalar authoring mistake)
|
||||
# would otherwise be iterated element-wise — a dict yields its string
|
||||
# keys, a str its characters — and crash the whole run with
|
||||
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
|
||||
# ``steps``; fail this step loudly on an unvalidated run instead,
|
||||
# mirroring the if/switch/fan-out steps. The body always runs on the
|
||||
# first call, so unlike the while step this guard is unconditional.
|
||||
if not isinstance(nested_steps, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output={
|
||||
"condition": condition,
|
||||
"max_iterations": max_iterations,
|
||||
"loop_type": "do-while",
|
||||
},
|
||||
error=(
|
||||
f"Do-while step {config.get('id', '?')!r}: 'steps' must be "
|
||||
f"a list of steps, got {type(nested_steps).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Always execute body at least once; the engine layer evaluates
|
||||
# `condition` after each iteration to decide whether to loop.
|
||||
return StepResult(
|
||||
|
||||
@@ -24,24 +24,6 @@ class FanInStep(StepBase):
|
||||
if not isinstance(output_config, dict):
|
||||
output_config = {}
|
||||
|
||||
# The engine does not auto-validate step config, so an unvalidated run
|
||||
# with a non-list ``wait_for`` reaches here raw. Iterating it then
|
||||
# either crashes the whole run (a scalar like an int or None raises
|
||||
# TypeError) or, worse, silently iterates a string's characters and
|
||||
# yields a bogus join of empty results with a COMPLETED status — the
|
||||
# exact "silent empty result + COMPLETED" wiring bug the engine's
|
||||
# fan-in validation guards against. Fail this step loudly instead,
|
||||
# mirroring the fan-out step's non-list ``items`` handling.
|
||||
if not isinstance(wait_for, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' must be "
|
||||
f"a list of step IDs, got {type(wait_for).__name__}."
|
||||
),
|
||||
output={"results": []},
|
||||
)
|
||||
|
||||
# Collect results from referenced steps
|
||||
results = []
|
||||
for step_id in wait_for:
|
||||
|
||||
@@ -22,33 +22,10 @@ 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},
|
||||
@@ -70,8 +47,8 @@ class IfThenStep(StepBase):
|
||||
errors.append(
|
||||
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
|
||||
)
|
||||
else_branch = config.get("else")
|
||||
if else_branch is not None and not isinstance(else_branch, list):
|
||||
else_branch = config.get("else", [])
|
||||
if else_branch and not isinstance(else_branch, list):
|
||||
errors.append(
|
||||
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
|
||||
)
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
@@ -27,26 +25,14 @@ class ShellStep(StepBase):
|
||||
run_cmd = str(run_cmd)
|
||||
|
||||
cwd = context.project_root or "."
|
||||
# Per-step execution timeout in seconds; defaults to 300 for backward
|
||||
# compatibility. The engine does not auto-validate step config, so
|
||||
# validate here as well — a caller that skips WorkflowEngine.validate()
|
||||
# must fail the step cleanly rather than crash subprocess.run() with a
|
||||
# TypeError (or silently coerce ``timeout: true`` to a 1s duration,
|
||||
# since bool is an int subclass).
|
||||
# Defensive: the engine does not auto-validate step config, so an
|
||||
# invalid ``timeout`` (string, None, ...) would otherwise raise a
|
||||
# TypeError from subprocess.run() and crash the whole run. Mirror
|
||||
# the engine's handling of unvalidated ``continue_on_error`` by
|
||||
# only honoring well-formed values and falling back to the default.
|
||||
timeout = config.get("timeout", 300)
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=timeout_error,
|
||||
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
|
||||
)
|
||||
|
||||
env = {**os.environ}
|
||||
if context.workflow_dir:
|
||||
env["SPECKIT_WORKFLOW_DIR"] = context.workflow_dir
|
||||
else:
|
||||
env.pop("SPECKIT_WORKFLOW_DIR", None)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
|
||||
timeout = 300
|
||||
|
||||
# NOTE: shell=True is required to support pipes, redirects, and
|
||||
# multi-command expressions in workflow YAML. Workflow authors
|
||||
@@ -59,7 +45,6 @@ class ShellStep(StepBase):
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
)
|
||||
output = {
|
||||
@@ -107,32 +92,6 @@ class ShellStep(StepBase):
|
||||
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _timeout_error(config: dict[str, Any]) -> str | None:
|
||||
"""Return an error message if ``config['timeout']`` is invalid, else None.
|
||||
|
||||
Shared by execute() and validate() so both paths reject the same
|
||||
values with the same message. An absent ``timeout`` is valid (the
|
||||
default is used). bool is a subclass of int, but ``timeout: true`` is a
|
||||
config error rather than a duration, so it is rejected explicitly.
|
||||
Non-finite floats (YAML ``.inf``/``.nan``) pass a plain ``> 0`` check
|
||||
but would raise in subprocess.run(), so they are rejected too.
|
||||
"""
|
||||
if "timeout" not in config:
|
||||
return None
|
||||
timeout = config["timeout"]
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout)
|
||||
or timeout <= 0
|
||||
):
|
||||
return (
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive number of seconds, got {timeout!r}."
|
||||
)
|
||||
return None
|
||||
|
||||
def validate(self, config: dict[str, Any]) -> list[str]:
|
||||
errors = super().validate(config)
|
||||
if "run" not in config:
|
||||
@@ -155,7 +114,16 @@ class ShellStep(StepBase):
|
||||
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
|
||||
f"be 'json' when present, got {output_format!r}."
|
||||
)
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
errors.append(timeout_error)
|
||||
if "timeout" in config:
|
||||
timeout = config["timeout"]
|
||||
# bool is an int subclass, so reject it explicitly.
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, int)
|
||||
or timeout <= 0
|
||||
):
|
||||
errors.append(
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive integer (seconds) when present, got {timeout!r}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -26,26 +26,8 @@ class SwitchStep(StepBase):
|
||||
str_value = str(value) if value is not None else ""
|
||||
|
||||
cases = config.get("cases", {})
|
||||
if not isinstance(cases, dict):
|
||||
# The engine does not auto-validate step config, so an unvalidated
|
||||
# run with a non-mapping ``cases`` (a list/scalar authoring mistake)
|
||||
# would otherwise raise AttributeError from ``.items()`` below and
|
||||
# crash the whole run. Fail this step loudly instead, mirroring the
|
||||
# fan-out step's non-list ``items`` handling.
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Switch step {config.get('id', '?')!r}: 'cases' must be a "
|
||||
f"mapping, got {type(cases).__name__}."
|
||||
),
|
||||
output={"matched_case": None, "expression_value": value},
|
||||
)
|
||||
for case_key, case_steps in cases.items():
|
||||
if str(case_key) == str_value:
|
||||
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},
|
||||
@@ -54,41 +36,12 @@ 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,32 +26,6 @@ class WhileStep(StepBase):
|
||||
nested_steps = config.get("steps", [])
|
||||
|
||||
result = evaluate_condition(condition, context)
|
||||
|
||||
# The engine does not auto-validate step config (see
|
||||
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
|
||||
# into ``_execute_steps``, which iterates them as step mappings. A
|
||||
# non-list ``steps`` (a single mapping or scalar authoring mistake)
|
||||
# would otherwise be iterated element-wise — a dict yields its string
|
||||
# keys, a str its characters — and crash the whole run with
|
||||
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
|
||||
# ``steps``; fail this step loudly on an unvalidated run instead,
|
||||
# mirroring the if/switch/fan-out steps. The guard fires only when the
|
||||
# body would actually be dispatched (condition truthy). The condition is
|
||||
# still evaluated first, so its result is surfaced for downstream context.
|
||||
if result and not isinstance(nested_steps, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output={
|
||||
"condition_result": True,
|
||||
"max_iterations": max_iterations,
|
||||
"loop_type": "while",
|
||||
},
|
||||
error=(
|
||||
f"While step {config.get('id', '?')!r}: 'steps' must be a "
|
||||
f"list of steps, got {type(nested_steps).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
if result:
|
||||
return StepResult(
|
||||
status=StepStatus.COMPLETED,
|
||||
|
||||
@@ -81,7 +81,7 @@ Follow this execution flow:
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -64,42 +63,6 @@ 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
|
||||
@@ -212,23 +175,7 @@ def test_build_produces_artifact(project: Path):
|
||||
assert len(artifacts) == 1
|
||||
|
||||
|
||||
def _mock_manifest_download(monkeypatch, source_path: Path) -> None:
|
||||
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.
|
||||
|
||||
Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer
|
||||
point one at a local file. Patch ``_download_manifest`` to return the
|
||||
manifest parsed from *source_path* (a bundle.yml or a .zip artifact),
|
||||
exercising ``info``'s expansion without a network call.
|
||||
"""
|
||||
from specify_cli.commands.bundle import _local_manifest_source
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.commands.bundle._download_manifest",
|
||||
lambda resolved, *, offline: _local_manifest_source(str(source_path)),
|
||||
)
|
||||
|
||||
|
||||
def test_info_expands_full_component_set(project: Path, monkeypatch):
|
||||
def test_info_expands_full_component_set(project: Path):
|
||||
bundle_dir = project / "src-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
@@ -236,14 +183,13 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
|
||||
)
|
||||
catalog = project / "local-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -261,7 +207,7 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
|
||||
assert "Trust" in text.output
|
||||
|
||||
|
||||
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
def test_info_expands_discovery_only_bundle(project: Path):
|
||||
# Discovery-only bundles must still be fully inspectable via `info`;
|
||||
# only `install` is refused for them.
|
||||
bundle_dir = project / "disc-bundle"
|
||||
@@ -271,7 +217,7 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
)
|
||||
catalog = project / "disc-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
config = {
|
||||
@@ -284,7 +230,6 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
@@ -292,9 +237,8 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
|
||||
# A .zip artifact is extracted to read bundle.yml; info expands it. (The
|
||||
# download itself is HTTPS-only now and mocked here — see contract note.)
|
||||
def test_info_resolves_local_zip_download_url(project: Path):
|
||||
# A local .zip artifact as download_url is extracted to read bundle.yml.
|
||||
bundle_dir = project / "zip-src"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
@@ -305,15 +249,12 @@ def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
|
||||
catalog = project / "zip-catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
)},
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
|
||||
)
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
_mock_manifest_download(monkeypatch, artifact)
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
@@ -469,16 +410,25 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
|
||||
# ===== Private GitHub release asset URL resolution =====
|
||||
|
||||
|
||||
class FakeBundleResponse(io.BytesIO):
|
||||
class FakeBundleResponse:
|
||||
"""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"):
|
||||
super().__init__(data)
|
||||
self._data = 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,19 +653,6 @@ 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:
|
||||
@@ -987,21 +974,6 @@ 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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,647 +0,0 @@
|
||||
"""
|
||||
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, records_path
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
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,212 +97,6 @@ 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())
|
||||
@@ -334,7 +128,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 not in result.skipped
|
||||
assert gone in result.skipped
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
@@ -112,62 +112,3 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
|
||||
assert not ExtensionManager(project).registry.is_installed("agent-context")
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
|
||||
def test_download_manifest_rejects_file_url(tmp_path: Path):
|
||||
"""A catalog ``file://`` download_url is rejected — catalog URLs are
|
||||
HTTPS-only, matching extensions/presets/workflows. Disk installs go through
|
||||
the positional path (see the local-source tests above), not download_url.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
manifest_path = write_manifest(tmp_path / "my bundles")
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri())
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="bundle install"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
|
||||
def test_download_manifest_rejects_bare_path(tmp_path: Path):
|
||||
"""A bare filesystem path download_url is likewise rejected."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
manifest_path = write_manifest(tmp_path / "plain")
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path))
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="bundle install"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
|
||||
def test_local_install_still_resolves_via_positional_path(tmp_path: Path):
|
||||
"""The supported local route — a positional path, not a download_url —
|
||||
still resolves the manifest via _local_manifest_source."""
|
||||
manifest_path = write_manifest(tmp_path / "my bundles")
|
||||
manifest = _local_manifest_source(str(manifest_path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
|
||||
"""A non-HTTPS download_url must report the HTTPS problem, not a misleading
|
||||
'Network access disabled', even under --offline (scheme is validated before
|
||||
the offline gate)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(
|
||||
id="demo-bundle", download_url="http://example.com/bundle.zip"
|
||||
)
|
||||
)
|
||||
with pytest.raises(BundlerError, match="HTTPS"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
@@ -115,63 +115,6 @@ class TestInitIntegrationFlag:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||
|
||||
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
|
||||
"""`init --here` on a non-empty directory with no confirmation input (empty
|
||||
stdin) must fail fast with guidance to use --force, instead of the bare
|
||||
'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides
|
||||
empty stdin, so typer.confirm raises Abort, which the command converts to the
|
||||
actionable error."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "nonempty-here"
|
||||
project.mkdir()
|
||||
(project / "existing.txt").write_text("keep me", encoding="utf-8")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "--force" in result.output
|
||||
# Aborted before scaffolding: the pre-existing file is untouched.
|
||||
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
def test_init_here_interactive_cancel_exits_zero(self, tmp_path, monkeypatch):
|
||||
"""An interactive Ctrl+C at the merge confirmation (typer.Abort on a TTY)
|
||||
is a normal cancellation — exit 0, "cancelled" — NOT the missing-input
|
||||
--force error, which is reserved for non-interactive EOF. Guards the
|
||||
regression where Abort was caught unconditionally and every cancel became
|
||||
an exit-1 --force error."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
import specify_cli.commands.init as init_mod
|
||||
|
||||
# Simulate an interactive terminal so the Abort is treated as a cancel.
|
||||
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
|
||||
|
||||
project = tmp_path / "cancel-here"
|
||||
project.mkdir()
|
||||
(project / "existing.txt").write_text("keep me", encoding="utf-8")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
# No input → typer.confirm raises Abort (stands in for Ctrl+C).
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "cancelled" in result.output.lower()
|
||||
assert "--force" not in result.output # not the missing-input error
|
||||
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
def test_integration_copilot_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
@@ -224,66 +167,6 @@ 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
|
||||
@@ -374,18 +257,6 @@ 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
|
||||
@@ -964,8 +835,7 @@ class TestInitIntegrationFlag:
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||
|
||||
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
|
||||
"""E2E: confirming the merge with piped "y" (no --force) preserves
|
||||
existing shared infra files (unlike --force, which overwrites them)."""
|
||||
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
@@ -253,7 +253,6 @@ 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,7 +399,6 @@ 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,7 +517,6 @@ 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,36 +201,6 @@ 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.
|
||||
|
||||
@@ -431,7 +401,6 @@ 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,7 +214,6 @@ 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,7 +252,6 @@ 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",
|
||||
@@ -314,7 +313,6 @@ 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",
|
||||
@@ -726,7 +724,6 @@ 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,7 +286,6 @@ 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",
|
||||
@@ -343,7 +342,6 @@ 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",
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
"""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
|
||||
@@ -42,9 +42,6 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
|
||||
COMMANDS_SUBDIR = "prompts"
|
||||
REGISTRAR_DIR = ".kiro/prompts"
|
||||
|
||||
def test_declares_multi_install_safe(self):
|
||||
assert get_integration(self.KEY).multi_install_safe is True
|
||||
|
||||
def test_registrar_config(self):
|
||||
"""Override base assertion: kiro-cli uses a prose fallback for args
|
||||
because Kiro CLI file-based prompts do not natively substitute
|
||||
|
||||
@@ -1566,43 +1566,6 @@ 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()
|
||||
@@ -2712,27 +2675,6 @@ class TestParseIntegrationOptionsEqualsForm:
|
||||
assert result_space["commands_dir"] == "./mydir"
|
||||
assert result_equals["commands_dir"] == "./mydir"
|
||||
|
||||
def test_unbalanced_quote_exits_cleanly(self, capsys):
|
||||
"""An unbalanced quote must exit(1) with a message, not a raw ValueError.
|
||||
|
||||
shlex.split() raises ValueError("No closing quotation") on an unbalanced
|
||||
quote; the parser must translate that into the same clean typer.Exit(1)
|
||||
UX as unknown-option / missing-value, rather than letting the traceback
|
||||
escape (issue #3457).
|
||||
"""
|
||||
import typer
|
||||
|
||||
from specify_cli.integrations._commands import _parse_integration_options
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
integration = get_integration("generic")
|
||||
assert integration is not None
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
_parse_integration_options(integration, '--commands-dir "foo')
|
||||
assert excinfo.value.exit_code == 1
|
||||
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestUninstallNoManifestClearsInitOptions:
|
||||
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
|
||||
|
||||
@@ -141,8 +141,6 @@ 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"),
|
||||
|
||||
@@ -272,20 +272,6 @@ class TestMultiInstallSafeContracts:
|
||||
f"these files: {sorted(overlap)}"
|
||||
)
|
||||
|
||||
def test_kiro_cli_is_declared_multi_install_safe(self):
|
||||
"""kiro-cli confines itself to an isolated ``.kiro/`` root that no
|
||||
other integration touches, so it must be declared multi-install safe
|
||||
(issue #3471).
|
||||
|
||||
Before the fix, co-installing kiro-cli alongside another integration
|
||||
left ``specify integration status`` permanently in ERROR
|
||||
(``unsafe-multi-install``) with no way to acknowledge it. The
|
||||
parametrized isolation/manifest contracts above already exercise
|
||||
kiro-cli once the flag is set; this pins the declaration itself so a
|
||||
future edit cannot silently drop it and reintroduce the error.
|
||||
"""
|
||||
assert INTEGRATION_REGISTRY["kiro-cli"].multi_install_safe is True
|
||||
|
||||
|
||||
class TestCatalogParity:
|
||||
"""The discovery catalog must list every registered integration."""
|
||||
|
||||
@@ -25,7 +25,6 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"gemini",
|
||||
"copilot",
|
||||
"goose",
|
||||
"grok",
|
||||
"hermes",
|
||||
"bob",
|
||||
"junie",
|
||||
|
||||
@@ -911,172 +911,6 @@ class TestExtensionSkillRegistration:
|
||||
assert ".specify/scripts/bash/resolve-skill.sh" not in content
|
||||
assert ".specify/scripts/bash/ensure-skills.sh" not in content
|
||||
|
||||
def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, temp_dir):
|
||||
"""Auto-registered skills should resolve extension-relative subdir
|
||||
references (agents/, knowledge-base/) to their installed location,
|
||||
matching the rewrite already applied by register_commands() (#2101)."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
ext_dir = temp_dir / "path-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "path-ext",
|
||||
"name": "Path Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.path-ext.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Run command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.safe_dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "agents" / "control").mkdir(parents=True)
|
||||
(ext_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
||||
(ext_dir / "knowledge-base").mkdir()
|
||||
(ext_dir / "knowledge-base" / "agent-scores.yaml").write_text("scores: {}\n")
|
||||
(ext_dir / "templates").mkdir()
|
||||
(ext_dir / "templates" / "kill-report.md").write_text("# Kill Report\n")
|
||||
|
||||
(ext_dir / "commands" / "run.md").write_text(
|
||||
"---\n"
|
||||
"description: Run command\n"
|
||||
"---\n\n"
|
||||
"Read agents/control/commander.md and knowledge-base/agent-scores.yaml.\n"
|
||||
"Use templates/kill-report.md as the report template.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-path-ext-run" / "SKILL.md").read_text()
|
||||
assert ".specify/extensions/path-ext/agents/control/commander.md" in content
|
||||
assert ".specify/extensions/path-ext/knowledge-base/agent-scores.yaml" in content
|
||||
# extension's own templates/ dir must resolve under the extension,
|
||||
# not the project-level .specify/templates/
|
||||
assert ".specify/extensions/path-ext/templates/kill-report.md" in content
|
||||
assert "Read agents/control" not in content
|
||||
assert "and knowledge-base/" not in content
|
||||
|
||||
@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
|
||||
|
||||
@@ -2376,185 +2376,6 @@ Run {SCRIPT}
|
||||
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
|
||||
assert ".specify/scripts/bash/setup-plan.sh" not in content
|
||||
|
||||
@staticmethod
|
||||
def _make_subdir_extension(temp_dir, ext_id="echelon", aliases=None):
|
||||
"""Create an extension whose command body references bundled subdirs."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / ext_id
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "agents" / "control").mkdir(parents=True)
|
||||
(ext_dir / "knowledge-base").mkdir()
|
||||
(ext_dir / "templates").mkdir()
|
||||
(ext_dir / "specs" / "001-internal").mkdir(parents=True)
|
||||
|
||||
command = {
|
||||
"name": f"speckit.{ext_id}.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Run",
|
||||
}
|
||||
if aliases:
|
||||
command["aliases"] = aliases
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": ext_id,
|
||||
"name": "Echelon",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"commands": [command]},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "run.md").write_text(
|
||||
"---\ndescription: Run\n---\n\n"
|
||||
"Read agents/control/commander.md for instructions.\n"
|
||||
"Load knowledge-base/agent-scores.yaml for calibration.\n"
|
||||
"Use templates/kill-report.md as output format.\n"
|
||||
"Artifacts go to specs/001-internal/plan.md.\n"
|
||||
"See commands/run.md for the source.\n"
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
def test_codex_skill_registration_rewrites_extension_subdir_paths(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Extension-relative subdir refs must point at the installed location."""
|
||||
ext_dir = self._make_subdir_extension(temp_dir)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
content = (skills_dir / "speckit-echelon-run" / "SKILL.md").read_text()
|
||||
assert ".specify/extensions/echelon/agents/control/commander.md" in content
|
||||
assert ".specify/extensions/echelon/knowledge-base/agent-scores.yaml" in content
|
||||
assert ".specify/extensions/echelon/templates/kill-report.md" in content
|
||||
assert "Read agents/" not in content
|
||||
# specs/ refs point at the user's project artifacts, never the extension
|
||||
assert "to specs/001-internal/plan.md" in content
|
||||
assert ".specify/extensions/echelon/specs/" not in content
|
||||
# commands/ refs are slash-command sources, not runtime reads
|
||||
assert "See commands/run.md" in content
|
||||
|
||||
def test_skill_registration_rewrites_extension_subdir_paths_in_aliases(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Alias skills reuse the rewritten body."""
|
||||
ext_dir = self._make_subdir_extension(
|
||||
temp_dir, ext_id="ext-alias-paths", aliases=["speckit.ext-alias-paths.go"]
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
alias_content = (
|
||||
skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md"
|
||||
).read_text()
|
||||
assert (
|
||||
".specify/extensions/ext-alias-paths/agents/control/commander.md"
|
||||
in alias_content
|
||||
)
|
||||
assert "Read agents/" not in alias_content
|
||||
|
||||
def test_markdown_registration_rewrites_extension_subdir_paths(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Markdown-format agents get the same rewrite via the shared path."""
|
||||
ext_dir = self._make_subdir_extension(temp_dir, ext_id="ext-md-paths")
|
||||
|
||||
amp_dir = project_dir / ".agents" / "commands"
|
||||
amp_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("amp", manifest, ext_dir, project_dir)
|
||||
|
||||
content = (amp_dir / "speckit.ext-md-paths.run.md").read_text()
|
||||
assert ".specify/extensions/ext-md-paths/agents/control/commander.md" in content
|
||||
assert "Read agents/" not in content
|
||||
|
||||
def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir):
|
||||
"""Only directories present in the extension are rewritten."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
ext_dir = temp_dir / "ext-existing"
|
||||
(ext_dir / "agents").mkdir(parents=True)
|
||||
(ext_dir / ".hidden").mkdir()
|
||||
|
||||
text = (
|
||||
"Read agents/one.md then knowledge-base/two.md.\n"
|
||||
"Also ./agents/three.md but not /agents/abs.md.\n"
|
||||
"Keep .hidden/secret.md alone.\n"
|
||||
)
|
||||
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "ext-existing", ext_dir
|
||||
)
|
||||
|
||||
assert ".specify/extensions/ext-existing/agents/one.md" in rewritten
|
||||
assert "Also .specify/extensions/ext-existing/agents/three.md" in rewritten
|
||||
# absolute paths keep their meaning
|
||||
assert "not /agents/abs.md" in rewritten
|
||||
# knowledge-base/ does not exist in this extension: left untouched
|
||||
assert "then knowledge-base/two.md" in rewritten
|
||||
assert ".hidden/secret.md" in rewritten
|
||||
assert ".specify/extensions/ext-existing/.hidden/" not in rewritten
|
||||
|
||||
def test_rewrite_extension_paths_handles_regex_special_replacement_text(
|
||||
self, temp_dir
|
||||
):
|
||||
"""subdir/extension_id containing regex-replacement-special characters
|
||||
(e.g. backslash / group references) must not raise or be misinterpreted
|
||||
by re.sub's replacement template (#2101).
|
||||
|
||||
The subdir name uses brackets rather than a backslash: on Windows,
|
||||
"\\" is a path separator, so a subdir literally named "assets\\q"
|
||||
would create nested directories "assets/q" instead of a single
|
||||
directory, and iterdir() would then only discover "assets" - never
|
||||
exercising the intended replacement text. extension_id isn't used to
|
||||
create a directory, so it's free to contain a real backslash/"\\1"
|
||||
to verify the callable replacement treats it literally.
|
||||
"""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
ext_dir = temp_dir / "ext-backslash"
|
||||
weird_subdir = "assets[q]"
|
||||
(ext_dir / weird_subdir).mkdir(parents=True)
|
||||
# sanity-check the cross-platform assumption above
|
||||
assert [p.name for p in ext_dir.iterdir()] == [weird_subdir]
|
||||
|
||||
text = f"Read {weird_subdir}/file.md but not /{weird_subdir}/abs.md.\n"
|
||||
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "ext\\1", ext_dir
|
||||
)
|
||||
|
||||
assert f".specify/extensions/ext\\1/{weird_subdir}/file.md" in rewritten
|
||||
# absolute paths are still left untouched
|
||||
assert f"/{weird_subdir}/abs.md" in rewritten
|
||||
|
||||
def test_rewrite_extension_paths_missing_dir_returns_text(self, temp_dir):
|
||||
"""A missing extension directory leaves the text unchanged."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
text = "Read agents/one.md."
|
||||
assert (
|
||||
AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "gone", temp_dir / "does-not-exist"
|
||||
)
|
||||
== text
|
||||
)
|
||||
|
||||
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Copilot agent with .agent.md extension."""
|
||||
# Create .github/agents directory (Copilot project)
|
||||
@@ -6603,42 +6424,6 @@ class TestExtensionPriorityCLI:
|
||||
plain = strip_ansi(result.output)
|
||||
assert "already has priority 5" in plain
|
||||
|
||||
def test_set_priority_repairs_corrupted_bool(self, extension_dir, project_dir):
|
||||
"""A corrupted boolean priority must be repaired, not skipped.
|
||||
|
||||
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
|
||||
stored ``True`` priority would short-circuit the ``already has
|
||||
priority 1`` skip path and never get rewritten to a real int —
|
||||
contradicting the comment that promises corrupted values are
|
||||
repaired. The guard must exclude bools (like normalize_priority).
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False, priority=5
|
||||
)
|
||||
# Inject a corrupted boolean priority (True == 1).
|
||||
manager.registry.update("test-ext", {"priority": True})
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "1"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = strip_ansi(result.output)
|
||||
# The corrupted bool must be repaired, not reported as already-set.
|
||||
assert "already has priority" not in plain
|
||||
assert "priority changed" in plain
|
||||
|
||||
# The stored value is now a real int, not a bool.
|
||||
reloaded = ExtensionManager(project_dir).registry.get("test-ext")
|
||||
assert reloaded["priority"] == 1
|
||||
assert not isinstance(reloaded["priority"], bool)
|
||||
|
||||
def test_set_priority_invalid_value(self, extension_dir, project_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -7843,188 +7628,3 @@ class TestConfigManagerNonMappingYaml:
|
||||
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
|
||||
executor = HookExecutor(tmp_path)
|
||||
assert executor._evaluate_condition("config.x is set", "jira") is False
|
||||
|
||||
|
||||
class TestConfigManagerEnvPrefixCollision:
|
||||
"""Prefix-colliding env vars must not crash or clobber nested config."""
|
||||
|
||||
def test_scalar_then_nested_yields_nested(self, tmp_path, monkeypatch):
|
||||
"""SPECKIT_X_CONNECTION=x then SPECKIT_X_CONNECTION_URL=y.
|
||||
|
||||
The scalar-first order previously raised TypeError ('str' object
|
||||
does not support item assignment) when the walk indexed into 'x'.
|
||||
"""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
assert cm._get_env_config() == {"connection": {"url": "y"}}
|
||||
|
||||
def test_nested_then_scalar_does_not_clobber(self, tmp_path, monkeypatch):
|
||||
"""Reverse order previously returned {'connection': 'x'}, losing url."""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
assert cm._get_env_config() == {"connection": {"url": "y"}}
|
||||
|
||||
def test_colliding_env_does_not_disable_hook_condition(self, tmp_path, monkeypatch):
|
||||
"""`config.connection.url is set` must stay True under colliding env.
|
||||
|
||||
Before the fix the TypeError propagated into should_execute_hook's
|
||||
blanket `except Exception: return False`, silently disabling the hook.
|
||||
"""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "testext"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "testext-config.yml").write_text(
|
||||
"connection:\n url: https://example.com\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
executor = HookExecutor(tmp_path)
|
||||
# Exercise the public API: before the fix the TypeError was swallowed
|
||||
# by should_execute_hook's `except Exception: return False`, so the
|
||||
# hook was silently disabled (False); after the fix it returns True.
|
||||
assert executor.should_execute_hook(
|
||||
{"condition": "config.connection.url is set", "extension": "testext"}
|
||||
) is True
|
||||
|
||||
def test_malformed_env_names_ignored(self, tmp_path, monkeypatch):
|
||||
"""A name with no key (SPECKIT_X_) or empty parts (consecutive
|
||||
underscores) must not create an entry under an empty key."""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_", "orphan") # no key at all
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_A__B", "z") # empty middle part
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
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,44 +152,6 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_metadata_lookup_is_bounded_and_redirect_validated(self):
|
||||
"""Release metadata reads stay bounded and use the caller's policy."""
|
||||
captured = {}
|
||||
|
||||
class OversizedResponse:
|
||||
def read(self, amount=None):
|
||||
captured["read_amount"] = amount
|
||||
return b"x" * amount
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def redirect_validator(old_url, new_url):
|
||||
return None
|
||||
|
||||
def fake_open(
|
||||
url,
|
||||
timeout=None,
|
||||
extra_headers=None,
|
||||
redirect_validator=None,
|
||||
):
|
||||
captured["redirect_validator"] = redirect_validator
|
||||
return OversizedResponse()
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1/pack.zip",
|
||||
fake_open,
|
||||
redirect_validator=redirect_validator,
|
||||
max_metadata_bytes=8,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert captured["read_amount"] == 9
|
||||
assert captured["redirect_validator"] is redirect_validator
|
||||
|
||||
def test_tag_with_special_characters_is_url_encoded(self):
|
||||
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
|
||||
captured_urls = []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -112,14 +112,8 @@ Run a shell command and capture output:
|
||||
- id: run-tests
|
||||
type: shell
|
||||
run: "cd {{ inputs.project_dir }} && npm test"
|
||||
timeout: 1800 # Optional: max seconds before the command is killed (default 300)
|
||||
```
|
||||
|
||||
`timeout` is the maximum time in seconds the command may run before it is
|
||||
killed and the step fails; it must be a positive number and defaults to
|
||||
`300` (five minutes) when omitted. Raise it for long-running gates such as
|
||||
full builds, linter aggregators, or integration-test targets.
|
||||
|
||||
### Init Steps
|
||||
|
||||
Bootstrap a project the same way `specify init` does — scaffolding
|
||||
@@ -348,7 +342,6 @@ 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.
|
||||
@@ -366,11 +359,6 @@ 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
|
||||
@@ -451,7 +439,6 @@ 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