mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48685926cf | ||
|
|
bb8fd50763 | ||
|
|
cc6f203dd9 | ||
|
|
de9d98683a | ||
|
|
4133c8a543 | ||
|
|
6ee8a887e0 | ||
|
|
b13eea1e27 | ||
|
|
9fac01fb47 | ||
|
|
5edc9a5358 | ||
|
|
da1bf028ab | ||
|
|
7cedd85f2a | ||
|
|
2cb848f0d3 | ||
|
|
237e918f11 | ||
|
|
ab9c70262d | ||
|
|
c079b2cc32 | ||
|
|
1049e17a43 | ||
|
|
9cf3151a72 | ||
|
|
9483e5cb1f | ||
|
|
38f99e8381 | ||
|
|
16aa57fce4 | ||
|
|
bc3409e340 | ||
|
|
0aa588a9b4 | ||
|
|
ea92155b52 | ||
|
|
047be2308c | ||
|
|
7d0f670b83 | ||
|
|
5b3ebabcaf | ||
|
|
719eef3ff1 | ||
|
|
fe9f19d569 | ||
|
|
56f9b95b0d | ||
|
|
7b99fef2bc | ||
|
|
bd3ae9aaef | ||
|
|
a0634ef96e | ||
|
|
a918979236 | ||
|
|
3a7f64c8a5 | ||
|
|
77ca5f4ed5 | ||
|
|
171b65ac33 | ||
|
|
232c19cb04 | ||
|
|
ca51d739fb | ||
|
|
03f3024c66 | ||
|
|
aad7b16188 | ||
|
|
6cec171772 | ||
|
|
37745ec2ee | ||
|
|
998f927576 | ||
|
|
9f14dfc6c6 | ||
|
|
8750e94d10 | ||
|
|
52c0a5f88f | ||
|
|
6413414907 | ||
|
|
7f708b9e6f | ||
|
|
13d88d22a6 | ||
|
|
6bf4ebbe33 | ||
|
|
5a52b7623e | ||
|
|
89fc554ce5 | ||
|
|
a067d4c2e3 | ||
|
|
8fefd2a532 | ||
|
|
b278d66b2c | ||
|
|
709457cec2 | ||
|
|
9e259e1f8d | ||
|
|
3970855797 | ||
|
|
f612e1a30d | ||
|
|
ecb3b94b43 | ||
|
|
c5c20134df | ||
|
|
58f7a43ec3 | ||
|
|
efb04e26eb | ||
|
|
c52ea23ba2 | ||
|
|
d402a392c3 | ||
|
|
deb80956f3 | ||
|
|
4dcf2921d1 | ||
|
|
dd9c0b0500 | ||
|
|
22e76995c7 | ||
|
|
569d18a59d | ||
|
|
f10fd07481 |
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI
|
||||
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI, Devin for Terminal
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23
|
||||
uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
24
AGENTS.md
24
AGENTS.md
@@ -264,13 +264,13 @@ The base classes handle most work automatically. Override only when the agent de
|
||||
| Override | When to use | Example |
|
||||
|---|---|---|
|
||||
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
|
||||
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` |
|
||||
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
|
||||
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
|
||||
|
||||
**Example — Copilot (fully custom `setup`):**
|
||||
|
||||
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
|
||||
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
|
||||
|
||||
### 7. Update Devcontainer files (Optional)
|
||||
|
||||
@@ -391,6 +391,24 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
||||
2. Generates companion `.prompt.md` files
|
||||
3. Merges VS Code settings
|
||||
|
||||
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
|
||||
via `--integration-options="--skills"`. When enabled:
|
||||
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
|
||||
- No companion `.prompt.md` files are generated
|
||||
- No `.vscode/settings.json` merge
|
||||
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
|
||||
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
|
||||
|
||||
The two modes are mutually exclusive — a project uses one or the other:
|
||||
|
||||
```bash
|
||||
# Default mode: .agent.md agents + .prompt.md companions + settings merge
|
||||
specify init my-project --integration copilot
|
||||
|
||||
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
|
||||
specify init my-project --integration copilot --integration-options="--skills"
|
||||
```
|
||||
|
||||
### Forge Integration
|
||||
|
||||
Forge has special frontmatter and argument requirements:
|
||||
|
||||
90
CHANGELOG.md
90
CHANGELOG.md
@@ -2,6 +2,96 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.8.4] - 2026-05-01
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(specify): correct self-referencing step number in validation flow (#2152)
|
||||
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#2425)
|
||||
- Add security-governance to community catalog (#2386)
|
||||
- Add cross-platform-governance to community catalog (#2384)
|
||||
- Add architecture-governance to community catalog (#2383)
|
||||
- Add a11y-governance to community catalog (#2381)
|
||||
- feat(extensions): add Spec2Cloud extension for Azure deployment workflow (#2412)
|
||||
- fix: migrate extension commands on integration switch (#2404)
|
||||
- feat: add Squad Bridge extension to community catalog (#2417)
|
||||
- chore: release 0.8.3, begin 0.8.4.dev0 development (#2418)
|
||||
|
||||
## [0.8.3] - 2026-04-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Add Work IQ extension to community catalog (#2415)
|
||||
- feat(integrations): add Devin for Terminal skills-based integration (#2364)
|
||||
- fix: include --from git+... in upgrade hint to avoid PyPI squat package (#2411)
|
||||
- fix: dispatch opencode commands via run (#2410)
|
||||
- feat: add catalog discovery CLI commands (#2360)
|
||||
- update security review extension catalog to v1.3.0 (#2374)
|
||||
- chore(catalog): bump v-model extension to v0.6.0 (#2399)
|
||||
- feat: add threatmodel extension to community catalog (#2369)
|
||||
- Add isaqb-architecture-governance to community catalog (#2385)
|
||||
- chore: release 0.8.2, begin 0.8.3.dev0 development (#2397)
|
||||
|
||||
## [0.8.2] - 2026-04-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Add MarkItDown Document Converter extension to community catalog (#2390)
|
||||
- feat: Speckit preset fiction book v1.7 - Support for RAG (Chroma DB) offline semantic search (#2367)
|
||||
- fix(extensions): use explicit UTF-8 encoding when reading manifest YAML (#2370)
|
||||
- catalog: add m365 community extension
|
||||
- docs: replace deprecated --ai flag with --integration in all documentation (#2359)
|
||||
- feat(extensions,presets): authenticate GitHub-hosted catalog and download requests with GITHUB_TOKEN/GH_TOKEN (#2331)
|
||||
- Update extensify to v1.1.0 in community catalog (#2337)
|
||||
- feat(init): deprecate --no-git flag, gate deprecations at v0.10.0 (#2357)
|
||||
- Add Spec Orchestrator extension to community catalog (#2350)
|
||||
- chore: release 0.8.1, begin 0.8.2.dev0 development (#2356)
|
||||
|
||||
## [0.8.1] - 2026-04-24
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(plan): use .specify/feature.json to allow /speckit.plan on custom git branches (#2305) (#2349)
|
||||
- feat(vibe): migrate to SkillsIntegration from the old prompts-based MarkdownIntegration (#2336)
|
||||
- docs: move community presets table to docs site, add missing entries (#2341)
|
||||
- docs(presets): add lean preset README and enrich catalog metadata (#2340)
|
||||
- fix: resolve command references per integration type (dot vs hyphen) (#2354)
|
||||
- Update product-forge to v1.5.1 in community catalog (#2352)
|
||||
- chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#2345)
|
||||
- fix: replace xargs trim with sed to handle quotes in descriptions (#2351)
|
||||
- feat: register jira preset in community catalog (#2224)
|
||||
- feat: Preset screenwriting (#2332)
|
||||
- chore: release 0.8.0, begin 0.8.1.dev0 development (#2333)
|
||||
|
||||
## [0.8.0] - 2026-04-23
|
||||
|
||||
### Changed
|
||||
|
||||
- feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133)
|
||||
- feat(copilot): support `--integration-options="--skills"` for skills-based scaffolding (#2324)
|
||||
- docs(install): add pipx as alternative installation method (#2288)
|
||||
- Add Memory MD community extension (#2327)
|
||||
- Update version-guard to v1.2.0 (#2321)
|
||||
- fix: `--force` now overwrites shared infra files during init and upgrade (#2320)
|
||||
- chore: release 0.7.5, begin 0.7.6.dev0 development (#2322)
|
||||
|
||||
## [0.7.5] - 2026-04-22
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313)
|
||||
- feat(cli): add specify self check and self upgrade stub (#2316)
|
||||
- Update version-guard to v1.1.0 (#2318)
|
||||
- docs: move community presets from README to docs/community (#2314)
|
||||
- catalog: add wireframe extension (v0.1.1) (#2262)
|
||||
- Move community walkthroughs from README to docs/community (#2312)
|
||||
- docs(readme): list red-team in community-extensions table (#2311)
|
||||
- feat(catalog): add red-team extension to community catalog (#2306)
|
||||
- Add superpowers-bridge community extension (#2309)
|
||||
- feat: implement preset wrap strategy (#2189)
|
||||
- fix(agents): block directory traversal in command write paths (#2229) (#2296)
|
||||
- chore: release 0.7.4, begin 0.7.5.dev0 development (#2299)
|
||||
|
||||
## [0.7.4] - 2026-04-21
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -94,7 +94,7 @@ uv pip install -e .
|
||||
# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing.
|
||||
|
||||
# Initialize a test project using your local changes
|
||||
uv run specify init <temp-dir>/speckit-test --ai <agent> --offline
|
||||
uv run specify init <temp-dir>/speckit-test --integration <agent>
|
||||
cd <temp-dir>/speckit-test
|
||||
|
||||
# Open in your agent
|
||||
@@ -102,7 +102,7 @@ cd <temp-dir>/speckit-test
|
||||
|
||||
#### Manual testing process
|
||||
|
||||
Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR.
|
||||
Any change that affects a slash command's behavior requires manually testing that command through a coding agent and submitting results with the PR.
|
||||
|
||||
1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing.
|
||||
2. **Set up a test project** — scaffold from your local branch (see [Testing setup](#testing-setup)).
|
||||
|
||||
102
README.md
102
README.md
@@ -62,6 +62,10 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX
|
||||
|
||||
# Or install latest from main (may include unreleased changes)
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
|
||||
|
||||
# Alternative: using pipx (also works)
|
||||
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
pipx install git+https://github.com/github/spec-kit.git
|
||||
```
|
||||
|
||||
Then verify the correct version is installed:
|
||||
@@ -77,9 +81,9 @@ And use the tool directly:
|
||||
specify init <PROJECT_NAME>
|
||||
|
||||
# Or initialize in existing project
|
||||
specify init . --ai copilot
|
||||
specify init . --integration copilot
|
||||
# or
|
||||
specify init --here --ai copilot
|
||||
specify init --here --integration copilot
|
||||
|
||||
# Check installed tools
|
||||
specify check
|
||||
@@ -89,6 +93,7 @@ To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed inst
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
#### Option 2: One-time Usage
|
||||
@@ -100,9 +105,9 @@ Run directly without installing:
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||
|
||||
# Or initialize in existing project
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --integration copilot
|
||||
# or
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
|
||||
```
|
||||
|
||||
**Benefits of persistent installation:**
|
||||
@@ -118,7 +123,7 @@ If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-G
|
||||
|
||||
### 2. Establish project principles
|
||||
|
||||
Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||
|
||||
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
||||
|
||||
@@ -223,45 +228,56 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
|
||||
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
|
||||
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
|
||||
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
|
||||
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
|
||||
| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
|
||||
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
|
||||
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
|
||||
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
|
||||
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
|
||||
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
|
||||
| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) |
|
||||
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
|
||||
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
|
||||
|
||||
@@ -269,44 +285,16 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX
|
||||
|
||||
## 🎨 Community Presets
|
||||
|
||||
Community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page.
|
||||
|
||||
> [!NOTE]
|
||||
> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
|
||||
> Community presets are third-party contributions and are not maintained by the Spec Kit team. Review them carefully before use, and see the docs page above for the full disclaimer.
|
||||
|
||||
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json):
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
|
||||
To submit your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
|
||||
|
||||
## 🚶 Community Walkthroughs
|
||||
|
||||
> [!NOTE]
|
||||
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||
|
||||
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
|
||||
|
||||
- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.
|
||||
|
||||
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
|
||||
|
||||
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
|
||||
|
||||
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
|
||||
|
||||
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
|
||||
|
||||
- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit.
|
||||
See Spec-Driven Development in action across different scenarios with community-contributed walkthroughs; find the full list on the [Community Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) page.
|
||||
|
||||
## 🛠️ Community Friends
|
||||
|
||||
@@ -320,7 +308,7 @@ Run `specify integration list` to see all available integrations in your install
|
||||
|
||||
## Available Slash Commands
|
||||
|
||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai <agent> --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`.
|
||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration <agent> --integration-options="--skills"` installs agent skills instead of slash-command prompt files.
|
||||
|
||||
#### Core Commands
|
||||
|
||||
@@ -455,7 +443,7 @@ Our research and experimentation focus on:
|
||||
|
||||
- **Linux/macOS/Windows**
|
||||
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
|
||||
- [uv](https://docs.astral.sh/uv/) for package management
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
@@ -493,37 +481,37 @@ specify init --here --force
|
||||
|
||||

|
||||
|
||||
You will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal:
|
||||
You will be prompted to select the coding agent integration you are using. You can also proactively specify it directly in the terminal:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --ai copilot
|
||||
specify init <project_name> --ai gemini
|
||||
specify init <project_name> --ai copilot
|
||||
specify init <project_name> --integration copilot
|
||||
specify init <project_name> --integration gemini
|
||||
specify init <project_name> --integration codex
|
||||
|
||||
# Or in current directory:
|
||||
specify init . --ai copilot
|
||||
specify init . --ai codex --ai-skills
|
||||
specify init . --integration copilot
|
||||
specify init . --integration codex --integration-options="--skills"
|
||||
|
||||
# or use --here flag
|
||||
specify init --here --ai copilot
|
||||
specify init --here --ai codex --ai-skills
|
||||
specify init --here --integration copilot
|
||||
specify init --here --integration codex --integration-options="--skills"
|
||||
|
||||
# Force merge into a non-empty current directory
|
||||
specify init . --force --ai copilot
|
||||
specify init . --force --integration copilot
|
||||
|
||||
# or
|
||||
specify init --here --force --ai copilot
|
||||
specify init --here --force --integration copilot
|
||||
```
|
||||
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --ai copilot --ignore-agent-tools
|
||||
specify init <project_name> --integration copilot --ignore-agent-tools
|
||||
```
|
||||
|
||||
### **STEP 1:** Establish project principles
|
||||
|
||||
Go to the project folder and run your AI agent. In our example, we're using `claude`.
|
||||
Go to the project folder and run your coding agent. In our example, we're using `claude`.
|
||||
|
||||

|
||||
|
||||
@@ -535,7 +523,7 @@ The first step should be establishing your project's governing principles using
|
||||
/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements. Include governance for how these principles should guide technical decisions and implementation choices.
|
||||
```
|
||||
|
||||
This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the AI agent will reference during specification, planning, and implementation phases.
|
||||
This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the coding agent will reference during specification, planning, and implementation phases.
|
||||
|
||||
### **STEP 2:** Create project specifications
|
||||
|
||||
@@ -743,9 +731,9 @@ The `/speckit.implement` command will:
|
||||
- Provide progress updates and handle errors appropriately
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The AI agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine.
|
||||
> The coding agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine.
|
||||
|
||||
Once the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your AI agent for resolution.
|
||||
Once the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your coding agent for resolution.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
27
docs/community/presets.md
Normal file
27
docs/community/presets.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Community Presets
|
||||
|
||||
> [!NOTE]
|
||||
> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
|
||||
|
||||
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json):
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-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) |
|
||||
| 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) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 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. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-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) |
|
||||
| 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) |
|
||||
| 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, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/VEX/SLSA, OpenSSF Scorecard, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| 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) |
|
||||
|
||||
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).
|
||||
20
docs/community/walkthroughs.md
Normal file
20
docs/community/walkthroughs.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Community Walkthroughs
|
||||
|
||||
> [!NOTE]
|
||||
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||
|
||||
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
|
||||
|
||||
- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.
|
||||
|
||||
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
|
||||
|
||||
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
|
||||
|
||||
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
|
||||
|
||||
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
|
||||
|
||||
- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||
- [uv](https://docs.astral.sh/uv/) for package management
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
@@ -24,6 +24,13 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJE
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For a persistent installation, `pipx` works equally well:
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
> ```
|
||||
> The project uses a standard `hatchling` build backend and has no uv-specific dependencies.
|
||||
|
||||
Or initialize in the current directory:
|
||||
|
||||
```bash
|
||||
@@ -32,16 +39,16 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init .
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here
|
||||
```
|
||||
|
||||
### Specify AI Agent
|
||||
### Specify Integration
|
||||
|
||||
You can proactively specify your AI agent during initialization:
|
||||
You can proactively specify your coding agent integration during initialization:
|
||||
|
||||
```bash
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai claude
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai gemini
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai copilot
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai codebuddy
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai pi
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration gemini
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration copilot
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration codebuddy
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration pi
|
||||
```
|
||||
|
||||
### Specify Script Type (Shell vs PowerShell)
|
||||
@@ -66,7 +73,7 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <proje
|
||||
If you prefer to get the templates without checking for the right tools:
|
||||
|
||||
```bash
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai claude --ignore-agent-tools
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude --ignore-agent-tools
|
||||
```
|
||||
|
||||
## Verification
|
||||
@@ -79,7 +86,7 @@ specify version
|
||||
|
||||
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
|
||||
|
||||
After initialization, you should see the following commands available in your AI agent:
|
||||
After initialization, you should see the following commands available in your coding agent:
|
||||
|
||||
- `/speckit.specify` - Create specifications
|
||||
- `/speckit.plan` - Generate implementation plans
|
||||
@@ -124,12 +131,10 @@ pip install --no-index --find-links=./dist specify-cli
|
||||
|
||||
```bash
|
||||
# Initialize a project — no GitHub access needed
|
||||
specify init my-project --ai claude --offline
|
||||
specify init my-project --integration claude
|
||||
```
|
||||
|
||||
The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub.
|
||||
|
||||
> **Deprecation notice:** Starting with v0.6.0, `specify init` will use bundled assets by default and the `--offline` flag will be removed. The GitHub download path will be retired because bundled assets eliminate the need for network access, avoid proxy/firewall issues, and guarantee that templates always match the installed CLI version. No action will be needed — `specify init` will simply work without network access out of the box.
|
||||
Bundled assets are used by default — no network access is required.
|
||||
|
||||
> **Note:** Python 3.11+ is required.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ You can execute the CLI via the module entrypoint without installing anything:
|
||||
```bash
|
||||
# From repo root
|
||||
python -m src.specify_cli --help
|
||||
python -m src.specify_cli init demo-project --ai claude --ignore-agent-tools --script sh
|
||||
python -m src.specify_cli init demo-project --integration claude --ignore-agent-tools --script sh
|
||||
```
|
||||
|
||||
If you prefer invoking the script file style (uses shebang):
|
||||
@@ -52,7 +52,7 @@ Re-running after code edits requires no reinstall because of editable mode.
|
||||
`uvx` can run from a local path (or a Git ref) to simulate user flows:
|
||||
|
||||
```bash
|
||||
uvx --from . specify init demo-uvx --ai copilot --ignore-agent-tools --script sh
|
||||
uvx --from . specify init demo-uvx --integration copilot --ignore-agent-tools --script sh
|
||||
```
|
||||
|
||||
You can also point uvx at a specific branch without merging:
|
||||
@@ -69,14 +69,14 @@ If you're in another directory, use an absolute path instead of `.`:
|
||||
|
||||
```bash
|
||||
uvx --from /mnt/c/GitHub/spec-kit specify --help
|
||||
uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --ai copilot --ignore-agent-tools --script sh
|
||||
uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --integration copilot --ignore-agent-tools --script sh
|
||||
```
|
||||
|
||||
Set an environment variable for convenience:
|
||||
|
||||
```bash
|
||||
export SPEC_KIT_SRC=/mnt/c/GitHub/spec-kit
|
||||
uvx --from "$SPEC_KIT_SRC" specify init demo-env --ai copilot --ignore-agent-tools --script ps
|
||||
uvx --from "$SPEC_KIT_SRC" specify init demo-env --integration copilot --ignore-agent-tools --script ps
|
||||
```
|
||||
|
||||
(Optional) Define a shell function:
|
||||
@@ -123,7 +123,7 @@ When testing `init --here` in a dirty directory, create a temp workspace:
|
||||
|
||||
```bash
|
||||
mkdir /tmp/spec-test && cd /tmp/spec-test
|
||||
python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script sh # if repo copied here
|
||||
python -m src.specify_cli init --here --integration claude --ignore-agent-tools --script sh # if repo copied here
|
||||
```
|
||||
|
||||
Or copy only the modified CLI portion if you want a lighter sandbox.
|
||||
|
||||
@@ -22,6 +22,17 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init .
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> You can also install the CLI persistently with `pipx`:
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git
|
||||
> ```
|
||||
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
|
||||
> ```bash
|
||||
> specify init <PROJECT_NAME>
|
||||
> specify init .
|
||||
> ```
|
||||
|
||||
Pick script type explicitly (optional):
|
||||
|
||||
```bash
|
||||
@@ -31,7 +42,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
|
||||
### Step 2: Define Your Constitution
|
||||
|
||||
**In your AI Agent's chat interface**, use the `/speckit.constitution` slash command to establish the core rules and principles for your project. You should provide your project's specific principles as arguments.
|
||||
**In your coding agent's chat interface**, use the `/speckit.constitution` slash command to establish the core rules and principles for your project. You should provide your project's specific principles as arguments.
|
||||
|
||||
```markdown
|
||||
/speckit.constitution This project follows a "Library-First" approach. All features must be implemented as standalone libraries first. We use TDD strictly. We prefer functional programming patterns.
|
||||
@@ -148,7 +159,7 @@ Generate an actionable task list using the `/speckit.tasks` command:
|
||||
|
||||
### Step 7: Validate and Implement
|
||||
|
||||
Have your AI agent audit the implementation plan using `/speckit.analyze`:
|
||||
Have your coding agent audit the implementation plan using `/speckit.analyze`:
|
||||
|
||||
```bash
|
||||
/speckit.analyze
|
||||
@@ -169,7 +180,7 @@ Finally, implement the solution:
|
||||
- **Don't focus on tech stack** during specification phase
|
||||
- **Iterate and refine** your specifications before implementation
|
||||
- **Validate** the plan before coding begins
|
||||
- **Let the AI agent handle** the implementation details
|
||||
- **Let the coding agent handle** the implementation details
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
|
||||
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
|
||||
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
|
||||
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
|
||||
| [Forge](https://forgecode.dev/) | `forge` | |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
|
||||
|
||||
@@ -37,5 +37,9 @@
|
||||
# Community
|
||||
- name: Community
|
||||
items:
|
||||
- name: Presets
|
||||
href: community/presets.md
|
||||
- name: Walkthroughs
|
||||
href: community/walkthroughs.md
|
||||
- name: Friends
|
||||
href: community/friends.md
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
| What to Upgrade | Command | When to Use |
|
||||
|----------------|---------|-------------|
|
||||
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
|
||||
| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
|
||||
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||
|
||||
---
|
||||
@@ -31,7 +32,15 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki
|
||||
Specify the desired release tag:
|
||||
|
||||
```bash
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
|
||||
```
|
||||
|
||||
### If you installed with `pipx`
|
||||
|
||||
Upgrade to a specific release:
|
||||
|
||||
```bash
|
||||
pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
### Verify the upgrade
|
||||
@@ -53,8 +62,8 @@ When Spec Kit releases new features (like new slash commands or updated template
|
||||
Running `specify init --here --force` will update:
|
||||
|
||||
- ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
|
||||
- ✅ **Script files** (`.specify/scripts/`)
|
||||
- ✅ **Template files** (`.specify/templates/`)
|
||||
- ✅ **Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added
|
||||
- ✅ **Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added
|
||||
- ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
|
||||
|
||||
### What stays safe?
|
||||
@@ -73,7 +82,7 @@ The `specs/` directory is completely excluded from template packages and will ne
|
||||
Run this inside your project directory:
|
||||
|
||||
```bash
|
||||
specify init --here --force --ai <your-agent>
|
||||
specify init --here --force --integration <your-agent>
|
||||
```
|
||||
|
||||
Replace `<your-agent>` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
|
||||
@@ -81,7 +90,7 @@ Replace `<your-agent>` with your AI coding agent. Refer to this list of [Support
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
specify init --here --force --ai copilot
|
||||
specify init --here --force --integration copilot
|
||||
```
|
||||
|
||||
### Understanding the `--force` flag
|
||||
@@ -94,7 +103,9 @@ Template files will be merged with existing content and may overwrite existing f
|
||||
Proceed? [y/N]
|
||||
```
|
||||
|
||||
With `--force`, it skips the confirmation and proceeds immediately.
|
||||
With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release.
|
||||
|
||||
Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated.
|
||||
|
||||
**Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten.
|
||||
|
||||
@@ -113,7 +124,7 @@ With `--force`, it skips the confirmation and proceeds immediately.
|
||||
cp .specify/memory/constitution.md .specify/memory/constitution-backup.md
|
||||
|
||||
# 2. Run the upgrade
|
||||
specify init --here --force --ai copilot
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
# 3. Restore your customized constitution
|
||||
mv .specify/memory/constitution-backup.md .specify/memory/constitution.md
|
||||
@@ -126,13 +137,14 @@ Or use git to restore it:
|
||||
git restore .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
### 2. Custom template modifications
|
||||
### 2. Custom script or template modifications
|
||||
|
||||
If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first:
|
||||
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
|
||||
|
||||
```bash
|
||||
# Back up custom templates
|
||||
# Back up custom templates and scripts
|
||||
cp -r .specify/templates .specify/templates-backup
|
||||
cp -r .specify/scripts .specify/scripts-backup
|
||||
|
||||
# After upgrade, merge your changes back manually
|
||||
```
|
||||
@@ -170,7 +182,7 @@ Restart your IDE to refresh the command list.
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
||||
|
||||
# Update project files to get new commands
|
||||
specify init --here --force --ai copilot
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
# Restore your constitution if customized
|
||||
git restore .specify/memory/constitution.md
|
||||
@@ -187,7 +199,7 @@ cp -r .specify/templates /tmp/templates-backup
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
||||
|
||||
# 3. Update project
|
||||
specify init --here --force --ai copilot
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
# 4. Restore customizations
|
||||
mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
@@ -220,7 +232,7 @@ If you initialized your project with `--no-git`, you can still upgrade:
|
||||
cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||
|
||||
# Run upgrade
|
||||
specify init --here --force --ai copilot --no-git
|
||||
specify init --here --force --integration copilot --no-git
|
||||
|
||||
# Restore customizations
|
||||
mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
@@ -241,13 +253,13 @@ The `--no-git` flag tells Spec Kit to **skip git repository initialization**. Th
|
||||
**During initial setup:**
|
||||
|
||||
```bash
|
||||
specify init my-project --ai copilot --no-git
|
||||
specify init my-project --integration copilot --no-git
|
||||
```
|
||||
|
||||
**During upgrade:**
|
||||
|
||||
```bash
|
||||
specify init --here --force --ai copilot --no-git
|
||||
specify init --here --force --integration copilot --no-git
|
||||
```
|
||||
|
||||
### What `--no-git` does NOT do
|
||||
@@ -355,7 +367,7 @@ Only Spec Kit infrastructure files:
|
||||
- **Use `--force` flag** - Skip this confirmation entirely:
|
||||
|
||||
```bash
|
||||
specify init --here --force --ai copilot
|
||||
specify init --here --force --integration copilot
|
||||
```
|
||||
|
||||
**When you see this warning:**
|
||||
|
||||
@@ -669,7 +669,7 @@ hooks:
|
||||
|
||||
**Error**: `Extension requires spec-kit >=0.2.0`
|
||||
|
||||
- **Fix**: Update spec-kit with `uv tool install specify-cli --force`
|
||||
- **Fix**: Update spec-kit with `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git`. The bare `specify-cli` package on PyPI is a different, unrelated project — installing it without `--from git+...` will give you a stub CLI that does not include `extension`, `preset`, or other spec-kit commands.
|
||||
|
||||
**Error**: `Command file not found`
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ This will:
|
||||
2. Validate the manifest
|
||||
3. Check compatibility with your spec-kit version
|
||||
4. Install to `.specify/extensions/jira/`
|
||||
5. Register commands with your AI agent
|
||||
5. Register commands with your coding agent
|
||||
6. Create config template
|
||||
|
||||
### Install from URL
|
||||
@@ -189,7 +189,7 @@ Provided commands:
|
||||
|
||||
### Automatic Agent Skill Registration
|
||||
|
||||
If your project was initialized with `--ai-skills`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification.
|
||||
If your project uses a skills-based integration (e.g., `--integration claude`, `--integration codex`) or was initialized with `--integration-options="--skills"`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification.
|
||||
|
||||
```text
|
||||
✓ Extension installed successfully!
|
||||
@@ -208,7 +208,7 @@ When an extension is removed, its corresponding skills are also cleaned up autom
|
||||
|
||||
### Using Extension Commands
|
||||
|
||||
Extensions add commands that appear in your AI agent (Claude Code):
|
||||
Extensions add commands that appear in your coding agent (Claude Code):
|
||||
|
||||
```text
|
||||
# In Claude Code
|
||||
@@ -423,7 +423,7 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`),
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack |
|
||||
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None |
|
||||
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or extension ZIPs are hosted in a private GitHub repository. | None |
|
||||
|
||||
#### Example: Using a custom catalog for testing
|
||||
|
||||
@@ -435,6 +435,21 @@ export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json"
|
||||
export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json"
|
||||
```
|
||||
|
||||
#### Example: Using a private GitHub-hosted catalog
|
||||
|
||||
```bash
|
||||
# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI)
|
||||
export GITHUB_TOKEN=$(gh auth token)
|
||||
|
||||
# Search a private catalog added via `specify extension catalog add`
|
||||
specify extension search jira
|
||||
|
||||
# Install from a private catalog
|
||||
specify extension add jira-sync
|
||||
```
|
||||
|
||||
The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials.
|
||||
|
||||
---
|
||||
|
||||
## Extension Catalogs
|
||||
@@ -780,12 +795,12 @@ specify extension add --dev /path/to/extension
|
||||
|
||||
### Command Not Available
|
||||
|
||||
**Issue**: Extension command not appearing in AI agent
|
||||
**Issue**: Extension command not appearing in coding agent
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check extension is enabled: `specify extension list`
|
||||
2. Restart AI agent (Claude Code)
|
||||
2. Restart coding agent (Claude Code)
|
||||
3. Check command file exists:
|
||||
|
||||
```bash
|
||||
@@ -819,8 +834,8 @@ specify extension add --dev /path/to/extension
|
||||
**Solutions**:
|
||||
|
||||
1. Check MCP server is installed
|
||||
2. Check AI agent MCP configuration
|
||||
3. Restart AI agent
|
||||
2. Check coding agent MCP configuration
|
||||
3. Restart coding agent
|
||||
4. Check extension requirements: `specify extension info jira`
|
||||
|
||||
### Permission Denied
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-21T00:00:00Z",
|
||||
"updated_at": "2026-04-30T09:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -657,18 +657,18 @@
|
||||
"id": "extensify",
|
||||
"description": "Create and validate extensions and extension catalogs.",
|
||||
"author": "mnriem",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.0.0/extensify.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.1.0/extensify.zip",
|
||||
"repository": "https://github.com/mnriem/spec-kit-extensions",
|
||||
"homepage": "https://github.com/mnriem/spec-kit-extensions",
|
||||
"documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/README.md",
|
||||
"changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"commands": 5,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -681,7 +681,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
"updated_at": "2026-04-23T00:00:00Z"
|
||||
},
|
||||
"fix-findings": {
|
||||
"name": "Fix Findings",
|
||||
@@ -941,6 +941,44 @@
|
||||
"created_at": "2026-03-17T00:00:00Z",
|
||||
"updated_at": "2026-03-17T00:00:00Z"
|
||||
},
|
||||
"m365": {
|
||||
"name": "Microsoft 365 Integration",
|
||||
"id": "m365",
|
||||
"description": "Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation.",
|
||||
"author": "BenBtg",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/BenBtg/spec-kit-m365/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/BenBtg/spec-kit-m365",
|
||||
"homepage": "https://github.com/BenBtg/spec-kit-m365",
|
||||
"documentation": "https://github.com/BenBtg/spec-kit-m365/blob/main/README.md",
|
||||
"changelog": "https://github.com/BenBtg/spec-kit-m365/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "m365",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"microsoft-365",
|
||||
"teams",
|
||||
"transcripts",
|
||||
"collaboration",
|
||||
"summarization"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-28T00:00:00Z",
|
||||
"updated_at": "2026-04-28T00:00:00Z"
|
||||
},
|
||||
"maqa": {
|
||||
"name": "MAQA — Multi-Agent & Quality Assurance",
|
||||
"id": "maqa",
|
||||
@@ -1167,6 +1205,45 @@
|
||||
"created_at": "2026-03-26T00:00:00Z",
|
||||
"updated_at": "2026-03-26T00:00:00Z"
|
||||
},
|
||||
"markitdown": {
|
||||
"name": "MarkItDown Document Converter",
|
||||
"id": "markitdown",
|
||||
"description": "Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material in Spec Kit workflows.",
|
||||
"author": "BenBtg",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/BenBtg/spec-kit-markitdown/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/BenBtg/spec-kit-markitdown",
|
||||
"homepage": "https://github.com/BenBtg/spec-kit-markitdown",
|
||||
"documentation": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/README.md",
|
||||
"changelog": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "markitdown",
|
||||
"version": ">=0.1.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"markdown",
|
||||
"pdf",
|
||||
"document-conversion",
|
||||
"reference-material",
|
||||
"extraction"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-28T00:00:00Z",
|
||||
"updated_at": "2026-04-28T00:00:00Z"
|
||||
},
|
||||
"memory-loader": {
|
||||
"name": "Memory Loader",
|
||||
"id": "memory-loader",
|
||||
@@ -1198,6 +1275,38 @@
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
},
|
||||
"memory-md": {
|
||||
"name": "Memory MD",
|
||||
"id": "memory-md",
|
||||
"description": "Repository-native durable memory for Spec Kit projects",
|
||||
"author": "DyanGalih",
|
||||
"version": "0.6.2",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
|
||||
"changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
"workflow",
|
||||
"docs",
|
||||
"copilot",
|
||||
"markdown"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-23T00:00:00Z",
|
||||
"updated_at": "2026-04-23T00:00:00Z"
|
||||
},
|
||||
"memorylint": {
|
||||
"name": "MemoryLint",
|
||||
"id": "memorylint",
|
||||
@@ -1295,6 +1404,38 @@
|
||||
"created_at": "2026-04-03T00:00:00Z",
|
||||
"updated_at": "2026-04-03T00:00:00Z"
|
||||
},
|
||||
"orchestrator": {
|
||||
"name": "Spec Orchestrator",
|
||||
"id": "orchestrator",
|
||||
"description": "Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-orchestrator",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-orchestrator",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/releases",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"orchestration",
|
||||
"multi-feature",
|
||||
"coordination",
|
||||
"workflow",
|
||||
"parallel"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-24T14:00:00Z",
|
||||
"updated_at": "2026-04-24T14:00:00Z"
|
||||
},
|
||||
"plan-review-gate": {
|
||||
"name": "Plan Review Gate",
|
||||
"id": "plan-review-gate",
|
||||
@@ -1392,10 +1533,10 @@
|
||||
"product-forge": {
|
||||
"name": "Product Forge",
|
||||
"id": "product-forge",
|
||||
"description": "Full product lifecycle: research → product spec → SpecKit → implement → verify → test",
|
||||
"description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model",
|
||||
"author": "VaiYav",
|
||||
"version": "1.1.1",
|
||||
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.1.1.zip",
|
||||
"version": "1.5.1",
|
||||
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip",
|
||||
"repository": "https://github.com/VaiYav/speckit-product-forge",
|
||||
"homepage": "https://github.com/VaiYav/speckit-product-forge",
|
||||
"documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md",
|
||||
@@ -1405,21 +1546,21 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 10,
|
||||
"commands": 29,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"process",
|
||||
"research",
|
||||
"product-spec",
|
||||
"lifecycle",
|
||||
"testing"
|
||||
"monorepo",
|
||||
"v-model",
|
||||
"portfolio"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-28T00:00:00Z",
|
||||
"updated_at": "2026-03-28T00:00:00Z"
|
||||
"updated_at": "2026-04-24T15:52:00Z"
|
||||
},
|
||||
"qa": {
|
||||
"name": "QA Testing Extension",
|
||||
@@ -1523,6 +1664,38 @@
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
},
|
||||
"red-team": {
|
||||
"name": "Red Team",
|
||||
"id": "red-team",
|
||||
"description": "Adversarial review of functional specs before /speckit.plan. Parallel adversarial lens agents catch hostile actors, silent failures, and regulatory blind spots that clarify/analyze cannot.",
|
||||
"author": "Ash Brener",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-red-team/releases/download/v1.0.2/red-team-v1.0.2.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-red-team",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-red-team",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-red-team/blob/main/README.md",
|
||||
"changelog": "https://github.com/ashbrener/spec-kit-red-team/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"adversarial-review",
|
||||
"quality-gate",
|
||||
"spec-hardening",
|
||||
"pre-plan",
|
||||
"audit"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-04-22T00:00:00Z"
|
||||
},
|
||||
"refine": {
|
||||
"name": "Spec Refine",
|
||||
"id": "refine",
|
||||
@@ -1756,10 +1929,10 @@
|
||||
"security-review": {
|
||||
"name": "Security Review",
|
||||
"id": "security-review",
|
||||
"description": "Comprehensive security audit of codebases using AI-powered DevSecOps analysis",
|
||||
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.1.1",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.1.1.zip",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.3.0.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
||||
@@ -1769,7 +1942,7 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"commands": 6,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -1783,7 +1956,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-03T03:24:03Z",
|
||||
"updated_at": "2026-04-03T04:15:00Z"
|
||||
"updated_at": "2026-04-29T00:00:00Z"
|
||||
},
|
||||
"sf": {
|
||||
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
||||
@@ -1922,6 +2095,38 @@
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-21T00:00:00Z"
|
||||
},
|
||||
"spec2cloud": {
|
||||
"name": "Spec2Cloud",
|
||||
"id": "spec2cloud",
|
||||
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.",
|
||||
"author": "Azure Samples",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/extension.zip",
|
||||
"repository": "https://github.com/Azure-Samples/Spec2Cloud",
|
||||
"homepage": "https://aka.ms/spec2cloud",
|
||||
"documentation": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/README.md",
|
||||
"changelog": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"spec2cloud",
|
||||
"azure",
|
||||
"cloud",
|
||||
"deploy",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
"id": "speckit-utils",
|
||||
@@ -1987,6 +2192,45 @@
|
||||
"created_at": "2026-04-10T16:00:00Z",
|
||||
"updated_at": "2026-04-10T16:00:00Z"
|
||||
},
|
||||
"squad": {
|
||||
"name": "Squad Bridge",
|
||||
"id": "squad",
|
||||
"description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.",
|
||||
"author": "jwill824",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/jwill824/spec-kit-squad",
|
||||
"homepage": "https://github.com/jwill824/spec-kit-squad",
|
||||
"documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md",
|
||||
"changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "@bradygaster/squad-cli",
|
||||
"version": ">=0.1.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"multi-agent",
|
||||
"agents",
|
||||
"orchestration",
|
||||
"process",
|
||||
"integration"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-29T00:00:00Z",
|
||||
"updated_at": "2026-04-29T00:00:00Z"
|
||||
},
|
||||
"staff-review": {
|
||||
"name": "Staff Review Extension",
|
||||
"id": "staff-review",
|
||||
@@ -2122,6 +2366,39 @@
|
||||
"created_at": "2026-03-30T00:00:00Z",
|
||||
"updated_at": "2026-04-16T14:08:23Z"
|
||||
},
|
||||
"superpowers-bridge": {
|
||||
"name": "Superpowers Bridge",
|
||||
"id": "superpowers-bridge",
|
||||
"description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.",
|
||||
"author": "WangX0111",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/WangX0111/superspec",
|
||||
"homepage": "https://github.com/WangX0111/superspec",
|
||||
"documentation": "https://github.com/WangX0111/superspec/blob/main/README.md",
|
||||
"changelog": "https://github.com/WangX0111/superspec/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"superpowers",
|
||||
"brainstorming",
|
||||
"tdd",
|
||||
"code-review",
|
||||
"subagent",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-04-22T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
"id": "sync",
|
||||
@@ -2186,13 +2463,45 @@
|
||||
"created_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z"
|
||||
},
|
||||
"threatmodel": {
|
||||
"name": "OWASP LLM Threat Model",
|
||||
"id": "threatmodel",
|
||||
"description": "OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts",
|
||||
"author": "NaviaSamal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/NaviaSamal/spec-kit-threatmodel/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/NaviaSamal/spec-kit-threatmodel",
|
||||
"homepage": "https://github.com/NaviaSamal/spec-kit-threatmodel",
|
||||
"documentation": "https://github.com/NaviaSamal/spec-kit-threatmodel/blob/main/README.md",
|
||||
"changelog": "https://github.com/NaviaSamal/spec-kit-threatmodel/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"owasp",
|
||||
"threat-model",
|
||||
"llm",
|
||||
"analysis"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-25T00:00:00Z",
|
||||
"updated_at": "2026-04-25T00:00:00Z"
|
||||
},
|
||||
"v-model": {
|
||||
"name": "V-Model Extension Pack",
|
||||
"id": "v-model",
|
||||
"description": "Enforces V-Model paired generation of development specs and test specs with full traceability.",
|
||||
"author": "leocamello",
|
||||
"version": "0.5.0",
|
||||
"download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.5.0.zip",
|
||||
"version": "0.6.0",
|
||||
"download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.6.0.zip",
|
||||
"repository": "https://github.com/leocamello/spec-kit-v-model",
|
||||
"homepage": "https://github.com/leocamello/spec-kit-v-model",
|
||||
"documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md",
|
||||
@@ -2214,9 +2523,9 @@
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"stars": 21,
|
||||
"created_at": "2026-02-20T00:00:00Z",
|
||||
"updated_at": "2026-04-06T00:00:00Z"
|
||||
"updated_at": "2026-04-25T00:00:00Z"
|
||||
},
|
||||
"verify": {
|
||||
"name": "Verify Extension",
|
||||
@@ -2286,8 +2595,8 @@
|
||||
"id": "version-guard",
|
||||
"description": "Verify tech stack versions against live registries before planning and implementation",
|
||||
"author": "KevinBrown5280",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.2.0",
|
||||
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.2.0.zip",
|
||||
"repository": "https://github.com/KevinBrown5280/spec-kit-version-guard",
|
||||
"homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard",
|
||||
"documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md",
|
||||
@@ -2297,8 +2606,8 @@
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 2
|
||||
"commands": 3,
|
||||
"hooks": 4
|
||||
},
|
||||
"tags": [
|
||||
"versioning",
|
||||
@@ -2310,7 +2619,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
"updated_at": "2026-04-22T21:10:00Z"
|
||||
},
|
||||
"whatif": {
|
||||
"name": "What-if Analysis",
|
||||
@@ -2340,6 +2649,85 @@
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
},
|
||||
"wireframe": {
|
||||
"name": "Wireframe Visual Feedback Loop",
|
||||
"id": "wireframe",
|
||||
"description": "SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement.",
|
||||
"author": "TortoiseWolfe",
|
||||
"version": "0.1.1",
|
||||
"download_url": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/archive/refs/tags/v0.1.1.zip",
|
||||
"repository": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe",
|
||||
"homepage": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe",
|
||||
"documentation": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/README.md",
|
||||
"changelog": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"wireframe",
|
||||
"visual",
|
||||
"design",
|
||||
"ui",
|
||||
"mockup",
|
||||
"svg",
|
||||
"feedback-loop",
|
||||
"sign-off"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-04-22T00:00:00Z"
|
||||
},
|
||||
"workiq": {
|
||||
"name": "Work IQ",
|
||||
"id": "workiq",
|
||||
"description": "Integrate Microsoft 365 organizational knowledge into spec-driven development workflows",
|
||||
"author": "sakitA",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/sakitA/spec-kit-workiq/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/sakitA/spec-kit-workiq",
|
||||
"homepage": "https://github.com/sakitA/spec-kit-workiq",
|
||||
"documentation": "https://github.com/sakitA/spec-kit-workiq/blob/main/README.md",
|
||||
"changelog": "https://github.com/sakitA/spec-kit-workiq/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "workiq",
|
||||
"version": ">=1.0.0",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
"version": ">=18.0.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"microsoft-365",
|
||||
"work-iq",
|
||||
"context",
|
||||
"integration",
|
||||
"productivity"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-29T00:00:00Z",
|
||||
"updated_at": "2026-04-29T00:00:00Z"
|
||||
},
|
||||
"worktree": {
|
||||
"name": "Worktree Isolation",
|
||||
"id": "worktree",
|
||||
@@ -2402,7 +2790,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||
exit 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-08T00:00:00Z",
|
||||
"updated_at": "2026-04-28T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"claude": {
|
||||
@@ -66,6 +66,15 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills"]
|
||||
},
|
||||
"devin": {
|
||||
"id": "devin",
|
||||
"name": "Devin for Terminal",
|
||||
"version": "1.0.0",
|
||||
"description": "Devin for Terminal CLI skills-based integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills"]
|
||||
},
|
||||
"qwen": {
|
||||
"id": "qwen",
|
||||
"name": "Qwen Code",
|
||||
|
||||
@@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency:
|
||||
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
|
||||
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`
|
||||
|
||||
### Composition Strategies
|
||||
|
||||
Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it:
|
||||
|
||||
| Strategy | Description | Templates | Commands | Scripts |
|
||||
|----------|-------------|-----------|----------|---------|
|
||||
| `replace` (default) | Fully replaces lower-priority content | ✓ | ✓ | ✓ |
|
||||
| `prepend` | Places content before lower-priority content (separated by a blank line) | ✓ | ✓ | — |
|
||||
| `append` | Places content after lower-priority content (separated by a blank line) | ✓ | ✓ | — |
|
||||
| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | ✓ | ✓ | ✓ |
|
||||
|
||||
Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy.
|
||||
|
||||
Content resolution functions for composition:
|
||||
- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py` (templates, commands, and scripts)
|
||||
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
|
||||
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)
|
||||
|
||||
## Command Registration
|
||||
|
||||
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
|
||||
|
||||
@@ -205,11 +205,21 @@ Edit `presets/catalog.community.json` and add your preset.
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Submit Pull Request
|
||||
### 3. Update Community Presets Table
|
||||
|
||||
Add your preset to the Community Presets table on the docs site at `docs/community/presets.md`:
|
||||
|
||||
```markdown
|
||||
| Your Preset Name | Brief description of what your preset does | N templates, M commands[, P scripts] | — | [repo-name](https://github.com/your-org/spec-kit-preset-your-preset) |
|
||||
```
|
||||
|
||||
Insert your row in alphabetical order by preset **name** (the first column of the table).
|
||||
|
||||
### 4. Submit Pull Request
|
||||
|
||||
```bash
|
||||
git checkout -b add-your-preset
|
||||
git add presets/catalog.community.json
|
||||
git add presets/catalog.community.json docs/community/presets.md
|
||||
git commit -m "Add your-preset to community catalog
|
||||
|
||||
- Preset ID: your-preset
|
||||
@@ -240,6 +250,7 @@ git push origin add-your-preset
|
||||
- [ ] Commands register to agent directories (if applicable)
|
||||
- [ ] Commands match template sections (command + template are coherent)
|
||||
- [ ] Added to presets/catalog.community.json
|
||||
- [ ] Added row to docs/community/presets.md table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -61,7 +61,37 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa
|
||||
specify preset add pm-workflow --priority 1 # overrides everything
|
||||
```
|
||||
|
||||
Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
|
||||
Presets **override by default**, they don't merge. If two presets both provide `spec-template` with the default `replace` strategy, the one with the lowest priority number wins entirely. However, presets can use **composition strategies** to augment rather than replace content.
|
||||
|
||||
### Composition Strategies
|
||||
|
||||
Presets can declare a `strategy` per template to control how content is combined. The `name` field identifies which template to compose with in the priority stack, while `file` points to the actual content file (which can differ from the convention path `templates/<name>.md`):
|
||||
|
||||
```yaml
|
||||
provides:
|
||||
templates:
|
||||
- type: "template"
|
||||
name: "spec-template"
|
||||
file: "templates/spec-addendum.md"
|
||||
strategy: "append" # adds content after the core template
|
||||
```
|
||||
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| `replace` (default) | Fully replaces the lower-priority template |
|
||||
| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line |
|
||||
| `append` | Places content **after** the resolved lower-priority template, separated by a blank line |
|
||||
| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content |
|
||||
|
||||
**Supported combinations:**
|
||||
|
||||
| Type | `replace` | `prepend` | `append` | `wrap` |
|
||||
|------|-----------|-----------|----------|--------|
|
||||
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||
| **script** | ✓ (default) | — | — | ✓ |
|
||||
|
||||
Multiple composing presets chain recursively. For example, a security preset with `prepend` and a compliance preset with `append` will produce: security header + core content + compliance footer.
|
||||
|
||||
## Catalog Management
|
||||
|
||||
@@ -93,9 +123,25 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SPECKIT_PRESET_CATALOG_URL` | Override the full catalog stack with a single URL (replaces all defaults) | Built-in default stack |
|
||||
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or preset ZIPs are hosted in a private GitHub repository. | None |
|
||||
|
||||
#### Example: Using a private GitHub-hosted catalog
|
||||
|
||||
```bash
|
||||
# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI)
|
||||
export GITHUB_TOKEN=$(gh auth token)
|
||||
|
||||
# Search a private catalog added via `specify preset catalog add`
|
||||
specify preset search my-template
|
||||
|
||||
# Install from a private catalog
|
||||
specify preset add my-template
|
||||
```
|
||||
|
||||
The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials.
|
||||
|
||||
## Configuration Files
|
||||
|
||||
@@ -108,13 +154,5 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset
|
||||
|
||||
The following enhancements are under consideration for future releases:
|
||||
|
||||
- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:
|
||||
|
||||
| Type | `replace` | `prepend` | `append` | `wrap` |
|
||||
|------|-----------|-----------|----------|--------|
|
||||
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||
| **script** | ✓ (default) | — | — | ✓ |
|
||||
|
||||
For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.
|
||||
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.
|
||||
- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security").
|
||||
- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts.
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"a11y",
|
||||
"accessibility",
|
||||
"bilingual",
|
||||
"wcag",
|
||||
"inclusion"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
"id": "aide-in-place",
|
||||
@@ -16,7 +44,9 @@
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"extensions": ["aide"]
|
||||
"extensions": [
|
||||
"aide"
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"templates": 2,
|
||||
@@ -29,6 +59,34 @@
|
||||
"aide"
|
||||
]
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 11,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"threat-modeling",
|
||||
"stride",
|
||||
"zero-trust"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
"id": "canon-core",
|
||||
@@ -80,6 +138,34 @@
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
},
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Adds Bash and PowerShell parity, dry-run/WhatIf parity, man-page expectations, and Verb-Noun Cmdlet discipline.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 8,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"cross-platform",
|
||||
"bash",
|
||||
"powershell",
|
||||
"man-page",
|
||||
"cmdlet"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
"id": "explicit-task-dependencies",
|
||||
@@ -108,11 +194,11 @@
|
||||
"fiction-book-writing": {
|
||||
"name": "Fiction Book Writing",
|
||||
"id": "fiction-book-writing",
|
||||
"version": "1.6.0",
|
||||
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported.",
|
||||
"version": "1.7.0",
|
||||
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
|
||||
"author": "Andreas Daumann",
|
||||
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.6.0.zip",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.7.0.zip",
|
||||
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
|
||||
"license": "MIT",
|
||||
@@ -122,7 +208,7 @@
|
||||
"provides": {
|
||||
"templates": 22,
|
||||
"commands": 27,
|
||||
"scripts": 1
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"writing",
|
||||
@@ -140,8 +226,63 @@
|
||||
"language-support"
|
||||
],
|
||||
"created_at": "2026-04-09T08:00:00Z",
|
||||
"updated_at": "2026-04-19T08:00:00Z"
|
||||
},
|
||||
"updated_at": "2026-04-27T08:00:00Z"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 13,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"isaqb",
|
||||
"arc42",
|
||||
"adr"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
"id": "jira",
|
||||
"version": "1.0.0",
|
||||
"description": "Overrides speckit.taskstoissues to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools.",
|
||||
"author": "luno",
|
||||
"repository": "https://github.com/luno/spec-kit-preset-jira",
|
||||
"download_url": "https://github.com/luno/spec-kit-preset-jira/archive/refs/tags/v1.0.0.zip",
|
||||
"homepage": "https://github.com/luno/spec-kit-preset-jira",
|
||||
"documentation": "https://github.com/luno/spec-kit-preset-jira/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 0,
|
||||
"commands": 1
|
||||
},
|
||||
"tags": [
|
||||
"jira",
|
||||
"atlassian",
|
||||
"issue-tracking",
|
||||
"preset"
|
||||
],
|
||||
"created_at": "2026-04-15T00:00:00Z",
|
||||
"updated_at": "2026-04-15T00:00:00Z"
|
||||
},
|
||||
"multi-repo-branching": {
|
||||
"name": "Multi-Repo Branching",
|
||||
"id": "multi-repo-branching",
|
||||
@@ -194,6 +335,72 @@
|
||||
"experimental"
|
||||
]
|
||||
},
|
||||
"screenwriting": {
|
||||
"name": "Screenwriting",
|
||||
"id": "screenwriting",
|
||||
"version": "1.0.0",
|
||||
"description": "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 replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
|
||||
"author": "Andreas Daumann",
|
||||
"repository": "https://github.com/adaumann/speckit-preset-screenwriting",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip",
|
||||
"homepage": "https://github.com/adaumann/speckit-preset-screenwriting",
|
||||
"documentation": "https://github.com/adaumann/speckit-preset-screenwriting/blob/main/screenwriting/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.5.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 26,
|
||||
"commands": 32,
|
||||
"scripts": 1
|
||||
},
|
||||
"tags": [
|
||||
"writing",
|
||||
"screenplay",
|
||||
"scriptwriting",
|
||||
"film",
|
||||
"tv",
|
||||
"fountain",
|
||||
"fountain-format",
|
||||
"beat-sheet",
|
||||
"teleplay",
|
||||
"drama",
|
||||
"comedy",
|
||||
"storytelling",
|
||||
"tutorial",
|
||||
"education"
|
||||
],
|
||||
"created_at": "2026-04-23T08:00:00Z",
|
||||
"updated_at": "2026-04-23T08:00:00Z"
|
||||
},
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds secure development governance, MSL preference, ASVS verification, supply-chain transparency, and EU CRA awareness.",
|
||||
"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.2.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",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 12,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"governance",
|
||||
"msl",
|
||||
"asvs",
|
||||
"supply-chain"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
},
|
||||
"toc-navigation": {
|
||||
"name": "Table of Contents Navigation",
|
||||
"id": "toc-navigation",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-24T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
|
||||
"presets": {
|
||||
"lean": {
|
||||
@@ -10,7 +10,15 @@
|
||||
"description": "Minimal core workflow commands - just the prompt, just the artifact",
|
||||
"author": "github",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"license": "MIT",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"templates": 0
|
||||
},
|
||||
"tags": [
|
||||
"lean",
|
||||
"minimal",
|
||||
|
||||
45
presets/lean/README.md
Normal file
45
presets/lean/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Lean Workflow
|
||||
|
||||
A minimal preset that strips the Spec Kit workflow down to its essentials — just the prompt, just the artifact.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use Lean when you want the structured specify → plan → tasks → implement pipeline without the ceremony of the full templates. Each command produces a single focused Markdown file with no boilerplate sections to fill in.
|
||||
|
||||
## Commands Included
|
||||
|
||||
| Command | Output | Description |
|
||||
|---------|--------|-------------|
|
||||
| `speckit.specify` | `spec.md` | Create a specification from a feature description |
|
||||
| `speckit.plan` | `plan.md` | Create an implementation plan from the spec |
|
||||
| `speckit.tasks` | `tasks.md` | Create dependency-ordered tasks from spec and plan |
|
||||
| `speckit.implement` | *(code)* | Execute all tasks in order, marking progress |
|
||||
| `speckit.constitution` | `constitution.md` | Create or update the project constitution |
|
||||
|
||||
## What It Replaces
|
||||
|
||||
Lean overrides the five core workflow commands with self-contained prompts that produce each artifact directly — no separate template files involved. The result is a shorter, more direct workflow.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Lean is a bundled preset — no download needed
|
||||
specify preset add lean
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Test from local directory
|
||||
specify preset add --dev ./presets/lean
|
||||
|
||||
# Verify commands resolve
|
||||
specify preset resolve speckit.specify
|
||||
|
||||
# Remove when done
|
||||
specify preset remove lean
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -48,3 +48,4 @@ tags:
|
||||
- "lean"
|
||||
- "minimal"
|
||||
- "workflow"
|
||||
- "core"
|
||||
|
||||
@@ -32,6 +32,15 @@ provides:
|
||||
templates:
|
||||
# CUSTOMIZE: Define your template overrides
|
||||
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
|
||||
#
|
||||
# Strategy options (optional, defaults to "replace"):
|
||||
# replace - Fully replaces the lower-priority template (default)
|
||||
# prepend - Places this content BEFORE the lower-priority template
|
||||
# append - Places this content AFTER the lower-priority template
|
||||
# wrap - Uses {CORE_TEMPLATE} placeholder (templates/commands) or
|
||||
# $CORE_SCRIPT placeholder (scripts), replaced with lower-priority content
|
||||
#
|
||||
# Note: Scripts only support "replace" and "wrap" strategies.
|
||||
- type: "template"
|
||||
name: "spec-template"
|
||||
file: "templates/spec-template.md"
|
||||
@@ -45,6 +54,26 @@ provides:
|
||||
# description: "Custom plan template"
|
||||
# replaces: "plan-template"
|
||||
|
||||
# COMPOSITION EXAMPLES:
|
||||
# The `file` field points to the content file (can differ from the
|
||||
# convention path `templates/<name>.md`). The `name` field identifies
|
||||
# which template to compose with in the priority stack.
|
||||
#
|
||||
# Append additional sections to an existing template:
|
||||
# - type: "template"
|
||||
# name: "spec-template"
|
||||
# file: "templates/spec-addendum.md"
|
||||
# description: "Add compliance section to spec template"
|
||||
# strategy: "append"
|
||||
#
|
||||
# Wrap a command with preamble/sign-off:
|
||||
# - type: "command"
|
||||
# name: "speckit.specify"
|
||||
# file: "commands/specify-wrapper.md"
|
||||
# description: "Wrap specify command with compliance checks"
|
||||
# strategy: "wrap"
|
||||
# # In the wrapper file, use {CORE_TEMPLATE} where the original content goes
|
||||
|
||||
# OVERRIDE EXTENSION TEMPLATES:
|
||||
# Presets sit above extensions in the resolution stack, so you can
|
||||
# override templates provided by any installed extension.
|
||||
|
||||
14
presets/self-test/commands/speckit.wrap-test.md
Normal file
14
presets/self-test/commands/speckit.wrap-test.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: "Self-test wrap command — pre/post around core"
|
||||
strategy: wrap
|
||||
---
|
||||
|
||||
## Preset Pre-Logic
|
||||
|
||||
preset:self-test wrap-pre
|
||||
|
||||
{CORE_TEMPLATE}
|
||||
|
||||
## Preset Post-Logic
|
||||
|
||||
preset:self-test wrap-post
|
||||
@@ -56,6 +56,11 @@ provides:
|
||||
description: "Self-test override of the specify command"
|
||||
replaces: "speckit.specify"
|
||||
|
||||
- type: "command"
|
||||
name: "speckit.wrap-test"
|
||||
file: "commands/speckit.wrap-test.md"
|
||||
description: "Self-test wrap strategy command"
|
||||
|
||||
tags:
|
||||
- "testing"
|
||||
- "self-test"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.7.4"
|
||||
version = "0.8.4"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -153,6 +153,59 @@ check_feature_branch() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Safely read .specify/feature.json's "feature_directory" value.
|
||||
# Prints the raw value (possibly relative) to stdout, or empty string if the file
|
||||
# is missing, unparseable, or does not contain the key. Always returns 0 so callers
|
||||
# under `set -e` cannot be aborted by parser failure.
|
||||
# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
|
||||
read_feature_json_feature_directory() {
|
||||
local repo_root="$1"
|
||||
local fj="$repo_root/.specify/feature.json"
|
||||
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
||||
|
||||
local _fd=''
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
||||
_fd=''
|
||||
fi
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
# Use Python so pretty-printed/multi-line JSON still parses correctly.
|
||||
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
|
||||
_fd=''
|
||||
fi
|
||||
else
|
||||
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
||||
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
||||
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
||||
| head -n 1 \
|
||||
| sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
|
||||
fi
|
||||
|
||||
printf '%s' "$_fd"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||
# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks).
|
||||
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||
feature_json_matches_feature_dir() {
|
||||
local repo_root="$1"
|
||||
local active_feature_dir="$2"
|
||||
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
|
||||
[[ -n "$_fd" ]] || return 1
|
||||
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
|
||||
[[ -d "$_fd" ]] || return 1
|
||||
|
||||
local norm_json norm_active
|
||||
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
|
||||
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
|
||||
|
||||
[[ "$norm_json" == "$norm_active" ]]
|
||||
}
|
||||
|
||||
# Find feature directory by numeric prefix instead of exact branch match
|
||||
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||
find_feature_dir_by_prefix() {
|
||||
@@ -217,16 +270,10 @@ get_feature_paths() {
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
|
||||
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
|
||||
local _fd
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
_fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null)
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
# Fallback: use Python to parse JSON so pretty-printed/multi-line files work
|
||||
_fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('feature_directory',''))" "$repo_root/.specify/feature.json" 2>/dev/null)
|
||||
else
|
||||
# Last resort: single-line grep fallback (won't work on multi-line JSON)
|
||||
_fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
fi
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ -n "$_fd" ]]; then
|
||||
feature_dir="$_fd"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
@@ -320,8 +367,9 @@ try:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
|
||||
print(pid)
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
@@ -373,3 +421,225 @@ except Exception:
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve a template name to composed content using composition strategies.
|
||||
# Reads strategy metadata from preset manifests and composes content
|
||||
# from multiple layers using prepend, append, or wrap strategies.
|
||||
#
|
||||
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
|
||||
# Returns composed content string on stdout; exit code 1 if not found.
|
||||
resolve_template_content() {
|
||||
local template_name="$1"
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
local -a layer_paths=()
|
||||
local -a layer_strategies=()
|
||||
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
if [ -f "$override" ]; then
|
||||
layer_paths+=("$override")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
local sorted_presets=""
|
||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||
import json, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
if [ -n "$sorted_presets" ]; then
|
||||
local yaml_warned=false
|
||||
while IFS= read -r preset_id; do
|
||||
# Read strategy and file path from preset manifest
|
||||
local strategy="replace"
|
||||
local manifest_file=""
|
||||
local manifest="$presets_dir/$preset_id/preset.yml"
|
||||
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
|
||||
# Requires PyYAML; falls back to replace/convention if unavailable
|
||||
local result
|
||||
local py_stderr
|
||||
py_stderr=$(mktemp)
|
||||
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
|
||||
import sys, os
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(os.environ['SPECKIT_MANIFEST']) as f:
|
||||
data = yaml.safe_load(f)
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
sys.exit(0)
|
||||
print('replace\t')
|
||||
except Exception:
|
||||
print('replace\t')
|
||||
" 2>"$py_stderr")
|
||||
local parse_status=$?
|
||||
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
|
||||
IFS=$'\t' read -r strategy manifest_file <<< "$result"
|
||||
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
|
||||
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
|
||||
yaml_warned=true
|
||||
fi
|
||||
rm -f "$py_stderr"
|
||||
fi
|
||||
# Try manifest file path first, then convention path
|
||||
local candidate=""
|
||||
if [ -n "$manifest_file" ]; then
|
||||
# Reject absolute paths and parent traversal
|
||||
case "$manifest_file" in
|
||||
/*|*../*|../*) manifest_file="" ;;
|
||||
esac
|
||||
fi
|
||||
if [ -n "$manifest_file" ]; then
|
||||
local mf="$presets_dir/$preset_id/$manifest_file"
|
||||
[ -f "$mf" ] && candidate="$mf"
|
||||
fi
|
||||
if [ -z "$candidate" ]; then
|
||||
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$cf" ] && candidate="$cf"
|
||||
fi
|
||||
if [ -n "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("$strategy")
|
||||
fi
|
||||
done <<< "$sorted_presets"
|
||||
fi
|
||||
else
|
||||
# python3 failed — fall back to unordered directory scan (replace only)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
else
|
||||
# No python3 or registry — fall back to unordered directory scan (replace only)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
case "$(basename "$ext")" in .*) continue;; esac
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
local core="$base/${template_name}.md"
|
||||
if [ -f "$core" ]; then
|
||||
layer_paths+=("$core")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
|
||||
local count=${#layer_paths[@]}
|
||||
[ "$count" -eq 0 ] && return 1
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
local has_composition=false
|
||||
for s in "${layer_strategies[@]}"; do
|
||||
[ "$s" != "replace" ] && has_composition=true && break
|
||||
done
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if [ "${layer_strategies[0]}" = "replace" ]; then
|
||||
cat "${layer_paths[0]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$has_composition" = false ]; then
|
||||
cat "${layer_paths[0]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
# to find the nearest replace layer. Only compose layers above that base.
|
||||
local base_idx=-1
|
||||
local i
|
||||
for (( i=0; i<count; i++ )); do
|
||||
if [ "${layer_strategies[$i]}" = "replace" ]; then
|
||||
base_idx=$i
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $base_idx -lt 0 ]; then
|
||||
return 1 # no base layer found
|
||||
fi
|
||||
|
||||
# Read the base content; compose layers above the base (higher priority)
|
||||
local content
|
||||
content=$(cat "${layer_paths[$base_idx]}"; printf x)
|
||||
content="${content%x}"
|
||||
|
||||
for (( i=base_idx-1; i>=0; i-- )); do
|
||||
local path="${layer_paths[$i]}"
|
||||
local strat="${layer_strategies[$i]}"
|
||||
local layer_content
|
||||
# Preserve trailing newlines
|
||||
layer_content=$(cat "$path"; printf x)
|
||||
layer_content="${layer_content%x}"
|
||||
|
||||
case "$strat" in
|
||||
replace) content="$layer_content" ;;
|
||||
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
|
||||
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
|
||||
wrap)
|
||||
case "$layer_content" in
|
||||
*'{CORE_TEMPLATE}'*) ;;
|
||||
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
|
||||
esac
|
||||
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
|
||||
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
|
||||
local after="${layer_content#*\{CORE_TEMPLATE\}}"
|
||||
layer_content="${before}${content}${after}"
|
||||
done
|
||||
content="$layer_content"
|
||||
;;
|
||||
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '%s' "$content"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||
exit 1
|
||||
|
||||
@@ -32,8 +32,10 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Check if we're on a proper feature branch (only for git repos)
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
@@ -164,6 +164,74 @@ function Test-FeatureBranch {
|
||||
return $true
|
||||
}
|
||||
|
||||
# True when .specify/feature.json pins an existing feature directory that matches the
|
||||
# active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.plan can skip git branch pattern checks).
|
||||
function Test-FeatureJsonMatchesFeatureDir {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
|
||||
)
|
||||
|
||||
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $featureJson -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
$fd = $cfg.feature_directory
|
||||
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($fd)) {
|
||||
$fd = Join-Path $RepoRoot $fd
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
|
||||
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
|
||||
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
|
||||
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
|
||||
if ($resolvedJson) {
|
||||
$normJson = $resolvedJson.Path
|
||||
} else {
|
||||
$normJson = [System.IO.Path]::GetFullPath($fd)
|
||||
}
|
||||
|
||||
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
|
||||
if ($resolvedActive) {
|
||||
$normActive = $resolvedActive.Path
|
||||
} else {
|
||||
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
|
||||
}
|
||||
|
||||
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
|
||||
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
|
||||
# absence as "we're on Windows".
|
||||
if ($null -ne $IsWindows) {
|
||||
$onWindows = $IsWindows
|
||||
} else {
|
||||
$onWindows = $true
|
||||
}
|
||||
|
||||
if ($onWindows) {
|
||||
$comparison = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$comparison = [System.StringComparison]::Ordinal
|
||||
}
|
||||
|
||||
return [string]::Equals($normJson, $normActive, $comparison)
|
||||
}
|
||||
|
||||
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
|
||||
function Find-FeatureDirByPrefix {
|
||||
param(
|
||||
@@ -287,6 +355,21 @@ function Test-DirHasFiles {
|
||||
}
|
||||
}
|
||||
|
||||
# Find a usable Python 3 executable (python3, python, or py -3).
|
||||
# Returns the command/arguments as an array, or $null if none found.
|
||||
function Get-Python3Command {
|
||||
if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
|
||||
if (Get-Command python -ErrorAction SilentlyContinue) {
|
||||
$ver = & python --version 2>&1
|
||||
if ($ver -match 'Python 3') { return @('python') }
|
||||
}
|
||||
if (Get-Command py -ErrorAction SilentlyContinue) {
|
||||
$ver = & py -3 --version 2>&1
|
||||
if ($ver -match 'Python 3') { return @('py', '-3') }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
@@ -315,6 +398,7 @@ function Resolve-Template {
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
@@ -354,3 +438,206 @@ function Resolve-Template {
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolve a template name to composed content using composition strategies.
|
||||
# Reads strategy metadata from preset manifests and composes content
|
||||
# from multiple layers using prepend, append, or wrap strategies.
|
||||
function Resolve-TemplateContent {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
$layerPaths = @()
|
||||
$layerStrategies = @()
|
||||
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) {
|
||||
$layerPaths += $override
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
} catch {
|
||||
$sortedPresets = @()
|
||||
}
|
||||
}
|
||||
|
||||
if ($sortedPresets.Count -gt 0) {
|
||||
$pyCmd = Get-Python3Command
|
||||
if (-not $pyCmd) {
|
||||
# Check if any preset has strategy fields that would be ignored
|
||||
foreach ($pid in $sortedPresets) {
|
||||
$mf = Join-Path $presetsDir "$pid/preset.yml"
|
||||
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$yamlWarned = $false
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
# Read strategy and file path from preset manifest
|
||||
$strategy = 'replace'
|
||||
$manifestFilePath = ''
|
||||
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
|
||||
if ((Test-Path $manifest) -and $pyCmd) {
|
||||
try {
|
||||
# Use Python to parse YAML manifest for strategy and file path
|
||||
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
|
||||
$pyStderrFile = [System.IO.Path]::GetTempFileName()
|
||||
$stratResult = & $pyCmd[0] @pyArgs -c @"
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
data = yaml.safe_load(f)
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
sys.exit(0)
|
||||
print('replace\t')
|
||||
except Exception:
|
||||
print('replace\t')
|
||||
"@ $manifest $TemplateName 2>$pyStderrFile
|
||||
if ($stratResult) {
|
||||
$parts = $stratResult.Trim() -split "`t", 2
|
||||
$strategy = $parts[0].ToLowerInvariant()
|
||||
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
|
||||
}
|
||||
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
|
||||
Write-Warning "PyYAML not available; composition strategies may be ignored"
|
||||
$yamlWarned = $true
|
||||
}
|
||||
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
$strategy = 'replace'
|
||||
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
# Try manifest file path first, then convention path
|
||||
$candidate = $null
|
||||
if ($manifestFilePath) {
|
||||
# Reject absolute paths and parent traversal
|
||||
if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
|
||||
$manifestFilePath = ''
|
||||
}
|
||||
}
|
||||
if ($manifestFilePath) {
|
||||
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
|
||||
if (Test-Path $mf) { $candidate = $mf }
|
||||
}
|
||||
if (-not $candidate) {
|
||||
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $cf) { $candidate = $cf }
|
||||
}
|
||||
if ($candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += $strategy
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order (no registry or parse failure)
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) {
|
||||
$layerPaths += $core
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
|
||||
if ($layerPaths.Count -eq 0) { return $null }
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if ($layerStrategies[0] -eq 'replace') {
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
}
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
$hasComposition = $false
|
||||
foreach ($s in $layerStrategies) {
|
||||
if ($s -ne 'replace') { $hasComposition = $true; break }
|
||||
}
|
||||
|
||||
if (-not $hasComposition) {
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
}
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
# to find the nearest replace layer. Only compose layers above that base.
|
||||
$baseIdx = -1
|
||||
for ($i = 0; $i -lt $layerPaths.Count; $i++) {
|
||||
if ($layerStrategies[$i] -eq 'replace') {
|
||||
$baseIdx = $i
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($baseIdx -lt 0) { return $null }
|
||||
|
||||
$content = Get-Content $layerPaths[$baseIdx] -Raw
|
||||
|
||||
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
|
||||
$path = $layerPaths[$i]
|
||||
$strat = $layerStrategies[$i]
|
||||
$layerContent = Get-Content $path -Raw
|
||||
|
||||
switch ($strat) {
|
||||
'replace' { $content = $layerContent }
|
||||
'prepend' { $content = "$layerContent`n`n$content" }
|
||||
'append' { $content = "$content`n`n$layerContent" }
|
||||
'wrap' {
|
||||
if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
|
||||
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
|
||||
}
|
||||
$content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
|
||||
}
|
||||
default { throw "Unknown strategy: $strat" }
|
||||
}
|
||||
}
|
||||
|
||||
return $content
|
||||
}
|
||||
@@ -23,9 +23,11 @@ if ($Help) {
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# Check if we're on a proper feature branch (only for git repos)
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
80
src/specify_cli/_github_http.py
Normal file
80
src/specify_cli/_github_http.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Shared GitHub-authenticated HTTP helpers.
|
||||
|
||||
Used by both ExtensionCatalog and PresetCatalog to attach
|
||||
GITHUB_TOKEN / GH_TOKEN credentials to requests targeting
|
||||
GitHub-hosted domains, while preventing token leakage to
|
||||
third-party hosts on redirects.
|
||||
"""
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
from typing import Dict
|
||||
|
||||
# GitHub-owned hostnames that should receive the Authorization header.
|
||||
# Includes codeload.github.com because GitHub archive URL downloads
|
||||
# (e.g. /archive/refs/tags/<tag>.zip) redirect there and require auth
|
||||
# for private repositories.
|
||||
GITHUB_HOSTS = frozenset({
|
||||
"raw.githubusercontent.com",
|
||||
"github.com",
|
||||
"api.github.com",
|
||||
"codeload.github.com",
|
||||
})
|
||||
|
||||
|
||||
def build_github_request(url: str) -> urllib.request.Request:
|
||||
"""Build a urllib Request, adding a GitHub auth header when available.
|
||||
|
||||
Reads GITHUB_TOKEN or GH_TOKEN from the environment and attaches an
|
||||
``Authorization: Bearer <value>`` header when the target hostname is one
|
||||
of the known GitHub-owned domains. Non-GitHub URLs are returned as plain
|
||||
requests so credentials are never leaked to third-party hosts.
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
github_token = (os.environ.get("GITHUB_TOKEN") or "").strip()
|
||||
gh_token = (os.environ.get("GH_TOKEN") or "").strip()
|
||||
token = github_token or gh_token or None
|
||||
hostname = (urlparse(url).hostname or "").lower()
|
||||
if token and hostname in GITHUB_HOSTS:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Redirect handler that drops the Authorization header when leaving GitHub.
|
||||
|
||||
Prevents token leakage to CDNs or other third-party hosts that GitHub
|
||||
may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com).
|
||||
Auth is preserved as long as the redirect target remains within GITHUB_HOSTS.
|
||||
"""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
original_auth = req.get_header("Authorization")
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
hostname = (urlparse(newurl).hostname or "").lower()
|
||||
if hostname in GITHUB_HOSTS:
|
||||
if original_auth:
|
||||
new_req.add_unredirected_header("Authorization", original_auth)
|
||||
else:
|
||||
new_req.headers.pop("Authorization", None)
|
||||
new_req.unredirected_hdrs.pop("Authorization", None)
|
||||
return new_req
|
||||
|
||||
|
||||
def open_github_url(url: str, timeout: int = 10):
|
||||
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||
|
||||
When the request carries an Authorization header, a custom redirect
|
||||
handler drops that header if the redirect target is not a GitHub-owned
|
||||
domain, preventing token leakage to CDNs or other third-party hosts
|
||||
that GitHub may redirect to (e.g. S3 for release asset downloads).
|
||||
"""
|
||||
req = build_github_request(url)
|
||||
|
||||
if not req.get_header("Authorization"):
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect)
|
||||
return opener.open(req, timeout=timeout)
|
||||
@@ -6,8 +6,9 @@ Used by both the extension system and the preset system to write
|
||||
command files into agent-specific directories in the correct format.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
import platform
|
||||
import re
|
||||
@@ -281,7 +282,8 @@ class CommandRegistrar:
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
if agent_name in {"codex", "kimi"}:
|
||||
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
@@ -399,6 +401,28 @@ class CommandRegistrar:
|
||||
|
||||
return f"speckit-{short_name}"
|
||||
|
||||
@staticmethod
|
||||
def _ensure_inside(candidate: Path, base: Path) -> None:
|
||||
"""Validate that a write target stays within the expected base directory.
|
||||
|
||||
Uses lexical normalization so traversal via ``..`` or absolute paths is
|
||||
rejected while intentionally symlinked sub-directories remain
|
||||
supported.
|
||||
|
||||
Args:
|
||||
candidate: Path that will be written.
|
||||
base: Directory the write must remain within.
|
||||
|
||||
Raises:
|
||||
ValueError: If the normalized candidate path escapes ``base``.
|
||||
"""
|
||||
normalized = Path(os.path.normpath(candidate))
|
||||
base_normalized = Path(os.path.normpath(base))
|
||||
if not normalized.is_relative_to(base_normalized):
|
||||
raise ValueError(
|
||||
f"Output path {candidate!r} escapes directory {base!r}"
|
||||
)
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -445,6 +469,15 @@ class CommandRegistrar:
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
frontmatter, body = self.parse_frontmatter(content)
|
||||
|
||||
if frontmatter.get("strategy") == "wrap":
|
||||
from .presets import _substitute_core_template
|
||||
body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self)
|
||||
frontmatter = dict(frontmatter)
|
||||
for key in ("scripts", "agent_scripts"):
|
||||
if key not in frontmatter and key in core_frontmatter:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
frontmatter.pop("strategy", None)
|
||||
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
|
||||
for key in agent_config.get("strip_frontmatter_keys", []):
|
||||
@@ -472,10 +505,12 @@ class CommandRegistrar:
|
||||
project_root,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
output = self.render_markdown_command(
|
||||
frontmatter, body, source_id, context_note
|
||||
)
|
||||
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
|
||||
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||
elif agent_config["format"] == "toml":
|
||||
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
|
||||
output = self.render_toml_command(frontmatter, body, source_id)
|
||||
elif agent_config["format"] == "yaml":
|
||||
output = self.render_yaml_command(
|
||||
@@ -485,6 +520,7 @@ class CommandRegistrar:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
self._ensure_inside(dest_file, commands_dir)
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_file.write_text(output, encoding="utf-8")
|
||||
|
||||
@@ -550,12 +586,7 @@ class CommandRegistrar:
|
||||
alias_file = (
|
||||
commands_dir / f"{alias_output_name}{agent_config['extension']}"
|
||||
)
|
||||
try:
|
||||
alias_file.resolve().relative_to(commands_dir.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Alias output path escapes commands directory: {alias_file!r}"
|
||||
)
|
||||
self._ensure_inside(alias_file, commands_dir)
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(alias_output, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
@@ -575,6 +606,7 @@ class CommandRegistrar:
|
||||
prompts_dir = project_root / ".github" / "prompts"
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
|
||||
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
|
||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
@@ -620,6 +652,49 @@ class CommandRegistrar:
|
||||
|
||||
return results
|
||||
|
||||
def register_commands_for_non_skill_agents(
|
||||
self,
|
||||
commands: List[Dict[str, Any]],
|
||||
source_id: str,
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: Optional[str] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all non-skill agents in the project.
|
||||
|
||||
Like register_commands_for_all_agents but skips skill-based agents
|
||||
(those with extension '/SKILL.md'). Used by reconciliation to avoid
|
||||
overwriting properly formatted SKILL.md files.
|
||||
|
||||
Args:
|
||||
commands: List of command info dicts
|
||||
source_id: Identifier of the source
|
||||
source_dir: Directory containing command source files
|
||||
project_root: Path to project root
|
||||
context_note: Custom context comment for markdown output
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
"""
|
||||
results = {}
|
||||
self._ensure_configs()
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
continue
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name, commands, source_id,
|
||||
source_dir, project_root,
|
||||
context_note=context_note,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
except ValueError:
|
||||
continue
|
||||
return results
|
||||
|
||||
def unregister_commands(
|
||||
self, registered_commands: Dict[str, List[str]], project_root: Path
|
||||
) -> None:
|
||||
|
||||
@@ -139,12 +139,23 @@ class ExtensionManifest:
|
||||
def _load_yaml(self, path: Path) -> dict:
|
||||
"""Load YAML file safely."""
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
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}")
|
||||
except FileNotFoundError:
|
||||
raise ValidationError(f"Manifest not found: {path}")
|
||||
except UnicodeDecodeError as e:
|
||||
raise ValidationError(
|
||||
f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})"
|
||||
)
|
||||
except OSError as e:
|
||||
raise ValidationError(f"Could not read manifest {path}: {e}")
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}"
|
||||
)
|
||||
return data
|
||||
|
||||
def _validate(self):
|
||||
"""Validate manifest structure and required fields."""
|
||||
@@ -951,29 +962,40 @@ class ExtensionManager:
|
||||
|
||||
return written
|
||||
|
||||
def _unregister_extension_skills(self, skill_names: List[str], extension_id: str) -> None:
|
||||
def _unregister_extension_skills(
|
||||
self,
|
||||
skill_names: List[str],
|
||||
extension_id: str,
|
||||
skills_dir: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Remove SKILL.md directories for extension skills.
|
||||
|
||||
Called during extension removal to clean up skill files that
|
||||
were created by ``_register_extension_skills()``.
|
||||
|
||||
If ``_get_skills_dir()`` returns ``None`` (e.g. the user removed
|
||||
init-options.json or toggled ai_skills after installation), we
|
||||
fall back to scanning all known agent skills directories so that
|
||||
orphaned skill directories are still cleaned up. In that case
|
||||
each candidate directory is verified against the SKILL.md
|
||||
``metadata.source`` field before removal to avoid accidentally
|
||||
deleting user-created skills with the same name.
|
||||
If *skills_dir* is not provided and ``_get_skills_dir()`` returns
|
||||
``None`` (e.g. the user removed init-options.json or toggled
|
||||
ai_skills after installation), we fall back to scanning all known
|
||||
agent skills directories so that orphaned skill directories are
|
||||
still cleaned up. In that case each candidate directory is
|
||||
verified against the SKILL.md ``metadata.source`` field before
|
||||
removal to avoid accidentally deleting user-created skills with
|
||||
the same name.
|
||||
|
||||
Args:
|
||||
skill_names: List of skill names to remove.
|
||||
extension_id: Extension ID used to verify ownership during
|
||||
fallback candidate scanning.
|
||||
skills_dir: Optional explicit skills directory to use instead
|
||||
of resolving via ``_get_skills_dir()``. Useful when the
|
||||
caller needs to target a specific agent's skills directory
|
||||
regardless of the currently-active agent in init-options.
|
||||
"""
|
||||
if not skill_names:
|
||||
return
|
||||
|
||||
skills_dir = self._get_skills_dir()
|
||||
if skills_dir is None:
|
||||
skills_dir = self._get_skills_dir()
|
||||
|
||||
if skills_dir:
|
||||
# Fast path: we know the exact skills directory
|
||||
@@ -1097,7 +1119,7 @@ class ExtensionManager:
|
||||
raise CompatibilityError(
|
||||
f"Extension requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: uv tool install specify-cli --force"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
except InvalidSpecifier:
|
||||
raise CompatibilityError(f"Invalid version specifier: {required}")
|
||||
@@ -1321,6 +1343,156 @@ class ExtensionManager:
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _valid_name_list(value: Any) -> List[str]:
|
||||
"""Return string entries from a registry list, ignoring corrupt values."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, str)]
|
||||
|
||||
def unregister_agent_artifacts(self, agent_name: str) -> None:
|
||||
"""Remove extension files registered for a specific agent.
|
||||
|
||||
Extension command files are tracked per agent in ``registered_commands``.
|
||||
Extension skills are scoped to the provided *agent_name*; they are removed
|
||||
from that agent's skills directory (resolved via its integration config)
|
||||
and the registry field is cleared.
|
||||
|
||||
Skips cleanup when *agent_name* is not a supported agent to avoid
|
||||
losing registry entries while leaving orphaned files on disk.
|
||||
"""
|
||||
if not agent_name:
|
||||
return
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
if agent_name not in registrar.AGENT_CONFIGS:
|
||||
return
|
||||
|
||||
# Resolve the skills directory for the specific agent so cleanup is
|
||||
# agent-scoped and does not depend on the currently-active agent in
|
||||
# init-options. Use the same helper that extension install uses.
|
||||
from . import _get_skills_dir as resolve_skills_dir
|
||||
|
||||
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
|
||||
|
||||
for ext_id, metadata in self.registry.list().items():
|
||||
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 command_names:
|
||||
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", []))
|
||||
if registered_skills:
|
||||
# Only pass the resolved skills_dir when it actually exists.
|
||||
# Otherwise let _unregister_extension_skills fall back to
|
||||
# scanning all known agent skills directories, which is useful
|
||||
# for cleaning up stale entries created by earlier installs.
|
||||
skills_dir = agent_skills_dir if agent_skills_dir.is_dir() else None
|
||||
self._unregister_extension_skills(
|
||||
registered_skills, ext_id, skills_dir=skills_dir
|
||||
)
|
||||
|
||||
# Only reconcile registry state when cleanup was scoped to a
|
||||
# specific existing directory. When skills_dir is None,
|
||||
# _unregister_extension_skills falls back to scanning multiple
|
||||
# candidate directories, so agent_skills_dir cannot be used to
|
||||
# infer what was removed. When skills_dir is set,
|
||||
# _unregister_extension_skills may intentionally skip deletion
|
||||
# when ownership cannot be verified (e.g., corrupted/missing
|
||||
# SKILL.md or mismatching metadata.source). Only drop registry
|
||||
# entries for skill directories that were actually removed so
|
||||
# future cleanup attempts can still find skipped ones.
|
||||
if skills_dir is not None:
|
||||
remaining_skills = [
|
||||
skill_name
|
||||
for skill_name in registered_skills
|
||||
if (skills_dir / skill_name).is_dir()
|
||||
]
|
||||
if remaining_skills != registered_skills:
|
||||
updates["registered_skills"] = remaining_skills
|
||||
|
||||
if updates:
|
||||
self.registry.update(ext_id, updates)
|
||||
|
||||
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
|
||||
"""Register installed, enabled extensions for ``agent_name``.
|
||||
|
||||
This is intended to be called after switching integrations. Command
|
||||
registration is scoped to the explicit ``agent_name`` argument, but some
|
||||
behavior still depends on the current init-options state (for example,
|
||||
skills-mode handling uses the active ``ai`` / ``ai_skills`` settings).
|
||||
|
||||
Callers should therefore pass the agent that has just been made active
|
||||
in init-options; in normal use, ``agent_name`` is expected to match the
|
||||
current ``ai`` value. This mirrors extension install behavior while
|
||||
avoiding stale default-mode command directories when that active agent
|
||||
is running in skills mode (notably Copilot ``--skills``).
|
||||
"""
|
||||
if not agent_name:
|
||||
return
|
||||
|
||||
from . import load_init_options
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
|
||||
init_options = load_init_options(self.project_root)
|
||||
if not isinstance(init_options, dict):
|
||||
init_options = {}
|
||||
|
||||
active_agent = init_options.get("ai")
|
||||
skills_mode_active = (
|
||||
active_agent == agent_name
|
||||
and bool(init_options.get("ai_skills"))
|
||||
and bool(agent_config)
|
||||
and agent_config.get("extension") != "/SKILL.md"
|
||||
)
|
||||
|
||||
for ext_id, metadata in self.registry.list().items():
|
||||
if not metadata.get("enabled", True):
|
||||
continue
|
||||
|
||||
manifest = self.get_extension(ext_id)
|
||||
if manifest is None:
|
||||
continue
|
||||
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
if agent_config and not skills_mode_active:
|
||||
registered = registrar.register_commands_for_agent(
|
||||
agent_name, manifest, ext_dir, self.project_root
|
||||
)
|
||||
registered_commands = metadata.get("registered_commands", {})
|
||||
if not isinstance(registered_commands, dict):
|
||||
registered_commands = {}
|
||||
new_registered = copy.deepcopy(registered_commands)
|
||||
if registered:
|
||||
new_registered[agent_name] = registered
|
||||
else:
|
||||
# Registration returned empty list (e.g., corrupted
|
||||
# manifest pointing at missing command files). Clear
|
||||
# stale entry so later cleanup doesn't try to remove
|
||||
# files that were never written.
|
||||
new_registered.pop(agent_name, None)
|
||||
if new_registered != registered_commands:
|
||||
updates["registered_commands"] = new_registered
|
||||
|
||||
registered_skills = self._register_extension_skills(manifest, ext_dir)
|
||||
if 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
|
||||
|
||||
if updates:
|
||||
self.registry.update(ext_id, updates)
|
||||
|
||||
def list_installed(self) -> List[Dict[str, Any]]:
|
||||
"""List all installed extensions with metadata.
|
||||
|
||||
@@ -1534,6 +1706,22 @@ class ExtensionCatalog:
|
||||
if not parsed.netloc:
|
||||
raise ValidationError("Catalog URL must be a valid URL with a host.")
|
||||
|
||||
def _make_request(self, url: str):
|
||||
"""Build a urllib Request, adding a GitHub auth header when available.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.build_github_request`.
|
||||
"""
|
||||
from specify_cli._github_http import build_github_request
|
||||
return build_github_request(url)
|
||||
|
||||
def _open_url(self, url: str, timeout: int = 10):
|
||||
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.open_github_url`.
|
||||
"""
|
||||
from specify_cli._github_http import open_github_url
|
||||
return open_github_url(url, timeout)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
@@ -1690,7 +1878,6 @@ class ExtensionCatalog:
|
||||
Raises:
|
||||
ExtensionError: If catalog cannot be fetched or has invalid format
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Determine cache file paths (backward compat for default catalog)
|
||||
@@ -1724,7 +1911,7 @@ class ExtensionCatalog:
|
||||
|
||||
# Fetch from network
|
||||
try:
|
||||
with urllib.request.urlopen(entry.url, timeout=10) as response:
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
@@ -1838,10 +2025,9 @@ class ExtensionCatalog:
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
with urllib.request.urlopen(catalog_url, timeout=10) as response:
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
# Validate catalog structure
|
||||
@@ -1952,7 +2138,6 @@ class ExtensionCatalog:
|
||||
Raises:
|
||||
ExtensionError: If extension not found or download fails
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Get extension info from catalog
|
||||
@@ -1992,7 +2177,7 @@ class ExtensionCatalog:
|
||||
|
||||
# Download the ZIP file
|
||||
try:
|
||||
with urllib.request.urlopen(download_url, timeout=60) as response:
|
||||
with self._open_url(download_url, timeout=60) as response:
|
||||
zip_data = response.read()
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
|
||||
@@ -56,6 +56,7 @@ def _register_builtins() -> None:
|
||||
from .codex import CodexIntegration
|
||||
from .copilot import CopilotIntegration
|
||||
from .cursor_agent import CursorAgentIntegration
|
||||
from .devin import DevinIntegration
|
||||
from .forge import ForgeIntegration
|
||||
from .gemini import GeminiIntegration
|
||||
from .generic import GenericIntegration
|
||||
@@ -86,6 +87,7 @@ def _register_builtins() -> None:
|
||||
_register(CodexIntegration())
|
||||
_register(CopilotIntegration())
|
||||
_register(CursorAgentIntegration())
|
||||
_register(DevinIntegration())
|
||||
_register(ForgeIntegration())
|
||||
_register(GeminiIntegration())
|
||||
_register(GenericIntegration())
|
||||
|
||||
@@ -84,6 +84,9 @@ class IntegrationBase(ABC):
|
||||
context_file: str | None = None
|
||||
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
|
||||
|
||||
invoke_separator: str = "."
|
||||
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
|
||||
|
||||
# -- Markers for managed context section ------------------------------
|
||||
|
||||
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
|
||||
@@ -96,6 +99,18 @@ class IntegrationBase(ABC):
|
||||
"""Return options this integration accepts. Default: none."""
|
||||
return []
|
||||
|
||||
def effective_invoke_separator(
|
||||
self, parsed_options: dict[str, Any] | None = None
|
||||
) -> str:
|
||||
"""Return the invoke separator for the given options.
|
||||
|
||||
Subclasses whose separator depends on runtime options (e.g.
|
||||
Copilot in ``--skills`` mode) should override this method.
|
||||
The default implementation ignores *parsed_options* and returns
|
||||
the class-level ``invoke_separator``.
|
||||
"""
|
||||
return self.invoke_separator
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -122,11 +137,12 @@ class IntegrationBase(ABC):
|
||||
agents or ``"/speckit-specify my-feature"`` for skills agents.
|
||||
|
||||
*command_name* may be a full dotted name like
|
||||
``"speckit.specify"`` or a bare stem like ``"specify"``.
|
||||
``"speckit.specify"``, an extension command like
|
||||
``"speckit.git.commit"``, or a bare stem like ``"specify"``.
|
||||
"""
|
||||
stem = command_name
|
||||
if "." in stem:
|
||||
stem = stem.rsplit(".", 1)[-1]
|
||||
if stem.startswith("speckit."):
|
||||
stem = stem[len("speckit."):]
|
||||
|
||||
invocation = f"/speckit.{stem}"
|
||||
if args:
|
||||
@@ -597,6 +613,24 @@ class IntegrationBase(ABC):
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def resolve_command_refs(content: str, separator: str = ".") -> str:
|
||||
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
|
||||
|
||||
Each placeholder encodes a command name in upper-case with
|
||||
underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``,
|
||||
``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses
|
||||
*separator* to join the segments:
|
||||
|
||||
* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
|
||||
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``
|
||||
"""
|
||||
return re.sub(
|
||||
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
|
||||
lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
|
||||
content,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def process_template(
|
||||
content: str,
|
||||
@@ -604,6 +638,7 @@ class IntegrationBase(ABC):
|
||||
script_type: str,
|
||||
arg_placeholder: str = "$ARGUMENTS",
|
||||
context_file: str = "",
|
||||
invoke_separator: str = ".",
|
||||
) -> str:
|
||||
"""Process a raw command template into agent-ready content.
|
||||
|
||||
@@ -615,6 +650,7 @@ class IntegrationBase(ABC):
|
||||
5. Replace ``__AGENT__`` with *agent_name*
|
||||
6. Replace ``__CONTEXT_FILE__`` with *context_file*
|
||||
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
||||
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||
"""
|
||||
# 1. Extract script command from frontmatter
|
||||
script_command = ""
|
||||
@@ -684,6 +720,9 @@ class IntegrationBase(ABC):
|
||||
|
||||
content = CommandRegistrar.rewrite_project_relative_paths(content)
|
||||
|
||||
# 8. Replace __SPECKIT_COMMAND_<NAME>__ with invocation strings
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
|
||||
return content
|
||||
|
||||
def setup(
|
||||
@@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase):
|
||||
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
|
||||
"""
|
||||
|
||||
invoke_separator = "-"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -1311,10 +1352,10 @@ class SkillsIntegration(IntegrationBase):
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
|
||||
stem = command_name
|
||||
if "." in stem:
|
||||
stem = stem.rsplit(".", 1)[-1]
|
||||
if stem.startswith("speckit."):
|
||||
stem = stem[len("speckit."):]
|
||||
|
||||
invocation = f"/speckit-{stem}"
|
||||
invocation = "/speckit-" + stem.replace(".", "-")
|
||||
if args:
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
@@ -1395,6 +1436,7 @@ class SkillsIntegration(IntegrationBase):
|
||||
processed_body = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
context_file=self.context_file or "",
|
||||
invoke_separator=self.invoke_separator,
|
||||
)
|
||||
# Strip the processed frontmatter — we rebuild it for skills.
|
||||
# Preserve leading whitespace in the body to match release ZIP
|
||||
|
||||
@@ -16,7 +16,7 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
from packaging import version as pkg_version
|
||||
@@ -30,6 +30,10 @@ class IntegrationCatalogError(Exception):
|
||||
"""Raised when a catalog operation fails."""
|
||||
|
||||
|
||||
class IntegrationValidationError(IntegrationCatalogError):
|
||||
"""Validation error for catalog config or catalog management operations."""
|
||||
|
||||
|
||||
class IntegrationDescriptorError(Exception):
|
||||
"""Raised when an integration.yml descriptor is invalid."""
|
||||
|
||||
@@ -96,28 +100,36 @@ class IntegrationCatalog:
|
||||
Returns None when the file does not exist.
|
||||
|
||||
Raises:
|
||||
IntegrationCatalogError: on invalid content
|
||||
IntegrationValidationError: on any local-config / YAML problem
|
||||
(parse failures, wrong shape, missing/invalid fields,
|
||||
invalid catalog URLs, etc.). This is a subclass of
|
||||
:class:`IntegrationCatalogError`, so any caller that already
|
||||
catches ``IntegrationCatalogError`` keeps working — but
|
||||
callers that want to distinguish *local config* problems
|
||||
from *remote/network* problems can match the subclass.
|
||||
"""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
except (yaml.YAMLError, OSError, UnicodeError) as exc:
|
||||
raise IntegrationCatalogError(
|
||||
raise IntegrationValidationError(
|
||||
f"Failed to read catalog config {config_path}: {exc}"
|
||||
)
|
||||
) from exc
|
||||
if data is None:
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
raise IntegrationCatalogError(
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not isinstance(catalogs_data, list):
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid catalog config: 'catalogs' must be a list, "
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog config {config_path}: 'catalogs' must be a list, "
|
||||
f"got {type(catalogs_data).__name__}"
|
||||
)
|
||||
if not catalogs_data:
|
||||
raise IntegrationCatalogError(
|
||||
raise IntegrationValidationError(
|
||||
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
|
||||
f"Remove the file to use built-in defaults, or add valid catalog entries."
|
||||
)
|
||||
@@ -125,31 +137,52 @@ class IntegrationCatalog:
|
||||
skipped: List[int] = []
|
||||
for idx, item in enumerate(catalogs_data):
|
||||
if not isinstance(item, dict):
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid catalog entry at index {idx}: "
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog config {config_path}: catalog entry at index {idx}: "
|
||||
f"expected a mapping, got {type(item).__name__}"
|
||||
)
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
skipped.append(idx)
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raise IntegrationCatalogError(
|
||||
self._validate_catalog_url(url)
|
||||
except IntegrationCatalogError as exc:
|
||||
# ``_validate_catalog_url`` raises the base class for direct
|
||||
# callers (e.g. ``add_catalog`` validating user input); when
|
||||
# the bad URL came from a local config file, surface it as a
|
||||
# validation error so CLI handlers can route it accordingly.
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog URL in {config_path} at index {idx}: {exc}"
|
||||
) from exc
|
||||
raw_priority = item.get("priority", idx + 1)
|
||||
if isinstance(raw_priority, bool):
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog config {config_path}: "
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError):
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog config {config_path}: "
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {raw_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)
|
||||
raw_name = item.get("name")
|
||||
name = str(raw_name).strip() if raw_name is not None else ""
|
||||
if not name:
|
||||
name = f"catalog-{len(entries) + 1}"
|
||||
entries.append(
|
||||
IntegrationCatalogEntry(
|
||||
url=url,
|
||||
name=str(item.get("name", f"catalog-{idx + 1}")),
|
||||
name=name,
|
||||
priority=priority,
|
||||
install_allowed=install_allowed,
|
||||
description=str(item.get("description", "")),
|
||||
@@ -157,7 +190,7 @@ class IntegrationCatalog:
|
||||
)
|
||||
entries.sort(key=lambda e: e.priority)
|
||||
if not entries:
|
||||
raise IntegrationCatalogError(
|
||||
raise IntegrationValidationError(
|
||||
f"Catalog config {config_path} contains {len(catalogs_data)} "
|
||||
f"entries but none have valid URLs (entries at indices {skipped} "
|
||||
f"were skipped). Each catalog entry must have a 'url' field."
|
||||
@@ -196,12 +229,12 @@ class IntegrationCatalog:
|
||||
)
|
||||
]
|
||||
|
||||
project_cfg = self.project_root / ".specify" / "integration-catalogs.yml"
|
||||
project_cfg = self.project_root / ".specify" / self.CONFIG_FILENAME
|
||||
catalogs = self._load_catalog_config(project_cfg)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
user_cfg = Path.home() / ".specify" / "integration-catalogs.yml"
|
||||
user_cfg = Path.home() / ".specify" / self.CONFIG_FILENAME
|
||||
catalogs = self._load_catalog_config(user_cfg)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
@@ -408,6 +441,288 @@ class IntegrationCatalog:
|
||||
for f in self.cache_dir.glob(pattern):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
# -- Catalog-source management ----------------------------------------
|
||||
|
||||
CONFIG_FILENAME = "integration-catalogs.yml"
|
||||
|
||||
def get_catalog_configs(self) -> List[Dict[str, Any]]:
|
||||
"""Return the active catalog stack as a list of dicts.
|
||||
|
||||
Thin adapter over :meth:`get_active_catalogs` that yields plain dicts
|
||||
suitable for CLI rendering and JSON-like consumers.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"name": e.name,
|
||||
"url": e.url,
|
||||
"priority": e.priority,
|
||||
"install_allowed": e.install_allowed,
|
||||
"description": e.description,
|
||||
}
|
||||
for e in self.get_active_catalogs()
|
||||
]
|
||||
|
||||
def get_project_catalog_configs(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Return removable project-level catalog config entries, if configured."""
|
||||
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
|
||||
entries = self._load_catalog_config(config_path)
|
||||
if entries is None:
|
||||
return None
|
||||
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: Optional[str] = None) -> None:
|
||||
"""Add a catalog source to the project-level config file.
|
||||
|
||||
The URL is normalized (whitespace stripped) and validated before being
|
||||
written. Duplicate URLs are rejected, including near-duplicates that
|
||||
differ only by surrounding whitespace. Priority is derived as
|
||||
``max(existing) + 1`` so the new entry sorts last in the resolution
|
||||
order unless the user edits the file manually.
|
||||
"""
|
||||
url = url.strip()
|
||||
if not url:
|
||||
raise IntegrationValidationError("Catalog URL must be non-empty.")
|
||||
self._validate_catalog_url(url)
|
||||
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
|
||||
|
||||
data: Dict[str, Any] = {"catalogs": []}
|
||||
if config_path.exists():
|
||||
try:
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
except (yaml.YAMLError, OSError, UnicodeError) as exc:
|
||||
raise IntegrationValidationError(
|
||||
f"Failed to read catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
if raw is None:
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raise IntegrationValidationError(
|
||||
f"Catalog config file {config_path} is corrupted "
|
||||
"(expected a mapping)."
|
||||
)
|
||||
data = raw
|
||||
|
||||
catalogs = data.get("catalogs", [])
|
||||
if not isinstance(catalogs, list):
|
||||
raise IntegrationValidationError(
|
||||
f"Catalog config {config_path} has invalid 'catalogs' value: "
|
||||
"must be a list."
|
||||
)
|
||||
|
||||
# Validate each existing entry before mutating anything. Fail fast so
|
||||
# we don't silently preserve a corrupt sibling entry or derive a new
|
||||
# priority from a bogus value.
|
||||
existing_priorities: List[int] = []
|
||||
valid_catalog_count = 0
|
||||
for idx, cat in enumerate(catalogs):
|
||||
if not isinstance(cat, dict):
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog entry at index {idx} in {config_path}: "
|
||||
f"expected a mapping, got {type(cat).__name__}."
|
||||
)
|
||||
existing_url = str(cat.get("url", "")).strip()
|
||||
if not existing_url:
|
||||
continue
|
||||
# Re-run the same URL validation used when loading, so a corrupt
|
||||
# entry surfaces here instead of at the next `integration` call.
|
||||
try:
|
||||
self._validate_catalog_url(existing_url)
|
||||
except IntegrationCatalogError as exc:
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog entry at index {idx} in {config_path}: {exc}"
|
||||
) from exc
|
||||
if existing_url == url:
|
||||
raise IntegrationValidationError(
|
||||
f"Catalog URL already configured: {url}"
|
||||
)
|
||||
valid_catalog_count += 1
|
||||
if "priority" in cat:
|
||||
raw_priority = cat.get("priority")
|
||||
if isinstance(raw_priority, bool):
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog entry at index {idx} in {config_path}: "
|
||||
f"'priority' must be an integer, got "
|
||||
f"{type(raw_priority).__name__}."
|
||||
)
|
||||
try:
|
||||
normalized_priority = int(raw_priority)
|
||||
except (TypeError, ValueError):
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog entry at index {idx} in {config_path}: "
|
||||
f"'priority' must be an integer, got "
|
||||
f"{raw_priority!r}."
|
||||
) from None
|
||||
existing_priorities.append(normalized_priority)
|
||||
else:
|
||||
# Match `_load_catalog_config()`'s defaulting rule so the new
|
||||
# entry still sorts after implicit-priority siblings.
|
||||
existing_priorities.append(idx + 1)
|
||||
|
||||
max_priority = max(existing_priorities, default=0)
|
||||
normalized_name = str(name).strip() if name is not None else ""
|
||||
generated_name = f"catalog-{valid_catalog_count + 1}"
|
||||
catalogs.append(
|
||||
{
|
||||
"name": normalized_name or generated_name,
|
||||
"url": url,
|
||||
"priority": max_priority + 1,
|
||||
"install_allowed": True,
|
||||
"description": "",
|
||||
}
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
def remove_catalog(self, index: int) -> str:
|
||||
"""Remove a catalog source by 0-based index.
|
||||
|
||||
``index`` is interpreted in the same display order shown by
|
||||
``integration catalog list`` (i.e. sorted ascending by priority,
|
||||
with missing priority defaulting to ``yaml_index + 1``, matching
|
||||
``_load_catalog_config()``). This way, the index a user sees in
|
||||
``catalog list`` is the index they pass to ``catalog remove``,
|
||||
even if the underlying YAML lists entries in a different order
|
||||
from how they sort by priority.
|
||||
|
||||
Returns the removed catalog's name.
|
||||
"""
|
||||
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
|
||||
if not config_path.exists():
|
||||
raise IntegrationValidationError("No catalog config file found.")
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
except (yaml.YAMLError, OSError, UnicodeError) as exc:
|
||||
raise IntegrationValidationError(
|
||||
f"Failed to read catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
if data is None:
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
raise IntegrationValidationError(
|
||||
f"Catalog config file {config_path} is corrupted "
|
||||
"(expected a mapping)."
|
||||
)
|
||||
|
||||
catalogs = data.get("catalogs", [])
|
||||
if not isinstance(catalogs, list):
|
||||
raise IntegrationValidationError(
|
||||
f"Catalog config {config_path} has invalid 'catalogs' value: "
|
||||
"must be a list."
|
||||
)
|
||||
|
||||
if not catalogs:
|
||||
# An empty list is the kind of state that only happens if the
|
||||
# user hand-edited the file; our own `remove_catalog` deletes
|
||||
# the file when the last entry is popped. Surface a clear
|
||||
# message instead of `out of range (0--1)`.
|
||||
raise IntegrationValidationError(
|
||||
"Catalog config contains no catalog entries."
|
||||
)
|
||||
|
||||
# Map displayed index -> raw YAML index using the same priority
|
||||
# defaulting as ``_load_catalog_config``. We deliberately stay
|
||||
# tolerant here (no new validation errors) because the goal is
|
||||
# only to mirror the order shown by ``catalog list``; entries
|
||||
# that ``_load_catalog_config`` would have rejected outright
|
||||
# would have failed ``catalog list`` already.
|
||||
def _is_removable_catalog_entry(item: Any) -> bool:
|
||||
if not isinstance(item, dict):
|
||||
return False
|
||||
raw_url = item.get("url")
|
||||
if raw_url is None:
|
||||
return False
|
||||
return bool(str(raw_url).strip())
|
||||
|
||||
priority_pairs: List[Tuple[int, int]] = []
|
||||
for yaml_idx, item in enumerate(catalogs):
|
||||
if not _is_removable_catalog_entry(item):
|
||||
continue
|
||||
|
||||
raw_priority = item.get("priority", yaml_idx + 1)
|
||||
if isinstance(raw_priority, bool):
|
||||
priority = yaml_idx + 1
|
||||
else:
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError):
|
||||
priority = yaml_idx + 1
|
||||
priority_pairs.append((priority, yaml_idx))
|
||||
if not priority_pairs:
|
||||
raise IntegrationValidationError(
|
||||
"Catalog config contains no removable catalog entries."
|
||||
)
|
||||
# Stable sort: ties keep their YAML order, matching list-view ordering.
|
||||
priority_pairs.sort(key=lambda p: p[0])
|
||||
display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs]
|
||||
|
||||
if index < 0 or index >= len(display_order):
|
||||
raise IntegrationValidationError(
|
||||
f"Catalog index {index} out of range (0-{len(display_order) - 1})."
|
||||
)
|
||||
|
||||
target_yaml_idx = display_order[index]
|
||||
removed = catalogs.pop(target_yaml_idx)
|
||||
|
||||
if any(_is_removable_catalog_entry(item) for item in catalogs):
|
||||
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,
|
||||
)
|
||||
else:
|
||||
# Removing the final entry: delete the config file rather than
|
||||
# leaving behind an empty `catalogs:` list. `_load_catalog_config`
|
||||
# treats an empty list as an error, so leaving the file would
|
||||
# break every subsequent `integration` command until the user
|
||||
# manually deletes `.specify/integration-catalogs.yml`.
|
||||
# Deleting the file lets the project fall back to built-in
|
||||
# defaults, which matches the behavior before any
|
||||
# `catalog add` was ever run.
|
||||
try:
|
||||
config_path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
raise IntegrationValidationError(
|
||||
f"Failed to delete catalog config {config_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
fallback_name = f"catalog-{index + 1}"
|
||||
if isinstance(removed, dict):
|
||||
removed_name = removed.get("name")
|
||||
if removed_name is not None:
|
||||
normalized_name = str(removed_name).strip()
|
||||
if normalized_name:
|
||||
return normalized_name
|
||||
|
||||
removed_url = removed.get("url")
|
||||
if removed_url is not None:
|
||||
normalized_url = str(removed_url).strip()
|
||||
if normalized_url:
|
||||
return normalized_url
|
||||
return fallback_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationDescriptor (integration.yml)
|
||||
|
||||
@@ -5,6 +5,10 @@ Copilot has several unique behaviors compared to standard markdown agents:
|
||||
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
|
||||
- Installs ``.vscode/settings.json`` with prompt file recommendations
|
||||
- Context file lives at ``.github/copilot-instructions.md``
|
||||
|
||||
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
|
||||
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
|
||||
instead. The two modes are mutually exclusive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,7 +20,7 @@ import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationBase
|
||||
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
@@ -44,12 +48,40 @@ def _allow_all() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _CopilotSkillsHelper(SkillsIntegration):
|
||||
"""Internal helper used when Copilot is scaffolded in skills mode.
|
||||
|
||||
Not registered in the integration registry — only used as a delegate
|
||||
by ``CopilotIntegration`` when ``--skills`` is passed.
|
||||
"""
|
||||
|
||||
key = "copilot"
|
||||
config = {
|
||||
"name": "GitHub Copilot",
|
||||
"folder": ".github/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".github/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = ".github/copilot-instructions.md"
|
||||
|
||||
|
||||
class CopilotIntegration(IntegrationBase):
|
||||
"""Integration for GitHub Copilot (VS Code IDE + CLI).
|
||||
|
||||
The IDE integration (``requires_cli: False``) installs ``.agent.md``
|
||||
command files. Workflow dispatch additionally requires the
|
||||
``copilot`` CLI to be installed separately.
|
||||
|
||||
When ``--skills`` is passed via ``--integration-options``, commands
|
||||
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
|
||||
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
|
||||
"""
|
||||
|
||||
key = "copilot"
|
||||
@@ -68,6 +100,30 @@ class CopilotIntegration(IntegrationBase):
|
||||
}
|
||||
context_file = ".github/copilot-instructions.md"
|
||||
|
||||
# Mutable flag set by setup() — indicates the active scaffolding mode.
|
||||
_skills_mode: bool = False
|
||||
|
||||
def effective_invoke_separator(
|
||||
self, parsed_options: dict[str, Any] | None = None
|
||||
) -> str:
|
||||
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
|
||||
if parsed_options and parsed_options.get("skills"):
|
||||
return "-"
|
||||
if self._skills_mode:
|
||||
return "-"
|
||||
return self.invoke_separator
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
|
||||
),
|
||||
]
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -92,7 +148,19 @@ class CopilotIntegration(IntegrationBase):
|
||||
return args
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Copilot agents are not slash-commands — just return the args as prompt."""
|
||||
"""Build the native invocation for a Copilot command.
|
||||
|
||||
Default mode: agents are not slash-commands — return args as prompt.
|
||||
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
|
||||
"""
|
||||
if self._skills_mode:
|
||||
stem = command_name
|
||||
if stem.startswith("speckit."):
|
||||
stem = stem[len("speckit."):]
|
||||
invocation = "/speckit-" + stem.replace(".", "-")
|
||||
if args:
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
return args or ""
|
||||
|
||||
def dispatch_command(
|
||||
@@ -110,19 +178,37 @@ class CopilotIntegration(IntegrationBase):
|
||||
Copilot ``.agent.md`` files are agents, not skills. The CLI
|
||||
selects them with ``--agent <name>`` and the prompt is just
|
||||
the user's arguments.
|
||||
|
||||
In skills mode, the prompt includes the skill invocation
|
||||
(``/speckit-<stem>``).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
stem = command_name
|
||||
if "." in stem:
|
||||
stem = stem.rsplit(".", 1)[-1]
|
||||
agent_name = f"speckit.{stem}"
|
||||
if stem.startswith("speckit."):
|
||||
stem = stem[len("speckit."):]
|
||||
|
||||
prompt = args or ""
|
||||
cli_args = [
|
||||
"copilot", "-p", prompt,
|
||||
"--agent", agent_name,
|
||||
]
|
||||
# Detect skills mode from project layout when not set via setup()
|
||||
skills_mode = self._skills_mode
|
||||
if not skills_mode and project_root:
|
||||
skills_dir = project_root / ".github" / "skills"
|
||||
if skills_dir.is_dir():
|
||||
skills_mode = any(
|
||||
d.is_dir() and (d / "SKILL.md").is_file()
|
||||
for d in skills_dir.glob("speckit-*")
|
||||
)
|
||||
|
||||
if skills_mode:
|
||||
prompt = "/speckit-" + stem.replace(".", "-")
|
||||
if args:
|
||||
prompt = f"{prompt} {args}"
|
||||
else:
|
||||
agent_name = f"speckit.{stem}"
|
||||
prompt = args or ""
|
||||
|
||||
cli_args = ["copilot", "-p", prompt]
|
||||
if not skills_mode:
|
||||
cli_args.extend(["--agent", agent_name])
|
||||
if _allow_all():
|
||||
cli_args.append("--yolo")
|
||||
if model:
|
||||
@@ -168,6 +254,59 @@ class CopilotIntegration(IntegrationBase):
|
||||
"""Copilot commands use ``.agent.md`` extension."""
|
||||
return f"speckit.{template_name}.agent.md"
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
|
||||
|
||||
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
|
||||
Copilot can associate the skill with its agent mode.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Extract skill name from frontmatter to derive the mode value
|
||||
dash_count = 0
|
||||
skill_name = ""
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1:
|
||||
if stripped.startswith("mode:"):
|
||||
return content # already present
|
||||
if stripped.startswith("name:"):
|
||||
# Parse: name: "speckit-plan" → speckit.plan
|
||||
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
# Convert speckit-plan → speckit.plan
|
||||
if val.startswith("speckit-"):
|
||||
skill_name = "speckit." + val[len("speckit-"):]
|
||||
else:
|
||||
skill_name = val
|
||||
|
||||
if not skill_name:
|
||||
return content
|
||||
|
||||
# Inject mode: before the closing --- of frontmatter
|
||||
out: list[str] = []
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2 and not injected:
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
elif line.endswith("\n"):
|
||||
eol = "\n"
|
||||
else:
|
||||
eol = ""
|
||||
out.append(f"mode: {skill_name}{eol}")
|
||||
injected = True
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -177,10 +316,24 @@ class CopilotIntegration(IntegrationBase):
|
||||
) -> list[Path]:
|
||||
"""Install copilot commands, companion prompts, and VS Code settings.
|
||||
|
||||
Uses base class primitives to: read templates, process them
|
||||
(replace placeholders, strip script blocks, rewrite paths),
|
||||
write as ``.agent.md``, then add companion prompts and VS Code settings.
|
||||
When ``parsed_options["skills"]`` is truthy, delegates to skills
|
||||
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
|
||||
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
|
||||
"""
|
||||
parsed_options = parsed_options or {}
|
||||
self._skills_mode = bool(parsed_options.get("skills"))
|
||||
if self._skills_mode:
|
||||
return self._setup_skills(project_root, manifest, parsed_options, **opts)
|
||||
return self._setup_default(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
def _setup_default(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
|
||||
project_root_resolved = project_root.resolve()
|
||||
if manifest.project_root != project_root_resolved:
|
||||
raise ValueError(
|
||||
@@ -252,6 +405,37 @@ class CopilotIntegration(IntegrationBase):
|
||||
|
||||
return created
|
||||
|
||||
def _setup_skills(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process."""
|
||||
helper = _CopilotSkillsHelper()
|
||||
created = SkillsIntegration.setup(
|
||||
helper, project_root, manifest, parsed_options, **opts
|
||||
)
|
||||
|
||||
# Post-process generated skill files with Copilot-specific frontmatter
|
||||
skills_dir = helper.skills_dest(project_root).resolve()
|
||||
for path in created:
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
updated = self.post_process_skill_content(content)
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
def _vscode_settings_path(self) -> Path | None:
|
||||
"""Return path to the bundled vscode-settings.json template."""
|
||||
tpl_dir = self.shared_templates_dir()
|
||||
|
||||
65
src/specify_cli/integrations/devin/__init__.py
Normal file
65
src/specify_cli/integrations/devin/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Devin for Terminal integration — skills-based agent.
|
||||
|
||||
Devin uses the ``.devin/skills/speckit-<name>/SKILL.md`` layout and
|
||||
reads project context from ``AGENTS.md`` at the repo root. The CLI
|
||||
binary is ``devin`` and skills are invoked via ``/<name>`` inside an
|
||||
interactive ``devin`` session.
|
||||
|
||||
See: https://cli.devin.ai/docs/extensibility/skills/overview
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
|
||||
class DevinIntegration(SkillsIntegration):
|
||||
"""Integration for Cognition AI's Devin for Terminal."""
|
||||
|
||||
key = "devin"
|
||||
config = {
|
||||
"name": "Devin for Terminal",
|
||||
"folder": ".devin/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://cli.devin.ai/docs",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".devin/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
"""Build non-interactive CLI args for Devin for Terminal.
|
||||
|
||||
Devin supports ``devin -p <prompt>`` for single-turn execution
|
||||
and ``--model`` for model selection, but its CLI has no flag
|
||||
for structured JSON output. When ``output_json`` is requested,
|
||||
Devin is still dispatched normally and returns plain-text
|
||||
stdout instead of structured JSON. ``requires_cli=True`` is
|
||||
kept on the integration for tool detection.
|
||||
"""
|
||||
args = [self.key, "-p", prompt]
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
return args
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Devin)",
|
||||
),
|
||||
]
|
||||
@@ -19,3 +19,27 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
args = [self.key, "run"]
|
||||
|
||||
message = prompt
|
||||
if prompt.startswith("/"):
|
||||
command, _, remainder = prompt[1:].partition(" ")
|
||||
if command:
|
||||
args.extend(["--command", command])
|
||||
message = remainder
|
||||
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
if output_json:
|
||||
args.extend(["--format", "json"])
|
||||
if message:
|
||||
args.append(message)
|
||||
return args
|
||||
|
||||
@@ -1,21 +1,133 @@
|
||||
"""Mistral Vibe CLI integration."""
|
||||
"""
|
||||
Mistral Vibe CLI integration — skills-based agent.
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
Vibe uses ``.vibe/skills/speckit-<name>/SKILL.md`` layout (enforced since v2.0.0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class VibeIntegration(MarkdownIntegration):
|
||||
class VibeIntegration(SkillsIntegration):
|
||||
key = "vibe"
|
||||
config = {
|
||||
"name": "Mistral Vibe",
|
||||
"folder": ".vibe/",
|
||||
"commands_subdir": "prompts",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://github.com/mistralai/mistral-vibe",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".vibe/prompts",
|
||||
"dir": ".vibe/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = ".vibe/agents/specify-agents.md"
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills",
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
|
||||
"""
|
||||
Insert ``key: value`` before the closing ``---`` if not already present.
|
||||
Value: true by default
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Pre-scan: bail out if already present in frontmatter
|
||||
dash_count = 0
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1 and stripped.startswith(f"{key}:"):
|
||||
return content
|
||||
|
||||
# Inject before the closing --- of frontmatter
|
||||
out: list[str] = []
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2 and not injected:
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
elif line.endswith("\n"):
|
||||
eol = "\n"
|
||||
else:
|
||||
eol = ""
|
||||
out.append(f"{key}: {value}{eol}")
|
||||
injected = True
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""
|
||||
Inject Vibe-specific frontmatter flags:
|
||||
- user-invocable: allows the skill to be invoked by the user (not just other agents)
|
||||
"""
|
||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
||||
return updated
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Vibe skills then inject Vibe-specific flags"""
|
||||
import click
|
||||
|
||||
click.secho(
|
||||
"Warning: The .vibe/skills layout requires Mistral Vibe v2.0.0 or newer. "
|
||||
"Please ensure your installation is up to date.",
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
|
||||
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||
|
||||
# Post-process generated skill files
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
|
||||
for path in created:
|
||||
# Only touch SKILL.md files under the skills directory
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
updated = self.post_process_skill_content(content)
|
||||
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,13 @@
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
||||
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||
|
||||
The /speckit.checklist command MUST replace these with actual items based on:
|
||||
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
|
||||
- User's specific checklist request
|
||||
- Feature requirements from spec.md
|
||||
- Technical context from plan.md
|
||||
|
||||
@@ -49,13 +49,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `__SPECKIT_COMMAND_TASKS__` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `__SPECKIT_COMMAND_ANALYZE__`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
@@ -191,9 +191,9 @@ Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
||||
- If CRITICAL issues exist: Recommend resolving before `__SPECKIT_COMMAND_IMPLEMENT__`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
- Provide explicit command suggestions: e.g., "Run __SPECKIT_COMMAND_SPECIFY__ with refinement", "Run __SPECKIT_COMMAND_PLAN__ to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||
**Important**: Each `__SPECKIT_COMMAND_CHECKLIST__` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
|
||||
@@ -55,7 +55,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `__SPECKIT_COMMAND_PLAN__`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
@@ -63,7 +63,7 @@ Execution steps:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
||||
- If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
@@ -202,13 +202,13 @@ Execution steps:
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
||||
- If spec file missing, instruct user to run `__SPECKIT_COMMAND_SPECIFY__` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
|
||||
@@ -169,7 +169,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `__SPECKIT_COMMAND_TASKS__` first to regenerate the task list.
|
||||
|
||||
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||
|
||||
@@ -54,7 +54,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
The text the user typed after `__SPECKIT_COMMAND_SPECIFY__` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
@@ -100,10 +100,10 @@ Given that feature description, do this:
|
||||
}
|
||||
```
|
||||
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||
This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||
This allows downstream commands (`__SPECKIT_COMMAND_PLAN__`, `__SPECKIT_COMMAND_TASKS__`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||
|
||||
**IMPORTANT**:
|
||||
- You must only create one feature per `/speckit.specify` invocation
|
||||
- You must only create one feature per `__SPECKIT_COMMAND_SPECIFY__` invocation
|
||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||
- The spec directory and file are always created by this command, never by the hook
|
||||
|
||||
@@ -174,7 +174,7 @@ Given that feature description, do this:
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
@@ -183,7 +183,7 @@ Given that feature description, do this:
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 7
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 8
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
@@ -232,7 +232,7 @@ Given that feature description, do this:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`)
|
||||
- Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
|
||||
|
||||
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -39,12 +39,12 @@
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
├── plan.md # This file (__SPECKIT_COMMAND_PLAN__ command output)
|
||||
├── research.md # Phase 0 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||
├── data-model.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||
├── quickstart.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||
├── contracts/ # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||
└── tasks.md # Phase 2 output (__SPECKIT_COMMAND_TASKS__ command - NOT created by __SPECKIT_COMMAND_PLAN__)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
@@ -29,7 +29,7 @@ description: "Task list template for feature implementation"
|
||||
============================================================================
|
||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||
|
||||
The /speckit.tasks command MUST replace these with actual tasks based on:
|
||||
The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
|
||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||
- Feature requirements from plan.md
|
||||
- Entities from data-model.md
|
||||
|
||||
@@ -6,6 +6,7 @@ from specify_cli.integrations.base import (
|
||||
IntegrationBase,
|
||||
IntegrationOption,
|
||||
MarkdownIntegration,
|
||||
SkillsIntegration,
|
||||
)
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
from .conftest import StubIntegration
|
||||
@@ -167,3 +168,130 @@ class TestBasePrimitives:
|
||||
assert f.parent.name == "commands"
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
|
||||
class TestBuildCommandInvocation:
|
||||
"""Tests for build_command_invocation across integration types."""
|
||||
|
||||
def test_base_core_command_dotted(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("speckit.plan") == "/speckit.plan"
|
||||
|
||||
def test_base_core_command_bare(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("plan") == "/speckit.plan"
|
||||
|
||||
def test_base_core_command_with_args(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature"
|
||||
|
||||
def test_base_extension_command(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit"
|
||||
|
||||
def test_base_extension_command_bare(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("git.commit") == "/speckit.git.commit"
|
||||
|
||||
def test_skills_core_command(self):
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("codex")
|
||||
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
|
||||
assert i.build_command_invocation("plan") == "/speckit-plan"
|
||||
|
||||
def test_skills_extension_command(self):
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("codex")
|
||||
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
||||
assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
|
||||
|
||||
def test_skills_extension_command_with_args(self):
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("codex")
|
||||
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
|
||||
|
||||
|
||||
class TestResolveCommandRefs:
|
||||
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""
|
||||
|
||||
def test_dot_separator_core_command(self):
|
||||
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "Run `/speckit.plan` to plan."
|
||||
|
||||
def test_hyphen_separator_core_command(self):
|
||||
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
|
||||
result = IntegrationBase.resolve_command_refs(text, "-")
|
||||
assert result == "Run `/speckit-plan` to plan."
|
||||
|
||||
def test_multiple_placeholders(self):
|
||||
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "/speckit.specify then /speckit.plan then /speckit.tasks"
|
||||
|
||||
def test_extension_command_dot(self):
|
||||
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "Run /speckit.git.commit to commit."
|
||||
|
||||
def test_extension_command_hyphen(self):
|
||||
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
|
||||
result = IntegrationBase.resolve_command_refs(text, "-")
|
||||
assert result == "Run /speckit-git-commit to commit."
|
||||
|
||||
def test_no_placeholders_unchanged(self):
|
||||
text = "No placeholders here."
|
||||
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||
|
||||
def test_default_separator_is_dot(self):
|
||||
text = "__SPECKIT_COMMAND_PLAN__"
|
||||
assert IntegrationBase.resolve_command_refs(text) == "/speckit.plan"
|
||||
|
||||
def test_invoke_separator_class_attribute(self):
|
||||
assert IntegrationBase.invoke_separator == "."
|
||||
assert SkillsIntegration.invoke_separator == "-"
|
||||
|
||||
def test_effective_invoke_separator_default(self):
|
||||
"""Base classes return invoke_separator regardless of parsed_options."""
|
||||
from .conftest import StubIntegration
|
||||
stub = StubIntegration()
|
||||
assert stub.effective_invoke_separator() == "."
|
||||
assert stub.effective_invoke_separator({"skills": True}) == "."
|
||||
|
||||
def test_process_template_resolves_placeholders(self):
|
||||
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
|
||||
result = IntegrationBase.process_template(
|
||||
content, "test-agent", "sh", invoke_separator="."
|
||||
)
|
||||
assert "/speckit.plan" in result
|
||||
assert "__SPECKIT_COMMAND_" not in result
|
||||
|
||||
def test_process_template_skills_separator(self):
|
||||
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
|
||||
result = IntegrationBase.process_template(
|
||||
content, "test-agent", "sh", invoke_separator="-"
|
||||
)
|
||||
assert "/speckit-plan" in result
|
||||
assert "__SPECKIT_COMMAND_" not in result
|
||||
|
||||
def test_unclosed_placeholder_unchanged(self):
|
||||
text = "Run __SPECKIT_COMMAND_PLAN to plan."
|
||||
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||
|
||||
def test_empty_name_not_matched(self):
|
||||
text = "Run __SPECKIT_COMMAND___ to plan."
|
||||
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||
|
||||
def test_lowercase_placeholder_not_matched(self):
|
||||
text = "Run __SPECKIT_COMMAND_plan__ to plan."
|
||||
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||
|
||||
def test_placeholder_adjacent_to_text(self):
|
||||
text = "foo__SPECKIT_COMMAND_PLAN__bar"
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "foo/speckit.planbar"
|
||||
|
||||
def test_placeholder_with_digits(self):
|
||||
text = "__SPECKIT_COMMAND_V2_PLAN__"
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "/speckit.v2.plan"
|
||||
|
||||
@@ -112,7 +112,7 @@ class TestInitIntegrationFlag:
|
||||
assert "--ai" in normalized_output
|
||||
assert "deprecated" in normalized_output
|
||||
assert "no longer be available" in normalized_output
|
||||
assert "1.0.0" in normalized_output
|
||||
assert "0.10.0" in normalized_output
|
||||
assert "--integration copilot" in normalized_output
|
||||
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
@@ -173,13 +173,13 @@ class TestInitIntegrationFlag:
|
||||
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_shared_infra_skips_existing_files(self, tmp_path):
|
||||
"""Pre-existing shared files are not overwritten by _install_shared_infra."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
|
||||
"""Pre-existing shared files are not overwritten without --force."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "skip-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
# Pre-create a shared script with custom content
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
@@ -193,6 +193,97 @@ class TestInitIntegrationFlag:
|
||||
custom_template = "# user-modified spec-template\n"
|
||||
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
# User's files should be preserved (not overwritten)
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
|
||||
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
|
||||
|
||||
# Other shared files should still be installed
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
|
||||
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
|
||||
"""Pre-existing shared files ARE overwritten when force=True."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "force-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
# Pre-create a shared script with custom content
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
custom_content = "# user-modified common.sh\n"
|
||||
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
||||
|
||||
# Pre-create a shared template with custom content
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
custom_template = "# user-modified spec-template\n"
|
||||
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
# Files should be overwritten with bundled versions
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template
|
||||
|
||||
# Other shared files should also be installed
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
|
||||
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
|
||||
"""Console warning is displayed when files are skipped."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "warn-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "already exist and were not updated" in captured.out
|
||||
assert "specify init --here --force" in captured.out
|
||||
# Rich may wrap long lines; normalize whitespace for the second command
|
||||
normalized = " ".join(captured.out.split())
|
||||
assert "specify integration upgrade --force" in normalized
|
||||
|
||||
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
|
||||
"""No skip warning when force=True (all files overwritten)."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "no-warn-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "already exist and were not updated" not in captured.out
|
||||
|
||||
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
|
||||
"""E2E: specify init --here --force overwrites shared infra files."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "e2e-force"
|
||||
project.mkdir()
|
||||
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
custom_content = "# user-modified common.sh\n"
|
||||
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
@@ -207,14 +298,40 @@ class TestInitIntegrationFlag:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0
|
||||
# --force should overwrite the custom file
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||
|
||||
# User's files should be preserved
|
||||
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
|
||||
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "e2e-no-force"
|
||||
project.mkdir()
|
||||
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
custom_content = "# user-modified common.sh\n"
|
||||
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
], input="y\n", catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Without --force, custom file should be preserved
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
|
||||
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
|
||||
|
||||
# Other shared files should still be installed
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
# Warning about skipped files should appear
|
||||
assert "not updated" in result.output
|
||||
|
||||
|
||||
class TestForceExistingDirectory:
|
||||
@@ -261,7 +378,7 @@ class TestForceExistingDirectory:
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in result.output
|
||||
assert "already exists" in _normalize_cli_output(result.output)
|
||||
|
||||
|
||||
class TestGitExtensionAutoInstall:
|
||||
@@ -329,6 +446,33 @@ class TestGitExtensionAutoInstall:
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
|
||||
|
||||
def test_no_git_emits_deprecation_warning(self, tmp_path):
|
||||
"""Using --no-git emits a visible deprecation warning."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-git-warn"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--no-git" in normalized_output
|
||||
assert "deprecated" in normalized_output
|
||||
assert "0.10.0" in normalized_output
|
||||
assert "specify extension" in normalized_output
|
||||
assert "will be removed" in normalized_output
|
||||
assert "git extension will no longer be enabled by default" in normalized_output
|
||||
|
||||
def test_git_extension_commands_registered(self, tmp_path):
|
||||
"""Git extension commands are registered with the agent during init."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -354,3 +498,680 @@ class TestGitExtensionAutoInstall:
|
||||
assert claude_skills.exists(), "Claude skills directory was not created"
|
||||
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
||||
assert len(git_skills) > 0, "no git extension commands registered"
|
||||
|
||||
|
||||
class TestSharedInfraCommandRefs:
|
||||
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
|
||||
|
||||
def test_dot_separator_in_page_templates(self, tmp_path):
|
||||
"""Markdown agents get /speckit.<name> in page templates."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "dot-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
_install_shared_infra(project, "sh", invoke_separator=".")
|
||||
|
||||
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert plan.exists()
|
||||
content = plan.read_text(encoding="utf-8")
|
||||
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
|
||||
assert "/speckit.plan" in content
|
||||
|
||||
checklist = project / ".specify" / "templates" / "checklist-template.md"
|
||||
content = checklist.read_text(encoding="utf-8")
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
assert "/speckit.checklist" in content
|
||||
|
||||
def test_hyphen_separator_in_page_templates(self, tmp_path):
|
||||
"""Skills agents get /speckit-<name> in page templates."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "hyphen-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
_install_shared_infra(project, "sh", invoke_separator="-")
|
||||
|
||||
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert plan.exists()
|
||||
content = plan.read_text(encoding="utf-8")
|
||||
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
|
||||
assert "/speckit-plan" in content
|
||||
assert "/speckit.plan" not in content, "dot-notation leaked into skills page template"
|
||||
|
||||
tasks = project / ".specify" / "templates" / "tasks-template.md"
|
||||
content = tasks.read_text(encoding="utf-8")
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
assert "/speckit-tasks" in content
|
||||
|
||||
def test_full_init_claude_resolves_page_templates(self, tmp_path):
|
||||
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "init-claude"
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project),
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||
content = plan.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
|
||||
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "init-copilot"
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project),
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||
content = plan.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
|
||||
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "init-copilot-skills"
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project),
|
||||
"--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||
content = plan.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
|
||||
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
|
||||
class TestIntegrationCatalogDiscoveryCLI:
|
||||
"""End-to-end CLI tests for `integration search`, `info`, and `catalog …`.
|
||||
|
||||
All tests patch `IntegrationCatalog._get_merged_integrations` so no network
|
||||
or on-disk cache is touched. Adds #2344 coverage without affecting any
|
||||
existing integration install/switch/uninstall/upgrade behavior.
|
||||
"""
|
||||
|
||||
FAKE_INTEGRATIONS = [
|
||||
{
|
||||
"id": "acme-coder",
|
||||
"name": "Acme Coder",
|
||||
"version": "2.0.0",
|
||||
"description": "Community integration for Acme Coder",
|
||||
"author": "acme-org",
|
||||
"tags": ["cli", "acme"],
|
||||
"_catalog_name": "community",
|
||||
"_install_allowed": False,
|
||||
},
|
||||
{
|
||||
"id": "stellar-agent",
|
||||
"name": "Stellar Agent",
|
||||
"version": "1.3.0",
|
||||
"description": "First-party Stellar agent integration",
|
||||
"author": "stellar-labs",
|
||||
"tags": ["ide"],
|
||||
"_catalog_name": "default",
|
||||
"_install_allowed": True,
|
||||
},
|
||||
]
|
||||
|
||||
def _make_project(self, tmp_path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
return project
|
||||
|
||||
def _patch_catalog(self, monkeypatch, integrations=None):
|
||||
"""Return a stubbed `_get_merged_integrations` that yields *integrations*."""
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
|
||||
data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS)
|
||||
|
||||
def fake_merged(self, force_refresh=False):
|
||||
return data
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged)
|
||||
|
||||
def _invoke(self, argv, cwd):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(cwd)
|
||||
return runner.invoke(app, argv, catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
# -- Project guard -----------------------------------------------------
|
||||
|
||||
def test_search_requires_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_catalog_list_requires_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
# -- search ------------------------------------------------------------
|
||||
|
||||
def test_search_lists_all(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Found 2 integration(s)" in result.output
|
||||
assert "acme-coder" in result.output
|
||||
assert "stellar-agent" in result.output
|
||||
assert "specify integration install stellar-agent" not in normalized_output
|
||||
assert "Only built-in integration IDs can be installed" in normalized_output
|
||||
|
||||
def test_search_validates_integration_json_before_catalog_lookup(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
"{bad json\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
|
||||
def fail_search(self, **kwargs):
|
||||
raise AssertionError("catalog search should not be called")
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "contains invalid JSON" in normalized_output
|
||||
assert "integration.json" in normalized_output
|
||||
|
||||
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(["integration", "search", "--tag", "acme"], project)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Found 1 integration(s)" in result.output
|
||||
assert "acme-coder" in result.output
|
||||
assert "stellar-agent" not in result.output
|
||||
|
||||
def test_search_filters_by_author(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(
|
||||
["integration", "search", "--author", "stellar-labs"], project
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Found 1 integration(s)" in result.output
|
||||
assert "stellar-agent" in result.output
|
||||
|
||||
def test_search_no_match_hint(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(
|
||||
["integration", "search", "--tag", "nope"], project
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No integrations found" in result.output
|
||||
assert "specify integration search" in result.output
|
||||
|
||||
def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(["integration", "search", "acme"], project)
|
||||
assert result.exit_code == 0, result.output
|
||||
# acme-coder is flagged _install_allowed=False, so we should warn
|
||||
assert "Not directly installable" in result.output
|
||||
|
||||
# -- info --------------------------------------------------------------
|
||||
|
||||
def test_info_found(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(
|
||||
["integration", "info", "stellar-agent"], project
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Stellar Agent" in result.output
|
||||
assert "stellar-agent" in result.output
|
||||
assert "v1.3.0" in result.output
|
||||
|
||||
def test_info_not_found(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(
|
||||
["integration", "info", "does-not-exist"], project
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "not found" in result.output
|
||||
|
||||
def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
# Empty catalog, but copilot is a registered built-in.
|
||||
self._patch_catalog(monkeypatch, integrations=[])
|
||||
result = self._invoke(["integration", "info", "copilot"], project)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Built-in integration" in result.output
|
||||
|
||||
# -- validation vs network guidance ------------------------------------
|
||||
|
||||
def test_search_local_config_error_shows_local_config_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""`integration search` must point at .specify/integration-catalogs.yml
|
||||
for local-config errors (not the generic 'temporarily unavailable')."""
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
# Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError.
|
||||
cfg = project / ".specify" / "integration-catalogs.yml"
|
||||
invalid_yaml = "catalogs:\n - [bad\n"
|
||||
cfg.write_text(invalid_yaml, encoding="utf-8")
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "configuration file path shown above" in normalized_output
|
||||
assert ".specify/integration-catalogs.yml" in normalized_output
|
||||
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
||||
assert "temporarily unavailable" not in normalized_output
|
||||
|
||||
def test_search_invalid_env_catalog_url_shows_env_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL",
|
||||
"http://insecure.example.com/catalog.json",
|
||||
)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output
|
||||
assert "unset it to use the configured catalog files" in normalized_output
|
||||
assert ".specify/integration-catalogs.yml" in normalized_output
|
||||
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
||||
assert "temporarily unavailable" not in normalized_output
|
||||
|
||||
def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ")
|
||||
|
||||
from specify_cli.integrations.catalog import (
|
||||
IntegrationCatalog,
|
||||
IntegrationCatalogError,
|
||||
)
|
||||
|
||||
def fail_search(self, **kwargs):
|
||||
raise IntegrationCatalogError("catalog offline")
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "temporarily unavailable" in normalized_output
|
||||
assert (
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL environment variable"
|
||||
not in normalized_output
|
||||
)
|
||||
|
||||
def test_info_unknown_with_local_config_error_shows_local_config_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""`integration info <unknown>` falls back to the catalog-error branch
|
||||
and must show local-config guidance, not 'Try again when online'."""
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
cfg = project / ".specify" / "integration-catalogs.yml"
|
||||
invalid_yaml = "catalogs:\n - [bad\n"
|
||||
cfg.write_text(invalid_yaml, encoding="utf-8")
|
||||
|
||||
result = self._invoke(
|
||||
["integration", "info", "definitely-not-real"], project
|
||||
)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "configuration file path shown above" in normalized_output
|
||||
assert ".specify/integration-catalogs.yml" in normalized_output
|
||||
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
||||
assert "Try again when online" not in normalized_output
|
||||
|
||||
def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL",
|
||||
"http://insecure.example.com/catalog.json",
|
||||
)
|
||||
|
||||
result = self._invoke(
|
||||
["integration", "info", "definitely-not-real"], project
|
||||
)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output
|
||||
assert "unset it to use the configured catalog files" in normalized_output
|
||||
assert "Try again when online" not in normalized_output
|
||||
|
||||
# -- catalog list / add / remove ---------------------------------------
|
||||
|
||||
def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Integration Catalog Sources" in result.output
|
||||
assert "No project-level catalog sources configured" in result.output
|
||||
assert "Active catalog sources" in result.output
|
||||
assert "non-removable" in result.output
|
||||
assert "default" in result.output
|
||||
assert "community" in result.output
|
||||
# Built-in defaults are active, but not removable project entries.
|
||||
assert "[0]" not in result.output
|
||||
assert "[1]" not in result.output
|
||||
|
||||
def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
|
||||
add_result = self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
"https://new.example.com/catalog.json",
|
||||
"--name",
|
||||
"mine",
|
||||
],
|
||||
project,
|
||||
)
|
||||
assert add_result.exit_code == 0, add_result.output
|
||||
assert "Catalog source added" in add_result.output
|
||||
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
assert cfg_path.exists()
|
||||
|
||||
list_result = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert list_result.exit_code == 0, list_result.output
|
||||
assert "Project catalog sources" in list_result.output
|
||||
assert "[0]" in list_result.output
|
||||
assert "mine" in list_result.output
|
||||
assert "default" not in list_result.output
|
||||
assert "community" not in list_result.output
|
||||
|
||||
remove_result = self._invoke(
|
||||
["integration", "catalog", "remove", "0"], project
|
||||
)
|
||||
assert remove_result.exit_code == 0, remove_result.output
|
||||
assert "'mine' removed" in remove_result.output
|
||||
|
||||
def test_catalog_list_normalizes_blank_project_catalog_names(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://null-name.example.com/catalog.json",
|
||||
"name": None,
|
||||
},
|
||||
{
|
||||
"url": "https://blank-name.example.com/catalog.json",
|
||||
"name": " ",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "[0] catalog-1" in normalized_output
|
||||
assert "[1] catalog-2" in normalized_output
|
||||
assert "None" not in normalized_output
|
||||
|
||||
def test_catalog_list_env_override_supersedes_project_config(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL",
|
||||
"https://env.example.com/catalog.json",
|
||||
)
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://project.example.com/catalog.json",
|
||||
"name": "project",
|
||||
"priority": 1,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output
|
||||
assert "supersedes configured catalog files" in normalized_output
|
||||
assert "non-removable" in normalized_output
|
||||
assert "https://env.example.com/catalog.json" in normalized_output
|
||||
assert "https://project.example.com/catalog.json" not in normalized_output
|
||||
assert "[0]" not in normalized_output
|
||||
|
||||
def test_catalog_add_strips_whitespace_in_success_output_and_storage(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Surrounding whitespace in the URL must not appear in the success
|
||||
message or be persisted to the YAML config."""
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
|
||||
padded_url = " https://padded.example.com/catalog.json "
|
||||
clean_url = "https://padded.example.com/catalog.json"
|
||||
|
||||
add_result = self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
padded_url,
|
||||
"--name",
|
||||
"padded",
|
||||
],
|
||||
project,
|
||||
)
|
||||
assert add_result.exit_code == 0, add_result.output
|
||||
assert clean_url in add_result.output
|
||||
assert padded_url not in add_result.output
|
||||
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
import yaml as _yaml
|
||||
data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
urls = [c["url"] for c in data["catalogs"]]
|
||||
assert clean_url in urls
|
||||
assert padded_url not in urls
|
||||
|
||||
def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
result = self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
"http://insecure.example.com/catalog.json",
|
||||
],
|
||||
project,
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "HTTPS" in result.output
|
||||
|
||||
def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
url = "https://dup.example.com/catalog.json"
|
||||
first = self._invoke(
|
||||
["integration", "catalog", "add", url], project
|
||||
)
|
||||
assert first.exit_code == 0, first.output
|
||||
second = self._invoke(
|
||||
["integration", "catalog", "add", url], project
|
||||
)
|
||||
assert second.exit_code == 1
|
||||
assert "already configured" in second.output
|
||||
|
||||
def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
# Need a config file for remove to attempt an index lookup
|
||||
self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
"https://only.example.com/catalog.json",
|
||||
],
|
||||
project,
|
||||
)
|
||||
result = self._invoke(
|
||||
["integration", "catalog", "remove", "9"], project
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "out of range" in result.output
|
||||
|
||||
def test_catalog_remove_without_config(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
result = self._invoke(
|
||||
["integration", "catalog", "remove", "0"], project
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "No catalog config" in result.output
|
||||
|
||||
def test_catalog_remove_final_entry_restores_defaults(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""End-to-end: add → remove-last-entry → list should not error.
|
||||
|
||||
Regression for the flow where a user adds a catalog, removes it, then
|
||||
runs any follow-up integration command. Without the fix the config
|
||||
file would be left as `catalogs: []` and every subsequent
|
||||
`integration` call would fail with "contains no 'catalogs' entries".
|
||||
"""
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
|
||||
add = self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
"https://only.example.com/catalog.json",
|
||||
"--name",
|
||||
"only",
|
||||
],
|
||||
project,
|
||||
)
|
||||
assert add.exit_code == 0, add.output
|
||||
|
||||
remove = self._invoke(
|
||||
["integration", "catalog", "remove", "0"], project
|
||||
)
|
||||
assert remove.exit_code == 0, remove.output
|
||||
assert "'only' removed" in remove.output
|
||||
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
assert not cfg_path.exists(), (
|
||||
"config file should be deleted when the final catalog is removed"
|
||||
)
|
||||
|
||||
# Follow-up command must succeed and show the built-in defaults,
|
||||
# not error out on "contains no 'catalogs' entries".
|
||||
listing = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert listing.exit_code == 0, listing.output
|
||||
assert "default" in listing.output
|
||||
assert "community" in listing.output
|
||||
|
||||
@@ -98,6 +98,7 @@ class MarkdownIntegrationTests:
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
|
||||
@@ -159,6 +159,22 @@ class SkillsIntegrationTests:
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_command_refs_use_hyphen_separator(self, tmp_path):
|
||||
"""Skills agents must resolve command refs with hyphen separator."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
# Skills agents must use /speckit-<name>, not /speckit.<name>
|
||||
assert "/speckit." not in content, (
|
||||
f"{f.name} contains dot-notation /speckit. reference; "
|
||||
f"skills agents must use /speckit-<name>"
|
||||
)
|
||||
|
||||
def test_skill_body_has_content(self, tmp_path):
|
||||
"""Each SKILL.md body should contain template content after the frontmatter."""
|
||||
|
||||
@@ -106,6 +106,7 @@ class TomlIntegrationTests:
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_toml_has_description(self, tmp_path):
|
||||
"""Every TOML command file should have a description key."""
|
||||
|
||||
@@ -105,6 +105,7 @@ class YamlIntegrationTests:
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_yaml_has_title(self, tmp_path):
|
||||
"""Every YAML recipe should have a title field."""
|
||||
|
||||
@@ -12,6 +12,7 @@ from specify_cli.integrations.catalog import (
|
||||
IntegrationCatalogError,
|
||||
IntegrationDescriptor,
|
||||
IntegrationDescriptorError,
|
||||
IntegrationValidationError,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,8 +116,45 @@ class TestActiveCatalogs:
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(yaml.dump({"catalogs": []}))
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"):
|
||||
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries") as exc_info:
|
||||
cat.get_active_catalogs()
|
||||
assert str(cfg) in str(exc_info.value)
|
||||
|
||||
def test_empty_config_file_raises_no_catalogs(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text("", encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="no 'catalogs' entries"
|
||||
) as exc_info:
|
||||
cat.get_active_catalogs()
|
||||
assert str(cfg) in str(exc_info.value)
|
||||
|
||||
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
|
||||
def test_load_catalog_config_rejects_falsy_non_mapping_roots(
|
||||
self, tmp_path, monkeypatch, config_content
|
||||
):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(config_content, encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="expected a YAML mapping at the root",
|
||||
) as exc_info:
|
||||
cat.get_active_catalogs()
|
||||
assert str(cfg) in str(exc_info.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -654,3 +692,838 @@ class TestIntegrationUpgrade:
|
||||
os.chdir(old)
|
||||
assert result.exit_code == 0
|
||||
assert "Nothing to upgrade" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalog — catalog source management (get_catalog_configs / add / remove)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCatalogSourceManagement:
|
||||
"""Unit tests for add_catalog / remove_catalog / get_catalog_configs."""
|
||||
|
||||
def _isolate(self, tmp_path, monkeypatch):
|
||||
"""Point HOME at tmp_path and clear the env override so we read built-ins."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
|
||||
def test_get_catalog_configs_returns_builtin_stack(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
configs = cat.get_catalog_configs()
|
||||
assert [c["name"] for c in configs] == ["default", "community"]
|
||||
assert all(isinstance(c["url"], str) and c["url"] for c in configs)
|
||||
assert configs[0]["install_allowed"] is True
|
||||
assert configs[1]["install_allowed"] is False
|
||||
|
||||
def test_add_catalog_creates_config_file(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://new.example.com/catalog.json", name="mine")
|
||||
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
assert cfg_path.exists()
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"] == [
|
||||
{
|
||||
"name": "mine",
|
||||
"url": "https://new.example.com/catalog.json",
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
"description": "",
|
||||
}
|
||||
]
|
||||
# Round-trip: active catalogs should now come from the config file.
|
||||
active = cat.get_active_catalogs()
|
||||
assert [e.name for e in active] == ["mine"]
|
||||
|
||||
def test_add_catalog_recovers_from_empty_config_file(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text("", encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://example.com/catalog.json")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"] == [
|
||||
{
|
||||
"name": "catalog-1",
|
||||
"url": "https://example.com/catalog.json",
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
"description": "",
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
|
||||
def test_add_catalog_rejects_falsy_non_mapping_config_roots(
|
||||
self, tmp_path, monkeypatch, config_content
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(config_content, encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="corrupted.*expected a mapping",
|
||||
) as exc_info:
|
||||
cat.add_catalog("https://example.com/catalog.json")
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
def test_add_catalog_auto_derives_name_and_priority(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json")
|
||||
cat.add_catalog("https://b.example.com/catalog.json")
|
||||
|
||||
data = yaml.safe_load(
|
||||
(tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
|
||||
)
|
||||
entries = data["catalogs"]
|
||||
assert [e["name"] for e in entries] == ["catalog-1", "catalog-2"]
|
||||
assert [e["priority"] for e in entries] == [1, 2]
|
||||
|
||||
def test_add_catalog_normalizes_name(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json", name=" mine ")
|
||||
cat.add_catalog("https://b.example.com/catalog.json", name=" ")
|
||||
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
entries = data["catalogs"]
|
||||
assert [e["name"] for e in entries] == ["mine", "catalog-2"]
|
||||
|
||||
def test_add_catalog_rejects_duplicate_url(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://dup.example.com/catalog.json")
|
||||
with pytest.raises(IntegrationValidationError, match="already configured"):
|
||||
cat.add_catalog("https://dup.example.com/catalog.json")
|
||||
|
||||
def test_add_catalog_rejects_invalid_url(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationCatalogError, match="HTTPS"):
|
||||
cat.add_catalog("http://insecure.example.com/catalog.json")
|
||||
assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()
|
||||
|
||||
def test_add_catalog_rejects_empty_url(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationValidationError, match="must be non-empty"):
|
||||
cat.add_catalog(" ")
|
||||
assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()
|
||||
|
||||
def test_remove_catalog_without_config_errors(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationValidationError, match="No catalog config"):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_happy_path(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json", name="a")
|
||||
cat.add_catalog("https://b.example.com/catalog.json", name="b")
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "a"
|
||||
|
||||
data = yaml.safe_load(
|
||||
(tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
|
||||
)
|
||||
assert [e["name"] for e in data["catalogs"]] == ["b"]
|
||||
|
||||
def test_remove_catalog_index_out_of_range(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json", name="a")
|
||||
with pytest.raises(IntegrationValidationError, match="out of range"):
|
||||
cat.remove_catalog(5)
|
||||
with pytest.raises(IntegrationValidationError, match="out of range"):
|
||||
cat.remove_catalog(-1)
|
||||
|
||||
def test_corrupt_config_rejected_on_add(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text("- just\n- a\n- list\n", encoding="utf-8")
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationValidationError, match="corrupted") as exc_info:
|
||||
cat.add_catalog("https://new.example.com/catalog.json")
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
def test_add_catalog_rejects_non_list_catalogs_with_config_path(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="invalid 'catalogs' value"
|
||||
) as exc_info:
|
||||
cat.add_catalog("https://new.example.com/catalog.json")
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
def test_add_catalog_rejects_non_mapping_entry_with_config_path(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": ["not-a-mapping"]}), encoding="utf-8"
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Invalid catalog entry at index 0"
|
||||
) as exc_info:
|
||||
cat.add_catalog("https://new.example.com/catalog.json")
|
||||
message = str(exc_info.value)
|
||||
assert str(cfg_path) in message
|
||||
assert "expected a mapping" in message
|
||||
|
||||
def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": " ", "name": "blank", "priority": 99},
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"name": "a",
|
||||
"priority": 5,
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://b.example.com/catalog.json", name="b")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"][-1]["name"] == "b"
|
||||
assert data["catalogs"][-1]["priority"] == 6
|
||||
|
||||
def test_add_catalog_default_name_ignores_blank_url_entries(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": [{"url": " ", "name": "blank"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://example.com/catalog.json")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"][-1]["name"] == "catalog-1"
|
||||
|
||||
def test_add_catalog_rejects_non_integer_priority(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"name": "a",
|
||||
"priority": "first",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="'priority' must be an integer, got 'first'",
|
||||
):
|
||||
cat.add_catalog("https://b.example.com/catalog.json")
|
||||
|
||||
def test_add_catalog_accepts_numeric_string_priority(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"name": "a",
|
||||
"priority": "10",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://b.example.com/catalog.json", name="b")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"][-1]["name"] == "b"
|
||||
assert data["catalogs"][-1]["priority"] == 11
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("bad_url", "reason"),
|
||||
[
|
||||
("http://insecure.example.com/catalog.json", "HTTPS"),
|
||||
(123, "HTTPS"),
|
||||
],
|
||||
)
|
||||
def test_add_catalog_rejects_existing_entry_with_bad_url(
|
||||
self, tmp_path, monkeypatch, bad_url, reason
|
||||
):
|
||||
"""A sibling entry with an http:// URL should block a new add."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": bad_url,
|
||||
"name": "bad",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationValidationError) as exc_info:
|
||||
cat.add_catalog("https://good.example.com/catalog.json")
|
||||
message = str(exc_info.value)
|
||||
assert str(cfg_path) in message
|
||||
assert "index 0" in message
|
||||
assert reason in message
|
||||
|
||||
def test_add_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
|
||||
"""Invalid YAML on disk surfaces as IntegrationValidationError, not a raw YAMLError."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n"
|
||||
cfg_path.write_text(invalid_yaml, encoding="utf-8")
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Failed to read catalog config"
|
||||
):
|
||||
cat.add_catalog("https://b.example.com/catalog.json")
|
||||
|
||||
def test_remove_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
|
||||
"""Invalid YAML on disk surfaces as IntegrationValidationError from remove_catalog too."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n"
|
||||
cfg_path.write_text(invalid_yaml, encoding="utf-8")
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Failed to read catalog config"
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_add_catalog_defaults_missing_priority_to_index_plus_one(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Existing entries without `priority` should be treated as idx + 1.
|
||||
|
||||
Matches the rule in `_load_catalog_config()`: a valid catalog entry
|
||||
without an explicit `priority` sorts at `idx + 1`, so the new entry
|
||||
should get `max(...) + 1` from those derived values.
|
||||
"""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
# No explicit priority → should be treated as 1
|
||||
{"url": "https://a.example.com/cat.json", "name": "a"},
|
||||
# No explicit priority → should be treated as 2
|
||||
{"url": "https://b.example.com/cat.json", "name": "b"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://c.example.com/cat.json", name="c")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
new_entry = data["catalogs"][-1]
|
||||
assert new_entry["name"] == "c"
|
||||
# max(implicit [1, 2]) + 1 == 3
|
||||
assert new_entry["priority"] == 3
|
||||
|
||||
def test_add_catalog_strips_whitespace_in_url(self, tmp_path, monkeypatch):
|
||||
"""Whitespace around the incoming URL should be normalized before write."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog(" https://a.example.com/catalog.json\n", name="a")
|
||||
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"][0]["url"] == "https://a.example.com/catalog.json"
|
||||
|
||||
def test_add_catalog_rejects_whitespace_only_duplicate(self, tmp_path, monkeypatch):
|
||||
"""A second add with only whitespace differences must be rejected as a duplicate."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json", name="a")
|
||||
with pytest.raises(IntegrationValidationError, match="already configured"):
|
||||
cat.add_catalog(" https://a.example.com/catalog.json ")
|
||||
|
||||
def test_remove_catalog_wraps_unlink_oserror(self, tmp_path, monkeypatch):
|
||||
"""An OSError from `Path.unlink` surfaces as IntegrationValidationError."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://only.example.com/catalog.json", name="only")
|
||||
|
||||
from pathlib import Path as _Path
|
||||
|
||||
def boom(self, *args, **kwargs):
|
||||
raise OSError("simulated unlink failure")
|
||||
|
||||
monkeypatch.setattr(_Path, "unlink", boom)
|
||||
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Failed to delete catalog config"
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_ignores_missing_final_config_during_unlink(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://only.example.com/catalog.json", name="only")
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
|
||||
from pathlib import Path as _Path
|
||||
|
||||
original_unlink = _Path.unlink
|
||||
|
||||
def delete_first_then_unlink(self, *args, **kwargs):
|
||||
if self == cfg_path and self.exists():
|
||||
original_unlink(self)
|
||||
return original_unlink(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_Path, "unlink", delete_first_then_unlink)
|
||||
|
||||
assert cat.remove_catalog(0) == "only"
|
||||
assert not cfg_path.exists()
|
||||
|
||||
def test_remove_catalog_empty_list_gives_clear_error(self, tmp_path, monkeypatch):
|
||||
"""Hand-edited empty `catalogs:` produces a clear error, not '0--1'."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(yaml.dump({"catalogs": []}), encoding="utf-8")
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="contains no catalog entries"
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_empty_config_file_gives_clear_error(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text("", encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="contains no catalog entries"
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_rejects_non_list_catalogs_with_config_path(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="invalid 'catalogs' value"
|
||||
) as exc_info:
|
||||
cat.remove_catalog(0)
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
|
||||
def test_remove_catalog_rejects_falsy_non_mapping_config_roots(
|
||||
self, tmp_path, monkeypatch, config_content
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(config_content, encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="corrupted.*expected a mapping",
|
||||
) as exc_info:
|
||||
cat.remove_catalog(0)
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
def test_remove_last_catalog_deletes_file_and_restores_defaults(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Removing the final catalog must not leave behind `catalogs: []`.
|
||||
|
||||
`_load_catalog_config` treats an empty `catalogs` list as an error,
|
||||
so writing that file would break every subsequent `integration`
|
||||
command. Removing the last entry should delete the config file so the
|
||||
project falls back to built-in defaults.
|
||||
"""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
|
||||
cat.add_catalog("https://only.example.com/catalog.json", name="only")
|
||||
assert cfg_path.exists()
|
||||
assert [e.name for e in cat.get_active_catalogs()] == ["only"]
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "only"
|
||||
|
||||
assert not cfg_path.exists(), (
|
||||
"remove_catalog should delete the config file when emptying it"
|
||||
)
|
||||
# Follow-up loads fall back to built-in defaults, not an error.
|
||||
active = cat.get_active_catalogs()
|
||||
assert [e.name for e in active] == ["default", "community"]
|
||||
|
||||
def test_load_catalog_config_raises_validation_error_for_invalid_yaml(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Local-config problems must surface as IntegrationValidationError so
|
||||
CLI handlers can route them to local-config (not network) guidance."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
invalid_yaml = "catalogs:\n - [bad\n"
|
||||
cfg_path.write_text(invalid_yaml, encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
# Subclass match: IntegrationValidationError (specifically), not the
|
||||
# bare IntegrationCatalogError parent that callers used previously.
|
||||
with pytest.raises(IntegrationValidationError, match="Failed to read catalog config"):
|
||||
cat.get_active_catalogs()
|
||||
|
||||
def test_load_catalog_config_rejects_boolean_priority(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"name": "a",
|
||||
"priority": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Invalid priority|expected integer"
|
||||
) as exc_info:
|
||||
cat.get_active_catalogs()
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
@pytest.mark.parametrize("raw_name", [None, " "])
|
||||
def test_load_catalog_config_defaults_blank_names(
|
||||
self, tmp_path, monkeypatch, raw_name
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": " ",
|
||||
"name": "skipped",
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/catalog.json",
|
||||
"name": raw_name,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
assert [entry.name for entry in cat.get_active_catalogs()] == ["catalog-1"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_name", "expected"),
|
||||
[
|
||||
(None, "https://one.example.com/c.json"),
|
||||
(" ", "https://one.example.com/c.json"),
|
||||
(123, "123"),
|
||||
],
|
||||
)
|
||||
def test_remove_catalog_normalizes_removed_display_name(
|
||||
self, tmp_path, monkeypatch, raw_name, expected
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://one.example.com/c.json", name="one")
|
||||
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
data["catalogs"][0]["name"] = raw_name
|
||||
cfg_path.write_text(yaml.dump(data), encoding="utf-8")
|
||||
|
||||
assert cat.remove_catalog(0) == expected
|
||||
|
||||
def test_remove_catalog_uses_display_order_with_explicit_priorities(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""`remove_catalog(index)` must remove the entry shown at that index by
|
||||
`catalog list`, not the entry at that raw YAML position."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
# YAML order: alpha (priority=20), beta (priority=10), gamma (priority=15).
|
||||
# Display (sorted by priority asc): beta (10), gamma (15), alpha (20).
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "https://alpha.example.com/c.json", "name": "alpha", "priority": 20},
|
||||
{"url": "https://beta.example.com/c.json", "name": "beta", "priority": 10},
|
||||
{"url": "https://gamma.example.com/c.json", "name": "gamma", "priority": 15},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Display index 0 = beta (lowest priority), not alpha (raw YAML idx 0).
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "beta"
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
remaining_names = [c["name"] for c in data["catalogs"]]
|
||||
# YAML order is preserved for the survivors; only beta is gone.
|
||||
assert remaining_names == ["alpha", "gamma"]
|
||||
|
||||
def test_remove_catalog_display_order_with_missing_priorities(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Entries without `priority` default to `idx + 1` (matching
|
||||
`_load_catalog_config`), so display order tracks YAML order and the
|
||||
first display entry is the first YAML entry."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "https://one.example.com/c.json", "name": "one"},
|
||||
{"url": "https://two.example.com/c.json", "name": "two"},
|
||||
{"url": "https://three.example.com/c.json", "name": "three"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Implicit priorities: one=1, two=2, three=3 → display order matches YAML.
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "one"
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert [c["name"] for c in data["catalogs"]] == ["two", "three"]
|
||||
|
||||
def test_remove_catalog_bool_priority_falls_back_to_yaml_index(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "https://one.example.com/c.json", "name": "one"},
|
||||
{
|
||||
"url": "https://bool.example.com/c.json",
|
||||
"name": "bool",
|
||||
"priority": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
|
||||
assert removed == "one"
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert [c["name"] for c in data["catalogs"]] == ["bool"]
|
||||
|
||||
def test_remove_catalog_display_order_skips_blank_url_entries(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Blank-url entries are not shown by catalog list, so remove skips them too."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": " ", "name": "blank", "priority": 0},
|
||||
{"url": "https://one.example.com/c.json", "name": "one"},
|
||||
{"url": "https://two.example.com/c.json", "name": "two"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "one"
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert [c["name"] for c in data["catalogs"]] == ["blank", "two"]
|
||||
|
||||
def test_remove_catalog_deletes_file_when_only_skipped_entries_remain(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": " ", "name": "blank", "priority": 0},
|
||||
{"url": "https://one.example.com/c.json", "name": "one"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "one"
|
||||
assert not cfg_path.exists()
|
||||
|
||||
active = cat.get_active_catalogs()
|
||||
assert [e.name for e in active] == ["default", "community"]
|
||||
|
||||
def test_remove_catalog_allows_numeric_url_entry_cleanup(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": [{"name": "numeric-url", "url": 123}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
|
||||
assert removed == "numeric-url"
|
||||
assert not cfg_path.exists()
|
||||
|
||||
def test_remove_catalog_errors_when_no_entries_are_removable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "", "name": "empty"},
|
||||
{"name": "missing"},
|
||||
"not-a-mapping",
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="no removable catalog entries",
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_display_order_mixes_explicit_and_default(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""An explicit low priority should sort ahead of default-priority
|
||||
siblings, even if it appears later in the YAML."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Defaults: a=1, b=2 (implicit). Explicit c=0 → display: c, a, b.
|
||||
# The blank name should fall back to the removed URL, not raw YAML idx.
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "https://a.example.com/c.json", "name": "a"},
|
||||
{"url": "https://b.example.com/c.json", "name": "b"},
|
||||
{
|
||||
"url": "https://c.example.com/c.json",
|
||||
"name": " ",
|
||||
"priority": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "https://c.example.com/c.json"
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert [c["name"] for c in data["catalogs"]] == ["a", "b"]
|
||||
|
||||
@@ -55,6 +55,8 @@ class TestClaudeIntegration:
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "/speckit." not in content, "skills agent must use /speckit-<name> not /speckit.<name>"
|
||||
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
@@ -142,6 +144,7 @@ class TestCopilotIntegration:
|
||||
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "\nscripts:\n" not in content
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
@@ -275,3 +278,447 @@ class TestCopilotIntegration:
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
|
||||
class TestCopilotSkillsMode:
|
||||
"""Tests for Copilot integration in --skills mode."""
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _make_copilot(self):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
return CopilotIntegration()
|
||||
|
||||
def _setup_skills(self, copilot, tmp_path):
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
|
||||
return created, m
|
||||
|
||||
# -- Options ----------------------------------------------------------
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
copilot = get_integration("copilot")
|
||||
opts = copilot.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 1
|
||||
assert skills_opts[0].is_flag is True
|
||||
assert skills_opts[0].default is False
|
||||
|
||||
# -- Skills directory structure ---------------------------------------
|
||||
|
||||
def test_skills_creates_skill_files(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
assert len(created) > 0
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
assert f.exists()
|
||||
assert f.parent.name.startswith("speckit-")
|
||||
|
||||
def test_skills_directory_under_github_skills(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skills_dir = tmp_path / ".github" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
assert f.resolve().parent.parent == skills_dir.resolve(), (
|
||||
f"{f} is not under {skills_dir}"
|
||||
)
|
||||
|
||||
def test_skills_directory_structure(self, tmp_path):
|
||||
"""Each command produces speckit-<name>/SKILL.md."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
expected_commands = set(self._SKILL_COMMANDS)
|
||||
actual_commands = set()
|
||||
for f in skill_files:
|
||||
skill_dir_name = f.parent.name
|
||||
assert skill_dir_name.startswith("speckit-")
|
||||
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
# -- No companion files in skills mode --------------------------------
|
||||
|
||||
def test_skills_no_prompt_md_companions(self, tmp_path):
|
||||
"""Skills mode must not generate .prompt.md companion files."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
prompt_files = [f for f in created if f.name.endswith(".prompt.md")]
|
||||
assert prompt_files == []
|
||||
prompts_dir = tmp_path / ".github" / "prompts"
|
||||
if prompts_dir.exists():
|
||||
assert list(prompts_dir.iterdir()) == []
|
||||
|
||||
def test_skills_no_vscode_settings(self, tmp_path):
|
||||
"""Skills mode must not create or merge .vscode/settings.json."""
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
settings = tmp_path / ".vscode" / "settings.json"
|
||||
assert not settings.exists()
|
||||
|
||||
def test_skills_no_agent_md_files(self, tmp_path):
|
||||
"""Skills mode must not produce .agent.md files."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
agent_files = [f for f in created if f.name.endswith(".agent.md")]
|
||||
assert agent_files == []
|
||||
|
||||
# -- Frontmatter structure --------------------------------------------
|
||||
|
||||
def test_skill_frontmatter_structure(self, tmp_path):
|
||||
"""SKILL.md must have name, description, compatibility, metadata."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n"), f"{f} missing frontmatter"
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "name" in fm, f"{f} frontmatter missing 'name'"
|
||||
assert "description" in fm, f"{f} frontmatter missing 'description'"
|
||||
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
|
||||
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
|
||||
assert fm["metadata"]["author"] == "github-spec-kit"
|
||||
|
||||
# -- Copilot-specific post-processing ---------------------------------
|
||||
|
||||
def test_post_process_skill_content_injects_mode(self):
|
||||
"""post_process_skill_content() should inject mode: field."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
'name: "speckit-plan"\n'
|
||||
'description: "Plan workflow"\n'
|
||||
"---\n"
|
||||
"\nBody content\n"
|
||||
)
|
||||
updated = copilot.post_process_skill_content(content)
|
||||
assert "mode: speckit.plan" in updated
|
||||
|
||||
def test_post_process_idempotent(self):
|
||||
"""post_process_skill_content() must be idempotent."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
'name: "speckit-plan"\n'
|
||||
'description: "Plan workflow"\n'
|
||||
"---\n"
|
||||
"\nBody content\n"
|
||||
)
|
||||
first = copilot.post_process_skill_content(content)
|
||||
second = copilot.post_process_skill_content(first)
|
||||
assert first == second
|
||||
|
||||
def test_skills_have_mode_in_frontmatter(self, tmp_path):
|
||||
"""Generated SKILL.md files should have mode: field from post-processing."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "mode" in fm, f"{f} frontmatter missing 'mode'"
|
||||
# mode should be speckit.<stem>
|
||||
skill_dir_name = f.parent.name
|
||||
stem = skill_dir_name.removeprefix("speckit-")
|
||||
assert fm["mode"] == f"speckit.{stem}"
|
||||
|
||||
# -- Template processing ----------------------------------------------
|
||||
|
||||
def test_skills_templates_are_processed(self, tmp_path):
|
||||
"""Skill body must have placeholders replaced."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_skills_command_refs_use_hyphen(self, tmp_path):
|
||||
"""Copilot skills mode must use /speckit-<name> not /speckit.<name>."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "/speckit." not in content, (
|
||||
f"{f.name} contains dot-notation /speckit. reference; "
|
||||
f"skills mode must use /speckit-<name>"
|
||||
)
|
||||
|
||||
def test_skills_mode_invoke_separator(self):
|
||||
"""Copilot effective_invoke_separator should reflect skills mode."""
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.effective_invoke_separator() == "."
|
||||
assert copilot.effective_invoke_separator({"skills": True}) == "-"
|
||||
assert copilot.effective_invoke_separator({"skills": False}) == "."
|
||||
|
||||
def test_skill_body_has_content(self, tmp_path):
|
||||
"""Each SKILL.md body should contain template content."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
body = parts[2].strip() if len(parts) >= 3 else ""
|
||||
assert len(body) > 0, f"{f} has empty body"
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan skill must reference copilot's context file."""
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert copilot.context_file in content
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
# -- Manifest tracking ------------------------------------------------
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
created, m = self._setup_skills(copilot, tmp_path)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"{rel} not tracked in manifest"
|
||||
|
||||
# -- Install/uninstall roundtrip --------------------------------------
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = copilot.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = copilot.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- build_command_invocation -----------------------------------------
|
||||
|
||||
def test_build_command_invocation_skills_mode(self):
|
||||
copilot = self._make_copilot()
|
||||
copilot._skills_mode = True
|
||||
assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan"
|
||||
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
||||
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
|
||||
|
||||
def test_build_command_invocation_skills_extension_command(self):
|
||||
copilot = self._make_copilot()
|
||||
copilot._skills_mode = True
|
||||
assert copilot.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
||||
assert copilot.build_command_invocation("git.commit") == "/speckit-git-commit"
|
||||
|
||||
def test_build_command_invocation_default_mode(self):
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.build_command_invocation("plan", "my args") == "my args"
|
||||
assert copilot.build_command_invocation("plan") == ""
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
|
||||
def test_skills_setup_upserts_context_section(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
ctx_path = tmp_path / copilot.context_file
|
||||
assert ctx_path.exists()
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" in content
|
||||
|
||||
# -- CLI integration test ---------------------------------------------
|
||||
|
||||
def test_init_with_integration_options_skills(self, tmp_path):
|
||||
"""specify init --integration copilot --integration-options='--skills' scaffolds skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "copilot-skills"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
skills_dir = project / ".github" / "skills"
|
||||
assert skills_dir.is_dir(), "Skills directory was not created"
|
||||
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
|
||||
assert plan_skill.exists(), "speckit-plan/SKILL.md not found"
|
||||
# Verify no default-mode artifacts
|
||||
assert not (project / ".github" / "agents").exists()
|
||||
assert not (project / ".github" / "prompts").exists()
|
||||
assert not (project / ".vscode" / "settings.json").exists()
|
||||
|
||||
def test_complete_file_inventory_skills_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration copilot --integration-options='--skills' --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "inventory-skills-sh"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
# Skill files
|
||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||
# Context file
|
||||
".github/copilot-instructions.md",
|
||||
# Integration metadata
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
# Scripts (sh)
|
||||
".specify/scripts/bash/check-prerequisites.sh",
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
".specify/scripts/bash/setup-plan.sh",
|
||||
# Templates
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
".specify/memory/constitution.md",
|
||||
# Bundled workflow
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
# -- Singleton leak: _skills_mode must reset --------------------------
|
||||
|
||||
def test_skills_mode_resets_on_default_setup(self, tmp_path):
|
||||
"""setup() with skills=True then without must reset _skills_mode."""
|
||||
copilot = self._make_copilot()
|
||||
|
||||
# First call: skills mode
|
||||
(tmp_path / "proj1").mkdir()
|
||||
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
|
||||
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
|
||||
assert copilot._skills_mode is True
|
||||
|
||||
# Second call: default mode (no skills option)
|
||||
(tmp_path / "proj2").mkdir()
|
||||
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
|
||||
copilot.setup(tmp_path / "proj2", m2)
|
||||
assert copilot._skills_mode is False
|
||||
|
||||
# build_command_invocation must use default (dotted) mode
|
||||
assert copilot.build_command_invocation("plan", "args") == "args"
|
||||
|
||||
# -- Auto-detection must ignore unrelated .github/skills/ -------------
|
||||
|
||||
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
|
||||
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
|
||||
copilot = self._make_copilot()
|
||||
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
|
||||
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
|
||||
unrelated.mkdir(parents=True)
|
||||
(unrelated / "README.md").write_text("# GitHub Skills training\n")
|
||||
|
||||
# Should NOT detect skills mode — cli_args should contain --agent
|
||||
import unittest.mock as mock
|
||||
with mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "--agent" in call_args, (
|
||||
f"Expected --agent in cli_args but got: {call_args}"
|
||||
)
|
||||
assert "speckit.plan" in call_args
|
||||
|
||||
def test_dispatch_detects_speckit_skills_layout(self, tmp_path):
|
||||
"""dispatch_command() detects speckit-*/SKILL.md as skills mode."""
|
||||
copilot = self._make_copilot()
|
||||
skill_dir = tmp_path / ".github" / "skills" / "speckit-plan"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n")
|
||||
|
||||
import unittest.mock as mock
|
||||
with mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "--agent" not in call_args, (
|
||||
f"Skills mode should not use --agent, got: {call_args}"
|
||||
)
|
||||
prompt = call_args[call_args.index("-p") + 1]
|
||||
assert "/speckit-plan" in prompt, (
|
||||
f"Skills mode prompt should invoke /speckit-plan, got: {prompt}"
|
||||
)
|
||||
assert "my args" in prompt, (
|
||||
f"Skills mode prompt should preserve user args, got: {prompt}"
|
||||
)
|
||||
|
||||
# -- Next-steps display for Copilot skills mode -----------------------
|
||||
|
||||
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
|
||||
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "copilot-nextsteps"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
# Skills mode should show /speckit-plan (hyphenated)
|
||||
assert "/speckit-plan" in result.output, (
|
||||
f"Expected /speckit-plan in next steps but got:\n{result.output}"
|
||||
)
|
||||
# Must NOT show the dotted /speckit.plan form
|
||||
assert "/speckit.plan" not in result.output, (
|
||||
f"Should not show /speckit.plan in skills mode:\n{result.output}"
|
||||
)
|
||||
75
tests/integrations/test_integration_devin.py
Normal file
75
tests/integrations/test_integration_devin.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Tests for DevinIntegration."""
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestDevinIntegration(SkillsIntegrationTests):
|
||||
KEY = "devin"
|
||||
FOLDER = ".devin/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".devin/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestDevinBuildExecArgs:
|
||||
"""Regression tests for DevinIntegration.build_exec_args.
|
||||
|
||||
Devin's CLI has no --output-format flag, so build_exec_args must
|
||||
omit it regardless of the output_json argument. The integration
|
||||
must also remain dispatchable (must not return None, which is the
|
||||
codebase's IDE-only sentinel checked by CommandStep).
|
||||
"""
|
||||
|
||||
def test_returns_args_not_none_for_dispatch(self):
|
||||
"""Devin is CLI-dispatchable; build_exec_args must not return None."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
impl = DevinIntegration()
|
||||
args = impl.build_exec_args("test prompt")
|
||||
assert args is not None, (
|
||||
"DevinIntegration.build_exec_args must not return None. "
|
||||
"None is the codebase sentinel for IDE-only integrations "
|
||||
"(see WindsurfIntegration); Devin is dispatchable via 'devin -p'."
|
||||
)
|
||||
assert args[:3] == ["devin", "-p", "test prompt"]
|
||||
|
||||
def test_output_json_does_not_emit_output_format_flag(self):
|
||||
"""Devin has no --output-format flag; output_json=True must not add it."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
impl = DevinIntegration()
|
||||
args_json = impl.build_exec_args("hello", output_json=True)
|
||||
args_text = impl.build_exec_args("hello", output_json=False)
|
||||
|
||||
assert "--output-format" not in args_json
|
||||
assert "json" not in args_json[3:]
|
||||
# The two should be identical: output_json is documented as having
|
||||
# no effect on the command line for Devin (plain-text stdout).
|
||||
assert args_json == args_text
|
||||
|
||||
def test_model_flag_passed_through(self):
|
||||
"""--model is supported and should appear when provided."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
impl = DevinIntegration()
|
||||
args = impl.build_exec_args("hi", model="claude-sonnet-4")
|
||||
assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"]
|
||||
|
||||
|
||||
class TestDevinAutoPromote:
|
||||
"""--ai devin auto-promotes to integration path."""
|
||||
|
||||
def test_ai_devin_without_ai_skills_auto_promotes(self, tmp_path):
|
||||
"""--ai devin should work the same as --integration devin."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --ai devin failed: {result.output}"
|
||||
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
@@ -152,6 +152,7 @@ class TestForgeIntegration:
|
||||
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{cmd_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
|
||||
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
|
||||
# Frontmatter sections should be stripped
|
||||
|
||||
@@ -101,6 +101,7 @@ class TestGenericIntegration:
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for OpencodeIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
@@ -9,3 +11,49 @@ class TestOpencodeIntegration(MarkdownIntegrationTests):
|
||||
COMMANDS_SUBDIR = "command"
|
||||
REGISTRAR_DIR = ".opencode/command"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
def test_build_exec_args_uses_run_command_dispatch(self):
|
||||
integration = get_integration(self.KEY)
|
||||
|
||||
args = integration.build_exec_args(
|
||||
"/speckit.specify build a login page",
|
||||
output_json=False,
|
||||
)
|
||||
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--command",
|
||||
"speckit.specify",
|
||||
"build a login page",
|
||||
]
|
||||
assert "-p" not in args
|
||||
assert "--output-format" not in args
|
||||
|
||||
def test_build_exec_args_maps_model_and_json_flags(self):
|
||||
integration = get_integration(self.KEY)
|
||||
|
||||
args = integration.build_exec_args(
|
||||
"/speckit.plan add OAuth",
|
||||
model="anthropic/claude-sonnet-4",
|
||||
output_json=True,
|
||||
)
|
||||
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--command",
|
||||
"speckit.plan",
|
||||
"-m",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"--format",
|
||||
"json",
|
||||
"add OAuth",
|
||||
]
|
||||
|
||||
def test_build_exec_args_keeps_plain_prompt_dispatch(self):
|
||||
integration = get_integration(self.KEY)
|
||||
|
||||
args = integration.build_exec_args("explain this repository", output_json=False)
|
||||
|
||||
assert args == ["opencode", "run", "explain this repository"]
|
||||
|
||||
@@ -31,6 +31,16 @@ def _init_project(tmp_path, integration="copilot"):
|
||||
return project
|
||||
|
||||
|
||||
def _run_in_project(project, args):
|
||||
"""Run a CLI command from inside a generated project."""
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
return runner.invoke(app, args, catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
# ── list ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -334,6 +344,142 @@ class TestIntegrationSwitch:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "copilot"
|
||||
|
||||
def test_switch_migrates_extension_commands(self, tmp_path):
|
||||
"""Switching should migrate extension commands to the new agent directory."""
|
||||
project = _init_project(tmp_path, "kimi")
|
||||
|
||||
# Install the bundled git extension
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
|
||||
# Verify git extension skills exist for kimi
|
||||
kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md"
|
||||
assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "switch", "opencode",
|
||||
"--script", "sh",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Git extension commands should exist for opencode
|
||||
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
|
||||
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
|
||||
|
||||
# Old kimi extension skills should be removed
|
||||
assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed"
|
||||
|
||||
# Extension registry should be updated
|
||||
registry = json.loads(
|
||||
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
||||
)
|
||||
registered_commands = registry["extensions"]["git"]["registered_commands"]
|
||||
assert "opencode" in registered_commands
|
||||
assert "kimi" not in registered_commands
|
||||
|
||||
# Switch to claude
|
||||
result = _run_in_project(project, [
|
||||
"integration", "switch", "claude",
|
||||
"--script", "sh",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Git extension skills should exist for claude
|
||||
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
|
||||
assert claude_git_feature.exists(), "Git extension skill should exist for claude"
|
||||
|
||||
# Old opencode extension commands should be removed
|
||||
assert not opencode_git_feature.exists(), "Old opencode extension command should be removed"
|
||||
|
||||
# Extension registry should be updated
|
||||
registry = json.loads(
|
||||
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
||||
)
|
||||
registered_commands = registry["extensions"]["git"]["registered_commands"]
|
||||
assert "claude" in registered_commands
|
||||
assert "opencode" not in registered_commands
|
||||
|
||||
def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path):
|
||||
"""Copilot --skills should receive extension skills, not .agent.md files."""
|
||||
project = _init_project(tmp_path, "opencode")
|
||||
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
"--integration-options", "--skills",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
||||
copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md"
|
||||
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
|
||||
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
|
||||
|
||||
# Verify Copilot-specific frontmatter: mode field should map from
|
||||
# skill name (speckit-git-feature) back to dot notation (speckit.git-feature)
|
||||
skill_content = copilot_git_feature.read_text(encoding="utf-8")
|
||||
assert "mode: speckit.git-feature" in skill_content, (
|
||||
"Copilot skill frontmatter should contain mode mapped from skill name"
|
||||
)
|
||||
|
||||
registry = json.loads(
|
||||
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
||||
)
|
||||
git_meta = registry["extensions"]["git"]
|
||||
assert "speckit-git-feature" in git_meta["registered_skills"]
|
||||
assert "copilot" not in git_meta["registered_commands"]
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "switch", "opencode",
|
||||
"--script", "sh",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
|
||||
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
|
||||
assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"
|
||||
|
||||
registry = json.loads(
|
||||
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
||||
)
|
||||
git_meta = registry["extensions"]["git"]
|
||||
assert git_meta["registered_skills"] == []
|
||||
assert "opencode" in git_meta["registered_commands"]
|
||||
assert "copilot" not in git_meta["registered_commands"]
|
||||
|
||||
def test_switch_does_not_register_disabled_extensions(self, tmp_path):
|
||||
"""Disabled extensions should stay disabled and should not migrate commands."""
|
||||
project = _init_project(tmp_path, "opencode")
|
||||
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
result = _run_in_project(project, ["extension", "disable", "git"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
|
||||
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "switch", "claude",
|
||||
"--script", "sh",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
|
||||
assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent"
|
||||
assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch"
|
||||
|
||||
registry = json.loads(
|
||||
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
||||
)
|
||||
git_meta = registry["extensions"]["git"]
|
||||
assert git_meta["enabled"] is False
|
||||
assert "claude" not in git_meta["registered_commands"]
|
||||
assert "opencode" not in git_meta["registered_commands"]
|
||||
|
||||
def test_switch_preserves_shared_infra(self, tmp_path):
|
||||
"""Switching preserves shared scripts, templates, and memory."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
"""Tests for VibeIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestVibeIntegration(MarkdownIntegrationTests):
|
||||
class TestVibeIntegration(SkillsIntegrationTests):
|
||||
KEY = "vibe"
|
||||
FOLDER = ".vibe/"
|
||||
COMMANDS_SUBDIR = "prompts"
|
||||
REGISTRAR_DIR = ".vibe/prompts"
|
||||
CONTEXT_FILE = ".vibe/agents/specify-agents.md"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".vibe/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestVibeUserInvocable:
|
||||
def test_all_skills_have_user_invocable(self, tmp_path):
|
||||
i = get_integration("vibe")
|
||||
m = IntegrationManifest("vibe", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert skill_files
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---"), (
|
||||
f"{f.parent.name}/SKILL.md is missing the opening frontmatter delimiter '---'"
|
||||
)
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3, (
|
||||
f"{f.parent.name}/SKILL.md has malformed frontmatter; expected a '--- ... ---' block"
|
||||
)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed.get("user-invocable") is True, (
|
||||
f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter"
|
||||
)
|
||||
@@ -217,6 +217,43 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Missing required field"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_non_mapping_yaml_raises_validation_error(self, temp_dir):
|
||||
"""Manifest whose YAML root is a scalar or list raises ValidationError, not TypeError."""
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
for bad_content in ("42\n", "[]\n", "null\n"):
|
||||
manifest_path.write_text(bad_content)
|
||||
with pytest.raises(ValidationError, match="YAML mapping"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_utf8_non_ascii_description_loads(self, temp_dir, valid_manifest_data):
|
||||
"""Regression for #2325: non-ASCII (UTF-8) description loads on any platform.
|
||||
|
||||
On Windows, Python's default text-mode encoding is the locale codepage
|
||||
(e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes
|
||||
outside the ASCII range. The loader must open with encoding='utf-8'.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["extension"]["description"] = "中文测试 — émojis 🚀"
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
# Write UTF-8 bytes explicitly so the test exercises the read path,
|
||||
# not the (locale-dependent) write path.
|
||||
manifest_path.write_bytes(
|
||||
yaml.safe_dump(valid_manifest_data, allow_unicode=True).encode("utf-8")
|
||||
)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
assert manifest.description == "中文测试 — émojis 🚀"
|
||||
|
||||
def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir):
|
||||
"""Negative case: file containing invalid UTF-8 bytes raises ValidationError, not raw UnicodeDecodeError."""
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
# 0xFF/0xFE are not valid UTF-8 lead bytes.
|
||||
manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n")
|
||||
|
||||
with pytest.raises(ValidationError, match="not valid UTF-8"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_invalid_extension_id(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with invalid extension ID format."""
|
||||
import yaml
|
||||
@@ -1361,6 +1398,79 @@ Agent __AGENT__
|
||||
assert "{ARGS}" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
|
||||
@pytest.mark.parametrize("agent_name,skills_path", [
|
||||
("codex", ".agents/skills"),
|
||||
("kimi", ".kimi/skills"),
|
||||
("claude", ".claude/skills"),
|
||||
("cursor-agent", ".cursor/skills"),
|
||||
("trae", ".trae/skills"),
|
||||
("agy", ".agents/skills"),
|
||||
])
|
||||
def test_all_skill_agents_register_commands_with_resolved_placeholders(
|
||||
self, project_dir, temp_dir, agent_name, skills_path
|
||||
):
|
||||
"""All SKILL.md agents must produce fully resolved SKILL.md files when commands are registered."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / f"ext-{agent_name}"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": f"ext-{agent_name}",
|
||||
"name": "Scripted Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": f"speckit.ext-{agent_name}.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Scripted command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "run.md").write_text(
|
||||
"---\n"
|
||||
"description: Scripted command\n"
|
||||
"scripts:\n"
|
||||
' sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"\n'
|
||||
"---\n\n"
|
||||
"Run {SCRIPT}\n"
|
||||
"Agent is __AGENT__.\n"
|
||||
)
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(f'{{"ai":"{agent_name}","script":"sh"}}')
|
||||
|
||||
skills_dir = project_dir
|
||||
for part in skills_path.split("/"):
|
||||
skills_dir = skills_dir / part
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent(agent_name, manifest, ext_dir, project_dir)
|
||||
|
||||
skill_dir_name = f"speckit-ext-{agent_name}-run"
|
||||
skill_file = skills_dir / skill_dir_name / "SKILL.md"
|
||||
assert skill_file.exists(), f"SKILL.md not created for {agent_name}"
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "{SCRIPT}" not in content, f"{{SCRIPT}} not resolved for {agent_name}"
|
||||
assert "__AGENT__" not in content, f"__AGENT__ not resolved for {agent_name}"
|
||||
assert "{ARGS}" not in content, f"{{ARGS}} not resolved for {agent_name}"
|
||||
assert '.specify/scripts/bash/setup-plan.sh' in content
|
||||
|
||||
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
|
||||
"""Codex alias skills should render their own matching `name:` frontmatter."""
|
||||
import yaml
|
||||
@@ -2335,6 +2445,215 @@ class TestExtensionCatalog:
|
||||
assert not catalog.cache_file.exists()
|
||||
assert not catalog.cache_metadata_file.exists()
|
||||
|
||||
# --- _make_request / GitHub auth ---
|
||||
|
||||
def _make_catalog(self, temp_dir):
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
return ExtensionCatalog(project_dir)
|
||||
|
||||
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
|
||||
"""Without a token, requests carry no Authorization header."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_whitespace_only_github_token_ignored(self, temp_dir, monkeypatch):
|
||||
"""A whitespace-only GITHUB_TOKEN is treated as unset."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, temp_dir, monkeypatch):
|
||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||
|
||||
def test_make_request_github_token_added_for_raw_githubusercontent(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_make_request_gh_token_fallback(self, temp_dir, monkeypatch):
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||
|
||||
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||
|
||||
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
|
||||
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the URL path."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the query string."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for api.github.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_redirect_preserves_auth_for_github_to_codeload(self):
|
||||
"""Auth header is preserved when GitHub redirects to codeload.github.com."""
|
||||
from specify_cli._github_http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect()
|
||||
original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip"
|
||||
redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1"
|
||||
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
|
||||
fp = io.BytesIO(b"")
|
||||
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
|
||||
assert new_req is not None
|
||||
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth == "Bearer ghp_test"
|
||||
|
||||
def test_redirect_strips_auth_for_github_to_external(self):
|
||||
"""Auth header is stripped when GitHub redirects to a non-GitHub host."""
|
||||
from specify_cli._github_http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect()
|
||||
original_url = "https://github.com/org/repo/releases/download/v1/asset.zip"
|
||||
redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345"
|
||||
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
|
||||
fp = io.BytesIO(b"")
|
||||
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
|
||||
assert new_req is not None
|
||||
auth_header = new_req.headers.get("Authorization")
|
||||
auth_unredirected = new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth_header is None
|
||||
assert auth_unredirected is None
|
||||
|
||||
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
catalog_data = {"schema_version": "1.0", "extensions": {}}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
return mock_response
|
||||
|
||||
mock_opener.open.side_effect = fake_open
|
||||
|
||||
entry = CatalogEntry(
|
||||
url="https://raw.githubusercontent.com/org/repo/main/catalog.json",
|
||||
name="private",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header via opener for GitHub URLs."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import zipfile, io
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
# Build a minimal valid ZIP in memory
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, "w") as zf:
|
||||
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = zip_bytes
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = {}
|
||||
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
return mock_response
|
||||
|
||||
mock_opener.open.side_effect = fake_open
|
||||
|
||||
ext_info = {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/org/repo/releases/download/v1/test-ext.zip",
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
|
||||
|
||||
# ===== CatalogEntry Tests =====
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
204
tests/test_registrar_path_traversal.py
Normal file
204
tests/test_registrar_path_traversal.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Tests for CommandRegistrar directory traversal guards around issue #2229."""
|
||||
|
||||
import errno
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
|
||||
TRAVERSAL_PAYLOADS = [
|
||||
"../pwned",
|
||||
"../../etc/passwd",
|
||||
"subdir/../../escape",
|
||||
"/absolute/evil",
|
||||
]
|
||||
|
||||
|
||||
def _write_source(ext_dir: Path) -> Path:
|
||||
ext_dir.mkdir(parents=True, exist_ok=True)
|
||||
(ext_dir / "commands").mkdir(exist_ok=True)
|
||||
(ext_dir / "commands" / "cmd.md").write_text(
|
||||
"---\ndescription: test\n---\n\nbody\n", encoding="utf-8"
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
|
||||
def _cmd(name: str, aliases: list[str] | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"name": name,
|
||||
"file": "commands/cmd.md",
|
||||
"aliases": list(aliases or []),
|
||||
}
|
||||
|
||||
|
||||
def _project_and_source(tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
ext_dir = _write_source(tmp_path / "ext-src")
|
||||
return project, ext_dir
|
||||
|
||||
|
||||
def _assert_no_stray_files(tmp_root: Path, marker: str) -> None:
|
||||
"""Fail if a file matching ``marker`` exists outside the project tree."""
|
||||
stray = [
|
||||
p for p in tmp_root.rglob("*")
|
||||
if p.is_file() and marker in p.name and "project" not in p.parts
|
||||
]
|
||||
assert stray == [], (
|
||||
f"Traversal payload leaked files outside the project tree: {stray}"
|
||||
)
|
||||
|
||||
|
||||
class TestPrimaryCommandTraversal:
|
||||
"""Primary command names must not escape the agent's commands directory."""
|
||||
|
||||
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
|
||||
def test_gemini_rejects_traversal_in_primary_name(self, tmp_path, bad_name):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
registrar.register_commands(
|
||||
"gemini", [_cmd(bad_name)], "myext", ext_dir, project
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
|
||||
|
||||
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
|
||||
def test_copilot_rejects_traversal_in_primary_name(self, tmp_path, bad_name):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".github" / "agents").mkdir(parents=True)
|
||||
(project / ".github" / "prompts").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
registrar.register_commands(
|
||||
"copilot", [_cmd(bad_name)], "myext", ext_dir, project
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
|
||||
|
||||
|
||||
class TestAliasTraversal:
|
||||
"""Free-form aliases must not escape commands_dir (regression for b67b285)."""
|
||||
|
||||
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
|
||||
def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
registrar.register_commands(
|
||||
"gemini",
|
||||
[_cmd("speckit.myext.ok", [bad_alias])],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
|
||||
|
||||
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
|
||||
def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".github" / "agents").mkdir(parents=True)
|
||||
(project / ".github" / "prompts").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
registrar.register_commands(
|
||||
"copilot",
|
||||
[_cmd("speckit.myext.ok", [bad_alias])],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
|
||||
|
||||
|
||||
class TestCopilotPromptTraversal:
|
||||
"""`write_copilot_prompt` is a public static method — guard it directly."""
|
||||
|
||||
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
|
||||
def test_rejects_traversal_names(self, tmp_path, bad_name):
|
||||
project = tmp_path / "project"
|
||||
(project / ".github" / "prompts").mkdir(parents=True)
|
||||
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
CommandRegistrar.write_copilot_prompt(project, bad_name)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
|
||||
|
||||
|
||||
class TestSafeRegistration:
|
||||
"""Positive regression — well-formed names continue to register."""
|
||||
|
||||
def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path):
|
||||
"""Lexical check must not block legitimately symlinked sub-directories.
|
||||
|
||||
Teams sometimes symlink shared skills into their agent commands dir
|
||||
(e.g. ``.gemini/commands/shared -> /team/shared-commands``). The
|
||||
guard is purely lexical, so such a setup continues to work even though
|
||||
the resolved target lives outside commands_dir on disk.
|
||||
"""
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
commands_dir = project / ".gemini" / "commands"
|
||||
commands_dir.mkdir(parents=True)
|
||||
|
||||
external_shared = tmp_path / "external-shared"
|
||||
external_shared.mkdir()
|
||||
try:
|
||||
(commands_dir / "shared").symlink_to(
|
||||
external_shared, target_is_directory=True
|
||||
)
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EPERM, errno.EACCES}:
|
||||
pytest.skip("symlink creation is not permitted in this environment")
|
||||
raise
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"gemini",
|
||||
[_cmd("shared/hello")],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert registered == ["shared/hello"]
|
||||
assert (external_shared / "hello.toml").exists()
|
||||
|
||||
def test_safe_command_and_alias_still_register(self, tmp_path):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".claude" / "skills").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"claude",
|
||||
[_cmd("speckit.myext.hello", ["speckit.myext.hi"])],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert "speckit.myext.hello" in registered
|
||||
assert "speckit.myext.hi" in registered
|
||||
assert (
|
||||
project
|
||||
/ ".claude"
|
||||
/ "skills"
|
||||
/ "speckit-myext-hello"
|
||||
/ "SKILL.md"
|
||||
).exists()
|
||||
assert (
|
||||
project
|
||||
/ ".claude"
|
||||
/ "skills"
|
||||
/ "speckit-myext-hi"
|
||||
/ "SKILL.md"
|
||||
).exists()
|
||||
202
tests/test_setup_plan_feature_json.py
Normal file
202
tests/test_setup_plan_feature_json.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Tests for setup-plan bypassing branch-pattern checks when feature.json is valid."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
SETUP_PLAN_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-plan.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
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")
|
||||
|
||||
|
||||
def _install_bash_scripts(repo: Path) -> None:
|
||||
d = repo / ".specify" / "scripts" / "bash"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, d / "common.sh")
|
||||
shutil.copy(SETUP_PLAN_SH, d / "setup-plan.sh")
|
||||
|
||||
|
||||
def _install_ps_scripts(repo: Path) -> None:
|
||||
d = repo / ".specify" / "scripts" / "powershell"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, d / "common.ps1")
|
||||
shutil.copy(SETUP_PLAN_PS, d / "setup-plan.ps1")
|
||||
|
||||
|
||||
def _minimal_templates(repo: Path) -> None:
|
||||
tdir = repo / ".specify" / "templates"
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""Return a copy of the current environment with any SPECIFY_* vars removed.
|
||||
|
||||
setup-plan.{sh,ps1} honors SPECIFY_FEATURE, SPECIFY_FEATURE_DIRECTORY, etc.,
|
||||
which would otherwise leak from a developer shell or CI runner and make these
|
||||
tests flaky. Stripping them forces every case to rely purely on git branch +
|
||||
.specify/feature.json state set up by the fixture.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_minimal_templates(repo)
|
||||
_install_bash_scripts(repo)
|
||||
_install_ps_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(plan_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
cwd=plan_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (feat / "plan.md").is_file()
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
cwd=plan_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_numbered_branch_unchanged_without_feature_json(
|
||||
plan_repo: Path,
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
cwd=plan_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (feat / "plan.md").is_file()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _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"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(plan_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script)],
|
||||
cwd=plan_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (feat / "plan.md").is_file()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_plan_ps_fails_custom_branch_without_feature_json(
|
||||
plan_repo: Path,
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script)],
|
||||
cwd=plan_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
@@ -1257,3 +1257,67 @@ class TestFeatureDirectoryResolution:
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
|
||||
# ── Description Quoting Tests (issue #2339) ──────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestDescriptionQuoting:
|
||||
"""Descriptions with quotes, apostrophes, and backslashes must not break the script.
|
||||
|
||||
Regression tests for https://github.com/github/spec-kit/issues/2339
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"description",
|
||||
[
|
||||
"Add user's profile page",
|
||||
"Fix the \"login\" bug",
|
||||
"Handle path\\with\\backslashes",
|
||||
"It's a \"complex\" feature\\here",
|
||||
],
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
def test_core_script_handles_special_chars(self, git_repo: Path, description: str):
|
||||
"""Core create-new-feature.sh succeeds with special characters in description."""
|
||||
result = run_script(git_repo, "--dry-run", "--short-name", "feat", description)
|
||||
assert result.returncode == 0, (
|
||||
f"Script failed for description {description!r}: {result.stderr}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"description",
|
||||
[
|
||||
"Add user's profile page",
|
||||
"Fix the \"login\" bug",
|
||||
"Handle path\\with\\backslashes",
|
||||
"It's a \"complex\" feature\\here",
|
||||
],
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
|
||||
"""Extension create-new-feature.sh succeeds with special characters in description."""
|
||||
script = (
|
||||
ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--dry-run", "--short-name", "feat", description],
|
||||
cwd=ext_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"Script failed for description {description!r}: {result.stderr}"
|
||||
)
|
||||
|
||||
def test_whitespace_only_still_rejected(self, git_repo: Path):
|
||||
"""Whitespace-only descriptions must still be rejected after trimming."""
|
||||
result = run_script(git_repo, "--dry-run", "--short-name", "feat", " ")
|
||||
assert result.returncode != 0
|
||||
assert "empty" in result.stderr.lower() or "whitespace" in result.stderr.lower()
|
||||
|
||||
def test_plain_description_still_works(self, git_repo: Path):
|
||||
"""Plain description without special characters continues to work."""
|
||||
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
384
tests/test_upgrade.py
Normal file
384
tests/test_upgrade.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""Tests for the `specify self` sub-app (`self check` and `self upgrade`).
|
||||
|
||||
Network isolation contract (SC-004 / FR-014): every test that exercises
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock
|
||||
`urllib.request.urlopen` so no real outbound call ever reaches
|
||||
api.github.com. The `self upgrade` stub tests do not need that patch because
|
||||
the stub is contractually network-free. Run this module under `pytest-socket`
|
||||
(if installed) with `--disable-socket` as an extra safety net.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import importlib.metadata
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import (
|
||||
_get_installed_version,
|
||||
_fetch_latest_release_tag,
|
||||
_is_newer,
|
||||
_normalize_tag,
|
||||
app,
|
||||
)
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
|
||||
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
|
||||
|
||||
def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
||||
return urllib.error.HTTPError(
|
||||
url="https://api.github.com/repos/github/spec-kit/releases/latest",
|
||||
code=code,
|
||||
msg=message,
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
|
||||
|
||||
class TestSelfUpgradeStub:
|
||||
"""Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016)."""
|
||||
|
||||
def test_prints_exactly_three_lines_and_exits_zero(self):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
lines = strip_ansi(result.output).strip().splitlines()
|
||||
assert lines == [
|
||||
"specify self upgrade is not implemented yet.",
|
||||
"Run 'specify self check' to see whether a newer release is available.",
|
||||
"Actual self-upgrade is planned as follow-up work.",
|
||||
]
|
||||
|
||||
def test_stub_makes_no_network_call(self):
|
||||
# If the stub ever starts calling urllib, this patch's side_effect
|
||||
# would fire and the assertion below would fail.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=AssertionError("stub must not hit the network"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestIsNewer:
|
||||
def test_latest_strictly_greater_returns_true(self):
|
||||
assert _is_newer("0.8.0", "0.7.4") is True
|
||||
|
||||
def test_equal_versions_returns_false(self):
|
||||
assert _is_newer("0.7.4", "0.7.4") is False
|
||||
|
||||
def test_current_greater_than_latest_returns_false(self):
|
||||
assert _is_newer("0.7.0", "0.7.4") is False
|
||||
|
||||
def test_dev_build_ahead_of_release_returns_false(self):
|
||||
assert _is_newer("0.7.4", "0.7.5.dev0") is False
|
||||
|
||||
def test_invalid_version_returns_false(self):
|
||||
assert _is_newer("not-a-version", "0.7.4") is False
|
||||
|
||||
def test_local_version_containing_unknown_is_not_treated_as_sentinel(self):
|
||||
assert _is_newer("1.2.4", "1.2.3+unknown") is True
|
||||
|
||||
|
||||
class TestInstalledVersion:
|
||||
def test_invalid_metadata_error_returns_unknown(self):
|
||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||
if invalid_metadata_error is None:
|
||||
# Python versions without InvalidMetadataError: simulate with a
|
||||
# custom exception to verify the guarded except path works.
|
||||
class _FakeInvalidMetadataError(Exception):
|
||||
pass
|
||||
invalid_metadata_error = _FakeInvalidMetadataError
|
||||
# Patch the attribute onto importlib.metadata so the production
|
||||
# getattr() finds it during this test.
|
||||
with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True):
|
||||
with patch(
|
||||
"importlib.metadata.version",
|
||||
side_effect=invalid_metadata_error("bad metadata"),
|
||||
):
|
||||
assert _get_installed_version() == "unknown"
|
||||
else:
|
||||
with patch(
|
||||
"importlib.metadata.version",
|
||||
side_effect=invalid_metadata_error("bad metadata"),
|
||||
):
|
||||
assert _get_installed_version() == "unknown"
|
||||
|
||||
|
||||
class TestNormalizeTag:
|
||||
def test_strips_single_leading_v(self):
|
||||
assert _normalize_tag("v0.7.4") == "0.7.4"
|
||||
|
||||
def test_idempotent_when_no_leading_v(self):
|
||||
assert _normalize_tag("0.7.4") == "0.7.4"
|
||||
|
||||
def test_strips_exactly_one_v(self):
|
||||
assert _normalize_tag("vv0.7.4") == "v0.7.4"
|
||||
|
||||
def test_empty_string_passthrough(self):
|
||||
assert _normalize_tag("") == ""
|
||||
|
||||
|
||||
class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" in output
|
||||
assert "0.7.4" in output
|
||||
assert "0.9.0" in output
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output
|
||||
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Up to date: 0.9.0" in output
|
||||
assert "Update available" not in output
|
||||
assert "git+https://" not in output
|
||||
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" not in output
|
||||
assert "Up to date" in output
|
||||
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Current version could not be determined" in output
|
||||
assert "0.7.4" in output
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" not in output
|
||||
assert "Up to date" in output
|
||||
assert "0.7.4" in output
|
||||
|
||||
|
||||
class TestFailureCategorization:
|
||||
def test_urlerror_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("no route to host"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "offline or timeout"
|
||||
|
||||
def test_timeout_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=TimeoutError(),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "offline or timeout"
|
||||
|
||||
def test_403_maps_to_rate_limited(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=_http_error(403, "rate limited"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||
|
||||
@pytest.mark.parametrize("code", [404, 500, 502])
|
||||
def test_other_http_uses_code_string(self, code):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=_http_error(code, "oops"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == f"HTTP {code}"
|
||||
|
||||
def test_generic_exception_propagates(self):
|
||||
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
_fetch_latest_release_tag()
|
||||
|
||||
|
||||
_FAILURE_CASES = [
|
||||
("offline or timeout", urllib.error.URLError("down")),
|
||||
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
|
||||
("HTTP 500", _http_error(500)),
|
||||
]
|
||||
|
||||
|
||||
class TestUserStory2:
|
||||
@pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_prints_installed_plus_one_line_reason(
|
||||
self, expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert "Installed: 0.7.4" in output
|
||||
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
|
||||
assert "Could not check latest release: rate limited" in output
|
||||
assert "GH_TOKEN" in output
|
||||
assert "GITHUB_TOKEN" in output
|
||||
else:
|
||||
assert f"Could not check latest release: {expected_reason}" in output
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_output_contains_no_traceback_no_url(
|
||||
self, _expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = (result.output or "") + (result.stderr or "")
|
||||
combined = strip_ansi(combined)
|
||||
assert "Traceback" not in combined
|
||||
assert "https://api.github.com" not in combined
|
||||
|
||||
|
||||
def _capture_request_via_urlopen():
|
||||
captured = {}
|
||||
|
||||
def _side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
return _mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
|
||||
return captured, _side_effect
|
||||
|
||||
|
||||
class TestUserStory3:
|
||||
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||
|
||||
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
|
||||
def test_no_authorization_header_when_both_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
|
||||
@pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES)
|
||||
def test_gh_token_never_appears_in_failure_output(
|
||||
self, _reason, side_effect, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
assert SENTINEL_GH_TOKEN not in combined
|
||||
|
||||
@pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES)
|
||||
def test_github_token_never_appears_in_failure_output(
|
||||
self, _reason, side_effect, monkeypatch
|
||||
):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
assert SENTINEL_GITHUB_TOKEN not in combined
|
||||
Reference in New Issue
Block a user