mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2380ea0a | ||
|
|
c52ccd7dc7 | ||
|
|
9cd20c6c25 | ||
|
|
497ca074ed | ||
|
|
6d057b6239 | ||
|
|
1150d32aee | ||
|
|
0fad994e86 | ||
|
|
b1348d1f01 | ||
|
|
79b3f6733a | ||
|
|
6c098ce1e0 | ||
|
|
00c15bc54c | ||
|
|
3b6b6f9f33 |
31
CHANGELOG.md
31
CHANGELOG.md
@@ -2,6 +2,37 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.11.0] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Add workflow step catalog — community-installable step types (#2394)
|
||||
- feat(dev): add integration scaffolder (#2685)
|
||||
- Add Command Density preset to community catalog (#3006)
|
||||
- fix(tests): don't run PowerShell tests via WSL-interop powershell.exe (#2971)
|
||||
- Add Zed integration (#2780)
|
||||
- Update architecture-governance preset to v0.5.0 (#2929)
|
||||
- Update Superpowers Implementation Bridge extension to v1.1.0 (#3011)
|
||||
- Update isaqb-architecture-governance preset to v0.2.0 (#2984)
|
||||
- Update security-governance preset to v0.6.0 (#2932)
|
||||
- chore: update CITATION.cff to v0.10.2 (2026-06-11) (#2966)
|
||||
- chore: release 0.10.4, begin 0.10.5.dev0 development (#3010)
|
||||
|
||||
## [0.10.4] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: fail loudly when a fan-out 'items' expression does not resolve to a list (#2957)
|
||||
- refactor: move preset command handlers to presets/_commands.py (PR-6/8) (#2826)
|
||||
- Update agent-parity-governance preset to v0.3.0 (#2982)
|
||||
- Update cross-platform-governance preset to v0.2.0 (#2983)
|
||||
- Add Data Model Diagram extension to community catalog (#2922)
|
||||
- Add Spec Kit TLDR extension to community catalog (#3007)
|
||||
- docs: add guide for handling complex features (#3004)
|
||||
- Add Loop Engineering extension to community catalog (#3002)
|
||||
- Update MemoryLint extension to v1.5.1 (#3000)
|
||||
- chore: release 0.10.3, begin 0.10.4.dev0 development (#2999)
|
||||
|
||||
## [0.10.3] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -20,8 +20,8 @@ authors:
|
||||
repository-code: "https://github.com/github/spec-kit"
|
||||
url: "https://github.github.io/spec-kit/"
|
||||
license: MIT
|
||||
version: "0.7.3"
|
||||
date-released: "2026-04-17"
|
||||
version: "0.10.2"
|
||||
date-released: "2026-06-11"
|
||||
keywords:
|
||||
- spec-driven development
|
||||
- ai coding agents
|
||||
|
||||
@@ -10,20 +10,21 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, inclusive-content guidance, and didactic inline-code-comment review | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds secure development governance: memory-safe-language preference, language-specific secure-coding profiles, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/AI-SBOM, VEX/SLSA, OpenSSF Scorecard, G7/BSI AI-SBOM target evidence, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Define what to build before building it — with any AI coding agent.**
|
||||
|
||||
Spec Kit is a toolkit for [Spec-Driven Development](concepts/sdd.md) (SDD), a methodology that puts specifications at the center of AI-assisted software development. Instead of jumping straight to code, you describe *what* to build, refine it through structured phases, and let your AI coding agent implement it.
|
||||
Spec Kit is a toolkit for [Spec-Driven Development](concepts/sdd.md) (SDD), a methodology that puts specifications at the center of AI-assisted software development. Instead of jumping straight to code, you describe _what_ to build, refine it through structured phases, and let your AI coding agent implement it.
|
||||
|
||||
<a href="installation.md" class="btn btn-primary btn-lg">Install Spec Kit</a>
|
||||
<a href="quickstart.md" class="btn btn-outline-primary btn-lg">Quick Start</a>
|
||||
@@ -31,7 +31,7 @@ Define what to build before building it. Rich templates, quality checklists, and
|
||||
|
||||
### Use any coding agent
|
||||
|
||||
<span class="pillar-stat">30 integrations</span> — Copilot, Gemini, Codex, Windsurf, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
|
||||
<span class="pillar-stat">30+ integrations</span> — Copilot, Gemini, Codex, Windsurf, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
|
||||
|
||||
Run `specify init` with your agent of choice and Spec Kit sets up the right command files, context rules, and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool.
|
||||
|
||||
@@ -90,7 +90,7 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
|
||||
<span class="stat-label">Contributors</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">30</span>
|
||||
<span class="stat-number">30+</span>
|
||||
<span class="stat-label">Integrations</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
|
||||
@@ -98,15 +98,41 @@ ls -l scripts | grep .sh
|
||||
|
||||
On Windows you will instead use the `.ps1` scripts (no chmod needed).
|
||||
|
||||
## 6. Run Lint / Basic Checks (Add Your Own)
|
||||
## 6. Scaffold a Built-In Integration
|
||||
|
||||
Currently no enforced lint config is bundled, but you can quickly sanity check importability:
|
||||
Use the integration scaffold command to create the initial Python package and
|
||||
test skeleton for a new built-in integration:
|
||||
|
||||
```bash
|
||||
specify integration scaffold my-agent --type markdown
|
||||
specify integration scaffold my-agent --type toml
|
||||
specify integration scaffold my-agent --type yaml
|
||||
specify integration scaffold my-agent --type skills
|
||||
```
|
||||
|
||||
Hyphenated keys are converted to Python-safe package names, for example
|
||||
`my-agent` creates `src/specify_cli/integrations/my_agent/` and
|
||||
`tests/integrations/test_integration_my_agent.py`.
|
||||
|
||||
The scaffold does not register the integration automatically. Review the
|
||||
generated metadata, then add the import and `_register()` call in
|
||||
`src/specify_cli/integrations/__init__.py`.
|
||||
|
||||
## 7. Run Lint / Basic Checks
|
||||
|
||||
CI enforces `ruff check src/` (see `.github/workflows/test.yml`), so run it locally before pushing:
|
||||
|
||||
```bash
|
||||
uvx ruff check src/
|
||||
```
|
||||
|
||||
You can also quickly sanity check importability:
|
||||
|
||||
```bash
|
||||
python -c "import specify_cli; print('Import OK')"
|
||||
```
|
||||
|
||||
## 7. Build a Wheel Locally (Optional)
|
||||
## 8. Build a Wheel Locally (Optional)
|
||||
|
||||
Validate packaging before publishing:
|
||||
|
||||
@@ -117,7 +143,7 @@ ls dist/
|
||||
|
||||
Install the built artifact into a fresh throwaway environment if needed.
|
||||
|
||||
## 8. Using a Temporary Workspace
|
||||
## 9. Using a Temporary Workspace
|
||||
|
||||
When testing `init --here` in a dirty directory, create a temp workspace:
|
||||
|
||||
@@ -128,7 +154,7 @@ python -m src.specify_cli init --here --integration claude --ignore-agent-tools
|
||||
|
||||
Or copy only the modified CLI portion if you want a lighter sandbox.
|
||||
|
||||
## 9. Debug Network / TLS Issues
|
||||
## 10. Debug Network / TLS Issues
|
||||
|
||||
> **Deprecated:** The `--skip-tls` flag is a no-op and has no effect.
|
||||
> It was previously used to bypass TLS validation during local testing.
|
||||
@@ -137,7 +163,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox.
|
||||
>
|
||||
> For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`.
|
||||
|
||||
## 10. Rapid Edit Loop Summary
|
||||
## 11. Rapid Edit Loop Summary
|
||||
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
@@ -148,7 +174,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox.
|
||||
| Git branch uvx | `uvx --from git+URL@branch specify ...` |
|
||||
| Build wheel | `uv build` |
|
||||
|
||||
## 11. Cleaning Up
|
||||
## 12. Cleaning Up
|
||||
|
||||
Remove build artifacts / virtual env quickly:
|
||||
|
||||
@@ -156,7 +182,7 @@ Remove build artifacts / virtual env quickly:
|
||||
rm -rf .venv dist build *.egg-info
|
||||
```
|
||||
|
||||
## 12. Common Issues
|
||||
## 13. Common Issues
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
@@ -166,7 +192,7 @@ rm -rf .venv dist build *.egg-info
|
||||
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
|
||||
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
||||
|
||||
## 13. Next Steps
|
||||
## 14. Next Steps
|
||||
|
||||
- Update docs and run through Quick Start using your modified CLI
|
||||
- Open a PR when satisfied
|
||||
|
||||
@@ -38,6 +38,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
|
||||
| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |
|
||||
| [Windsurf](https://windsurf.com/) | `windsurf` | |
|
||||
| [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-<command>` |
|
||||
| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir <path>"` for AI coding agents not listed above |
|
||||
|
||||
## List Available Integrations
|
||||
|
||||
@@ -3174,8 +3174,8 @@
|
||||
"id": "speckit-superpowers-bridge",
|
||||
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
|
||||
"author": "lihan3238",
|
||||
"version": "1.0.3",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v1.0.3/speckit-superpowers-bridge-v1.0.3.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v1.1.0/speckit-superpowers-bridge-v1.1.0.zip",
|
||||
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-14T00:00:00Z",
|
||||
"updated_at": "2026-06-16T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -92,11 +92,11 @@
|
||||
"architecture-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "architecture-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds secure architecture governance, threat modeling, STRIDE/CAPEC, Zero Trust, S-ADRs, and OWASP SAMM to Spec Kit.",
|
||||
"version": "0.5.0",
|
||||
"description": "Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -104,7 +104,7 @@
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 11,
|
||||
"templates": 13,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -112,10 +112,20 @@
|
||||
"governance",
|
||||
"threat-modeling",
|
||||
"stride",
|
||||
"zero-trust"
|
||||
"capec",
|
||||
"arc42",
|
||||
"adr",
|
||||
"zero-trust",
|
||||
"samm",
|
||||
"isaqb",
|
||||
"cloud",
|
||||
"sovereignty",
|
||||
"c3a",
|
||||
"c5",
|
||||
"assurance"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
@@ -168,6 +178,34 @@
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
},
|
||||
"command-density": {
|
||||
"name": "Command Density",
|
||||
"id": "command-density",
|
||||
"version": "1.0.0",
|
||||
"description": "Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure.",
|
||||
"author": "Maksim Kudriavtsev",
|
||||
"repository": "https://github.com/Xopoko/spec-kit-preset-command-density",
|
||||
"download_url": "https://github.com/Xopoko/spec-kit-preset-command-density/archive/refs/tags/v1.0.0.zip",
|
||||
"homepage": "https://github.com/Xopoko/spec-kit-preset-command-density",
|
||||
"documentation": "https://github.com/Xopoko/spec-kit-preset-command-density/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.10.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 0,
|
||||
"commands": 9
|
||||
},
|
||||
"tags": [
|
||||
"commands",
|
||||
"tokens",
|
||||
"compact",
|
||||
"workflow",
|
||||
"prompt-density"
|
||||
],
|
||||
"created_at": "2026-06-16T00:00:00Z",
|
||||
"updated_at": "2026-06-16T00:00:00Z"
|
||||
},
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
@@ -303,11 +341,11 @@
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Adds general iSAQB/CPSA-F and arc42 architecture governance, including views, quality scenarios, ADRs, risks, and technical debt.",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -322,11 +360,15 @@
|
||||
"architecture",
|
||||
"governance",
|
||||
"isaqb",
|
||||
"cpsa-f",
|
||||
"arc42",
|
||||
"adr"
|
||||
"adr",
|
||||
"quality-attributes",
|
||||
"architecture-views",
|
||||
"technical-debt"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
@@ -479,11 +521,11 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, ASVS verification, SBOM/AI-SBOM supply-chain transparency, and EU Cyber Resilience Act awareness.",
|
||||
"version": "0.6.0",
|
||||
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA to Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -491,7 +533,7 @@
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 12,
|
||||
"templates": 14,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -516,10 +558,15 @@
|
||||
"typescript",
|
||||
"g7",
|
||||
"bsi",
|
||||
"cra"
|
||||
"cra",
|
||||
"cyber-resilience-act",
|
||||
"nis2",
|
||||
"ai-act",
|
||||
"dora",
|
||||
"regulatory"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-05-26T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"spec2cloud": {
|
||||
"name": "Spec2Cloud",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.10.4.dev0"
|
||||
version = "0.11.0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -2058,6 +2058,20 @@ workflow_catalog_app = typer.Typer(
|
||||
)
|
||||
workflow_app.add_typer(workflow_catalog_app, name="catalog")
|
||||
|
||||
workflow_step_app = typer.Typer(
|
||||
name="step",
|
||||
help="Manage workflow step types",
|
||||
add_completion=False,
|
||||
)
|
||||
workflow_app.add_typer(workflow_step_app, name="step")
|
||||
|
||||
workflow_step_catalog_app = typer.Typer(
|
||||
name="catalog",
|
||||
help="Manage step catalogs",
|
||||
add_completion=False,
|
||||
)
|
||||
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
||||
"""Parse repeated ``key=value`` CLI inputs into a dict.
|
||||
@@ -2139,6 +2153,7 @@ def workflow_run(
|
||||
),
|
||||
):
|
||||
"""Run a workflow from an installed ID or local YAML path."""
|
||||
from .workflows import load_custom_steps
|
||||
from .workflows.engine import WorkflowEngine
|
||||
|
||||
source_path = Path(source).expanduser()
|
||||
@@ -2158,6 +2173,7 @@ def workflow_run(
|
||||
else:
|
||||
project_root = _require_specify_project()
|
||||
|
||||
load_custom_steps(project_root)
|
||||
engine = WorkflowEngine(project_root)
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
@@ -2227,9 +2243,11 @@ def workflow_resume(
|
||||
),
|
||||
):
|
||||
"""Resume a paused or failed workflow run."""
|
||||
from .workflows import load_custom_steps
|
||||
from .workflows.engine import WorkflowEngine
|
||||
|
||||
project_root = _require_specify_project()
|
||||
load_custom_steps(project_root)
|
||||
engine = WorkflowEngine(project_root)
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
@@ -2819,6 +2837,662 @@ def workflow_catalog_remove(
|
||||
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
|
||||
|
||||
|
||||
# ===== Workflow Step Commands =====
|
||||
|
||||
@workflow_step_app.command("list")
|
||||
def workflow_step_list():
|
||||
"""List installed step types (built-in and custom)."""
|
||||
from .workflows import STEP_REGISTRY
|
||||
from .workflows.catalog import StepRegistry
|
||||
|
||||
project_root = _require_specify_project()
|
||||
specify_dir = project_root / ".specify"
|
||||
|
||||
# Read installed custom steps from registry only — no dynamic imports
|
||||
installed: dict = {}
|
||||
if specify_dir.exists():
|
||||
registry = StepRegistry(project_root)
|
||||
installed = registry.list()
|
||||
|
||||
console.print("\n[bold cyan]Installed Step Types:[/bold cyan]\n")
|
||||
|
||||
built_in = sorted(k for k in STEP_REGISTRY if k not in installed)
|
||||
if built_in:
|
||||
console.print(" [bold]Built-in:[/bold]")
|
||||
for key in built_in:
|
||||
console.print(f" • {key}")
|
||||
console.print()
|
||||
|
||||
if installed:
|
||||
console.print(" [bold]Custom (installed):[/bold]")
|
||||
for key in sorted(installed):
|
||||
meta = installed[key] or {}
|
||||
name = meta.get("name", key)
|
||||
version = meta.get("version", "?")
|
||||
console.print(f" • [bold]{name}[/bold] ({key}) v{version}")
|
||||
console.print()
|
||||
|
||||
if not built_in and not installed:
|
||||
console.print("[yellow]No step types found.[/yellow]")
|
||||
|
||||
if specify_dir.exists():
|
||||
console.print(
|
||||
" Install a new step type with: [cyan]specify workflow step add <id>[/cyan]"
|
||||
)
|
||||
|
||||
|
||||
# IDs that map to internal names used under .specify/workflows/steps/ and must
|
||||
# not be used as custom step IDs (dotfile check is done separately at runtime).
|
||||
_RESERVED_STEP_IDS: frozenset[str] = frozenset({".cache", "step-registry.json"})
|
||||
|
||||
# Windows reserved device names (case-insensitive, with or without extensions)
|
||||
_WINDOWS_RESERVED_NAMES: frozenset[str] = frozenset({
|
||||
"con", "prn", "aux", "nul",
|
||||
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
||||
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
|
||||
})
|
||||
|
||||
# Characters invalid in filenames on Windows
|
||||
_WINDOWS_INVALID_CHARS: frozenset[str] = frozenset('<>:"|?*')
|
||||
|
||||
|
||||
def _validate_step_id_or_exit(step_id: str) -> None:
|
||||
"""Validate that ``step_id`` is a single safe path component.
|
||||
|
||||
Rejects empty strings, whitespace-only strings, leading/trailing whitespace,
|
||||
path separators, ``.``/``..`` components, dotfile prefixes, reserved names,
|
||||
Windows-invalid filename characters, trailing dots/spaces, and Windows
|
||||
reserved device names. Exits with code 1 on failure.
|
||||
"""
|
||||
# Strip the stem (before first dot) for Windows reserved-name check
|
||||
stem = step_id.split(".")[0].lower() if step_id else ""
|
||||
if (
|
||||
not step_id
|
||||
or not step_id.strip()
|
||||
or step_id != step_id.strip()
|
||||
or "/" in step_id
|
||||
or "\\" in step_id
|
||||
or step_id in (".", "..")
|
||||
or step_id.startswith(".")
|
||||
or step_id.endswith(".")
|
||||
or step_id.endswith(" ")
|
||||
or step_id.lower() in _RESERVED_STEP_IDS
|
||||
or stem in _WINDOWS_RESERVED_NAMES
|
||||
or any(c in _WINDOWS_INVALID_CHARS for c in step_id)
|
||||
or any(ord(c) < 32 for c in step_id)
|
||||
):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid step id '{step_id}': must be a single safe "
|
||||
"path component (no separators, no leading dot, not a reserved name, "
|
||||
"no invalid filename characters)"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _resolve_steps_base_dir_or_exit(project_root: Path) -> Path:
|
||||
"""Resolve .specify/workflows/steps while refusing symlinked parent directories."""
|
||||
project_root_resolved = project_root.resolve()
|
||||
steps_base_dir_unresolved = project_root / ".specify" / "workflows" / "steps"
|
||||
|
||||
current = project_root
|
||||
for part in (".specify", "workflows", "steps"):
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
console.print(
|
||||
f"[red]Error:[/red] Refusing to use symlinked step directory '{current}'"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if current.exists() and not current.is_dir():
|
||||
console.print(
|
||||
f"[red]Error:[/red] Step directory path is not a directory: '{current}'"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
steps_base_dir = steps_base_dir_unresolved.resolve()
|
||||
try:
|
||||
steps_base_dir.relative_to(project_root_resolved)
|
||||
except ValueError:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Step directory escapes project root: '{steps_base_dir}'"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
return steps_base_dir
|
||||
|
||||
|
||||
@workflow_step_app.command("add")
|
||||
def workflow_step_add(
|
||||
step_id: str = typer.Argument(..., help="Step type ID from catalog"),
|
||||
):
|
||||
"""Install a custom step type from the step catalog."""
|
||||
from .workflows.catalog import StepCatalog, StepCatalogError, StepRegistry, StepValidationError
|
||||
|
||||
project_root = _require_specify_project()
|
||||
|
||||
catalog = StepCatalog(project_root)
|
||||
try:
|
||||
info = catalog.get_step_info(step_id)
|
||||
except StepCatalogError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not info:
|
||||
console.print(f"[red]Error:[/red] Step type '{step_id}' not found in catalog")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not info.get("_install_allowed", True):
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Step type '{step_id}' is from a discovery-only catalog"
|
||||
)
|
||||
console.print("Direct installation is not enabled for this catalog source.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Reject step IDs that collide with built-in step types
|
||||
from .workflows import STEP_REGISTRY as _step_reg
|
||||
if step_id in _step_reg:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Step type '{step_id}' conflicts with a built-in step type"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Reject if already installed
|
||||
registry = StepRegistry(project_root)
|
||||
if registry.is_installed(step_id):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Step type '{step_id}' is already installed. "
|
||||
"Remove it first with: [cyan]specify workflow step remove "
|
||||
f"{step_id}[/cyan]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
step_yml_url = info.get("step_yml_url") or info.get("url")
|
||||
if not step_yml_url:
|
||||
console.print(f"[red]Error:[/red] Catalog entry for '{step_id}' has no URL")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Derive __init__.py URL: replace trailing step.yml with __init__.py
|
||||
# or use explicit init_url if provided.
|
||||
init_url = info.get("init_url")
|
||||
if not init_url:
|
||||
if step_yml_url.endswith("step.yml"):
|
||||
init_url = step_yml_url[: -len("step.yml")] + "__init__.py"
|
||||
else:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Cannot derive __init__.py URL from '{step_yml_url}'. "
|
||||
"Catalog entry should provide 'init_url' or a 'url' ending in 'step.yml'."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _safe_fetch(url: str) -> bytes:
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}")
|
||||
if not parsed.hostname:
|
||||
raise ValueError(f"Refusing to fetch from URL with no hostname: {url}")
|
||||
with _open_url(url, timeout=30) as resp:
|
||||
final_url = resp.geturl()
|
||||
final_parsed = urlparse(final_url)
|
||||
final_is_localhost = final_parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if final_parsed.scheme != "https" and not (
|
||||
final_parsed.scheme == "http" and final_is_localhost
|
||||
):
|
||||
raise ValueError(f"Redirect to non-HTTPS URL: {final_url}")
|
||||
if not final_parsed.hostname:
|
||||
raise ValueError(f"Redirect to URL with no hostname: {final_url}")
|
||||
return resp.read()
|
||||
|
||||
_validate_step_id_or_exit(step_id)
|
||||
|
||||
steps_base_dir = _resolve_steps_base_dir_or_exit(project_root)
|
||||
step_dir = (steps_base_dir / step_id).resolve()
|
||||
# Defense-in-depth: ensure the resolved directory is a direct child of
|
||||
# steps_base_dir even after symlink resolution.
|
||||
try:
|
||||
rel_parts = step_dir.relative_to(steps_base_dir).parts
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
|
||||
raise typer.Exit(1)
|
||||
if rel_parts != (step_id,):
|
||||
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
# Refuse if step_dir already exists (e.g. leftover from a previous failed/manual
|
||||
# install that wasn't registered). The user should remove it before retrying.
|
||||
if step_dir.exists():
|
||||
console.print(
|
||||
f"[red]Error:[/red] Step directory already exists at '{step_dir}'. "
|
||||
f"Remove it manually or use: [cyan]specify workflow step remove {step_id}[/cyan]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Create steps_base_dir now so the staging temp dir is on the same filesystem,
|
||||
# enabling a truly atomic os.rename() below.
|
||||
try:
|
||||
steps_base_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = Path(tempfile.mkdtemp(prefix="speckit_step_tmp_", dir=steps_base_dir))
|
||||
except OSError as exc:
|
||||
console.print(f"[red]Error:[/red] Failed to create staging directory: {exc}")
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
try:
|
||||
step_yml_content = _safe_fetch(step_yml_url)
|
||||
init_py_content = _safe_fetch(init_url)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error:[/red] Failed to download step files: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate step.yml
|
||||
try:
|
||||
import yaml as _yaml
|
||||
|
||||
meta = _yaml.safe_load(step_yml_content.decode("utf-8")) or {}
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error:[/red] Invalid step.yml: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not isinstance(meta, dict):
|
||||
console.print("[red]Error:[/red] step.yml must be a YAML mapping")
|
||||
raise typer.Exit(1)
|
||||
|
||||
step_meta = meta.get("step", {})
|
||||
if not isinstance(step_meta, dict):
|
||||
console.print("[red]Error:[/red] step.yml 'step' field must be a mapping")
|
||||
raise typer.Exit(1)
|
||||
type_key = step_meta.get("type_key", "")
|
||||
if not type_key:
|
||||
console.print("[red]Error:[/red] step.yml missing 'step.type_key' field")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if type_key != step_id:
|
||||
console.print(
|
||||
f"[red]Error:[/red] step.yml type_key ({type_key!r}) does not match "
|
||||
f"catalog ID ({step_id!r})"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Write the two required files.
|
||||
try:
|
||||
(tmp_path / "step.yml").write_bytes(step_yml_content)
|
||||
(tmp_path / "__init__.py").write_bytes(init_py_content)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to write step files to staging directory: {exc}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Optionally download additional package files declared in the catalog entry
|
||||
# (e.g. helper modules). Each entry in ``extra_files`` is a mapping of
|
||||
# relative-path → URL. step.yml and __init__.py are ignored here (already
|
||||
# written). Paths are validated to stay within the step package directory to
|
||||
# prevent path-traversal attacks.
|
||||
extra_files = info.get("extra_files")
|
||||
if extra_files is not None and not isinstance(extra_files, dict):
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
|
||||
"additional package files will not be downloaded."
|
||||
)
|
||||
extra_files = {}
|
||||
for rel_path, file_url in (extra_files or {}).items():
|
||||
if not isinstance(rel_path, str) or not rel_path.strip():
|
||||
console.print(
|
||||
"[red]Error:[/red] Catalog entry 'extra_files' contains an "
|
||||
"empty or non-string path key"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if rel_path in ("step.yml", "__init__.py"):
|
||||
continue # already written above
|
||||
# Reject dot-path segments ('', '.', '..') that would refer to the
|
||||
# package directory itself (IsADirectoryError) or escape it.
|
||||
rel_parts = Path(rel_path).parts
|
||||
if not rel_parts or any(seg in ("", ".", "..") for seg in rel_parts):
|
||||
console.print(
|
||||
f"[red]Error:[/red] extra_files path '{rel_path}' is not a "
|
||||
"valid relative file path"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if not isinstance(file_url, str) or not file_url.strip():
|
||||
console.print(
|
||||
f"[red]Error:[/red] extra_files entry '{rel_path}' has an "
|
||||
"empty or non-string URL"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
# Resolve both destination and base to handle any symlinks in tmp_path itself,
|
||||
# ensuring the traversal check is robust even on non-canonical paths.
|
||||
resolved_base = tmp_path.resolve()
|
||||
dest = (tmp_path / rel_path).resolve()
|
||||
try:
|
||||
dest.relative_to(resolved_base)
|
||||
except ValueError:
|
||||
console.print(
|
||||
f"[red]Error:[/red] extra_files path '{rel_path}' is outside "
|
||||
"the step package directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
file_content = _safe_fetch(file_url)
|
||||
except Exception as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(file_content)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to write extra file '{rel_path}': {exc}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Atomically rename the staging directory to the final location.
|
||||
# Both paths are under steps_base_dir (same filesystem), so os.rename()
|
||||
# is atomic on POSIX and won't leave a partially-written directory at
|
||||
# step_dir on failure.
|
||||
try:
|
||||
os.rename(tmp_path, step_dir)
|
||||
except OSError as exc:
|
||||
console.print(f"[red]Error:[/red] Failed to install step '{step_id}': {exc}")
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
# Clean up if the rename hasn't moved tmp_path yet (i.e. on any failure).
|
||||
shutil.rmtree(tmp_path, ignore_errors=True)
|
||||
|
||||
step_name = info.get("name") or step_id
|
||||
step_version = info.get("version") or step_meta.get("version") or "0.0.0"
|
||||
|
||||
# Register in step registry
|
||||
registry = StepRegistry(project_root)
|
||||
try:
|
||||
registry.add(
|
||||
step_id,
|
||||
{
|
||||
"name": step_name,
|
||||
"version": step_version,
|
||||
"description": info.get("description", step_meta.get("description", "")),
|
||||
"author": info.get("author", step_meta.get("author", "")),
|
||||
"source": "catalog",
|
||||
"catalog_name": info.get("_catalog_name", ""),
|
||||
"type_key": type_key,
|
||||
},
|
||||
)
|
||||
except StepValidationError as exc:
|
||||
# Roll back the just-installed directory so the system isn't left with
|
||||
# an unregistered step package on disk after a registry write failure
|
||||
# (e.g. read-only filesystem, permission denied).
|
||||
shutil.rmtree(step_dir, ignore_errors=True)
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Step type '{step_name}' ({step_id}) installed"
|
||||
)
|
||||
console.print(
|
||||
" Use [cyan]specify workflow step list[/cyan] to verify the installation."
|
||||
)
|
||||
|
||||
|
||||
@workflow_step_app.command("remove")
|
||||
def workflow_step_remove(
|
||||
step_id: str = typer.Argument(..., help="Step type ID to uninstall"),
|
||||
):
|
||||
"""Uninstall a custom step type."""
|
||||
from .workflows.catalog import StepRegistry, StepValidationError
|
||||
|
||||
project_root = _require_specify_project()
|
||||
|
||||
_validate_step_id_or_exit(step_id)
|
||||
|
||||
registry = StepRegistry(project_root)
|
||||
in_registry = registry.is_installed(step_id)
|
||||
|
||||
steps_base_dir = _resolve_steps_base_dir_or_exit(project_root)
|
||||
step_dir = (steps_base_dir / step_id).resolve()
|
||||
# Defense-in-depth: even though _validate_step_id_or_exit rejects path
|
||||
# separators, ensure that the resolved directory is a single child of
|
||||
# steps_base_dir and is not steps_base_dir itself.
|
||||
try:
|
||||
rel_parts = step_dir.relative_to(steps_base_dir).parts
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
|
||||
raise typer.Exit(1)
|
||||
if rel_parts != (step_id,):
|
||||
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
dir_exists = step_dir.exists()
|
||||
|
||||
if not in_registry and not dir_exists:
|
||||
console.print(f"[red]Error:[/red] Step type '{step_id}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not in_registry and dir_exists:
|
||||
# The registry was likely reset due to corruption. Warn the user that the
|
||||
# directory is being removed even though there is no registry entry, so
|
||||
# the orphaned package can be cleaned up and a fresh install attempted.
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] '{step_id}' has no registry entry "
|
||||
"(registry may have been reset). Removing the orphaned directory."
|
||||
)
|
||||
|
||||
if dir_exists and not in_registry:
|
||||
# No registry write needed; just delete the orphaned directory.
|
||||
import shutil
|
||||
try:
|
||||
shutil.rmtree(step_dir)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to remove step directory {step_dir}: {exc}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
elif in_registry:
|
||||
# Remove the registry entry, then the directory. If the directory
|
||||
# delete fails, restore the registry entry so state stays consistent
|
||||
# and a future `step add` isn't blocked by an orphaned directory
|
||||
# with no registry entry.
|
||||
registry_metadata = registry.get(step_id)
|
||||
try:
|
||||
registry.remove(step_id)
|
||||
except StepValidationError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
if dir_exists:
|
||||
import shutil
|
||||
try:
|
||||
shutil.rmtree(step_dir)
|
||||
except OSError as exc:
|
||||
# Restore the original registry entry verbatim (bypass add()
|
||||
# which would overwrite timestamps).
|
||||
try:
|
||||
if registry_metadata is not None:
|
||||
registry.data["steps"][step_id] = registry_metadata
|
||||
registry.save()
|
||||
except Exception as restore_exc: # noqa: BLE001
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Failed to restore registry entry "
|
||||
f"for '{step_id}' after directory removal failure: {restore_exc}"
|
||||
)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to remove step directory {step_dir}: {exc}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(f"[green]✓[/green] Step type '{step_id}' uninstalled")
|
||||
|
||||
|
||||
@workflow_step_app.command("search")
|
||||
def workflow_step_search(
|
||||
query: str | None = typer.Argument(None, help="Search query"),
|
||||
):
|
||||
"""Search the step type catalog."""
|
||||
from .workflows.catalog import StepCatalog, StepCatalogError
|
||||
|
||||
project_root = _require_specify_project()
|
||||
|
||||
catalog = StepCatalog(project_root)
|
||||
|
||||
try:
|
||||
results = catalog.search(query=query)
|
||||
except StepCatalogError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not results:
|
||||
if query:
|
||||
console.print(f"[yellow]No step types found matching '{query}'.[/yellow]")
|
||||
else:
|
||||
console.print("[yellow]No step types found in catalog.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold cyan]Step Types ({len(results)}):[/bold cyan]\n")
|
||||
for step in results:
|
||||
install_note = (
|
||||
"" if step.get("_install_allowed", True) else " [dim](discovery only)[/dim]"
|
||||
)
|
||||
console.print(
|
||||
f" [bold]{step.get('name', step.get('id', '?'))}[/bold]"
|
||||
f" ({step.get('id', '?')}) v{step.get('version', '?')}{install_note}"
|
||||
)
|
||||
desc = step.get("description", "")
|
||||
if desc:
|
||||
console.print(f" {desc}")
|
||||
console.print()
|
||||
|
||||
|
||||
@workflow_step_app.command("info")
|
||||
def workflow_step_info(
|
||||
step_id: str = typer.Argument(..., help="Step type ID"),
|
||||
):
|
||||
"""Show details for a step type."""
|
||||
from .workflows import STEP_REGISTRY
|
||||
from .workflows.catalog import StepCatalog, StepCatalogError, StepRegistry
|
||||
|
||||
project_root = _require_specify_project()
|
||||
|
||||
registry = StepRegistry(project_root)
|
||||
installed_meta = registry.get(step_id)
|
||||
|
||||
# Check if it's a built-in
|
||||
builtin_step = STEP_REGISTRY.get(step_id)
|
||||
is_builtin = builtin_step is not None and not installed_meta
|
||||
|
||||
if is_builtin:
|
||||
console.print(f"\n[bold cyan]{step_id}[/bold cyan] [dim](built-in)[/dim]")
|
||||
console.print(f" Type key: {step_id}")
|
||||
console.print(" [green]Built-in step type[/green]")
|
||||
return
|
||||
|
||||
if installed_meta:
|
||||
console.print(
|
||||
f"\n[bold cyan]{installed_meta.get('name', step_id)}[/bold cyan] ({step_id})"
|
||||
)
|
||||
console.print(f" Version: {installed_meta.get('version', '?')}")
|
||||
if installed_meta.get("author"):
|
||||
console.print(f" Author: {installed_meta['author']}")
|
||||
if installed_meta.get("description"):
|
||||
console.print(f" Description: {installed_meta['description']}")
|
||||
console.print(" [green]Installed[/green]")
|
||||
return
|
||||
|
||||
# Try catalog
|
||||
catalog = StepCatalog(project_root)
|
||||
try:
|
||||
info = catalog.get_step_info(step_id)
|
||||
except StepCatalogError:
|
||||
info = None
|
||||
|
||||
if info:
|
||||
console.print(
|
||||
f"\n[bold cyan]{info.get('name', step_id)}[/bold cyan] ({step_id})"
|
||||
)
|
||||
console.print(f" Version: {info.get('version', '?')}")
|
||||
if info.get("author"):
|
||||
console.print(f" Author: {info['author']}")
|
||||
if info.get("description"):
|
||||
console.print(f" Description: {info['description']}")
|
||||
console.print(" [yellow]Not installed[/yellow]")
|
||||
console.print(
|
||||
f"\n Install with: [cyan]specify workflow step add {step_id}[/cyan]"
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Step type '{step_id}' not found")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@workflow_step_catalog_app.command("list")
|
||||
def workflow_step_catalog_list():
|
||||
"""List configured step catalog sources."""
|
||||
from .workflows.catalog import StepCatalog, StepCatalogError
|
||||
|
||||
project_root = _require_specify_project()
|
||||
catalog = StepCatalog(project_root)
|
||||
|
||||
try:
|
||||
configs = catalog.get_catalog_configs()
|
||||
except StepCatalogError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("\n[bold cyan]Step Catalog Sources:[/bold cyan]\n")
|
||||
for i, cfg in enumerate(configs):
|
||||
install_status = (
|
||||
"[green]install allowed[/green]"
|
||||
if cfg["install_allowed"]
|
||||
else "[yellow]discovery only[/yellow]"
|
||||
)
|
||||
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
|
||||
console.print(f" {cfg['url']}")
|
||||
if cfg.get("description"):
|
||||
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@workflow_step_catalog_app.command("add")
|
||||
def workflow_step_catalog_add(
|
||||
url: str = typer.Argument(..., help="Catalog URL to add"),
|
||||
name: str = typer.Option(None, "--name", help="Catalog name"),
|
||||
):
|
||||
"""Add a step catalog source."""
|
||||
from .workflows.catalog import StepCatalog, StepValidationError
|
||||
|
||||
project_root = _require_specify_project()
|
||||
|
||||
catalog = StepCatalog(project_root)
|
||||
try:
|
||||
catalog.add_catalog(url, name)
|
||||
except StepValidationError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[green]✓[/green] Step catalog source added: {url}")
|
||||
|
||||
|
||||
@workflow_step_catalog_app.command("remove")
|
||||
def workflow_step_catalog_remove(
|
||||
index: int = typer.Argument(
|
||||
..., help="Catalog index to remove (from 'step catalog list')"
|
||||
),
|
||||
):
|
||||
"""Remove a step catalog source by index."""
|
||||
from .workflows.catalog import StepCatalog, StepValidationError
|
||||
|
||||
project_root = _require_specify_project()
|
||||
|
||||
catalog = StepCatalog(project_root)
|
||||
try:
|
||||
removed_name = catalog.remove_catalog(index)
|
||||
except StepValidationError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[green]✓[/green] Step catalog source '{removed_name}' removed")
|
||||
|
||||
|
||||
def main():
|
||||
# On Windows the default stdout/stderr code page (e.g. cp1252) cannot encode
|
||||
# the Rich banner and box-drawing glyphs, so the CLI crashes with
|
||||
|
||||
45
src/specify_cli/_invocation_style.py
Normal file
45
src/specify_cli/_invocation_style.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Agent invocation-style constants and helpers.
|
||||
|
||||
Agents that scaffold skills (``speckit-<name>/SKILL.md``) use different
|
||||
slash-command invocation formats depending on the agent. This module
|
||||
centralises the mapping so that ``HookExecutor._render_hook_invocation``
|
||||
and ``specify init``'s next-steps output stay consistent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Agents that always render /speckit-<name>, regardless of ai_skills.
|
||||
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(
|
||||
{
|
||||
"agy",
|
||||
"claude",
|
||||
"copilot",
|
||||
"cursor-agent",
|
||||
"hermes",
|
||||
"lingma",
|
||||
"rovodev",
|
||||
"vibe",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
|
||||
"""Return ``True`` if *selected_ai* uses ``/speckit-<name>`` invocations.
|
||||
|
||||
The decision is based on the agent sets defined in this module:
|
||||
|
||||
* Agents in `ALWAYS_SLASH_AGENTS` always use slash invocations.
|
||||
* Agents in `CONDITIONAL_SLASH_AGENTS` only use them when
|
||||
*ai_skills_enabled* is ``True``.
|
||||
* All other agents return ``False``.
|
||||
"""
|
||||
if selected_ai is None:
|
||||
return False
|
||||
if not isinstance(selected_ai, str):
|
||||
return False
|
||||
return selected_ai in ALWAYS_SLASH_AGENTS or (
|
||||
selected_ai in CONDITIONAL_SLASH_AGENTS and ai_skills_enabled
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""specify init command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -35,7 +36,9 @@ def ensure_constitution_from_template(
|
||||
) -> None:
|
||||
"""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"
|
||||
template_constitution = (
|
||||
project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
)
|
||||
|
||||
if memory_constitution.exists():
|
||||
if tracker:
|
||||
@@ -62,24 +65,75 @@ def ensure_constitution_from_template(
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", str(e))
|
||||
else:
|
||||
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
|
||||
console.print(
|
||||
f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
||||
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
||||
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
||||
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
|
||||
debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True),
|
||||
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
||||
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
|
||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
project_name: str = typer.Argument(
|
||||
None,
|
||||
help="Name for your new project directory (optional if using --here, or use '.' for current directory)",
|
||||
),
|
||||
script_type: str = typer.Option(
|
||||
None, "--script", help="Script type to use: sh or ps"
|
||||
),
|
||||
ignore_agent_tools: bool = typer.Option(
|
||||
False,
|
||||
"--ignore-agent-tools",
|
||||
help="Skip checks for coding agent tools like Claude Code",
|
||||
),
|
||||
here: bool = typer.Option(
|
||||
False,
|
||||
"--here",
|
||||
help="Initialize project in the current directory instead of creating a new one",
|
||||
),
|
||||
force: bool = typer.Option(
|
||||
False,
|
||||
"--force",
|
||||
help="Force merge/overwrite when using --here (skip confirmation)",
|
||||
),
|
||||
skip_tls: bool = typer.Option(
|
||||
False,
|
||||
"--skip-tls",
|
||||
help="Deprecated (no-op). Previously: skip SSL/TLS verification.",
|
||||
hidden=True,
|
||||
),
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug",
|
||||
help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.",
|
||||
hidden=True,
|
||||
),
|
||||
github_token: str = typer.Option(
|
||||
None,
|
||||
"--github-token",
|
||||
help="Deprecated (no-op). Previously: GitHub token for API requests.",
|
||||
hidden=True,
|
||||
),
|
||||
offline: bool = typer.Option(
|
||||
False,
|
||||
"--offline",
|
||||
help="Deprecated (no-op). All scaffolding now uses bundled assets.",
|
||||
hidden=True,
|
||||
),
|
||||
preset: str = typer.Option(
|
||||
None,
|
||||
"--preset",
|
||||
help="Install a preset during initialization (by preset ID)",
|
||||
),
|
||||
integration: str = typer.Option(
|
||||
None,
|
||||
"--integration",
|
||||
help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations.",
|
||||
),
|
||||
integration_options: str = typer.Option(
|
||||
None,
|
||||
"--integration-options",
|
||||
help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")',
|
||||
),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project.
|
||||
@@ -121,15 +175,18 @@ def register(app: typer.Typer) -> None:
|
||||
ensure_executable_scripts,
|
||||
save_init_options,
|
||||
)
|
||||
from ..integration_runtime import (
|
||||
with_integration_setting as _with_integration_setting,
|
||||
)
|
||||
from ..integrations._commands import (
|
||||
_parse_integration_options,
|
||||
_write_integration_json,
|
||||
)
|
||||
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
||||
|
||||
show_banner()
|
||||
|
||||
from ..integrations import INTEGRATION_REGISTRY, get_integration
|
||||
|
||||
if integration:
|
||||
resolved_integration = get_integration(integration)
|
||||
if not resolved_integration:
|
||||
@@ -143,15 +200,17 @@ def register(app: typer.Typer) -> None:
|
||||
project_name = None
|
||||
|
||||
if here and project_name:
|
||||
console.print("[red]Error:[/red] Cannot specify both project name and --here flag")
|
||||
console.print(
|
||||
"[red]Error:[/red] Cannot specify both project name and --here flag"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not here and not project_name:
|
||||
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||
console.print(
|
||||
"[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
|
||||
dir_existed_before = False
|
||||
if here:
|
||||
project_name = Path.cwd().name
|
||||
@@ -160,10 +219,16 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
existing_items = list(project_path.iterdir())
|
||||
if existing_items:
|
||||
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]")
|
||||
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:
|
||||
console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]")
|
||||
console.print(
|
||||
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
|
||||
)
|
||||
else:
|
||||
response = typer.confirm("Do you want to continue?")
|
||||
if not response:
|
||||
@@ -174,14 +239,22 @@ def register(app: typer.Typer) -> None:
|
||||
dir_existed_before = project_path.exists()
|
||||
if project_path.exists():
|
||||
if not project_path.is_dir():
|
||||
console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.")
|
||||
console.print(
|
||||
f"[red]Error:[/red] '{project_name}' exists but is not a directory."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
existing_items = list(project_path.iterdir())
|
||||
if force:
|
||||
if existing_items:
|
||||
console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)")
|
||||
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
||||
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)"
|
||||
)
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]"
|
||||
)
|
||||
else:
|
||||
error_panel = Panel(
|
||||
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
||||
@@ -189,7 +262,7 @@ def register(app: typer.Typer) -> None:
|
||||
"Use [bold]--force[/bold] to merge into the existing directory.",
|
||||
title="[red]Directory Conflict[/red]",
|
||||
border_style="red",
|
||||
padding=(1, 2)
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(error_panel)
|
||||
@@ -197,7 +270,9 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
if integration:
|
||||
if integration not in AGENT_CONFIG:
|
||||
console.print(f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_ai = integration
|
||||
elif not _stdin_is_interactive():
|
||||
@@ -221,8 +296,12 @@ def register(app: typer.Typer) -> None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
if selected_ai == "generic" and not integration_options:
|
||||
console.print("[red]Error:[/red] --integration generic requires --integration-options with --commands-dir")
|
||||
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
|
||||
console.print(
|
||||
"[red]Error:[/red] --integration generic requires --integration-options with --commands-dir"
|
||||
)
|
||||
console.print(
|
||||
'[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]'
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
current_dir = Path.cwd()
|
||||
@@ -237,7 +316,9 @@ def register(app: typer.Typer) -> None:
|
||||
if not here:
|
||||
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
|
||||
|
||||
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
|
||||
console.print(
|
||||
Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))
|
||||
)
|
||||
|
||||
if not ignore_agent_tools:
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
@@ -251,7 +332,7 @@ def register(app: typer.Typer) -> None:
|
||||
"Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check",
|
||||
title="[red]Agent Detection Error[/red]",
|
||||
border_style="red",
|
||||
padding=(1, 2)
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(error_panel)
|
||||
@@ -259,14 +340,20 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
if script_type:
|
||||
if script_type not in SCRIPT_TYPE_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}")
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_script = script_type
|
||||
else:
|
||||
default_script = "ps" if os.name == "nt" else "sh"
|
||||
|
||||
if _stdin_is_interactive():
|
||||
selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script)
|
||||
selected_script = select_with_arrows(
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
"Choose script type (or press Enter)",
|
||||
default_script,
|
||||
)
|
||||
else:
|
||||
selected_script = default_script
|
||||
|
||||
@@ -294,23 +381,31 @@ def register(app: typer.Typer) -> None:
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
|
||||
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
||||
with Live(
|
||||
tracker.render(), console=console, refresh_per_second=8, transient=True
|
||||
) as live:
|
||||
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
||||
try:
|
||||
from ..integrations.manifest import IntegrationManifest
|
||||
|
||||
tracker.start("integration")
|
||||
manifest = IntegrationManifest(
|
||||
resolved_integration.key, project_path, version=get_speckit_version()
|
||||
resolved_integration.key,
|
||||
project_path,
|
||||
version=get_speckit_version(),
|
||||
)
|
||||
|
||||
integration_parsed_options: dict[str, Any] = {}
|
||||
if integration_options:
|
||||
extra = _parse_integration_options(resolved_integration, integration_options)
|
||||
extra = _parse_integration_options(
|
||||
resolved_integration, integration_options
|
||||
)
|
||||
if extra:
|
||||
integration_parsed_options.update(extra)
|
||||
|
||||
resolved_integration.setup(
|
||||
project_path, manifest,
|
||||
project_path,
|
||||
manifest,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
@@ -332,7 +427,10 @@ def register(app: typer.Typer) -> None:
|
||||
integration_settings,
|
||||
)
|
||||
|
||||
tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key))
|
||||
tracker.complete(
|
||||
"integration",
|
||||
resolved_integration.config.get("name", resolved_integration.key),
|
||||
)
|
||||
|
||||
tracker.start("shared-infra")
|
||||
_install_shared_infra_or_exit(
|
||||
@@ -340,9 +438,13 @@ def register(app: typer.Typer) -> None:
|
||||
selected_script,
|
||||
tracker=tracker,
|
||||
force=force,
|
||||
invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options),
|
||||
invoke_separator=resolved_integration.effective_invoke_separator(
|
||||
integration_parsed_options
|
||||
),
|
||||
)
|
||||
tracker.complete(
|
||||
"shared-infra", f"scripts ({selected_script}) + templates"
|
||||
)
|
||||
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
@@ -351,29 +453,38 @@ def register(app: typer.Typer) -> None:
|
||||
if bundled_wf:
|
||||
from ..workflows.catalog import WorkflowRegistry
|
||||
from ..workflows.engine import WorkflowDefinition
|
||||
|
||||
wf_registry = WorkflowRegistry(project_path)
|
||||
if wf_registry.is_installed("speckit"):
|
||||
tracker.complete("workflow", "already installed")
|
||||
else:
|
||||
import shutil as _shutil
|
||||
dest_wf = project_path / ".specify" / "workflows" / "speckit"
|
||||
|
||||
dest_wf = (
|
||||
project_path / ".specify" / "workflows" / "speckit"
|
||||
)
|
||||
dest_wf.mkdir(parents=True, exist_ok=True)
|
||||
_shutil.copy2(
|
||||
bundled_wf / "workflow.yml",
|
||||
dest_wf / "workflow.yml",
|
||||
)
|
||||
definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml")
|
||||
wf_registry.add("speckit", {
|
||||
"name": definition.name,
|
||||
"version": definition.version,
|
||||
"description": definition.description,
|
||||
"source": "bundled",
|
||||
})
|
||||
definition = WorkflowDefinition.from_yaml(
|
||||
dest_wf / "workflow.yml"
|
||||
)
|
||||
wf_registry.add(
|
||||
"speckit",
|
||||
{
|
||||
"name": definition.name,
|
||||
"version": definition.version,
|
||||
"description": definition.description,
|
||||
"source": "bundled",
|
||||
},
|
||||
)
|
||||
tracker.complete("workflow", "speckit installed")
|
||||
else:
|
||||
tracker.skip("workflow", "bundled workflow not found")
|
||||
except Exception as wf_err:
|
||||
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
|
||||
sanitized_wf = str(wf_err).replace("\n", " ").strip()
|
||||
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
|
||||
|
||||
init_opts = {
|
||||
@@ -385,7 +496,10 @@ def register(app: typer.Typer) -> None:
|
||||
"speckit_version": get_speckit_version(),
|
||||
}
|
||||
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
||||
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
|
||||
|
||||
if isinstance(resolved_integration, _SkillsPersist) or getattr(
|
||||
resolved_integration, "_skills_mode", False
|
||||
):
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
|
||||
@@ -394,6 +508,7 @@ def register(app: typer.Typer) -> None:
|
||||
# registration can read ai_skills + integration key.
|
||||
try:
|
||||
from ..extensions import ExtensionManager as _ExtMgr
|
||||
|
||||
bundled_ac = _locate_bundled_extension("agent-context")
|
||||
if bundled_ac:
|
||||
ac_mgr = _ExtMgr(project_path)
|
||||
@@ -406,13 +521,14 @@ def register(app: typer.Typer) -> None:
|
||||
tracker.complete("agent-context", "extension installed")
|
||||
else:
|
||||
from ..extensions import REINSTALL_COMMAND as _ac_reinstall
|
||||
|
||||
tracker.error(
|
||||
"agent-context",
|
||||
f"bundled extension not found — installation may be "
|
||||
f"incomplete. Run: {_ac_reinstall}",
|
||||
)
|
||||
except Exception as ac_err:
|
||||
sanitized_ac = str(ac_err).replace('\n', ' ').strip()
|
||||
sanitized_ac = str(ac_err).replace("\n", " ").strip()
|
||||
tracker.error(
|
||||
"agent-context",
|
||||
f"extension install failed: {sanitized_ac[:120]}",
|
||||
@@ -432,24 +548,34 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
if preset:
|
||||
try:
|
||||
from ..presets import PresetManager, PresetCatalog, PresetError
|
||||
from ..presets import PresetCatalog, PresetError, PresetManager
|
||||
|
||||
preset_manager = PresetManager(project_path)
|
||||
speckit_ver = get_speckit_version()
|
||||
|
||||
local_path = Path(preset).resolve()
|
||||
if local_path.is_dir() and (local_path / "preset.yml").exists():
|
||||
preset_manager.install_from_directory(local_path, speckit_ver)
|
||||
preset_manager.install_from_directory(
|
||||
local_path, speckit_ver
|
||||
)
|
||||
else:
|
||||
bundled_path = _locate_bundled_preset(preset)
|
||||
if bundled_path:
|
||||
preset_manager.install_from_directory(bundled_path, speckit_ver)
|
||||
preset_manager.install_from_directory(
|
||||
bundled_path, speckit_ver
|
||||
)
|
||||
else:
|
||||
preset_catalog = PresetCatalog(project_path)
|
||||
pack_info = preset_catalog.get_pack_info(preset)
|
||||
if not pack_info:
|
||||
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
|
||||
elif pack_info.get("bundled") and not pack_info.get("download_url"):
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping."
|
||||
)
|
||||
elif pack_info.get("bundled") and not pack_info.get(
|
||||
"download_url"
|
||||
):
|
||||
from ..extensions import REINSTALL_COMMAND
|
||||
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
|
||||
f"but could not be found in the installed package."
|
||||
@@ -457,12 +583,16 @@ def register(app: typer.Typer) -> None:
|
||||
console.print(
|
||||
"This usually means the spec-kit installation is incomplete or corrupted."
|
||||
)
|
||||
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
|
||||
console.print(
|
||||
f"Try reinstalling: {REINSTALL_COMMAND}"
|
||||
)
|
||||
else:
|
||||
zip_path = None
|
||||
try:
|
||||
zip_path = preset_catalog.download_pack(preset)
|
||||
preset_manager.install_from_zip(zip_path, speckit_ver)
|
||||
preset_manager.install_from_zip(
|
||||
zip_path, speckit_ver
|
||||
)
|
||||
except PresetError as preset_err:
|
||||
_print_cli_warning(
|
||||
"install",
|
||||
@@ -491,7 +621,13 @@ def register(app: typer.Typer) -> None:
|
||||
raise
|
||||
except Exception as e:
|
||||
tracker.error("final", str(e))
|
||||
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
|
||||
console.print(
|
||||
Panel(
|
||||
f"Initialization failed: {e}",
|
||||
title="Failure",
|
||||
border_style="red",
|
||||
)
|
||||
)
|
||||
if debug:
|
||||
_env_pairs = [
|
||||
("Python", sys.version.split()[0]),
|
||||
@@ -499,8 +635,17 @@ def register(app: typer.Typer) -> None:
|
||||
("CWD", str(Path.cwd())),
|
||||
]
|
||||
_label_width = max(len(k) for k, _ in _env_pairs)
|
||||
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
|
||||
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
|
||||
env_lines = [
|
||||
f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]"
|
||||
for k, v in _env_pairs
|
||||
]
|
||||
console.print(
|
||||
Panel(
|
||||
"\n".join(env_lines),
|
||||
title="Debug Environment",
|
||||
border_style="magenta",
|
||||
)
|
||||
)
|
||||
if not here and project_path.exists() and not dir_existed_before:
|
||||
shutil.rmtree(project_path)
|
||||
raise typer.Exit(1)
|
||||
@@ -512,74 +657,132 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config:
|
||||
agent_folder = agent_config["folder"] or integration_parsed_options.get("commands_dir")
|
||||
agent_folder = agent_config["folder"] or integration_parsed_options.get(
|
||||
"commands_dir"
|
||||
)
|
||||
if agent_folder:
|
||||
security_notice = Panel(
|
||||
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
title="[yellow]Agent Folder Security[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2)
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(security_notice)
|
||||
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
||||
steps_lines.append(
|
||||
f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]"
|
||||
)
|
||||
step_num = 2
|
||||
else:
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
from ..integrations.base import SkillsIntegration as _SkillsInt
|
||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
|
||||
|
||||
_is_skills_integration = isinstance(
|
||||
resolved_integration, _SkillsInt
|
||||
) or getattr(resolved_integration, "_skills_mode", False)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
|
||||
claude_skill_mode = selected_ai == "claude" and _is_skills_integration
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||
trae_skill_mode = selected_ai == "trae"
|
||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and _is_skills_integration
|
||||
cursor_agent_skill_mode = (
|
||||
selected_ai == "cursor-agent" and _is_skills_integration
|
||||
)
|
||||
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
|
||||
cline_skill_mode = selected_ai == "cline"
|
||||
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
|
||||
native_skill_mode = (
|
||||
codex_skill_mode
|
||||
or claude_skill_mode
|
||||
or kimi_skill_mode
|
||||
or agy_skill_mode
|
||||
or trae_skill_mode
|
||||
or cursor_agent_skill_mode
|
||||
or copilot_skill_mode
|
||||
or devin_skill_mode
|
||||
or zed_skill_mode
|
||||
)
|
||||
|
||||
if codex_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
if claude_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
if cursor_agent_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
if devin_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]")
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
if zed_skill_mode:
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
from .._invocation_style import is_slash_skills_agent as _is_slash_skills_agent
|
||||
|
||||
# `_is_skills_integration` means the integration is installed in
|
||||
# skills mode, which is the semantic equivalent of `ai_skills_enabled`
|
||||
# used by `is_slash_skills_agent()`.
|
||||
_ai_skills_enabled = _is_skills_integration
|
||||
|
||||
def _display_cmd(name: str) -> str:
|
||||
if codex_skill_mode or agy_skill_mode or trae_skill_mode:
|
||||
if codex_skill_mode:
|
||||
return f"$speckit-{name}"
|
||||
if claude_skill_mode:
|
||||
return f"/speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
return f"/skill:speckit-{name}"
|
||||
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or cline_skill_mode:
|
||||
if (
|
||||
_is_slash_skills_agent(selected_ai, _ai_skills_enabled)
|
||||
or cline_skill_mode
|
||||
):
|
||||
return f"/speckit-{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:")
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start using {usage_label} with your coding agent:"
|
||||
)
|
||||
|
||||
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
|
||||
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
|
||||
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
|
||||
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
|
||||
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
|
||||
steps_lines.append(
|
||||
f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles"
|
||||
)
|
||||
steps_lines.append(
|
||||
f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification"
|
||||
)
|
||||
steps_lines.append(
|
||||
f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan"
|
||||
)
|
||||
steps_lines.append(
|
||||
f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks"
|
||||
)
|
||||
steps_lines.append(
|
||||
f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation"
|
||||
)
|
||||
|
||||
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1, 2))
|
||||
steps_panel = Panel(
|
||||
"\n".join(steps_lines),
|
||||
title="Next Steps",
|
||||
border_style="cyan",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(steps_panel)
|
||||
|
||||
@@ -593,9 +796,16 @@ def register(app: typer.Typer) -> None:
|
||||
"",
|
||||
f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
|
||||
f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
|
||||
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
|
||||
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])",
|
||||
]
|
||||
enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
|
||||
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1, 2))
|
||||
enhancements_title = (
|
||||
"Enhancement Skills" if native_skill_mode else "Enhancement Commands"
|
||||
)
|
||||
enhancements_panel = Panel(
|
||||
"\n".join(enhancement_lines),
|
||||
title=enhancements_title,
|
||||
border_style="cyan",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(enhancements_panel)
|
||||
|
||||
@@ -6,39 +6,44 @@ Extensions are modular packages that add commands and functionality to spec-kit
|
||||
without bloating the core framework.
|
||||
"""
|
||||
|
||||
import json
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any, Callable, Set
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
import pathspec
|
||||
|
||||
import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
|
||||
from ._init_options import is_ai_skills_enabled
|
||||
from ._invocation_style import is_slash_skills_agent
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry
|
||||
from .catalogs import CatalogStackBase
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
"analyze",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
})
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset(
|
||||
{
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
}
|
||||
)
|
||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
||||
|
||||
VALID_EFFECTS = frozenset({"read-only", "read-write"})
|
||||
@@ -80,16 +85,19 @@ CORE_COMMAND_NAMES = _load_core_command_names()
|
||||
|
||||
class ExtensionError(Exception):
|
||||
"""Base exception for extension-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ExtensionError):
|
||||
"""Raised when extension manifest validation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CompatibilityError(ExtensionError):
|
||||
"""Raised when extension is incompatible with current environment."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -152,7 +160,7 @@ class ExtensionManifest:
|
||||
def _load_yaml(self, path: Path) -> dict:
|
||||
"""Load YAML file safely."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValidationError(f"Invalid YAML in {path}: {e}")
|
||||
@@ -191,7 +199,7 @@ class ExtensionManifest:
|
||||
raise ValidationError(f"Missing extension.{field}")
|
||||
|
||||
# Validate extension ID format
|
||||
if not re.match(r'^[a-z0-9-]+$', ext["id"]):
|
||||
if not re.match(r"^[a-z0-9-]+$", ext["id"]):
|
||||
raise ValidationError(
|
||||
f"Invalid extension ID '{ext['id']}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
@@ -229,21 +237,15 @@ class ExtensionManifest:
|
||||
hooks = self.data.get("hooks")
|
||||
|
||||
if "commands" in provides and not isinstance(commands, list):
|
||||
raise ValidationError(
|
||||
"Invalid provides.commands: expected a list"
|
||||
)
|
||||
raise ValidationError("Invalid provides.commands: expected a list")
|
||||
if "hooks" in self.data and not isinstance(hooks, dict):
|
||||
raise ValidationError(
|
||||
"Invalid hooks: expected a mapping"
|
||||
)
|
||||
raise ValidationError("Invalid hooks: expected a mapping")
|
||||
|
||||
has_commands = bool(commands)
|
||||
has_hooks = bool(hooks)
|
||||
|
||||
if not has_commands and not has_hooks:
|
||||
raise ValidationError(
|
||||
"Extension must provide at least one command or hook"
|
||||
)
|
||||
raise ValidationError("Extension must provide at least one command or hook")
|
||||
|
||||
# Validate hook values (if present).
|
||||
# Each event is a single mapping or a list of mappings.
|
||||
@@ -363,9 +365,9 @@ class ExtensionManifest:
|
||||
|
||||
Returns the corrected name, or None if no safe correction is possible.
|
||||
"""
|
||||
parts = name.split('.')
|
||||
parts = name.split(".")
|
||||
if len(parts) == 2:
|
||||
if parts[0] == 'speckit' or parts[0] == ext_id:
|
||||
if parts[0] == "speckit" or parts[0] == ext_id:
|
||||
candidate = f"speckit.{ext_id}.{parts[1]}"
|
||||
if EXTENSION_COMMAND_NAME_PATTERN.match(candidate):
|
||||
return candidate
|
||||
@@ -418,7 +420,7 @@ class ExtensionManifest:
|
||||
|
||||
def get_hash(self) -> str:
|
||||
"""Calculate SHA256 hash of manifest file."""
|
||||
with open(self.path, 'rb') as f:
|
||||
with open(self.path, "rb") as f:
|
||||
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
|
||||
|
||||
|
||||
@@ -441,35 +443,26 @@ class ExtensionRegistry:
|
||||
def _load(self) -> dict:
|
||||
"""Load registry from disk."""
|
||||
if not self.registry_path.exists():
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"extensions": {}
|
||||
}
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, 'r') as f:
|
||||
with open(self.registry_path, "r") as f:
|
||||
data = json.load(f)
|
||||
# Validate loaded data is a dict (handles corrupted registry files)
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"extensions": {}
|
||||
}
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
# Normalize extensions field (handles corrupted extensions value)
|
||||
if not isinstance(data.get("extensions"), dict):
|
||||
data["extensions"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
# Corrupted or missing registry, start fresh
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"extensions": {}
|
||||
}
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, 'w') as f:
|
||||
with open(self.registry_path, "w") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, extension_id: str, metadata: dict):
|
||||
@@ -481,7 +474,7 @@ class ExtensionRegistry:
|
||||
"""
|
||||
self.data["extensions"][extension_id] = {
|
||||
**copy.deepcopy(metadata),
|
||||
"installed_at": datetime.now(timezone.utc).isoformat()
|
||||
"installed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self._save()
|
||||
|
||||
@@ -538,7 +531,9 @@ class ExtensionRegistry:
|
||||
ValueError: If metadata is None or not a dict
|
||||
"""
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
raise ValueError(f"Cannot restore '{extension_id}': metadata must be a dict")
|
||||
raise ValueError(
|
||||
f"Cannot restore '{extension_id}': metadata must be a dict"
|
||||
)
|
||||
# Ensure extensions dict exists (handle corrupted registry)
|
||||
if not isinstance(self.data.get("extensions"), dict):
|
||||
self.data["extensions"] = {}
|
||||
@@ -651,7 +646,9 @@ class ExtensionRegistry:
|
||||
if not include_disabled and not meta.get("enabled", True):
|
||||
continue
|
||||
metadata_copy = copy.deepcopy(meta)
|
||||
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
|
||||
metadata_copy["priority"] = normalize_priority(
|
||||
metadata_copy.get("priority", 10)
|
||||
)
|
||||
sortable_extensions.append((ext_id, metadata_copy))
|
||||
return sorted(
|
||||
sortable_extensions,
|
||||
@@ -797,7 +794,9 @@ class ExtensionManager:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
||||
def _load_extensionignore(
|
||||
source_dir: Path,
|
||||
) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
||||
"""Load .extensionignore and return an ignore function for shutil.copytree.
|
||||
|
||||
The .extensionignore file uses .gitignore-compatible patterns (one per line).
|
||||
@@ -905,7 +904,10 @@ class ExtensionManager:
|
||||
raise NotADirectoryError(f"{skills_dir} is not a directory")
|
||||
except (OSError, ValueError) as exc:
|
||||
_print_cli_warning(
|
||||
"resolve", "skills directory", str(skills_dir), exc,
|
||||
"resolve",
|
||||
"skills directory",
|
||||
str(skills_dir),
|
||||
exc,
|
||||
continuing="Continuing without skill registration.",
|
||||
)
|
||||
return None
|
||||
@@ -915,7 +917,10 @@ class ExtensionManager:
|
||||
skills_dir = resolve_active_skills_dir(self.project_root)
|
||||
except (ValueError, OSError) as exc:
|
||||
_print_cli_warning(
|
||||
"resolve", "skills directory", None, exc,
|
||||
"resolve",
|
||||
"skills directory",
|
||||
None,
|
||||
exc,
|
||||
continuing="Continuing without skill registration.",
|
||||
)
|
||||
return None
|
||||
@@ -969,7 +974,6 @@ class ExtensionManager:
|
||||
from . import load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from .integrations import get_integration
|
||||
import yaml
|
||||
|
||||
written: List[str] = []
|
||||
opts = load_init_options(self.project_root)
|
||||
@@ -1004,7 +1008,7 @@ class ExtensionManager:
|
||||
# convention as hook rendering and preset skill registration.
|
||||
short_name_raw = cmd_name
|
||||
if short_name_raw.startswith("speckit."):
|
||||
short_name_raw = short_name_raw[len("speckit."):]
|
||||
short_name_raw = short_name_raw[len("speckit.") :]
|
||||
skill_name = f"speckit-{short_name_raw.replace('.', '-')}"
|
||||
|
||||
# Check if skill already exists before creating the directory
|
||||
@@ -1074,20 +1078,16 @@ class ExtensionManager:
|
||||
# Derive a human-friendly title from the command name
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
short_name = short_name[len("speckit.") :]
|
||||
title_name = short_name.replace(".", " ").replace("-", " ").title()
|
||||
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"{frontmatter_text}\n"
|
||||
f"---\n\n"
|
||||
f"# {title_name} Skill\n\n"
|
||||
f"{body}\n"
|
||||
f"---\n{frontmatter_text}\n---\n\n# {title_name} Skill\n\n{body}\n"
|
||||
)
|
||||
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
||||
skill_content = integration.post_process_skill_content(
|
||||
skill_content
|
||||
)
|
||||
if integration is not None and hasattr(
|
||||
integration, "post_process_skill_content"
|
||||
):
|
||||
skill_content = integration.post_process_skill_content(skill_content)
|
||||
|
||||
if link_outputs:
|
||||
try:
|
||||
@@ -1178,6 +1178,7 @@ class ExtensionManager:
|
||||
continue
|
||||
try:
|
||||
import yaml as _yaml
|
||||
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
@@ -1202,7 +1203,9 @@ class ExtensionManager:
|
||||
for cfg in AGENT_CONFIG.values():
|
||||
folder = cfg.get("folder", "")
|
||||
if folder:
|
||||
candidate_dirs.add(self.project_root / folder.rstrip("/") / "skills")
|
||||
candidate_dirs.add(
|
||||
self.project_root / folder.rstrip("/") / "skills"
|
||||
)
|
||||
candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR)
|
||||
|
||||
for skills_candidate in candidate_dirs:
|
||||
@@ -1215,7 +1218,9 @@ class ExtensionManager:
|
||||
continue
|
||||
try:
|
||||
skill_subdir = (skills_candidate / skill_name).resolve()
|
||||
skill_subdir.relative_to(skills_candidate.resolve()) # raises if outside
|
||||
skill_subdir.relative_to(
|
||||
skills_candidate.resolve()
|
||||
) # raises if outside
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not skill_subdir.is_dir():
|
||||
@@ -1229,6 +1234,7 @@ class ExtensionManager:
|
||||
continue
|
||||
try:
|
||||
import yaml as _yaml
|
||||
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
@@ -1249,9 +1255,7 @@ class ExtensionManager:
|
||||
shutil.rmtree(skill_subdir)
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
manifest: ExtensionManifest,
|
||||
speckit_version: str
|
||||
self, manifest: ExtensionManifest, speckit_version: str
|
||||
) -> bool:
|
||||
"""Check if extension is compatible with current spec-kit version.
|
||||
|
||||
@@ -1393,9 +1397,13 @@ class ExtensionManager:
|
||||
backup_config_dir.unlink()
|
||||
elif backup_config_dir.is_dir():
|
||||
for cfg_file in backup_config_dir.iterdir():
|
||||
if cfg_file.is_file() and not cfg_file.is_symlink() and (
|
||||
cfg_file.name.endswith("-config.yml") or
|
||||
cfg_file.name.endswith("-config.local.yml")
|
||||
if (
|
||||
cfg_file.is_file()
|
||||
and not cfg_file.is_symlink()
|
||||
and (
|
||||
cfg_file.name.endswith("-config.yml")
|
||||
or cfg_file.name.endswith("-config.local.yml")
|
||||
)
|
||||
):
|
||||
shutil.copy2(cfg_file, dest_dir / cfg_file.name)
|
||||
shutil.rmtree(backup_config_dir)
|
||||
@@ -1403,15 +1411,18 @@ class ExtensionManager:
|
||||
backup_config_dir.unlink()
|
||||
|
||||
# Update registry
|
||||
self.registry.add(manifest.id, {
|
||||
"version": manifest.version,
|
||||
"source": "local",
|
||||
"manifest_hash": manifest.get_hash(),
|
||||
"enabled": True,
|
||||
"priority": priority,
|
||||
"registered_commands": registered_commands,
|
||||
"registered_skills": registered_skills,
|
||||
})
|
||||
self.registry.add(
|
||||
manifest.id,
|
||||
{
|
||||
"version": manifest.version,
|
||||
"source": "local",
|
||||
"manifest_hash": manifest.get_hash(),
|
||||
"enabled": True,
|
||||
"priority": priority,
|
||||
"registered_commands": registered_commands,
|
||||
"registered_skills": registered_skills,
|
||||
},
|
||||
)
|
||||
|
||||
return manifest
|
||||
|
||||
@@ -1446,7 +1457,7 @@ class ExtensionManager:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
# Extract ZIP safely (prevent Zip Slip attack)
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
# Validate all paths first before extracting anything
|
||||
temp_path_resolved = temp_path.resolve()
|
||||
for member in zf.namelist():
|
||||
@@ -1495,7 +1506,9 @@ class ExtensionManager:
|
||||
|
||||
# Get registered commands and skills before removal
|
||||
metadata = self.registry.get(extension_id)
|
||||
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
||||
registered_commands = (
|
||||
metadata.get("registered_commands", {}) if metadata else {}
|
||||
)
|
||||
raw_skills = metadata.get("registered_skills", []) if metadata else []
|
||||
# Normalize: must be a list of plain strings to avoid corrupted-registry errors
|
||||
if isinstance(raw_skills, list):
|
||||
@@ -1519,8 +1532,8 @@ class ExtensionManager:
|
||||
for child in extension_dir.iterdir():
|
||||
# Keep top-level *-config.yml and *-config.local.yml files
|
||||
if child.is_file() and (
|
||||
child.name.endswith("-config.yml") or
|
||||
child.name.endswith("-config.local.yml")
|
||||
child.name.endswith("-config.yml")
|
||||
or child.name.endswith("-config.local.yml")
|
||||
):
|
||||
continue
|
||||
if child.is_dir():
|
||||
@@ -1592,16 +1605,25 @@ class ExtensionManager:
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
registered_commands = metadata.get("registered_commands", {})
|
||||
if isinstance(registered_commands, dict) and agent_name in registered_commands:
|
||||
command_names = self._valid_name_list(registered_commands.get(agent_name))
|
||||
if (
|
||||
isinstance(registered_commands, dict)
|
||||
and agent_name in registered_commands
|
||||
):
|
||||
command_names = self._valid_name_list(
|
||||
registered_commands.get(agent_name)
|
||||
)
|
||||
if command_names:
|
||||
registrar.unregister_commands({agent_name: command_names}, self.project_root)
|
||||
registrar.unregister_commands(
|
||||
{agent_name: command_names}, self.project_root
|
||||
)
|
||||
|
||||
new_registered = copy.deepcopy(registered_commands)
|
||||
new_registered.pop(agent_name, None)
|
||||
updates["registered_commands"] = new_registered
|
||||
|
||||
registered_skills = self._valid_name_list(metadata.get("registered_skills", []))
|
||||
registered_skills = self._valid_name_list(
|
||||
metadata.get("registered_skills", [])
|
||||
)
|
||||
if registered_skills:
|
||||
# Only pass the resolved skills_dir when it actually exists.
|
||||
# Otherwise let _unregister_extension_skills fall back to
|
||||
@@ -1700,7 +1722,9 @@ class ExtensionManager:
|
||||
|
||||
registered_skills = self._register_extension_skills(manifest, ext_dir)
|
||||
if registered_skills:
|
||||
existing_skills = self._valid_name_list(metadata.get("registered_skills", []))
|
||||
existing_skills = self._valid_name_list(
|
||||
metadata.get("registered_skills", [])
|
||||
)
|
||||
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
|
||||
updates["registered_skills"] = merged_skills
|
||||
|
||||
@@ -1724,30 +1748,34 @@ class ExtensionManager:
|
||||
|
||||
try:
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
result.append({
|
||||
"id": ext_id,
|
||||
"name": manifest.name,
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": manifest.description,
|
||||
"enabled": metadata.get("enabled", True),
|
||||
"priority": normalize_priority(metadata.get("priority")),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"command_count": len(manifest.commands),
|
||||
"hook_count": len(manifest.hooks)
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"id": ext_id,
|
||||
"name": manifest.name,
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": manifest.description,
|
||||
"enabled": metadata.get("enabled", True),
|
||||
"priority": normalize_priority(metadata.get("priority")),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"command_count": len(manifest.commands),
|
||||
"hook_count": len(manifest.hooks),
|
||||
}
|
||||
)
|
||||
except ValidationError:
|
||||
# Corrupted extension
|
||||
result.append({
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": "⚠️ Corrupted extension",
|
||||
"enabled": False,
|
||||
"priority": normalize_priority(metadata.get("priority")),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"command_count": 0,
|
||||
"hook_count": 0
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": "⚠️ Corrupted extension",
|
||||
"enabled": False,
|
||||
"priority": normalize_priority(metadata.get("priority")),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"command_count": 0,
|
||||
"hook_count": 0,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1800,37 +1828,46 @@ class CommandRegistrar:
|
||||
|
||||
# Re-export AGENT_CONFIGS at class level for direct attribute access
|
||||
from .agents import CommandRegistrar as _AgentRegistrar
|
||||
|
||||
AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS
|
||||
|
||||
def __init__(self):
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
|
||||
self._registrar = _Registrar()
|
||||
|
||||
# Delegate static/utility methods
|
||||
@staticmethod
|
||||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
|
||||
return _Registrar.parse_frontmatter(content)
|
||||
|
||||
@staticmethod
|
||||
def render_frontmatter(fm: dict) -> str:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
|
||||
return _Registrar.render_frontmatter(fm)
|
||||
|
||||
@staticmethod
|
||||
def _write_copilot_prompt(project_root, cmd_name: str) -> None:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
|
||||
_Registrar.write_copilot_prompt(project_root, cmd_name)
|
||||
|
||||
def _render_markdown_command(self, frontmatter, body, ext_id):
|
||||
# Preserve extension-specific comment format for backward compatibility
|
||||
context_note = f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n"
|
||||
return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
return (
|
||||
self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
)
|
||||
|
||||
def _render_toml_command(self, frontmatter, body, ext_id):
|
||||
# Preserve extension-specific context comments for backward compatibility
|
||||
base = self._registrar.render_toml_command(frontmatter, body, ext_id)
|
||||
context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n"
|
||||
context_lines = (
|
||||
f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n"
|
||||
)
|
||||
return base.rstrip("\n") + "\n" + context_lines
|
||||
|
||||
def register_commands_for_agent(
|
||||
@@ -1846,7 +1883,11 @@ class CommandRegistrar:
|
||||
raise ExtensionError(f"Unsupported agent: {agent_name}")
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
return self._registrar.register_commands(
|
||||
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
|
||||
agent_name,
|
||||
manifest.commands,
|
||||
manifest.id,
|
||||
extension_dir,
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
)
|
||||
@@ -1862,16 +1903,17 @@ class CommandRegistrar:
|
||||
"""Register extension commands for all detected agents."""
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
return self._registrar.register_commands_for_all_agents(
|
||||
manifest.commands, manifest.id, extension_dir, project_root,
|
||||
manifest.commands,
|
||||
manifest.id,
|
||||
extension_dir,
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
create_missing_active_skills_dir=create_missing_active_skills_dir,
|
||||
)
|
||||
|
||||
def unregister_commands(
|
||||
self,
|
||||
registered_commands: Dict[str, List[str]],
|
||||
project_root: Path
|
||||
self, registered_commands: Dict[str, List[str]], project_root: Path
|
||||
) -> None:
|
||||
"""Remove previously registered command files from agent directories."""
|
||||
self._registrar.unregister_commands(registered_commands, project_root)
|
||||
@@ -1892,7 +1934,9 @@ class CommandRegistrar:
|
||||
class ExtensionCatalog(CatalogStackBase):
|
||||
"""Manages extension catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
DEFAULT_CATALOG_URL = (
|
||||
"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
)
|
||||
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
|
||||
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||
CONFIG_FILENAME = "extension-catalogs.yml"
|
||||
@@ -1918,6 +1962,7 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
Delegates to :func:`specify_cli.authentication.http.build_request`.
|
||||
"""
|
||||
from specify_cli.authentication.http import build_request
|
||||
|
||||
return build_request(url)
|
||||
|
||||
def _open_url(
|
||||
@@ -1931,6 +1976,7 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
"""
|
||||
from specify_cli.authentication.http import open_url
|
||||
|
||||
return open_url(url, timeout, extra_headers=extra_headers)
|
||||
|
||||
def _resolve_github_release_asset_api_url(
|
||||
@@ -1982,8 +2028,7 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
raise ExtensionError(f"Invalid catalog format from {url}")
|
||||
if not isinstance(catalog_data.get("extensions"), dict):
|
||||
raise ExtensionError(
|
||||
f"Invalid catalog format from {url}: "
|
||||
"'extensions' must be a JSON object"
|
||||
f"Invalid catalog format from {url}: 'extensions' must be a JSON object"
|
||||
)
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
@@ -2070,7 +2115,9 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
active = self.get_active_catalogs()
|
||||
return active[0].url if active else self.DEFAULT_CATALOG_URL
|
||||
|
||||
def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
def _fetch_single_catalog(
|
||||
self, entry: CatalogEntry, force_refresh: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch a single catalog with per-URL caching.
|
||||
|
||||
For the DEFAULT_CATALOG_URL, uses legacy cache files (self.cache_file /
|
||||
@@ -2101,9 +2148,7 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
is_valid = False
|
||||
if not force_refresh and cache_file.exists() and cache_meta_file.exists():
|
||||
try:
|
||||
metadata = json.loads(
|
||||
cache_meta_file.read_text(encoding="utf-8")
|
||||
)
|
||||
metadata = json.loads(cache_meta_file.read_text(encoding="utf-8"))
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2171,10 +2216,13 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
cache_meta_file.write_text(
|
||||
json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}, indent=2),
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
@@ -2187,7 +2235,9 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
except json.JSONDecodeError as e:
|
||||
raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}")
|
||||
|
||||
def _get_merged_extensions(self, force_refresh: bool = False) -> List[Dict[str, Any]]:
|
||||
def _get_merged_extensions(
|
||||
self, force_refresh: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch and merge extensions from all active catalogs.
|
||||
|
||||
Higher-priority (lower priority number) catalogs win on conflicts
|
||||
@@ -2264,9 +2314,7 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
metadata = json.loads(self.cache_metadata_file.read_text(encoding="utf-8"))
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2437,7 +2485,9 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
return ext_data
|
||||
return None
|
||||
|
||||
def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path:
|
||||
def download_extension(
|
||||
self, extension_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download extension ZIP from catalog.
|
||||
|
||||
Args:
|
||||
@@ -2471,6 +2521,7 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
|
||||
# Validate download URL requires HTTPS (prevent man-in-the-middle attacks)
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
@@ -2495,14 +2546,18 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
|
||||
# Download the ZIP file
|
||||
try:
|
||||
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
|
||||
with self._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
zip_data = response.read()
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise ExtensionError(f"Failed to download extension from {download_url}: {e}")
|
||||
raise ExtensionError(
|
||||
f"Failed to download extension from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise ExtensionError(f"Failed to save extension ZIP: {e}")
|
||||
|
||||
@@ -2614,7 +2669,7 @@ class ConfigManager:
|
||||
continue
|
||||
|
||||
# Remove prefix and split into parts
|
||||
config_path = key[len(prefix):].lower().split("_")
|
||||
config_path = key[len(prefix) :].lower().split("_")
|
||||
|
||||
# Build nested dict
|
||||
current = env_config
|
||||
@@ -2628,7 +2683,9 @@ class ConfigManager:
|
||||
|
||||
return env_config
|
||||
|
||||
def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _merge_configs(
|
||||
self, base: Dict[str, Any], override: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Recursively merge two configuration dictionaries.
|
||||
|
||||
Args:
|
||||
@@ -2641,7 +2698,11 @@ class ConfigManager:
|
||||
result = base.copy()
|
||||
|
||||
for key, value in override.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
if (
|
||||
key in result
|
||||
and isinstance(result[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
# Recursive merge for nested dicts
|
||||
result[key] = self._merge_configs(result[key], value)
|
||||
else:
|
||||
@@ -2755,7 +2816,7 @@ class HookExecutor:
|
||||
command_id = command.strip()
|
||||
if not command_id.startswith("speckit."):
|
||||
return ""
|
||||
return f"speckit-{command_id[len('speckit.'):].replace('.', '-')}"
|
||||
return f"speckit-{command_id[len('speckit.') :].replace('.', '-')}"
|
||||
|
||||
def _render_hook_invocation(self, command: Any) -> str:
|
||||
"""Render an agent-specific invocation string for a hook command."""
|
||||
@@ -2769,26 +2830,26 @@ class HookExecutor:
|
||||
init_options = self._load_init_options()
|
||||
selected_ai = init_options.get("ai")
|
||||
ai_skills_enabled = is_ai_skills_enabled(init_options)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and ai_skills_enabled
|
||||
claude_skill_mode = selected_ai == "claude" and ai_skills_enabled
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills_enabled
|
||||
cline_mode = selected_ai == "cline"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
if codex_skill_mode and skill_name:
|
||||
return f"${skill_name}"
|
||||
if claude_skill_mode and skill_name:
|
||||
return f"/{skill_name}"
|
||||
if kimi_skill_mode and skill_name:
|
||||
return f"/skill:{skill_name}"
|
||||
if cursor_skill_mode and skill_name:
|
||||
return f"/{skill_name}"
|
||||
if cline_mode:
|
||||
from .integrations.cline import format_cline_command_name
|
||||
|
||||
return f"/{format_cline_command_name(command_id)}"
|
||||
|
||||
use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled)
|
||||
|
||||
if skill_name and use_slash:
|
||||
return f"/{skill_name}"
|
||||
|
||||
return f"/{command_id}"
|
||||
|
||||
def get_project_config(self) -> Dict[str, Any]:
|
||||
@@ -2829,7 +2890,9 @@ class HookExecutor:
|
||||
if not isinstance(event_val, list):
|
||||
result["hooks"][event_key] = []
|
||||
else:
|
||||
result["hooks"][event_key] = [h for h in event_val if isinstance(h, dict)]
|
||||
result["hooks"][event_key] = [
|
||||
h for h in event_val if isinstance(h, dict)
|
||||
]
|
||||
return result
|
||||
except (yaml.YAMLError, OSError, UnicodeError):
|
||||
return {
|
||||
@@ -2846,7 +2909,9 @@ class HookExecutor:
|
||||
"""
|
||||
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.config_file.write_text(
|
||||
yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
||||
yaml.dump(
|
||||
config, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -2908,7 +2973,7 @@ class HookExecutor:
|
||||
Returns:
|
||||
A sanitized, deduplicated, alphabetically-sorted list.
|
||||
"""
|
||||
_VALID_ID = re.compile(r'^[a-z0-9-]+$')
|
||||
_VALID_ID = re.compile(r"^[a-z0-9-]+$")
|
||||
|
||||
installed = raw if isinstance(raw, list) else []
|
||||
|
||||
@@ -2984,7 +3049,8 @@ class HookExecutor:
|
||||
if h_name in declared_events:
|
||||
continue
|
||||
kept = [
|
||||
h for h in config["hooks"][h_name]
|
||||
h
|
||||
for h in config["hooks"][h_name]
|
||||
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
|
||||
]
|
||||
if kept != config["hooks"][h_name]:
|
||||
@@ -2993,7 +3059,9 @@ class HookExecutor:
|
||||
|
||||
# Register each hook
|
||||
for hook_name, hook_config in manifest.hooks.items():
|
||||
if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list):
|
||||
if hook_name not in config["hooks"] or not isinstance(
|
||||
config["hooks"][hook_name], list
|
||||
):
|
||||
config["hooks"][hook_name] = []
|
||||
changed = True
|
||||
|
||||
@@ -3026,7 +3094,8 @@ class HookExecutor:
|
||||
# then leaves no orphaned entries behind.
|
||||
original_list = config["hooks"][hook_name]
|
||||
deduped = [
|
||||
h for h in original_list
|
||||
h
|
||||
for h in original_list
|
||||
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
|
||||
]
|
||||
deduped.extend(new_entries.values())
|
||||
@@ -3146,7 +3215,9 @@ class HookExecutor:
|
||||
condition = condition.strip()
|
||||
|
||||
# Pattern: "config.key.path is set"
|
||||
if match := re.match(r'config\.([a-z0-9_.]+)\s+is\s+set', condition, re.IGNORECASE):
|
||||
if match := re.match(
|
||||
r"config\.([a-z0-9_.]+)\s+is\s+set", condition, re.IGNORECASE
|
||||
):
|
||||
key_path = match.group(1)
|
||||
if not extension_id:
|
||||
return False
|
||||
@@ -3155,7 +3226,11 @@ class HookExecutor:
|
||||
return config_manager.has_value(key_path)
|
||||
|
||||
# Pattern: "config.key.path == 'value'" or "config.key.path != 'value'"
|
||||
if match := re.match(r'config\.([a-z0-9_.]+)\s*(==|!=)\s*["\']([^"\']+)["\']', condition, re.IGNORECASE):
|
||||
if match := re.match(
|
||||
r'config\.([a-z0-9_.]+)\s*(==|!=)\s*["\']([^"\']+)["\']',
|
||||
condition,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
key_path = match.group(1)
|
||||
operator = match.group(2)
|
||||
expected_value = match.group(3)
|
||||
@@ -3179,12 +3254,16 @@ class HookExecutor:
|
||||
return normalized_value != expected_value
|
||||
|
||||
# Pattern: "env.VAR_NAME is set"
|
||||
if match := re.match(r'env\.([A-Z0-9_]+)\s+is\s+set', condition, re.IGNORECASE):
|
||||
if match := re.match(r"env\.([A-Z0-9_]+)\s+is\s+set", condition, re.IGNORECASE):
|
||||
var_name = match.group(1).upper()
|
||||
return var_name in os.environ
|
||||
|
||||
# Pattern: "env.VAR_NAME == 'value'" or "env.VAR_NAME != 'value'"
|
||||
if match := re.match(r'env\.([A-Z0-9_]+)\s*(==|!=)\s*["\']([^"\']+)["\']', condition, re.IGNORECASE):
|
||||
if match := re.match(
|
||||
r'env\.([A-Z0-9_]+)\s*(==|!=)\s*["\']([^"\']+)["\']',
|
||||
condition,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
var_name = match.group(1).upper()
|
||||
operator = match.group(2)
|
||||
expected_value = match.group(3)
|
||||
@@ -3199,9 +3278,7 @@ class HookExecutor:
|
||||
# Unknown condition format, default to False for safety
|
||||
return False
|
||||
|
||||
def format_hook_message(
|
||||
self, event_name: str, hooks: List[Dict[str, Any]]
|
||||
) -> str:
|
||||
def format_hook_message(self, event_name: str, hooks: List[Dict[str, Any]]) -> str:
|
||||
"""Format hook execution message for display in command output.
|
||||
|
||||
Args:
|
||||
@@ -3221,9 +3298,15 @@ class HookExecutor:
|
||||
extension = hook.get("extension")
|
||||
command = hook.get("command")
|
||||
invocation = self._render_hook_invocation(command)
|
||||
command_text = command if isinstance(command, str) and command.strip() else "<missing command>"
|
||||
command_text = (
|
||||
command
|
||||
if isinstance(command, str) and command.strip()
|
||||
else "<missing command>"
|
||||
)
|
||||
display_invocation = invocation or (
|
||||
f"/{command_text}" if command_text != "<missing command>" else "/<missing command>"
|
||||
f"/{command_text}"
|
||||
if command_text != "<missing command>"
|
||||
else "/<missing command>"
|
||||
)
|
||||
optional = hook.get("optional", True)
|
||||
prompt = hook.get("prompt", "")
|
||||
@@ -3261,11 +3344,7 @@ class HookExecutor:
|
||||
hooks = self.get_hooks_for_event(event_name)
|
||||
|
||||
if not hooks:
|
||||
return {
|
||||
"has_hooks": False,
|
||||
"hooks": [],
|
||||
"message": ""
|
||||
}
|
||||
return {"has_hooks": False, "hooks": [], "message": ""}
|
||||
|
||||
# Filter hooks by condition
|
||||
executable_hooks = []
|
||||
@@ -3277,13 +3356,13 @@ class HookExecutor:
|
||||
return {
|
||||
"has_hooks": False,
|
||||
"hooks": [],
|
||||
"message": f"# No executable hooks for event '{event_name}' (conditions not met)"
|
||||
"message": f"# No executable hooks for event '{event_name}' (conditions not met)",
|
||||
}
|
||||
|
||||
return {
|
||||
"has_hooks": True,
|
||||
"hooks": executable_hooks,
|
||||
"message": self.format_hook_message(event_name, executable_hooks)
|
||||
"message": self.format_hook_message(event_name, executable_hooks),
|
||||
}
|
||||
|
||||
def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -3308,7 +3387,7 @@ class HookExecutor:
|
||||
"extension": hook.get("extension"),
|
||||
"optional": hook.get("optional", True),
|
||||
"description": hook.get("description", ""),
|
||||
"prompt": hook.get("prompt", "")
|
||||
"prompt": hook.get("prompt", ""),
|
||||
}
|
||||
|
||||
def enable_hooks(self, extension_id: str):
|
||||
|
||||
287
src/specify_cli/integration_scaffold.py
Normal file
287
src/specify_cli/integration_scaffold.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Developer helpers for scaffolding built-in integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationScaffoldResult:
|
||||
"""Files and next steps produced by an integration scaffold run."""
|
||||
|
||||
key: str
|
||||
package_name: str
|
||||
class_name: str
|
||||
integration_file: Path
|
||||
test_file: Path
|
||||
next_steps: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _IntegrationTemplate:
|
||||
base_class: str
|
||||
commands_subdir: str
|
||||
registrar_format: str
|
||||
args: str
|
||||
extension: str
|
||||
|
||||
|
||||
_KEY_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
|
||||
_TEMPLATES = {
|
||||
"markdown": _IntegrationTemplate(
|
||||
base_class="MarkdownIntegration",
|
||||
commands_subdir="commands",
|
||||
registrar_format="markdown",
|
||||
args="$ARGUMENTS",
|
||||
extension=".md",
|
||||
),
|
||||
"toml": _IntegrationTemplate(
|
||||
base_class="TomlIntegration",
|
||||
commands_subdir="commands",
|
||||
registrar_format="toml",
|
||||
args="{{args}}",
|
||||
extension=".toml",
|
||||
),
|
||||
"yaml": _IntegrationTemplate(
|
||||
base_class="YamlIntegration",
|
||||
commands_subdir="recipes",
|
||||
registrar_format="yaml",
|
||||
args="{{args}}",
|
||||
extension=".yaml",
|
||||
),
|
||||
"skills": _IntegrationTemplate(
|
||||
base_class="SkillsIntegration",
|
||||
commands_subdir="skills",
|
||||
registrar_format="markdown",
|
||||
args="$ARGUMENTS",
|
||||
extension="/SKILL.md",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def supported_integration_scaffold_types() -> tuple[str, ...]:
|
||||
"""Return supported scaffold template names."""
|
||||
return tuple(sorted(_TEMPLATES))
|
||||
|
||||
|
||||
def _clean_key(key: str) -> str:
|
||||
clean = key.strip()
|
||||
if not _KEY_RE.fullmatch(clean):
|
||||
raise ValueError(
|
||||
"Integration key must be lowercase kebab-case, for example 'my-agent'."
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _package_name(key: str) -> str:
|
||||
return key.replace("-", "_")
|
||||
|
||||
|
||||
def _class_name(key: str) -> str:
|
||||
return "".join(part.capitalize() for part in key.split("-")) + "Integration"
|
||||
|
||||
|
||||
def _display_name(key: str) -> str:
|
||||
return " ".join(part.capitalize() for part in key.split("-"))
|
||||
|
||||
|
||||
def _integration_content(
|
||||
*,
|
||||
key: str,
|
||||
class_name: str,
|
||||
integration_type: str,
|
||||
) -> str:
|
||||
template = _TEMPLATES[integration_type]
|
||||
display_name = _display_name(key)
|
||||
folder = f".{key}/"
|
||||
commands_dir = f"{folder}{template.commands_subdir}"
|
||||
return f'''"""{display_name} integration."""
|
||||
|
||||
from ..base import {template.base_class}
|
||||
|
||||
|
||||
class {class_name}({template.base_class}):
|
||||
key = "{key}"
|
||||
config = {{
|
||||
"name": "{display_name}",
|
||||
"folder": "{folder}",
|
||||
"commands_subdir": "{template.commands_subdir}",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}}
|
||||
registrar_config = {{
|
||||
"dir": "{commands_dir}",
|
||||
"format": "{template.registrar_format}",
|
||||
"args": "{template.args}",
|
||||
"extension": "{template.extension}",
|
||||
}}
|
||||
context_file = "AGENTS.md"
|
||||
# Default to False so the generated boilerplate passes the registry
|
||||
# contract out of the box: multi-install-safe integrations must each have a
|
||||
# distinct context_file, and the placeholder above ("AGENTS.md") collides
|
||||
# with the existing codex integration. Opt in once you pick a unique one.
|
||||
multi_install_safe = False
|
||||
'''
|
||||
|
||||
|
||||
def _test_content(
|
||||
*,
|
||||
key: str,
|
||||
class_name: str,
|
||||
integration_type: str,
|
||||
) -> str:
|
||||
template = _TEMPLATES[integration_type]
|
||||
display_name = _display_name(key)
|
||||
package_name = _package_name(key)
|
||||
commands_dir = f".{key}/{template.commands_subdir}"
|
||||
return f'''"""Tests for the {key} integration."""
|
||||
|
||||
from specify_cli.integrations.{package_name} import {class_name}
|
||||
from specify_cli.integrations.base import {template.base_class}
|
||||
|
||||
|
||||
def test_metadata():
|
||||
integration = {class_name}()
|
||||
|
||||
assert isinstance(integration, {template.base_class})
|
||||
assert integration.key == "{key}"
|
||||
assert integration.config["name"] == "{display_name}"
|
||||
assert integration.config["folder"] == ".{key}/"
|
||||
assert integration.config["commands_subdir"] == "{template.commands_subdir}"
|
||||
assert integration.config["requires_cli"] is False
|
||||
assert integration.registrar_config["dir"] == "{commands_dir}"
|
||||
assert integration.registrar_config["format"] == "{template.registrar_format}"
|
||||
assert integration.registrar_config["args"] == "{template.args}"
|
||||
assert integration.registrar_config["extension"] == "{template.extension}"
|
||||
assert integration.context_file == "AGENTS.md"
|
||||
assert integration.multi_install_safe is False
|
||||
'''
|
||||
|
||||
|
||||
def _is_spec_kit_repo_root(project_root: Path) -> bool:
|
||||
"""Return True when `project_root` looks like the Spec Kit repository root."""
|
||||
return all(
|
||||
(
|
||||
(project_root / "pyproject.toml").is_file(),
|
||||
(project_root / "src" / "specify_cli" / "__init__.py").is_file(),
|
||||
(project_root / "src" / "specify_cli" / "integrations").is_dir(),
|
||||
(
|
||||
project_root / "src" / "specify_cli" / "integrations" / "__init__.py"
|
||||
).is_file(),
|
||||
(project_root / "tests" / "integrations").is_dir(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _assert_safe_scaffold_target(project_root: Path, target: Path) -> None:
|
||||
"""Refuse to scaffold through a symlinked path that could escape the repo.
|
||||
|
||||
Walks each component of *target* under *project_root* and rejects any
|
||||
existing symlinked directory (or symlinked target), then confirms the
|
||||
write destination still resolves inside the repository root. Mirrors the
|
||||
symlink-aware guarding used for integration manifests.
|
||||
"""
|
||||
try:
|
||||
rel = target.relative_to(project_root)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Refusing to scaffold outside the repository root: {target}"
|
||||
) from None
|
||||
|
||||
current = project_root
|
||||
for part in rel.parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
label = current.relative_to(project_root).as_posix()
|
||||
raise ValueError(f"Refusing to scaffold through symlinked path: {label}")
|
||||
|
||||
root_resolved = project_root.resolve()
|
||||
try:
|
||||
target.parent.resolve().relative_to(root_resolved)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(
|
||||
f"Refusing to scaffold outside the repository root: {target}"
|
||||
) from None
|
||||
|
||||
|
||||
def scaffold_integration(
|
||||
project_root: Path,
|
||||
key: str,
|
||||
integration_type: str,
|
||||
) -> IntegrationScaffoldResult:
|
||||
"""Create a minimal built-in integration package and test skeleton."""
|
||||
clean_key = _clean_key(key)
|
||||
normalized_type = integration_type.strip().lower()
|
||||
if normalized_type not in _TEMPLATES:
|
||||
supported = ", ".join(supported_integration_scaffold_types())
|
||||
raise ValueError(
|
||||
f"Unsupported integration type '{normalized_type}'. Use one of: {supported}."
|
||||
)
|
||||
|
||||
integrations_root = project_root / "src" / "specify_cli" / "integrations"
|
||||
tests_root = project_root / "tests" / "integrations"
|
||||
if not _is_spec_kit_repo_root(project_root):
|
||||
raise ValueError("Run this command from the Spec Kit repository root.")
|
||||
|
||||
package_name = _package_name(clean_key)
|
||||
class_name = _class_name(clean_key)
|
||||
integration_dir = integrations_root / package_name
|
||||
integration_file = integration_dir / "__init__.py"
|
||||
test_file = tests_root / f"test_integration_{package_name}.py"
|
||||
|
||||
for target in (integration_file, test_file):
|
||||
_assert_safe_scaffold_target(project_root, target)
|
||||
|
||||
existing = [path for path in (integration_file, test_file) if path.exists()]
|
||||
if existing:
|
||||
labels = ", ".join(path.relative_to(project_root).as_posix() for path in existing)
|
||||
raise FileExistsError(f"Refusing to overwrite existing scaffold file(s): {labels}")
|
||||
|
||||
created_integration_dir = not integration_dir.exists()
|
||||
try:
|
||||
integration_dir.mkdir(exist_ok=True)
|
||||
integration_file.write_text(
|
||||
_integration_content(
|
||||
key=clean_key,
|
||||
class_name=class_name,
|
||||
integration_type=normalized_type,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
test_file.write_text(
|
||||
_test_content(
|
||||
key=clean_key,
|
||||
class_name=class_name,
|
||||
integration_type=normalized_type,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
for path in (test_file, integration_file):
|
||||
try:
|
||||
if path.is_file() or path.is_symlink():
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if created_integration_dir:
|
||||
try:
|
||||
integration_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
next_steps = (
|
||||
f"Register {class_name} in src/specify_cli/integrations/__init__.py.",
|
||||
"Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.",
|
||||
f"Run pytest tests/integrations/test_integration_{package_name}.py -v.",
|
||||
)
|
||||
return IntegrationScaffoldResult(
|
||||
key=clean_key,
|
||||
package_name=package_name,
|
||||
class_name=class_name,
|
||||
integration_file=integration_file,
|
||||
test_file=test_file,
|
||||
next_steps=next_steps,
|
||||
)
|
||||
@@ -80,6 +80,7 @@ def _register_builtins() -> None:
|
||||
from .trae import TraeIntegration
|
||||
from .vibe import VibeIntegration
|
||||
from .windsurf import WindsurfIntegration
|
||||
from .zed import ZedIntegration
|
||||
|
||||
# -- Registration (alphabetical) --------------------------------------
|
||||
_register(AgyIntegration())
|
||||
@@ -115,6 +116,7 @@ def _register_builtins() -> None:
|
||||
_register(TraeIntegration())
|
||||
_register(VibeIntegration())
|
||||
_register(WindsurfIntegration())
|
||||
_register(ZedIntegration())
|
||||
|
||||
|
||||
_register_builtins()
|
||||
|
||||
@@ -31,4 +31,5 @@ def register(app: typer.Typer) -> None:
|
||||
from . import _install_commands # noqa: F401 — registers handlers via decorators
|
||||
from . import _migrate_commands # noqa: F401
|
||||
from . import _query_commands # noqa: F401
|
||||
from . import _scaffold_commands # noqa: F401
|
||||
app.add_typer(integration_app, name="integration")
|
||||
|
||||
52
src/specify_cli/integrations/_scaffold_commands.py
Normal file
52
src/specify_cli/integrations/_scaffold_commands.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""specify integration scaffold command handler."""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from .._console import console
|
||||
from ..integration_scaffold import supported_integration_scaffold_types
|
||||
from ._commands import integration_app
|
||||
|
||||
|
||||
INTEGRATION_SCAFFOLD_TYPES = supported_integration_scaffold_types()
|
||||
_IntegrationScaffoldType = Enum(
|
||||
"_IntegrationScaffoldType",
|
||||
{name: name for name in INTEGRATION_SCAFFOLD_TYPES},
|
||||
type=str,
|
||||
)
|
||||
|
||||
|
||||
@integration_app.command("scaffold")
|
||||
def integration_scaffold(
|
||||
key: str = typer.Argument(help="Integration key in lowercase kebab-case, e.g. my-agent"),
|
||||
integration_type: _IntegrationScaffoldType = typer.Option(
|
||||
_IntegrationScaffoldType.markdown,
|
||||
"--type",
|
||||
case_sensitive=False,
|
||||
help=f"Scaffold type: {', '.join(INTEGRATION_SCAFFOLD_TYPES)}",
|
||||
),
|
||||
):
|
||||
"""Create a minimal built-in integration package and test skeleton."""
|
||||
from ..integration_scaffold import scaffold_integration
|
||||
|
||||
project_root = Path.cwd()
|
||||
try:
|
||||
result = scaffold_integration(project_root, key, integration_type.value)
|
||||
except (OSError, ValueError) as exc:
|
||||
# OSError covers filesystem failures during mkdir()/write_text()
|
||||
# (permission denied, read-only checkout, a path component that is a
|
||||
# file, ...) as well as FileExistsError; surface them as a clean CLI
|
||||
# error instead of a traceback.
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[green]Created integration scaffold:[/green] {result.key}")
|
||||
console.print(f" {result.integration_file.relative_to(project_root).as_posix()}")
|
||||
console.print(f" {result.test_file.relative_to(project_root).as_posix()}")
|
||||
console.print()
|
||||
console.print("[bold]Next steps:[/bold]")
|
||||
for index, step in enumerate(result.next_steps, start=1):
|
||||
console.print(f"{index}. {step}")
|
||||
34
src/specify_cli/integrations/zed/__init__.py
Normal file
34
src/specify_cli/integrations/zed/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Zed editor integration — skills-based agent.
|
||||
|
||||
Zed uses the ``.agents/skills/speckit-<name>/SKILL.md`` layout so Spec Kit
|
||||
commands are exposed as project-local skills that can be invoked from Zed's
|
||||
slash-command menu.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
|
||||
class ZedIntegration(SkillsIntegration):
|
||||
"""Integration for Zed editor skills."""
|
||||
|
||||
key = "zed"
|
||||
config = {
|
||||
"name": "Zed",
|
||||
"folder": ".agents/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".agents/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return []
|
||||
@@ -7,10 +7,12 @@ Provides:
|
||||
- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances.
|
||||
- ``WorkflowEngine`` — orchestrator that loads, validates, and executes
|
||||
workflow YAML definitions.
|
||||
- ``load_custom_steps`` — loads community-installed step types into STEP_REGISTRY.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -66,3 +68,134 @@ def _register_builtin_steps() -> None:
|
||||
|
||||
|
||||
_register_builtin_steps()
|
||||
|
||||
|
||||
def load_custom_steps(project_root: Path) -> list[str]:
|
||||
"""Load community-installed custom step types into STEP_REGISTRY.
|
||||
|
||||
Scans ``.specify/workflows/steps/`` for installed step packages.
|
||||
Each valid package must contain ``step.yml`` (with a ``step.type_key``
|
||||
field) and ``__init__.py`` (a ``StepBase`` subclass).
|
||||
|
||||
Returns a list of type_keys that were successfully loaded.
|
||||
Silently skips packages that fail to import or validate.
|
||||
"""
|
||||
import hashlib as _hashlib
|
||||
import importlib.util as _importlib_util
|
||||
import re as _re
|
||||
import sys as _sys
|
||||
|
||||
steps_dir = Path(project_root) / ".specify" / "workflows" / "steps"
|
||||
|
||||
# Defense-in-depth: refuse to execute step code from a symlinked
|
||||
# parent directory under .specify/workflows/steps, which could redirect
|
||||
# the import outside the project root and bypass the install-time
|
||||
# symlink guard. Check symlinks *before* is_dir() since the latter
|
||||
# follows symlinks and would stat an external target.
|
||||
_current = Path(project_root)
|
||||
for _part in (".specify", "workflows", "steps"):
|
||||
_current = _current / _part
|
||||
if _current.is_symlink():
|
||||
return []
|
||||
|
||||
if not steps_dir.is_dir():
|
||||
return []
|
||||
|
||||
loaded: list[str] = []
|
||||
for step_dir in steps_dir.iterdir():
|
||||
# Check symlinks before is_dir() since the latter follows symlinks
|
||||
# and would stat an external target through a symlinked directory.
|
||||
if step_dir.is_symlink():
|
||||
continue
|
||||
if not step_dir.is_dir():
|
||||
continue
|
||||
step_yml = step_dir / "step.yml"
|
||||
init_py = step_dir / "__init__.py"
|
||||
if step_yml.is_symlink() or init_py.is_symlink():
|
||||
continue
|
||||
if not step_yml.is_file() or not init_py.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
import yaml as _yaml
|
||||
|
||||
meta = _yaml.safe_load(step_yml.read_text(encoding="utf-8")) or {}
|
||||
step_meta = meta.get("step", {})
|
||||
type_key = step_meta.get("type_key", "")
|
||||
if not type_key:
|
||||
continue
|
||||
|
||||
# Skip if already registered (e.g. built-in or previously loaded)
|
||||
if type_key in STEP_REGISTRY:
|
||||
continue
|
||||
|
||||
# Sanitize type_key so the synthetic module name is a valid identifier
|
||||
# (e.g. "test-custom" → "_speckit_custom_step_test_custom_<hash>").
|
||||
# The 8-char SHA-256 hash of the original type_key makes the name
|
||||
# collision-resistant when different type_keys produce the same
|
||||
# sanitized form (e.g. "a-b" and "a_b" both sanitize to "a_b" but
|
||||
# have different hashes).
|
||||
safe_key = _re.sub(r"[^A-Za-z0-9_]", "_", type_key)
|
||||
key_hash = _hashlib.sha256(type_key.encode()).hexdigest()[:8]
|
||||
module_name = f"_speckit_custom_step_{safe_key}_{key_hash}"
|
||||
|
||||
# Treat the step directory as a proper package so that relative
|
||||
# imports inside the step (e.g. ``from .helpers import …``) work.
|
||||
spec = _importlib_util.spec_from_file_location(
|
||||
module_name,
|
||||
init_py,
|
||||
submodule_search_locations=[str(step_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
module = _importlib_util.module_from_spec(spec)
|
||||
module.__package__ = module_name
|
||||
# Register before exec so relative imports resolve correctly.
|
||||
_sys.modules[module_name] = module
|
||||
registered = False
|
||||
try:
|
||||
spec.loader.exec_module(module) # type: ignore[union-attr]
|
||||
|
||||
# Find the StepBase subclass in the module
|
||||
from .base import StepBase as _StepBase
|
||||
|
||||
step_class = None
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
try:
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, _StepBase)
|
||||
and attr is not _StepBase
|
||||
and getattr(attr, "type_key", "") == type_key
|
||||
):
|
||||
step_class = attr
|
||||
break
|
||||
except TypeError:
|
||||
continue
|
||||
|
||||
if step_class is None:
|
||||
continue
|
||||
|
||||
_register_step(step_class())
|
||||
loaded.append(type_key)
|
||||
registered = True
|
||||
finally:
|
||||
# If the step wasn't successfully registered (failed import,
|
||||
# no matching StepBase subclass, or registration error), remove
|
||||
# the synthetic module — and any submodules loaded via relative
|
||||
# imports (e.g. ``from .helpers import …``) — from sys.modules so
|
||||
# a broken/skipped step package leaves no lingering import state
|
||||
# behind.
|
||||
if not registered:
|
||||
_sys.modules.pop(module_name, None)
|
||||
submodule_prefix = module_name + "."
|
||||
for _mod_key in [
|
||||
k for k in _sys.modules if k.startswith(submodule_prefix)
|
||||
]:
|
||||
_sys.modules.pop(_mod_key, None)
|
||||
except Exception: # noqa: BLE001
|
||||
# Silently skip broken step packages at load time
|
||||
continue
|
||||
|
||||
return loaded
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Workflow catalog — discovery, install, and management of workflows.
|
||||
"""Workflow catalog — discovery, install, and management of workflows and step types.
|
||||
|
||||
Mirrors the existing extension/preset catalog pattern with:
|
||||
- Multi-catalog stack (env var → project → user → built-in)
|
||||
- SHA256-hashed per-URL caching with 1-hour TTL
|
||||
- Workflow registry for installed workflow tracking
|
||||
- Step registry for installed custom step type tracking
|
||||
- Search across all configured catalog sources
|
||||
"""
|
||||
|
||||
@@ -165,7 +166,7 @@ class WorkflowCatalog:
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
if not parsed.hostname:
|
||||
raise WorkflowValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
@@ -181,6 +182,11 @@ class WorkflowCatalog:
|
||||
except (yaml.YAMLError, OSError, UnicodeError) as exc:
|
||||
raise WorkflowValidationError(
|
||||
f"Failed to read catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise WorkflowValidationError(
|
||||
f"Invalid catalog config: expected a mapping, "
|
||||
f"got {type(data).__name__}"
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
@@ -302,9 +308,9 @@ class WorkflowCatalog:
|
||||
try:
|
||||
with open(meta_file, encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
fetched_at = meta.get("fetched_at", 0)
|
||||
fetched_at = float(meta.get("fetched_at", 0))
|
||||
return (time.time() - fetched_at) < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
def _fetch_single_catalog(
|
||||
@@ -318,6 +324,7 @@ class WorkflowCatalog:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Ignore invalid/unreadable cache and fall back to fetching from source.
|
||||
pass
|
||||
|
||||
# Fetch from URL — validate scheme before opening and after redirects
|
||||
@@ -333,6 +340,10 @@ class WorkflowCatalog:
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from URL with no hostname: {url}"
|
||||
)
|
||||
|
||||
_validate_catalog_url(entry.url)
|
||||
|
||||
@@ -347,6 +358,7 @@ class WorkflowCatalog:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, ValueError, OSError):
|
||||
# Stale-cache read failed; let the original fetch error propagate.
|
||||
pass
|
||||
raise WorkflowCatalogError(
|
||||
f"Failed to fetch catalog from {entry.url}: {exc}"
|
||||
@@ -358,11 +370,14 @@ class WorkflowCatalog:
|
||||
)
|
||||
|
||||
# Write cache
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
with open(meta_file, "w", encoding="utf-8") as f:
|
||||
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
with open(meta_file, "w", encoding="utf-8") as f:
|
||||
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
|
||||
except OSError:
|
||||
pass # Proceed without caching if disk write fails
|
||||
|
||||
return data
|
||||
|
||||
@@ -468,7 +483,14 @@ class WorkflowCatalog:
|
||||
|
||||
data: dict[str, Any] = {"catalogs": []}
|
||||
if config_path.exists():
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
|
||||
raise WorkflowValidationError(
|
||||
f"Catalog config file is unreadable or malformed: {exc}"
|
||||
) from exc
|
||||
if raw is None:
|
||||
raw = {"catalogs": []}
|
||||
if not isinstance(raw, dict):
|
||||
raise WorkflowValidationError(
|
||||
"Catalog config file is corrupted (expected a mapping)."
|
||||
@@ -487,9 +509,21 @@ class WorkflowCatalog:
|
||||
f"Catalog URL already configured: {url}"
|
||||
)
|
||||
|
||||
# Derive priority from the highest existing priority + 1
|
||||
# Derive priority from the highest existing priority + 1.
|
||||
# Coerce existing priorities to int with a safe fallback so a user-edited
|
||||
# workflow-catalogs.yml with a non-integer priority (e.g. "1") doesn't blow up.
|
||||
def _coerce_priority(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
max_priority = max(
|
||||
(cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)),
|
||||
(
|
||||
_coerce_priority(cat.get("priority", 0))
|
||||
for cat in catalogs
|
||||
if isinstance(cat, dict)
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
catalogs.append(
|
||||
@@ -503,9 +537,14 @@ class WorkflowCatalog:
|
||||
)
|
||||
data["catalogs"] = catalogs
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||
except OSError as exc:
|
||||
raise WorkflowValidationError(
|
||||
f"Failed to write catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
def remove_catalog(self, index: int) -> str:
|
||||
"""Remove a catalog source by index (0-based). Returns the removed name."""
|
||||
@@ -513,7 +552,12 @@ class WorkflowCatalog:
|
||||
if not config_path.exists():
|
||||
raise WorkflowValidationError("No catalog config file found.")
|
||||
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
|
||||
raise WorkflowValidationError(
|
||||
f"Catalog config file is unreadable or malformed: {exc}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise WorkflowValidationError(
|
||||
"Catalog config file is corrupted (expected a mapping)."
|
||||
@@ -532,8 +576,623 @@ class WorkflowCatalog:
|
||||
removed = catalogs.pop(index)
|
||||
data["catalogs"] = catalogs
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||
try:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||
except OSError as exc:
|
||||
raise WorkflowValidationError(
|
||||
f"Failed to write catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
if isinstance(removed, dict):
|
||||
return removed.get("name", f"catalog-{index + 1}")
|
||||
return f"catalog-{index + 1}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step catalog errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StepCatalogError(Exception):
|
||||
"""Base error for step catalog operations."""
|
||||
|
||||
|
||||
class StepValidationError(StepCatalogError):
|
||||
"""Validation error for step catalog config or step data."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StepCatalogEntry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepCatalogEntry:
|
||||
"""Represents a single step catalog source in the catalog stack."""
|
||||
|
||||
url: str
|
||||
name: str
|
||||
priority: int
|
||||
install_allowed: bool
|
||||
description: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StepRegistry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StepRegistry:
|
||||
"""Manages the registry of installed custom step types.
|
||||
|
||||
Tracks installed step types and their metadata in
|
||||
``.specify/workflows/steps/step-registry.json``.
|
||||
"""
|
||||
|
||||
REGISTRY_FILE = "step-registry.json"
|
||||
SCHEMA_VERSION = "1.0"
|
||||
|
||||
def __init__(self, project_root: Path) -> None:
|
||||
self.project_root = project_root
|
||||
self.steps_dir = project_root / ".specify" / "workflows" / "steps"
|
||||
self.registry_path = self.steps_dir / self.REGISTRY_FILE
|
||||
self.data = self._load()
|
||||
|
||||
def _has_symlinked_parent(self) -> bool:
|
||||
"""Return True if any directory under .specify/workflows/steps is a symlink."""
|
||||
current = self.project_root
|
||||
for part in (".specify", "workflows", "steps"):
|
||||
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, "steps": {}}
|
||||
# Defense-in-depth: refuse to read the registry if any parent directory
|
||||
# under .specify/workflows/steps is a symlink, which could redirect the
|
||||
# read outside the project root.
|
||||
if self._has_symlinked_parent():
|
||||
return default_registry
|
||||
# Defense-in-depth: also refuse to read a symlinked registry file,
|
||||
# which could redirect the read outside the project root.
|
||||
if self.registry_path.is_symlink():
|
||||
return default_registry
|
||||
if self.registry_path.exists():
|
||||
try:
|
||||
with open(self.registry_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Validate shape: must be a dict with a dict "steps" field
|
||||
if not isinstance(data, dict):
|
||||
return default_registry
|
||||
if not isinstance(data.get("steps"), dict):
|
||||
data["steps"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
|
||||
return default_registry
|
||||
return default_registry
|
||||
|
||||
def save(self) -> None:
|
||||
"""Persist registry to disk.
|
||||
|
||||
Raises ``StepValidationError`` with a clear message on filesystem
|
||||
errors (read-only fs, permission denied, ...) so callers can surface
|
||||
a clean error to the user rather than an unhandled ``OSError``.
|
||||
"""
|
||||
if self._has_symlinked_parent() or self.registry_path.is_symlink():
|
||||
raise StepValidationError(
|
||||
"Refusing to write step registry through a symlinked path."
|
||||
)
|
||||
try:
|
||||
self.steps_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
except OSError as exc:
|
||||
raise StepValidationError(
|
||||
f"Failed to write step registry at {self.registry_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
def add(self, step_id: str, metadata: dict[str, Any]) -> None:
|
||||
"""Add or update an installed step entry."""
|
||||
import copy
|
||||
from datetime import datetime, timezone
|
||||
|
||||
existing = self.data["steps"].get(step_id, {})
|
||||
metadata_to_store = copy.deepcopy(metadata)
|
||||
metadata_to_store["installed_at"] = existing.get(
|
||||
"installed_at", datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
metadata_to_store["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
self.data["steps"][step_id] = metadata_to_store
|
||||
self.save()
|
||||
|
||||
def remove(self, step_id: str) -> bool:
|
||||
"""Remove an installed step entry. Returns True if found."""
|
||||
if step_id in self.data["steps"]:
|
||||
del self.data["steps"][step_id]
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, step_id: str) -> dict[str, Any] | None:
|
||||
"""Get metadata for an installed step."""
|
||||
return self.data["steps"].get(step_id)
|
||||
|
||||
def list(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return all installed steps."""
|
||||
return dict(self.data["steps"])
|
||||
|
||||
def is_installed(self, step_id: str) -> bool:
|
||||
"""Check if a step is installed."""
|
||||
return step_id in self.data["steps"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StepCatalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StepCatalog:
|
||||
"""Manages step catalog fetching, caching, and searching.
|
||||
|
||||
Resolution order for catalog sources:
|
||||
1. ``SPECKIT_STEP_CATALOG_URL`` env var (overrides all)
|
||||
2. Project-level ``.specify/step-catalogs.yml``
|
||||
3. User-level ``~/.specify/step-catalogs.yml``
|
||||
4. Built-in defaults (official + community)
|
||||
"""
|
||||
|
||||
DEFAULT_CATALOG_URL = (
|
||||
"https://raw.githubusercontent.com/github/spec-kit/main/"
|
||||
"workflows/step-catalog.json"
|
||||
)
|
||||
COMMUNITY_CATALOG_URL = (
|
||||
"https://raw.githubusercontent.com/github/spec-kit/main/"
|
||||
"workflows/step-catalog.community.json"
|
||||
)
|
||||
CACHE_DURATION = 3600 # 1 hour
|
||||
|
||||
def __init__(self, project_root: Path) -> None:
|
||||
self.project_root = project_root
|
||||
self.steps_dir = project_root / ".specify" / "workflows" / "steps"
|
||||
self.cache_dir = self.steps_dir / ".cache"
|
||||
|
||||
def _is_cache_path_safe(self) -> bool:
|
||||
"""Return False if any component of the cache path is a symlink."""
|
||||
current = self.project_root
|
||||
for part in (".specify", "workflows", "steps", ".cache"):
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
return False
|
||||
return True
|
||||
|
||||
# -- Catalog resolution -----------------------------------------------
|
||||
|
||||
def _validate_catalog_url(self, url: str) -> None:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise StepValidationError(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise StepValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
|
||||
def _load_catalog_config(
|
||||
self, config_path: Path
|
||||
) -> list[StepCatalogEntry] | None:
|
||||
"""Load catalog stack configuration from a YAML file."""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeError) as exc:
|
||||
raise StepValidationError(
|
||||
f"Failed to read catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise StepValidationError(
|
||||
f"Invalid catalog config: expected a mapping, "
|
||||
f"got {type(data).__name__}"
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
return None
|
||||
if not isinstance(catalogs_data, list):
|
||||
raise StepValidationError(
|
||||
f"Invalid catalog config: 'catalogs' must be a list, "
|
||||
f"got {type(catalogs_data).__name__}"
|
||||
)
|
||||
|
||||
entries: list[StepCatalogEntry] = []
|
||||
for idx, item in enumerate(catalogs_data):
|
||||
if not isinstance(item, dict):
|
||||
raise StepValidationError(
|
||||
f"Invalid catalog entry at index {idx}: "
|
||||
f"expected a mapping, got {type(item).__name__}"
|
||||
)
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raise StepValidationError(
|
||||
f"Invalid priority for catalog "
|
||||
f"'{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
install_allowed = raw_install.strip().lower() in (
|
||||
"true",
|
||||
"yes",
|
||||
"1",
|
||||
)
|
||||
else:
|
||||
install_allowed = bool(raw_install)
|
||||
entries.append(
|
||||
StepCatalogEntry(
|
||||
url=url,
|
||||
name=str(item.get("name", f"catalog-{idx + 1}")),
|
||||
priority=priority,
|
||||
install_allowed=install_allowed,
|
||||
description=str(item.get("description", "")),
|
||||
)
|
||||
)
|
||||
entries.sort(key=lambda e: e.priority)
|
||||
if not entries:
|
||||
raise StepValidationError(
|
||||
f"Catalog config {config_path} contains {len(catalogs_data)} "
|
||||
f"entries but none have valid URLs."
|
||||
)
|
||||
return entries
|
||||
|
||||
def get_active_catalogs(self) -> list[StepCatalogEntry]:
|
||||
"""Get the ordered list of active step catalogs."""
|
||||
# 1. Environment variable override
|
||||
env_url = os.environ.get("SPECKIT_STEP_CATALOG_URL", "").strip()
|
||||
if env_url:
|
||||
self._validate_catalog_url(env_url)
|
||||
return [
|
||||
StepCatalogEntry(
|
||||
url=env_url,
|
||||
name="env-override",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="From SPECKIT_STEP_CATALOG_URL",
|
||||
)
|
||||
]
|
||||
|
||||
# 2. Project-level config
|
||||
project_config = self.project_root / ".specify" / "step-catalogs.yml"
|
||||
project_entries = self._load_catalog_config(project_config)
|
||||
if project_entries is not None:
|
||||
return project_entries
|
||||
|
||||
# 3. User-level config
|
||||
home = Path.home()
|
||||
user_config = home / ".specify" / "step-catalogs.yml"
|
||||
user_entries = self._load_catalog_config(user_config)
|
||||
if user_entries is not None:
|
||||
return user_entries
|
||||
|
||||
# 4. Built-in defaults
|
||||
return [
|
||||
StepCatalogEntry(
|
||||
url=self.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Official step types",
|
||||
),
|
||||
StepCatalogEntry(
|
||||
url=self.COMMUNITY_CATALOG_URL,
|
||||
name="community",
|
||||
priority=2,
|
||||
install_allowed=False,
|
||||
description="Community-contributed step types (discovery only)",
|
||||
),
|
||||
]
|
||||
|
||||
# -- Caching ----------------------------------------------------------
|
||||
|
||||
def _get_cache_paths(self, url: str) -> tuple[Path, Path]:
|
||||
"""Get cache file paths for a URL (hash-based)."""
|
||||
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
|
||||
cache_file = self.cache_dir / f"step-catalog-{url_hash}.json"
|
||||
meta_file = self.cache_dir / f"step-catalog-{url_hash}-meta.json"
|
||||
return cache_file, meta_file
|
||||
|
||||
def _is_url_cache_valid(self, url: str) -> bool:
|
||||
"""Check if cached data for a URL is still fresh."""
|
||||
_, meta_file = self._get_cache_paths(url)
|
||||
if not meta_file.exists():
|
||||
return False
|
||||
try:
|
||||
with open(meta_file, encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
fetched_at = float(meta.get("fetched_at", 0))
|
||||
return (time.time() - fetched_at) < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
def _fetch_single_catalog(
|
||||
self, entry: StepCatalogEntry, force_refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch a single catalog, using cache when possible."""
|
||||
cache_safe = self._is_cache_path_safe()
|
||||
cache_file, meta_file = self._get_cache_paths(entry.url)
|
||||
|
||||
if cache_safe and not force_refresh and self._is_url_cache_valid(entry.url):
|
||||
try:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
cached = json.load(f)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Ignore invalid/unreadable cache and fall back to fetching from source.
|
||||
pass
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from URL with no hostname: {url}"
|
||||
)
|
||||
|
||||
_validate_url(entry.url)
|
||||
|
||||
try:
|
||||
with _open_url(entry.url, timeout=30) as resp:
|
||||
_validate_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
if cache_safe and cache_file.exists():
|
||||
try:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
cached = json.load(f)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
except (json.JSONDecodeError, ValueError, OSError):
|
||||
# Stale-cache read failed; let the original fetch error propagate.
|
||||
pass
|
||||
raise StepCatalogError(
|
||||
f"Failed to fetch catalog from {entry.url}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise StepCatalogError(
|
||||
f"Catalog from {entry.url} is not a valid JSON object."
|
||||
)
|
||||
|
||||
if cache_safe:
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
with open(meta_file, "w", encoding="utf-8") as f:
|
||||
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
|
||||
except OSError:
|
||||
pass # Proceed without caching if disk write fails
|
||||
|
||||
return data
|
||||
|
||||
def _get_merged_steps(
|
||||
self, force_refresh: bool = False
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Merge steps from all active catalogs (lower priority number wins)."""
|
||||
catalogs = self.get_active_catalogs()
|
||||
merged: dict[str, dict[str, Any]] = {}
|
||||
fetch_errors = 0
|
||||
|
||||
for entry in reversed(catalogs):
|
||||
try:
|
||||
data = self._fetch_single_catalog(entry, force_refresh)
|
||||
except StepCatalogError:
|
||||
fetch_errors += 1
|
||||
continue
|
||||
steps = data.get("steps", {})
|
||||
if isinstance(steps, dict):
|
||||
for step_id, step_data in steps.items():
|
||||
if not isinstance(step_data, dict):
|
||||
continue
|
||||
step_data["_catalog_name"] = entry.name
|
||||
step_data["_install_allowed"] = entry.install_allowed
|
||||
merged[step_id] = step_data
|
||||
elif isinstance(steps, list):
|
||||
for step_data in steps:
|
||||
if not isinstance(step_data, dict):
|
||||
continue
|
||||
raw_step_id = step_data.get("id")
|
||||
if raw_step_id is None:
|
||||
continue
|
||||
step_id = str(raw_step_id).strip()
|
||||
if step_id:
|
||||
step_data["id"] = step_id
|
||||
step_data["_catalog_name"] = entry.name
|
||||
step_data["_install_allowed"] = entry.install_allowed
|
||||
merged[step_id] = step_data
|
||||
if fetch_errors == len(catalogs) and catalogs:
|
||||
raise StepCatalogError("All configured step catalogs failed to fetch.")
|
||||
return merged
|
||||
|
||||
# -- Public API -------------------------------------------------------
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search step types across all configured catalogs."""
|
||||
merged = self._get_merged_steps()
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for step_id, step_data in merged.items():
|
||||
step_data.setdefault("id", step_id)
|
||||
if query:
|
||||
q = query.lower()
|
||||
searchable = " ".join(
|
||||
[
|
||||
str(step_data.get("name") or ""),
|
||||
str(step_data.get("description") or ""),
|
||||
str(step_data.get("id") or ""),
|
||||
]
|
||||
).lower()
|
||||
if q not in searchable:
|
||||
continue
|
||||
results.append(step_data)
|
||||
return results
|
||||
|
||||
def get_step_info(self, step_id: str) -> dict[str, Any] | None:
|
||||
"""Get details for a specific step from the catalog."""
|
||||
merged = self._get_merged_steps()
|
||||
step = merged.get(step_id)
|
||||
if step:
|
||||
step.setdefault("id", step_id)
|
||||
return step
|
||||
|
||||
def get_catalog_configs(self) -> list[dict[str, Any]]:
|
||||
"""Return current catalog configuration as a list of dicts."""
|
||||
entries = self.get_active_catalogs()
|
||||
return [
|
||||
{
|
||||
"name": e.name,
|
||||
"url": e.url,
|
||||
"priority": e.priority,
|
||||
"install_allowed": e.install_allowed,
|
||||
"description": e.description,
|
||||
}
|
||||
for e in entries
|
||||
]
|
||||
|
||||
def add_catalog(self, url: str, name: str | None = None) -> None:
|
||||
"""Add a catalog source to the project-level config."""
|
||||
self._validate_catalog_url(url)
|
||||
config_path = self.project_root / ".specify" / "step-catalogs.yml"
|
||||
|
||||
data: dict[str, Any] = {"catalogs": []}
|
||||
if config_path.exists():
|
||||
try:
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
|
||||
raise StepValidationError(
|
||||
f"Catalog config file is unreadable or malformed: {exc}"
|
||||
) from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise StepValidationError(
|
||||
"Catalog config file is corrupted (expected a mapping)."
|
||||
)
|
||||
data = raw
|
||||
|
||||
catalogs = data.get("catalogs", [])
|
||||
if not isinstance(catalogs, list):
|
||||
raise StepValidationError(
|
||||
"Catalog config 'catalogs' must be a list."
|
||||
)
|
||||
for cat in catalogs:
|
||||
if isinstance(cat, dict) and cat.get("url") == url:
|
||||
raise StepValidationError(
|
||||
f"Catalog URL already configured: {url}"
|
||||
)
|
||||
|
||||
# Coerce existing priorities to int with a safe fallback so a user-edited
|
||||
# step-catalogs.yml with a non-integer priority (e.g. "1") doesn't blow up.
|
||||
def _coerce_priority(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
max_priority = max(
|
||||
(
|
||||
_coerce_priority(cat.get("priority", 0))
|
||||
for cat in catalogs
|
||||
if isinstance(cat, dict)
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
catalogs.append(
|
||||
{
|
||||
"name": name or f"catalog-{len(catalogs) + 1}",
|
||||
"url": url,
|
||||
"priority": max_priority + 1,
|
||||
"install_allowed": True,
|
||||
"description": "",
|
||||
}
|
||||
)
|
||||
data["catalogs"] = catalogs
|
||||
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(
|
||||
data, f, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
)
|
||||
except OSError as exc:
|
||||
raise StepValidationError(
|
||||
f"Failed to write catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
def remove_catalog(self, index: int) -> str:
|
||||
"""Remove a catalog source by index (0-based). Returns the removed name."""
|
||||
config_path = self.project_root / ".specify" / "step-catalogs.yml"
|
||||
if not config_path.exists():
|
||||
raise StepValidationError("No step catalog config file found.")
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
|
||||
raise StepValidationError(
|
||||
f"Catalog config file is unreadable or malformed: {exc}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise StepValidationError(
|
||||
"Catalog config file is corrupted (expected a mapping)."
|
||||
)
|
||||
catalogs = data.get("catalogs", [])
|
||||
if not isinstance(catalogs, list):
|
||||
raise StepValidationError(
|
||||
"Catalog config 'catalogs' must be a list."
|
||||
)
|
||||
|
||||
if index < 0 or index >= len(catalogs):
|
||||
raise StepValidationError(
|
||||
f"Catalog index {index} out of range (0-{len(catalogs) - 1})."
|
||||
)
|
||||
|
||||
removed = catalogs.pop(index)
|
||||
data["catalogs"] = catalogs
|
||||
|
||||
try:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(
|
||||
data, f, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
)
|
||||
except OSError as exc:
|
||||
raise StepValidationError(
|
||||
f"Failed to write catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
if isinstance(removed, dict):
|
||||
return removed.get("name", f"catalog-{index + 1}")
|
||||
|
||||
238
tests/integrations/test_integration_scaffold.py
Normal file
238
tests/integrations/test_integration_scaffold.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Tests for integration scaffolding commands."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.integration_scaffold import scaffold_integration
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _repo_root(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "spec-kit"
|
||||
(root / "src" / "specify_cli" / "integrations").mkdir(parents=True)
|
||||
(root / "tests" / "integrations").mkdir(parents=True)
|
||||
(root / "pyproject.toml").write_text("[project]\nname = \"specify-cli\"\n", encoding="utf-8")
|
||||
(root / "src" / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
|
||||
(root / "src" / "specify_cli" / "integrations" / "__init__.py").write_text(
|
||||
"",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def test_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "scaffold", "my-agent",
|
||||
"--type", "markdown",
|
||||
], catch_exceptions=False)
|
||||
|
||||
output = strip_ansi(result.output)
|
||||
integration_file = root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
|
||||
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert integration_file.exists()
|
||||
assert test_file.exists()
|
||||
assert "Created integration scaffold: my-agent" in output
|
||||
assert "Register MyAgentIntegration" in output
|
||||
|
||||
content = integration_file.read_text(encoding="utf-8")
|
||||
assert "class MyAgentIntegration(MarkdownIntegration):" in content
|
||||
assert 'key = "my-agent"' in content
|
||||
assert '"folder": ".my-agent/"' in content
|
||||
assert '"extension": ".md"' in content
|
||||
assert "multi_install_safe = False" in content
|
||||
|
||||
test_content = test_file.read_text(encoding="utf-8")
|
||||
assert "from specify_cli.integrations.my_agent import MyAgentIntegration" in test_content
|
||||
assert 'assert integration.registrar_config["dir"] == ".my-agent/commands"' in test_content
|
||||
assert "assert integration.multi_install_safe is False" in test_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("integration_type", "base_class", "commands_subdir", "args", "extension"),
|
||||
[
|
||||
("markdown", "MarkdownIntegration", "commands", "$ARGUMENTS", ".md"),
|
||||
("toml", "TomlIntegration", "commands", "{{args}}", ".toml"),
|
||||
("yaml", "YamlIntegration", "recipes", "{{args}}", ".yaml"),
|
||||
("skills", "SkillsIntegration", "skills", "$ARGUMENTS", "/SKILL.md"),
|
||||
],
|
||||
)
|
||||
def test_scaffold_type_templates(
|
||||
tmp_path,
|
||||
integration_type,
|
||||
base_class,
|
||||
commands_subdir,
|
||||
args,
|
||||
extension,
|
||||
):
|
||||
root = _repo_root(tmp_path)
|
||||
|
||||
result = scaffold_integration(root, f"{integration_type}-agent", integration_type)
|
||||
|
||||
content = result.integration_file.read_text(encoding="utf-8")
|
||||
assert f"class {result.class_name}({base_class}):" in content
|
||||
assert f'"commands_subdir": "{commands_subdir}"' in content
|
||||
assert f'"args": "{args}"' in content
|
||||
assert f'"extension": "{extension}"' in content
|
||||
assert "multi_install_safe = False" in content
|
||||
|
||||
|
||||
def test_integration_scaffold_rejects_unknown_type_before_scaffolding(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "scaffold", "my-agent",
|
||||
"--type", "xml",
|
||||
])
|
||||
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 2
|
||||
assert "Invalid value for '--type'" in output
|
||||
assert not (root / "src" / "specify_cli" / "integrations" / "my_agent").exists()
|
||||
|
||||
|
||||
def test_integration_scaffold_reports_filesystem_errors_cleanly(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
import specify_cli.integration_scaffold as scaffold_module
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise PermissionError("Permission denied: read-only checkout")
|
||||
|
||||
monkeypatch.setattr(scaffold_module, "scaffold_integration", boom)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "scaffold", "my-agent",
|
||||
"--type", "markdown",
|
||||
], catch_exceptions=False)
|
||||
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "Error:" in output
|
||||
assert "Permission denied" in output
|
||||
|
||||
|
||||
def test_scaffold_refuses_invalid_key(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="lowercase kebab-case"):
|
||||
scaffold_integration(root, "Bad_Key", "markdown")
|
||||
|
||||
|
||||
def test_scaffold_refuses_unknown_type(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported integration type 'xml'"):
|
||||
scaffold_integration(root, "my-agent", " XML ")
|
||||
|
||||
|
||||
def test_scaffold_refuses_overwrite(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
with pytest.raises(FileExistsError, match="Refusing to overwrite"):
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
|
||||
def test_scaffold_rolls_back_partial_files_on_write_failure(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
integration_dir = root / "src" / "specify_cli" / "integrations" / "my_agent"
|
||||
integration_file = integration_dir / "__init__.py"
|
||||
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
|
||||
original_write_text = Path.write_text
|
||||
|
||||
def fail_test_write(path, *args, **kwargs):
|
||||
if path == test_file:
|
||||
raise PermissionError("simulated test file write failure")
|
||||
return original_write_text(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", fail_test_write)
|
||||
|
||||
with pytest.raises(PermissionError, match="simulated test file write failure"):
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
assert not integration_file.exists()
|
||||
assert not integration_dir.exists()
|
||||
assert not test_file.exists()
|
||||
|
||||
|
||||
def test_scaffold_creates_only_leaf_integration_directory(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
original_mkdir = Path.mkdir
|
||||
mkdir_calls = []
|
||||
|
||||
def record_mkdir(path, *args, **kwargs):
|
||||
mkdir_calls.append((path, args, kwargs))
|
||||
return original_mkdir(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "mkdir", record_mkdir)
|
||||
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
assert any(
|
||||
path == root / "src" / "specify_cli" / "integrations" / "my_agent"
|
||||
for path, _args, _kwargs in mkdir_calls
|
||||
)
|
||||
assert all(not kwargs.get("parents", False) for _path, _args, kwargs in mkdir_calls)
|
||||
|
||||
|
||||
def test_scaffold_requires_repo_root(tmp_path):
|
||||
with pytest.raises(ValueError, match="Spec Kit repository root"):
|
||||
scaffold_integration(tmp_path, "my-agent", "markdown")
|
||||
|
||||
|
||||
def test_scaffold_requires_integration_registry_file(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
(root / "src" / "specify_cli" / "integrations" / "__init__.py").unlink()
|
||||
|
||||
with pytest.raises(ValueError, match="Spec Kit repository root"):
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
|
||||
def test_scaffold_refuses_symlinked_target_directory(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
# `outside` carries its own __init__.py so the repo-root heuristic still
|
||||
# passes through the symlink, isolating the symlink guard under test.
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "__init__.py").write_text("", encoding="utf-8")
|
||||
integrations = root / "src" / "specify_cli" / "integrations"
|
||||
(integrations / "__init__.py").unlink()
|
||||
integrations.rmdir()
|
||||
try:
|
||||
integrations.symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
with pytest.raises(ValueError, match="symlinked path"):
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
assert not (outside / "my_agent").exists()
|
||||
|
||||
|
||||
def test_integration_scaffold_accepts_uppercase_type(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "scaffold", "my-agent",
|
||||
"--type", "YAML",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, strip_ansi(result.output)
|
||||
content = (
|
||||
root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "class MyAgentIntegration(YamlIntegration):" in content
|
||||
@@ -120,6 +120,7 @@ class TestIntegrationList:
|
||||
# Should show multiple integrations
|
||||
assert "claude" in result.output
|
||||
assert "gemini" in result.output
|
||||
assert "zed" in result.output
|
||||
|
||||
def test_list_shows_multi_install_safe_status(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
|
||||
164
tests/integrations/test_integration_zed.py
Normal file
164
tests/integrations/test_integration_zed.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Tests for ZedIntegration."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestZedIntegration(SkillsIntegrationTests):
|
||||
KEY = "zed"
|
||||
FOLDER = ".agents/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
"""Not applicable to Zed — Zed is always skills-based with no --skills flag."""
|
||||
pytest.skip("Zed is always skills-based and does not expose a --skills option")
|
||||
|
||||
def test_options_do_not_include_skills_flag(self):
|
||||
"""Zed is always skills-based; no --skills option is exposed."""
|
||||
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, (
|
||||
"Zed is always skills-based and should not expose a --skills option"
|
||||
)
|
||||
|
||||
def test_requires_cli_is_false(self):
|
||||
"""Zed is IDE-based; requires_cli must remain False."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i is not None
|
||||
assert i.config is not None
|
||||
assert i.config["requires_cli"] is False
|
||||
|
||||
|
||||
class TestZedHookInvocations:
|
||||
"""Zed hook messages should reference slash-invokable skills."""
|
||||
|
||||
def test_hooks_render_skill_invocation(self, tmp_path):
|
||||
"""Zed is always skills-based: renders /speckit-plan even with ai_skills=False."""
|
||||
from specify_cli.extensions import HookExecutor
|
||||
|
||||
project = tmp_path / "zed-hooks"
|
||||
project.mkdir()
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "zed", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_plan",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.plan",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
|
||||
|
||||
def test_init_persists_ai_skills_for_zed(self, tmp_path, monkeypatch):
|
||||
"""specify init --integration zed 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 / "zed-init-test"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
"zed",
|
||||
"--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") == "zed"
|
||||
assert opts.get("ai_skills") is True, (
|
||||
f"init must persist ai_skills=true for Zed, 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 Zed without hint injection"
|
||||
)
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
|
||||
|
||||
|
||||
class TestSlashSkillsSets:
|
||||
"""Parameterized coverage for ALWAYS_SLASH_AGENTS / CONDITIONAL_SLASH_AGENTS."""
|
||||
|
||||
@staticmethod
|
||||
def _render_invocation(project_path, ai: str, ai_skills: bool) -> str:
|
||||
"""Return the rendered invocation for ``speckit.plan`` via HookExecutor."""
|
||||
from specify_cli.extensions import HookExecutor
|
||||
|
||||
init_options = project_path / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": ai, "ai_skills": ai_skills}))
|
||||
hook_executor = HookExecutor(project_path)
|
||||
result = hook_executor.execute_hook(
|
||||
{"extension": "test-ext", "command": "speckit.plan", "optional": False}
|
||||
)
|
||||
return result.get("invocation", "")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ai", "ai_skills", "expected"),
|
||||
[
|
||||
# ALWAYS_SLASH_AGENTS — unconditional on ai_skills
|
||||
("devin", True, "/speckit-plan"),
|
||||
("devin", False, "/speckit-plan"),
|
||||
("trae", True, "/speckit-plan"),
|
||||
("trae", False, "/speckit-plan"),
|
||||
("zed", True, "/speckit-plan"),
|
||||
("zed", False, "/speckit-plan"),
|
||||
# CONDITIONAL_SLASH_AGENTS — only when ai_skills is enabled
|
||||
("agy", True, "/speckit-plan"),
|
||||
("agy", False, "/speckit.plan"),
|
||||
("claude", True, "/speckit-plan"),
|
||||
("claude", False, "/speckit.plan"),
|
||||
("copilot", True, "/speckit-plan"),
|
||||
("copilot", False, "/speckit.plan"),
|
||||
("cursor-agent", True, "/speckit-plan"),
|
||||
("cursor-agent", False, "/speckit.plan"),
|
||||
],
|
||||
)
|
||||
def test_hook_invocation_format(self, tmp_path, ai, ai_skills, expected):
|
||||
result = self._render_invocation(tmp_path, ai, ai_skills)
|
||||
assert result == expected, (
|
||||
f"{ai} (ai_skills={ai_skills}): expected {expected!r}, got {result!r}"
|
||||
)
|
||||
@@ -27,7 +27,7 @@ ALL_INTEGRATION_KEYS = [
|
||||
# Stage 4 — TOML integrations
|
||||
"gemini", "tabnine",
|
||||
# Stage 5 — skills, generic & option-driven integrations
|
||||
"codex", "kimi", "agy", "generic",
|
||||
"codex", "kimi", "agy", "zed", "generic",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_bash_scripts(repo: Path) -> None:
|
||||
@@ -160,14 +160,14 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must return paths when feature.json pins the feature dir."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
@@ -183,7 +183,7 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
|
||||
subprocess.run(
|
||||
@@ -195,7 +195,7 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
||||
result = subprocess.run(
|
||||
@@ -211,11 +211,11 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without -PathsOnly, feature directory validation must still fail on main."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=prereq_repo,
|
||||
|
||||
@@ -18,7 +18,7 @@ SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_bash_scripts(repo: Path) -> None:
|
||||
@@ -153,7 +153,7 @@ def test_setup_plan_numbered_branch_works_with_feature_json(
|
||||
assert (feat / "plan.md").is_file()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
@@ -165,7 +165,7 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script)],
|
||||
cwd=plan_repo,
|
||||
@@ -178,12 +178,12 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
|
||||
assert (feat / "plan.md").is_file()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_plan_ps_errors_without_feature_context(
|
||||
plan_repo: Path,
|
||||
) -> None:
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script)],
|
||||
cwd=plan_repo,
|
||||
|
||||
@@ -18,7 +18,7 @@ SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_bash_scripts(repo: Path) -> None:
|
||||
@@ -178,11 +178,11 @@ def test_setup_plan_json_parseable_on_first_run(plan_repo: Path) -> None:
|
||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None:
|
||||
"""First run must create plan.md from the template."""
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=plan_repo,
|
||||
@@ -199,7 +199,7 @@ def test_ps_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None:
|
||||
assert len(content) > 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None:
|
||||
"""Rerun must not overwrite an existing plan.md."""
|
||||
feat = plan_repo / "specs" / "001-my-feature"
|
||||
@@ -208,7 +208,7 @@ def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None:
|
||||
(feat / "plan.md").write_text(existing_content, encoding="utf-8")
|
||||
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=plan_repo,
|
||||
|
||||
@@ -20,7 +20,7 @@ CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites
|
||||
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -118,7 +118,7 @@ def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.Comple
|
||||
|
||||
def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
|
||||
script = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
return subprocess.run(
|
||||
[
|
||||
exe,
|
||||
@@ -606,7 +606,7 @@ def test_setup_tasks_bash_errors_without_feature_context(
|
||||
# POWERSHELL TESTS
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When the core tasks-template.md is present and all prerequisites are met,
|
||||
@@ -615,7 +615,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
@@ -635,7 +635,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
assert tasks_tmpl.name == "tasks-template.md"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When an override exists at .specify/templates/overrides/tasks-template.md,
|
||||
@@ -649,7 +649,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
override_file.write_text("# override tasks template\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
@@ -671,7 +671,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When tasks-template.md is absent from all locations, setup-tasks.ps1 must
|
||||
@@ -683,7 +683,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
core.unlink()
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
@@ -698,7 +698,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_normalizes_mixed_separators(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
@@ -717,7 +717,7 @@ def test_powershell_command_hint_normalizes_mixed_separators(
|
||||
assert result.stdout.strip() == "/speckit-git-commit"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_preserves_hyphens_inside_segments(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
@@ -729,7 +729,7 @@ def test_powershell_command_hint_preserves_hyphens_inside_segments(
|
||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
@@ -738,7 +738,7 @@ def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) ->
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
@@ -755,7 +755,7 @@ def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) ->
|
||||
assert "/speckit.plan" not in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
@@ -763,7 +763,7 @@ def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-RequireTasks"],
|
||||
@@ -780,7 +780,7 @@ def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
|
||||
assert "/speckit.tasks" not in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
@@ -801,7 +801,7 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
@@ -815,7 +815,7 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_errors_without_feature_context(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
@@ -826,7 +826,7 @@ def test_setup_tasks_ps_errors_without_feature_context(
|
||||
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
6
workflows/step-catalog.community.json
Normal file
6
workflows/step-catalog.community.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-28T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/step-catalog.community.json",
|
||||
"steps": {}
|
||||
}
|
||||
6
workflows/step-catalog.json
Normal file
6
workflows/step-catalog.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-28T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/step-catalog.json",
|
||||
"steps": {}
|
||||
}
|
||||
Reference in New Issue
Block a user