Compare commits

..

18 Commits

Author SHA1 Message Date
github-actions[bot]
b9b868ad99 chore: bump version to 0.6.2 2026-04-13 17:50:58 +00:00
Abdullah Khan
aa85b2f166 feat: Register "What-if Analysis" community extension (#2182)
* feat: implement read-only what-if analysis command

* chore: polish what-if analysis (Claude hints + optional tasks)

* refactor: deliver what-if analysis as a standalone extension

* Move What-if extension to standalone repo and update community catalog

* Fix: Reorder whatif extension alphabetically in community catalog
2026-04-13 12:01:37 -05:00
Fatima367
e27896e681 feat: add GitHub Issues Integration to community catalog (#2188)
* feat: add GitHub Issues Integration to community catalog

Add GitHub Issues Integration extension to the community catalog and README.

Extension provides:
- /speckit.github-issues.import - Import GitHub Issue and generate spec.md
- /speckit.github-issues.sync - Keep specs updated with issue changes
- /speckit.github-issues.link - Add bidirectional traceability

Resolves #2175

* Modify created_at and updated_at timestamps

Updated timestamps for created_at and updated_at fields.

* Update updated_at date in catalog.community.json
2026-04-13 11:22:46 -05:00
Furkan Köykıran
b67b2856b1 feat(agents): add Goose AI agent support (#2015)
* feat(integrations): add YamlIntegration base class for YAML recipe agents

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(integrations): add Goose integration subpackage with YAML recipe support

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(integrations): register GooseIntegration in the integration registry

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(agents): add YAML format support to CommandRegistrar for extension/preset commands

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(scripts): add goose agent type to bash update-agent-context script

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(scripts): add goose agent type to PowerShell update-agent-context script

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* docs(agents): add Goose to supported agents table and integration notes

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* docs(readme): add Goose to supported agents table

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(integrations): add YamlIntegrationTests base mixin for YAML agent testing

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(integrations): add Goose integration tests

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(consistency): add Goose consistency checks for config, registrar, and scripts

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* docs(agents): move Goose to YAML Format section in Command File Formats

Goose uses YAML recipes, not Markdown. Remove it from the Markdown Format
list and add a dedicated YAML Format subsection with a representative
recipe example showing prompt: | and {{args}} placeholders.

* refactor(agents): delegate render_yaml_command to YamlIntegration

Remove the duplicate header dict, yaml.safe_dump call, body indentation,
and _human_title logic from CommandRegistrar.render_yaml_command(). Delegate
to YamlIntegration._render_yaml() and _human_title() so YAML recipe output
stays consistent across the init-time generation and command-registration
code paths.

* fix(agents): guard alias output path against directory traversal

Validate that alias_file resolves within commands_dir before writing.
Uses the same resolve().relative_to() pattern already established in
extensions.py for ZIP path containment checks.

* docs(agents): add Goose to Multi-Agent Support comment list in update-agent-context.sh

* fix(agents): add goose to print_summary Usage line in bash context script

The print_summary() function listed all supported agents in its Usage
output but omitted goose, making it inconsistent with the header docs
and the error message in update_specific_agent().

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): add goose to Print-Summary Usage line in PowerShell context script

The Print-Summary function listed all supported agents in its Usage
output but omitted goose, making it inconsistent with the ValidateSet
and the header documentation.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): normalize description and title types in YamlIntegration.setup()

YAML frontmatter can contain non-string types (null, list, int).
Add isinstance checks matching TomlIntegration._extract_description()
to ensure Goose recipes always receive valid string fields.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): validate shared script exists before exec in Goose bash wrapper

Add Forge-style check that the shared update-agent-context.sh is
present and executable, producing a clear error instead of a cryptic
shell exec failure when the shared script is missing.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): validate shared script exists before invoke in Goose PowerShell wrapper

Add Forge-style Test-Path check that the shared update-agent-context.ps1
exists, producing a clear error instead of a cryptic PowerShell failure
when the shared script is missing.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): normalize title and description types in render_yaml_command()

Extension/preset frontmatter can contain non-string types. Add
isinstance checks matching the normalization in YamlIntegration.setup()
so both code paths produce valid Goose recipe fields.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): replace $ARGUMENTS with arg_placeholder in process_template()

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(agents): assert $ARGUMENTS absent from generated YAML recipes

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(agents): assert $ARGUMENTS absent from generated TOML commands

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(tests): rewrite docstring to avoid embedded triple-quote in TOML test

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

---------

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>
2026-04-13 07:55:44 -05:00
Ben Lawson
52ed84d723 Update ralph extension to v1.0.1 in community catalog (#2192)
- Extension ID: ralph
- Version: 1.0.0 → 1.0.1
- Author: Rubiss
- Changes: Fixed bash task count bug, removed hardcoded model,
  added regression tests, CI workflow, and release pipeline.
- Release: https://github.com/Rubiss/spec-kit-ralph/releases/tag/v1.0.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 07:42:08 -05:00
Manfred Riem
cdbea09e1a fix: skip docs deployment workflow on forks (#2171)
Add repository check to build and deploy jobs so they skip
with success on forks, avoiding failed Pages deployments for
contributors.
2026-04-10 17:57:47 -05:00
Manfred Riem
1cb794e516 chore: release 0.6.1, begin 0.6.2.dev0 development (#2162)
* chore: bump version to 0.6.1

* chore: begin 0.6.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-10 16:22:42 -05:00
Manfred Riem
43cb0fa7ab feat: add bundled lean preset with minimal workflow commands (#2161)
* feat: add bundled lean preset with minimal workflow commands

Add a lean preset that overrides the 5 core workflow commands (specify,
plan, tasks, implement, constitution) with minimal prompts that produce
exactly one artifact each — no extension hooks, no scripts, no git
branching, no templates.

Bundled preset infrastructure:
- Add _locate_bundled_preset() mirroring _locate_bundled_extension()
- Update 'specify init --preset' to try bundled -> catalog fallback
- Update 'specify preset add' to try bundled -> catalog fallback
- Add bundled guard in download_pack() for presets without download URLs
- Add lean to presets/catalog.json with 'bundled: true' marker
- Add lean to pyproject.toml force-include for wheel packaging
- Align error messages with bundled extension error pattern

Tests: 15 new tests (TestLeanPreset + TestBundledPresetLocator)

* refactor: address review — clean up unused imports, strengthen test assertions

- Remove unused MagicMock import and cache_dir setup in download test
- Assert 'bundled' and 'reinstall' in CLI error output (not just exit code)
- Mock catalog in missing-locally test for deterministic bundled error path
- Fix test versions to satisfy updated speckit_version >=0.6.0 requirement

* refactor: address review — fix constitution paths, add REINSTALL_COMMAND to presets.py

- Fix constitution path to .specify/memory/constitution.md in plan, tasks,
  implement commands (matching core command convention)
- Include REINSTALL_COMMAND in download_pack() bundled guard for consistent
  recovery instructions across bundled extensions and presets

* refactor: address review — explicit feature_directory paths, ZIP cleanup in finally

- Prefix spec.md/plan.md/tasks.md with <feature_directory>/ in plan, tasks,
  and implement commands so the agent doesn't operate on repo root by mistake
- Move ZIP unlink into finally block in init --preset path so cleanup runs
  even when install_from_zip raises (matching preset_add pattern)

* refactor: address review — replace Unicode em dashes with ASCII, fix grammar

- Replace all Unicode em dashes with ASCII hyphens in preset.yml and
  catalog.json to avoid decode errors on non-UTF-8 environments
- Fix grammar: 'store it in tasks.md' -> 'store them in tasks.md'

* refactor: address review - align task format between tasks and implement

- Remove undefined [P] marker from implement (lean uses sequential execution)
- Clarify checkbox update: 'change - [ ] to - [x]' instead of ambiguous '[X]'
- Simplify implement to execute tasks in order without parallel complexity

* refactor: address review - parse frontmatter instead of raw substring search

- Use CommandRegistrar.parse_frontmatter() to check for scripts/agent_scripts
  keys in YAML frontmatter instead of brittle 'scripts:' substring search
2026-04-10 16:18:06 -05:00
Quratulain-bilal
74e3f45aa9 Add Brownfield Bootstrap extension to community catalog (#2145)
- 4 commands: scan, bootstrap, validate, migrate for existing codebases
- 1 hook: after_init for auto-scanning project after spec-kit initialization
- Addresses community request in issue #1436 (30+ reactions)
2026-04-10 13:16:26 -05:00
Quratulain-bilal
97ea7cf6a0 Add CI Guard extension to community catalog (#2157)
* Add CI Guard extension to community catalog

Adds spec-kit-ci-guard: spec compliance gates for CI/CD pipelines.

5 commands:
- /speckit.ci.check — run all compliance checks with pass/fail
- /speckit.ci.report — generate requirement traceability matrix
- /speckit.ci.gate — configure merge gate rules and thresholds
- /speckit.ci.drift — detect bidirectional spec-to-code drift
- /speckit.ci.badge — generate spec compliance badges

2 hooks: before_implement, after_implement

Bridges the gap between SDD workflow and CI/CD enforcement.

* Fix updated_at to monotonically increase per Copilot review

Set to 2026-04-10T15:00:00Z (later than previous 2026-04-10T12:34:56Z).
2026-04-10 12:20:55 -05:00
Quratulain-bilal
f43b85096c Add SpecTest extension to community catalog (#2159)
* Add SpecTest extension to community catalog

Adds spec-kit-spectest: auto-generate test scaffolds from spec criteria.

4 commands:
- /speckit.test.generate — generate framework-native test scaffolds
- /speckit.test.coverage — map spec requirements to test coverage
- /speckit.test.gaps — find untested requirements with suggestions
- /speckit.test.plan — generate structured test plan documents

1 hook: after_implement (gap detection)

Bridges the spec-to-test gap in the SDD workflow.

* Update extensions/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix spectest created_at/updated_at to use current timestamp per Copilot review

Set both to 2026-04-10T16:00:00Z instead of midnight.

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 12:11:25 -05:00
Manfred Riem
d1b95c2f59 fix: bundled extensions should not have download URLs (#2155)
* fix: bundled extensions should not have download URLs (#2151)

- Remove selftest from default catalog (not a published extension)
- Replace download_url with 'bundled: true' flag for git extension
- Add bundled check in extension add flow with clear error message
  when bundled extension is missing from installed package
- Add bundled check in download_extension() with specific error
- Direct users to reinstall via uv with full GitHub URL
- Add 3 regression tests for bundled extension handling

* refactor: address review - move bundled check up-front, extract reinstall constant

- Move bundled check before download_url inspection in download_extension()
  so bundled extensions can never be downloaded even with a URL present
- Extract REINSTALL_COMMAND constant to avoid duplicated install strings

* fix: allow bundled extensions with download_url to be updated

Bundled extensions should only be blocked from download when they have
no download_url. If a newer version is published to the catalog with a
URL, users should be able to install it to get bug fixes.

Add test for bundled-with-URL download path.
2026-04-10 11:29:18 -05:00
Quratulain-bilal
8bb08ae1a0 Add PR Bridge extension to community catalog (#2148)
- 3 commands: generate PR descriptions, reviewer checklists, and change summaries
- 1 hook: after_implement for auto-generating PR description
- Closes the SDD workflow loop: specify → plan → tasks → implement → PR
2026-04-10 09:51:20 -05:00
Gabriel Henrique
5732de60d0 feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156)
Use SkillsIntegration so workflows ship as speckit-*/SKILL.md. Update
init next-steps, extension hook invocation, docs, and tests.

Made-with: Cursor
2026-04-10 09:35:19 -05:00
Quratulain-bilal
b6e19b49ec Add TinySpec extension to community catalog (#2147)
* Add TinySpec extension to community catalog

- 3 commands: tinyspec, implement, classify for lightweight single-file workflow
- 1 hook: before_specify for auto-classifying task complexity
- Addresses community request in issue #1174 (22+ reactions)

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-10 09:10:42 -05:00
Ismael
7f1e38491f chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146)
* chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1

*  Removed invalid aliases

* Change updated at
2026-04-10 07:32:48 -05:00
Wes Etheredge
bc0288832e Add Status Report extension to community catalog (#2123)
- Extension ID: status-report
- Version: 1.2.5
- Author: Open-Agent-Tools
- Description: Project status, feature progress, and next-action recommendations for spec-driven workflows

Co-authored-by: Unserious AI <121459476+unseriousAI@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:27:15 -05:00
Manfred Riem
e70495c2b8 chore: release 0.6.0, begin 0.6.1.dev0 development (#2144)
* chore: bump version to 0.6.0

* chore: begin 0.6.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-09 14:17:11 -05:00
34 changed files with 2192 additions and 221 deletions

View File

@@ -26,6 +26,7 @@ concurrency:
jobs:
# Build job
build:
if: github.repository == 'github/spec-kit'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -56,6 +57,7 @@ jobs:
# Deploy job
deploy:
if: github.repository == 'github/spec-kit'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

View File

@@ -17,7 +17,7 @@ Each AI agent is a self-contained **integration subpackage** under `src/specify_
```
src/specify_cli/integrations/
├── __init__.py # INTEGRATION_REGISTRY + _register_builtins()
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, SkillsIntegration
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration
├── manifest.py # IntegrationManifest (file tracking)
├── claude/ # Example: SkillsIntegration subclass
│ ├── __init__.py # ClaudeIntegration class
@@ -48,6 +48,7 @@ The registry is the **single source of truth for Python integration metadata**.
|---|---|
| Standard markdown commands (`.md`) | `MarkdownIntegration` |
| TOML-format commands (`.toml`) | `TomlIntegration` |
| YAML recipe files (`.yaml`) | `YamlIntegration` |
| Skill directories (`speckit-<name>/SKILL.md`) | `SkillsIntegration` |
| Fully custom output (companion files, settings merge, etc.) | `IntegrationBase` directly |
@@ -343,16 +344,82 @@ Command content with {SCRIPT} and {{args}} placeholders.
"""
```
### YAML Format
Used by: Goose
```yaml
version: 1.0.0
title: "Command Title"
description: "Command description"
author:
contact: spec-kit
extensions:
- type: builtin
name: developer
activities:
- Spec-Driven Development
prompt: |
Command content with {SCRIPT} and {{args}} placeholders.
```
## Argument Patterns
Different agents use different argument placeholders. The placeholder used in command files is always taken from `registrar_config["args"]` for each integration — check there first when in doubt:
- **Markdown/prompt-based**: `$ARGUMENTS` (default for most markdown agents)
- **TOML-based**: `{{args}}` (e.g., Gemini)
- **YAML-based**: `{{args}}` (e.g., Goose)
- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`)
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
## Special Processing Requirements
Some agents require custom processing beyond the standard template transformations:
### Copilot Integration
GitHub Copilot has unique requirements:
- Commands use `.agent.md` extension (not `.md`)
- 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`
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
1. Processes templates with `process_template()`
2. Generates companion `.prompt.md` files
3. Merges VS Code settings
### Forge Integration
Forge has special frontmatter and argument requirements:
- Uses `{{parameters}}` instead of `$ARGUMENTS`
- Strips `handoffs` frontmatter key (Forge-specific collaboration feature)
- Injects `name` field into frontmatter when missing
Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
1. Inherits standard template processing from `MarkdownIntegration`
2. Adds extra `$ARGUMENTS``{{parameters}}` replacement after template processing
3. Applies Forge-specific transformations via `_apply_forge_transformations()`
4. Strips `handoffs` frontmatter key
5. Injects missing `name` fields
6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text
### Goose Integration
Goose is a YAML-format agent using Block's recipe system:
- Uses `.goose/recipes/` directory for YAML recipe files
- Uses `{{args}}` argument placeholder
- Produces YAML with `prompt: |` block scalar for command content
Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
1. Processes templates through the standard placeholder pipeline
2. Extracts title and description from frontmatter
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge)
## Common Pitfalls
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.

View File

@@ -2,6 +2,33 @@
<!-- insert new changelog below this comment -->
## [0.6.2] - 2026-04-13
### Changed
- feat: Register "What-if Analysis" community extension (#2182)
- feat: add GitHub Issues Integration to community catalog (#2188)
- feat(agents): add Goose AI agent support (#2015)
- Update ralph extension to v1.0.1 in community catalog (#2192)
- fix: skip docs deployment workflow on forks (#2171)
- chore: release 0.6.1, begin 0.6.2.dev0 development (#2162)
## [0.6.1] - 2026-04-10
### Changed
- feat: add bundled lean preset with minimal workflow commands (#2161)
- Add Brownfield Bootstrap extension to community catalog (#2145)
- Add CI Guard extension to community catalog (#2157)
- Add SpecTest extension to community catalog (#2159)
- fix: bundled extensions should not have download URLs (#2155)
- Add PR Bridge extension to community catalog (#2148)
- feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156)
- Add TinySpec extension to community catalog (#2147)
- chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146)
- Add Status Report extension to community catalog (#2123)
- chore: release 0.6.0, begin 0.6.1.dev0 development (#2144)
## [0.6.0] - 2026-04-09
### Changed

View File

@@ -186,8 +186,10 @@ The following community-contributed extensions are available in [`catalog.commun
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
@@ -197,6 +199,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
@@ -211,6 +214,7 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| 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) |
| 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) |
@@ -229,11 +233,15 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec 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 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
@@ -304,6 +312,7 @@ Community projects that extend, visualize, or build on Spec Kit:
| [Cursor](https://cursor.sh/) | ✅ | |
| [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
| [Goose](https://block.github.io/goose/) | ✅ | Uses YAML recipe format in `.goose/recipes/` with slash command support |
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
| [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support |
| [Jules](https://jules.google.com/) | ✅ | |
@@ -648,7 +657,7 @@ specify init . --force --ai claude
specify init --here --force --ai claude
```
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, 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:
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 claude --ignore-agent-tools

View File

@@ -292,7 +292,7 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a
```bash
ls -la .claude/commands/ # Claude Code
ls -la .gemini/commands/ # Gemini
ls -la .cursor/commands/ # Cursor
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
```

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-09T00:00:00Z",
"updated_at": "2026-04-13T14:39:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -138,6 +138,38 @@
"created_at": "2026-04-08T00:00:00Z",
"updated_at": "2026-04-08T00:00:00Z"
},
"brownfield": {
"name": "Brownfield Bootstrap",
"id": "brownfield",
"description": "Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-brownfield/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-brownfield",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-brownfield",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 4,
"hooks": 1
},
"tags": [
"brownfield",
"bootstrap",
"existing-project",
"migration",
"onboarding"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z"
},
"bugfix": {
"name": "Bugfix Workflow",
"id": "bugfix",
@@ -205,6 +237,39 @@
"created_at": "2026-03-29T00:00:00Z",
"updated_at": "2026-03-29T00:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
"id": "ci-guard",
"description": "Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-ci-guard",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-ci-guard",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 5,
"hooks": 2
},
"tags": [
"ci-cd",
"compliance",
"governance",
"quality-gate",
"drift-detection",
"automation"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T17:00:00Z",
"updated_at": "2026-04-10T17:00:00Z"
},
"checkpoint": {
"name": "Checkpoint Extension",
"id": "checkpoint",
@@ -584,6 +649,46 @@
"created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-31T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration",
"id": "github-issues",
"description": "Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability",
"author": "Fatima367",
"version": "1.0.0",
"download_url": "https://github.com/Fatima367/spec-kit-github-issues/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Fatima367/spec-kit-github-issues",
"homepage": "https://github.com/Fatima367/spec-kit-github-issues",
"documentation": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/README.md",
"changelog": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "gh",
"version": ">=2.0.0",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"integration",
"github",
"issues",
"import",
"sync",
"traceability"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-12T15:30:00Z",
"updated_at": "2026-04-13T14:39:00Z"
},
"iterate": {
"name": "Iterate",
"id": "iterate",
@@ -1030,6 +1135,38 @@
"created_at": "2026-03-27T08:22:30Z",
"updated_at": "2026-03-27T08:22:30Z"
},
"pr-bridge": {
"name": "PR Bridge",
"id": "pr-bridge",
"description": "Auto-generate pull request descriptions, checklists, and summaries from spec artifacts.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"pull-request",
"automation",
"traceability",
"workflow",
"review"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z"
},
"presetify": {
"name": "Presetify",
"id": "presetify",
@@ -1128,8 +1265,8 @@
"id": "ralph",
"description": "Autonomous implementation loop using AI agent CLI.",
"author": "Rubiss",
"version": "1.0.0",
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.zip",
"version": "1.0.1",
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/Rubiss/spec-kit-ralph",
"homepage": "https://github.com/Rubiss/spec-kit-ralph",
"documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md",
@@ -1162,7 +1299,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-03-09T00:00:00Z"
"updated_at": "2026-04-12T19:00:00Z"
},
"reconcile": {
"name": "Reconcile Extension",
@@ -1331,8 +1468,8 @@
"id": "review",
"description": "Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.",
"author": "ismaelJimenez",
"version": "1.0.0",
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip",
"version": "1.0.1",
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/ismaelJimenez/spec-kit-review",
"homepage": "https://github.com/ismaelJimenez/spec-kit-review",
"documentation": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md",
@@ -1358,7 +1495,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-06T00:00:00Z"
"updated_at": "2026-04-09T00:00:00Z"
},
"security-review": {
"name": "Security Review",
@@ -1454,6 +1591,39 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
"spectest": {
"name": "SpecTest",
"id": "spectest",
"description": "Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-spectest/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-spectest",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-spectest",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 4,
"hooks": 1
},
"tags": [
"testing",
"test-generation",
"coverage",
"quality",
"automation",
"traceability"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T16:00:00Z",
"updated_at": "2026-04-10T16:00:00Z"
},
"staff-review": {
"name": "Staff Review Extension",
"id": "staff-review",
@@ -1516,6 +1686,36 @@
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
"status-report": {
"name": "Status Report",
"id": "status-report",
"description": "Project status, feature progress, and next-action recommendations for spec-driven workflows.",
"author": "Open-Agent-Tools",
"version": "1.2.5",
"download_url": "https://github.com/Open-Agent-Tools/spec-kit-status/archive/refs/tags/v1.2.5.zip",
"repository": "https://github.com/Open-Agent-Tools/spec-kit-status",
"homepage": "https://github.com/Open-Agent-Tools/spec-kit-status",
"documentation": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/README.md",
"changelog": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"workflow",
"project-management",
"status"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-08T15:05:14Z",
"updated_at": "2026-04-08T15:05:14Z"
},
"superb": {
"name": "Superpowers Bridge",
"id": "superb",
@@ -1591,6 +1791,38 @@
"created_at": "2026-03-02T00:00:00Z",
"updated_at": "2026-03-02T00:00:00Z"
},
"tinyspec": {
"name": "TinySpec",
"id": "tinyspec",
"description": "Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-tinyspec",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-tinyspec",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"lightweight",
"small-tasks",
"workflow",
"productivity",
"efficiency"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z"
},
"v-model": {
"name": "V-Model Extension Pack",
"id": "v-model",
@@ -1628,8 +1860,8 @@
"id": "verify",
"description": "Post-implementation quality gate that validates implemented code against specification artifacts.",
"author": "ismaelJimenez",
"version": "1.0.0",
"download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.zip",
"version": "1.0.3",
"download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.3.zip",
"repository": "https://github.com/ismaelJimenez/spec-kit-verify",
"homepage": "https://github.com/ismaelJimenez/spec-kit-verify",
"documentation": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/README.md",
@@ -1653,7 +1885,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-03T00:00:00Z",
"updated_at": "2026-03-03T00:00:00Z"
"updated_at": "2026-04-09T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
@@ -1686,6 +1918,34 @@
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
"whatif": {
"name": "What-if Analysis",
"id": "whatif",
"description": "Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them.",
"author": "DevAbdullah90",
"version": "1.0.0",
"repository": "https://github.com/DevAbdullah90/spec-kit-whatif",
"homepage": "https://github.com/DevAbdullah90/spec-kit-whatif",
"documentation": "https://github.com/DevAbdullah90/spec-kit-whatif/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"analysis",
"planning",
"simulation"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"worktree": {
"name": "Worktree Isolation",
"id": "worktree",
@@ -1719,4 +1979,4 @@
"updated_at": "2026-04-09T00:00:00Z"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-06T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
"extensions": {
"git": {
@@ -10,27 +10,13 @@
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip",
"bundled": true,
"tags": [
"git",
"branching",
"workflow",
"core"
]
},
"selftest": {
"name": "Spec Kit Self-Test Utility",
"id": "selftest",
"version": "1.0.0",
"description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip",
"tags": [
"testing",
"core",
"utility"
]
}
}
}

View File

@@ -1,6 +1,22 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"presets": {}
"presets": {
"lean": {
"name": "Lean Workflow",
"id": "lean",
"version": "1.0.0",
"description": "Minimal core workflow commands - just the prompt, just the artifact",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"bundled": true,
"tags": [
"lean",
"minimal",
"workflow",
"core"
]
}
}
}

View File

@@ -0,0 +1,15 @@
---
description: Create or update the project constitution.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
- Project name, guiding principles, non-negotiable rules
- Derive from user input and existing repo context (README, docs)

View File

@@ -0,0 +1,22 @@
---
description: Execute the implementation plan by processing all tasks in tasks.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md` and `<feature_directory>/tasks.md`.
3. **Execute tasks** in order:
- Complete each task before moving to the next
- Mark completed tasks by changing `- [ ]` to `- [x]` in `<feature_directory>/tasks.md`
- Halt on failure and report the issue
4. **Validate**: Verify all tasks are completed and the implementation matches the spec.

View File

@@ -0,0 +1,19 @@
---
description: Create a plan and store it in plan.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md`.
3. Create an implementation plan and store it in `<feature_directory>/plan.md`.
- Technical context: tech stack, dependencies, project structure
- Design decisions, architecture, file structure

View File

@@ -0,0 +1,23 @@
---
description: Create a specification and store it in spec.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. **Ask the user** for the feature directory path (e.g., `specs/my-feature`). Do not proceed until provided.
2. Create the directory and write `.specify/feature.json`:
```json
{ "feature_directory": "<feature_directory>" }
```
3. Create a specification from the user input and store it in `<feature_directory>/spec.md`.
- Overview, functional requirements, user scenarios, success criteria
- Every requirement must be testable
- Make informed defaults for unspecified details

View File

@@ -0,0 +1,19 @@
---
description: Create the tasks needed for implementation and store them in tasks.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md`.
3. Create dependency-ordered implementation tasks and store them in `<feature_directory>/tasks.md`.
- Every task uses checklist format: `- [ ] [TaskID] Description with file path`
- Organized by phase: setup, foundational, user stories in priority order, polish

50
presets/lean/preset.yml Normal file
View File

@@ -0,0 +1,50 @@
schema_version: "1.0"
preset:
id: "lean"
name: "Lean Workflow"
version: "1.0.0"
description: "Minimal core workflow commands - just the prompt, just the artifact"
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"
requires:
speckit_version: ">=0.6.0"
provides:
templates:
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"
description: "Lean specify - create spec.md from a feature description"
replaces: "speckit.specify"
- type: "command"
name: "speckit.plan"
file: "commands/speckit.plan.md"
description: "Lean plan - create plan.md from the spec"
replaces: "speckit.plan"
- type: "command"
name: "speckit.tasks"
file: "commands/speckit.tasks.md"
description: "Lean tasks - create tasks.md from plan and spec"
replaces: "speckit.tasks"
- type: "command"
name: "speckit.implement"
file: "commands/speckit.implement.md"
description: "Lean implement - execute tasks from tasks.md"
replaces: "speckit.implement"
- type: "command"
name: "speckit.constitution"
file: "commands/speckit.constitution.md"
description: "Lean constitution - create or update project constitution"
replaces: "speckit.constitution"
tags:
- "lean"
- "minimal"
- "workflow"

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.6.0"
version = "0.6.2"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [
@@ -41,6 +41,8 @@ packages = ["src/specify_cli"]
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
# Bundled extensions (installable via `specify extension add <name>`)
"extensions/git" = "specify_cli/core_pack/extensions/git"
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
"presets/lean" = "specify_cli/core_pack/presets/lean"
[project.optional-dependencies]
test = [

View File

@@ -30,12 +30,12 @@
#
# 5. Multi-Agent Support
# - Handles agent-specific file paths and naming conventions
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Antigravity or Generic
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Goose, Antigravity or Generic
# - Can update single agents or all existing agent files
# - Creates default Claude file if no agent files exist
#
# Usage: ./update-agent-context.sh [agent_type]
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic
# Leave empty to update all existing agent files
set -e
@@ -74,7 +74,7 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
QODER_FILE="$REPO_ROOT/QODER.md"
# Amp, Kiro CLI, IBM Bob, Pi, and Forge all share AGENTS.md — use AGENTS_FILE to avoid
# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid
# updating the same file multiple times.
AMP_FILE="$AGENTS_FILE"
SHAI_FILE="$REPO_ROOT/SHAI.md"
@@ -710,12 +710,15 @@ update_specific_agent() {
forge)
update_agent_file "$AGENTS_FILE" "Forge" || return 1
;;
goose)
update_agent_file "$AGENTS_FILE" "Goose" || return 1
;;
generic)
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
;;
*)
log_error "Unknown agent type '$agent_type'"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic"
exit 1
;;
esac
@@ -759,7 +762,7 @@ update_all_existing_agents() {
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _all_ok=false
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
_update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
@@ -800,7 +803,7 @@ print_summary() {
fi
echo
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]"
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]"
}
#==============================================================================

View File

@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
2. Plan Data Extraction
3. Agent File Management (create from template or update existing)
4. Content Generation (technology stack, recent changes, timestamp)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, generic)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, goose, generic)
.PARAMETER AgentType
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
#>
param(
[Parameter(Position=0)]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','generic')]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','goose','generic')]
[string]$AgentType
)
@@ -68,6 +68,7 @@ $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md'
$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md'
$FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$GOOSE_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
@@ -417,8 +418,9 @@ function Update-SpecificAgent {
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' }
'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' }
'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' }
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic'; return $false }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic'; return $false }
}
}
@@ -460,7 +462,7 @@ function Update-AllExistingAgents {
if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }
if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }
if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }
if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge')) { $ok = $false }
if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false }
if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }
if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }
if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }
@@ -490,7 +492,7 @@ function Print-Summary {
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
Write-Host ''
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]'
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]'
}
function Main {

View File

@@ -621,6 +621,31 @@ def _locate_bundled_extension(extension_id: str) -> Path | None:
return None
def _locate_bundled_preset(preset_id: str) -> Path | None:
"""Return the path to a bundled preset, or None.
Checks the wheel's core_pack first, then falls back to the
source-checkout ``presets/<id>/`` directory.
"""
import re as _re
if not _re.match(r'^[a-z0-9-]+$', preset_id):
return None
core = _locate_core_pack()
if core is not None:
candidate = core / "presets" / preset_id
if (candidate / "preset.yml").is_file():
return candidate
# Source-checkout / editable install: look relative to repo root
repo_root = Path(__file__).parent.parent.parent
candidate = repo_root / "presets" / preset_id
if (candidate / "preset.yml").is_file():
return candidate
return None
def _install_shared_infra(
project_path: Path,
script_type: str,
@@ -1266,27 +1291,44 @@ def init(
preset_manager = PresetManager(project_path)
speckit_ver = get_speckit_version()
# Try local directory first, then catalog
# Try local directory first, then bundled, then catalog
local_path = Path(preset).resolve()
if local_path.is_dir() and (local_path / "preset.yml").exists():
preset_manager.install_from_directory(local_path, speckit_ver)
else:
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
bundled_path = _locate_bundled_preset(preset)
if bundled_path:
preset_manager.install_from_directory(bundled_path, speckit_ver)
else:
try:
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
# Clean up downloaded ZIP to avoid cache accumulation
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
elif pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
console.print(
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"This usually means the spec-kit installation is incomplete or corrupted."
)
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
else:
zip_path = None
try:
zip_path.unlink(missing_ok=True)
except OSError:
# Best-effort cleanup; failure to delete is non-fatal
pass
except PresetError as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
except PresetError as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
finally:
if zip_path is not None:
# Clean up downloaded ZIP to avoid cache accumulation
try:
zip_path.unlink(missing_ok=True)
except OSError:
# Best-effort cleanup; failure to delete is non-fatal
pass
except Exception as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
@@ -1338,7 +1380,7 @@ def init(
step_num = 2
# Determine skill display mode for the next-steps panel.
# Skills integrations (codex, kimi, agy, trae) should show skill invocation syntax.
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
from .integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
@@ -1347,7 +1389,8 @@ def init(
kimi_skill_mode = selected_ai == "kimi"
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
trae_skill_mode = selected_ai == "trae"
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode
if codex_skill_mode and not ai_skills:
# Integration path installed skills; show the helpful notice
@@ -1356,6 +1399,9 @@ def init(
if claude_skill_mode and not ai_skills:
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
step_num += 1
if cursor_agent_skill_mode and not ai_skills:
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"
def _display_cmd(name: str) -> str:
@@ -1365,6 +1411,8 @@ def init(
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if cursor_agent_skill_mode:
return f"/speckit-{name}"
return f"/speckit.{name}"
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
@@ -2134,28 +2182,50 @@ def preset_add(
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
elif pack_id:
catalog = PresetCatalog(project_root)
pack_info = catalog.get_pack_info(pack_id)
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
raise typer.Exit(1)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
raise typer.Exit(1)
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
try:
zip_path = catalog.download_pack(pack_id)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
# Try bundled preset first, then catalog
bundled_path = _locate_bundled_preset(pack_id)
if bundled_path:
console.print(f"Installing bundled preset [cyan]{pack_id}[/cyan]...")
manifest = manager.install_from_directory(bundled_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
finally:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
else:
catalog = PresetCatalog(project_root)
pack_info = catalog.get_pack_info(pack_id)
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
raise typer.Exit(1)
# Bundled presets should have been caught above; if we reach
# here the bundled files are missing from the installation.
if pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
console.print(
f"[red]Error:[/red] Preset '{pack_id}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"\nThis usually means the spec-kit installation is incomplete or corrupted."
)
console.print("Try reinstalling spec-kit:")
console.print(f" {REINSTALL_COMMAND}")
raise typer.Exit(1)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
raise typer.Exit(1)
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
try:
zip_path = catalog.download_pack(pack_id)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
finally:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
else:
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)
@@ -3001,7 +3071,7 @@ def extension_add(
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND
project_root = Path.cwd()
@@ -3103,6 +3173,19 @@ def extension_add(
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
if bundled_path is None:
# Bundled extensions without a download URL must come from the local package
if ext_info.get("bundled") and not ext_info.get("download_url"):
console.print(
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"\nThis usually means the spec-kit installation is incomplete or corrupted."
)
console.print("Try reinstalling spec-kit:")
console.print(f" {REINSTALL_COMMAND}")
raise typer.Exit(1)
# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")

View File

@@ -18,6 +18,7 @@ import yaml
def _build_agent_configs() -> dict[str, Any]:
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
from specify_cli.integrations import INTEGRATION_REGISTRY
configs: dict[str, dict[str, Any]] = {}
for key, integration in INTEGRATION_REGISTRY.items():
if key == "generic":
@@ -75,7 +76,7 @@ class CommandRegistrar:
return {}, content
frontmatter_str = content[3:end_marker].strip()
body = content[end_marker + 3:].strip()
body = content[end_marker + 3 :].strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
@@ -100,7 +101,9 @@ class CommandRegistrar:
if not fm:
return ""
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True)
yaml_str = yaml.dump(
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
@@ -146,16 +149,16 @@ class CommandRegistrar:
# ".specify/extensions/<ext>/scripts/..." remain intact.
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
)
return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/")
return text.replace(".specify/.specify/", ".specify/").replace(
".specify.specify/", ".specify/"
)
def render_markdown_command(
self,
frontmatter: dict,
body: str,
source_id: str,
context_note: str = None
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
) -> str:
"""Render command in Markdown format.
@@ -172,12 +175,7 @@ class CommandRegistrar:
context_note = f"\n<!-- Source: {source_id} -->\n"
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
def render_toml_command(
self,
frontmatter: dict,
body: str,
source_id: str
) -> str:
def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str:
"""Render command in TOML format.
Args:
@@ -192,7 +190,7 @@ class CommandRegistrar:
if "description" in frontmatter:
toml_lines.append(
f'description = {self._render_basic_toml_string(frontmatter["description"])}'
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
)
toml_lines.append("")
@@ -226,6 +224,41 @@ class CommandRegistrar:
)
return f'"{escaped}"'
def render_yaml_command(
self,
frontmatter: dict,
body: str,
source_id: str,
cmd_name: str = "",
) -> str:
"""Render command in YAML recipe format for Goose.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
cmd_name: Command name used as title fallback
Returns:
Formatted YAML recipe file content
"""
from specify_cli.integrations.base import YamlIntegration
title = frontmatter.get("title", "") or frontmatter.get("name", "")
if not isinstance(title, str):
title = str(title) if title is not None else ""
if not title and cmd_name:
title = YamlIntegration._human_title(cmd_name)
if not title and source_id:
title = YamlIntegration._human_title(Path(str(source_id)).stem)
if not title:
title = "Command"
description = frontmatter.get("description", "")
if not isinstance(description, str):
description = str(description) if description is not None else ""
return YamlIntegration._render_yaml(title, description, body, source_id)
def render_skill_command(
self,
agent_name: str,
@@ -252,9 +285,13 @@ class CommandRegistrar:
frontmatter = {}
if agent_name in {"codex", "kimi"}:
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
description = frontmatter.get(
"description", f"Spec-kit workflow command: {skill_name}"
)
skill_frontmatter = self.build_skill_frontmatter(
agent_name,
skill_name,
@@ -288,7 +325,9 @@ class CommandRegistrar:
return skill_frontmatter
@staticmethod
def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str:
def resolve_skill_placeholders(
agent_name: str, frontmatter: dict, body: str, project_root: Path
) -> str:
"""Resolve script placeholders for skills-backed agents."""
try:
from . import load_init_options
@@ -312,7 +351,9 @@ class CommandRegistrar:
script_variant = init_opts.get("script")
if script_variant not in {"sh", "ps"}:
fallback_order = []
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
default_variant = (
"ps" if platform.system().lower().startswith("win") else "sh"
)
secondary_variant = "sh" if default_variant == "ps" else "ps"
if default_variant in scripts or default_variant in agent_scripts:
@@ -334,7 +375,9 @@ class CommandRegistrar:
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
agent_script_command = (
agent_scripts.get(script_variant) if script_variant else None
)
if agent_script_command:
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
@@ -342,7 +385,9 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str
) -> str:
"""Convert argument placeholder format.
Args:
@@ -356,14 +401,16 @@ class CommandRegistrar:
return content.replace(from_placeholder, to_placeholder)
@staticmethod
def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:
def _compute_output_name(
agent_name: str, cmd_name: str, agent_config: Dict[str, Any]
) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
return cmd_name
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
short_name = short_name[len("speckit.") :]
short_name = short_name.replace(".", "-")
return f"speckit-{short_name}"
@@ -375,7 +422,7 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None
context_note: str = None,
) -> List[str]:
"""Register commands for a specific agent.
@@ -432,12 +479,24 @@ class CommandRegistrar:
if agent_config["extension"] == "/SKILL.md":
output = self.render_skill_command(
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
agent_name,
output_name,
frontmatter,
body,
source_id,
cmd_file,
project_root,
)
elif agent_config["format"] == "markdown":
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
output = self.render_markdown_command(
frontmatter, body, source_id, context_note
)
elif agent_config["format"] == "toml":
output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml":
output = self.render_yaml_command(
frontmatter, body, source_id, cmd_name
)
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
@@ -451,34 +510,68 @@ class CommandRegistrar:
registered.append(cmd_name)
for alias in cmd_info.get("aliases", []):
alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
alias_output_name = self._compute_output_name(
agent_name, alias, agent_config
)
# For agents with inject_name, render with alias-specific frontmatter
if agent_config.get("inject_name"):
alias_frontmatter = deepcopy(frontmatter)
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
alias_frontmatter["name"] = format_name(alias) if format_name else alias
alias_frontmatter["name"] = (
format_name(alias) if format_name else alias
)
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root
agent_name,
alias_output_name,
alias_frontmatter,
body,
source_id,
cmd_file,
project_root,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note)
alias_output = self.render_markdown_command(
alias_frontmatter, body, source_id, context_note
)
elif agent_config["format"] == "toml":
alias_output = self.render_toml_command(alias_frontmatter, body, source_id)
alias_output = self.render_toml_command(
alias_frontmatter, body, source_id
)
elif agent_config["format"] == "yaml":
alias_output = self.render_yaml_command(
alias_frontmatter, body, source_id, alias
)
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
raise ValueError(
f"Unsupported format: {agent_config['format']}"
)
else:
# For other agents, reuse the primary output
alias_output = output
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
agent_name,
alias_output_name,
frontmatter,
body,
source_id,
cmd_file,
project_root,
)
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
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}"
)
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(alias_output, encoding="utf-8")
if agent_name == "copilot":
@@ -506,7 +599,7 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None
context_note: str = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -529,8 +622,12 @@ class CommandRegistrar:
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id, source_dir, project_root,
context_note=context_note
agent_name,
commands,
source_id,
source_dir,
project_root,
context_note=context_note,
)
if registered:
results[agent_name] = registered
@@ -540,9 +637,7 @@ class CommandRegistrar:
return results
def unregister_commands(
self,
registered_commands: Dict[str, List[str]],
project_root: Path
self, registered_commands: Dict[str, List[str]], project_root: Path
) -> None:
"""Remove previously registered command files from agent directories.
@@ -559,13 +654,17 @@ class CommandRegistrar:
commands_dir = project_root / agent_config["dir"]
for cmd_name in cmd_names:
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
if cmd_file.exists():
cmd_file.unlink()
if agent_name == "copilot":
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
prompt_file = (
project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
)
if prompt_file.exists():
prompt_file.unlink()

View File

@@ -38,6 +38,8 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
})
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
def _load_core_command_names() -> frozenset[str]:
"""Discover bundled core command names from the packaged templates.
@@ -1870,6 +1872,14 @@ class ExtensionCatalog:
if not ext_info:
raise ExtensionError(f"Extension '{extension_id}' not found in catalog")
# Bundled extensions without a download URL must be installed locally
if ext_info.get("bundled") and not ext_info.get("download_url"):
raise ExtensionError(
f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
f"Try reinstalling: {REINSTALL_COMMAND}"
)
download_url = ext_info.get("download_url")
if not download_url:
raise ExtensionError(f"Extension '{extension_id}' has no download URL")
@@ -2170,6 +2180,7 @@ class HookExecutor:
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
@@ -2178,6 +2189,8 @@ class HookExecutor:
return f"/{skill_name}"
if kimi_skill_mode and skill_name:
return f"/skill:{skill_name}"
if cursor_skill_mode and skill_name:
return f"/{skill_name}"
return f"/{command_id}"

View File

@@ -36,6 +36,7 @@ def get_integration(key: str) -> IntegrationBase | None:
# -- Register built-in integrations --------------------------------------
def _register_builtins() -> None:
"""Register all built-in integrations.
@@ -58,6 +59,7 @@ def _register_builtins() -> None:
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
from .goose import GooseIntegration
from .iflow import IflowIntegration
from .junie import JunieIntegration
from .kilocode import KilocodeIntegration
@@ -87,6 +89,7 @@ def _register_builtins() -> None:
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
_register(GooseIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())

View File

@@ -28,6 +28,7 @@ if TYPE_CHECKING:
# IntegrationOption
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class IntegrationOption:
"""Declares an option that an integration accepts via ``--integration-options``.
@@ -51,6 +52,7 @@ class IntegrationOption:
# IntegrationBase — abstract base class
# ---------------------------------------------------------------------------
class IntegrationBase(ABC):
"""Abstract base class every integration must implement.
@@ -275,7 +277,7 @@ class IntegrationBase(ABC):
2. Replace ``{SCRIPT}`` with the extracted script command
3. Extract ``agent_scripts.<script_type>`` and replace ``{AGENT_SCRIPT}``
4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter
5. Replace ``{ARGS}`` with *arg_placeholder*
5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
6. Replace ``__AGENT__`` with *agent_name*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
"""
@@ -348,8 +350,9 @@ class IntegrationBase(ABC):
output_lines.append(line)
content = "".join(output_lines)
# 5. Replace {ARGS}
# 5. Replace {ARGS} and $ARGUMENTS
content = content.replace("{ARGS}", arg_placeholder)
content = content.replace("$ARGUMENTS", arg_placeholder)
# 6. Replace __AGENT__
content = content.replace("__AGENT__", agent_name)
@@ -358,6 +361,7 @@ class IntegrationBase(ABC):
# CommandRegistrar so extension-local paths are preserved and
# boundary rules stay consistent across the codebase.
from specify_cli.agents import CommandRegistrar
content = CommandRegistrar.rewrite_project_relative_paths(content)
return content
@@ -433,9 +437,7 @@ class IntegrationBase(ABC):
**opts: Any,
) -> list[Path]:
"""High-level install — calls ``setup()`` and returns created files."""
return self.setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
return self.setup(project_root, manifest, parsed_options=parsed_options, **opts)
def uninstall(
self,
@@ -452,6 +454,7 @@ class IntegrationBase(ABC):
# MarkdownIntegration — covers ~20 standard agents
# ---------------------------------------------------------------------------
class MarkdownIntegration(IntegrationBase):
"""Concrete base for integrations that use standard Markdown commands.
@@ -492,12 +495,18 @@ class MarkdownIntegration(IntegrationBase):
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS"
arg_placeholder = (
self.registrar_config.get("args", "$ARGUMENTS")
if self.registrar_config
else "$ARGUMENTS"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
processed, dest / dst_name, project_root, manifest
@@ -512,6 +521,7 @@ class MarkdownIntegration(IntegrationBase):
# TomlIntegration — TOML-format agents (Gemini, Tabnine)
# ---------------------------------------------------------------------------
class TomlIntegration(IntegrationBase):
"""Concrete base for integrations that use TOML command format.
@@ -603,13 +613,17 @@ class TomlIntegration(IntegrationBase):
if "'''" not in value and not value.endswith("'"):
return "'''\n" + value + "'''"
return '"' + (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
) + '"'
return (
'"'
+ (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
+ '"'
)
@staticmethod
def _render_toml(description: str, body: str) -> str:
@@ -628,7 +642,9 @@ class TomlIntegration(IntegrationBase):
toml_lines: list[str] = []
if description:
toml_lines.append(f"description = {TomlIntegration._render_toml_string(description)}")
toml_lines.append(
f"description = {TomlIntegration._render_toml_string(description)}"
)
toml_lines.append("")
body = body.rstrip("\n")
@@ -665,13 +681,19 @@ class TomlIntegration(IntegrationBase):
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}"
arg_placeholder = (
self.registrar_config.get("args", "{{args}}")
if self.registrar_config
else "{{args}}"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
description = self._extract_description(raw)
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
dst_name = self.command_filename(src_file.stem)
@@ -684,6 +706,188 @@ class TomlIntegration(IntegrationBase):
return created
# ---------------------------------------------------------------------------
# YamlIntegration — YAML-format agents (Goose)
# ---------------------------------------------------------------------------
class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
Mirrors ``TomlIntegration`` closely: subclasses only need to set
``key``, ``config``, ``registrar_config`` (and optionally
``context_file``). Everything else is inherited.
``setup()`` processes command templates through the same placeholder
pipeline as ``MarkdownIntegration``, then converts the result to
YAML recipe format (version, title, description, prompt block scalar).
"""
def command_filename(self, template_name: str) -> str:
"""YAML commands use ``.yaml`` extension."""
return f"speckit.{template_name}.yaml"
@staticmethod
def _extract_frontmatter(content: str) -> dict[str, Any]:
"""Extract frontmatter as a dict from YAML frontmatter block."""
import yaml
if not content.startswith("---"):
return {}
lines = content.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
return {}
frontmatter_end = -1
for i, line in enumerate(lines[1:], start=1):
if line.rstrip("\r\n") == "---":
frontmatter_end = i
break
if frontmatter_end == -1:
return {}
frontmatter_text = "".join(lines[1:frontmatter_end])
try:
fm = yaml.safe_load(frontmatter_text) or {}
except yaml.YAMLError:
return {}
return fm if isinstance(fm, dict) else {}
@staticmethod
def _split_frontmatter(content: str) -> tuple[str, str]:
"""Split YAML frontmatter from the remaining body content."""
if not content.startswith("---"):
return "", content
lines = content.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
return "", content
frontmatter_end = -1
for i, line in enumerate(lines[1:], start=1):
if line.rstrip("\r\n") == "---":
frontmatter_end = i
break
if frontmatter_end == -1:
return "", content
frontmatter = "".join(lines[1:frontmatter_end])
body = "".join(lines[frontmatter_end + 1 :])
return frontmatter, body
@staticmethod
def _human_title(identifier: str) -> str:
"""Convert an identifier to a human-readable title.
Strips a leading ``speckit.`` prefix and replaces ``.``, ``-``,
and ``_`` with spaces before title-casing.
"""
text = identifier
if text.startswith("speckit."):
text = text[len("speckit.") :]
return text.replace(".", " ").replace("-", " ").replace("_", " ").title()
@staticmethod
def _render_yaml(title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
"""
import yaml
header = {
"version": "1.0.0",
"title": title,
"description": description,
"author": {"contact": "spec-kit"},
"extensions": [{"type": "builtin", "name": "developer"}],
"activities": ["Spec-Driven Development"],
}
header_yaml = yaml.safe_dump(
header,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
).strip()
# Indent each line for YAML block scalar
indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"]
return "\n".join(lines) + "\n"
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
dest = self.commands_dest(project_root).resolve()
try:
dest.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Integration destination {dest} escapes "
f"project root {project_root_resolved}"
) from exc
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = (
self.registrar_config.get("args", "{{args}}")
if self.registrar_config
else "{{args}}"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
fm = self._extract_frontmatter(raw)
description = fm.get("description", "")
if not isinstance(description, str):
description = str(description) if description is not None else ""
title = fm.get("title", "") or fm.get("name", "")
if not isinstance(title, str):
title = str(title) if title is not None else ""
if not title:
title = self._human_title(src_file.stem)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
title, description, body, f"templates/commands/{src_file.name}"
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
yaml_content, dest / dst_name, project_root, manifest
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created
# ---------------------------------------------------------------------------
# SkillsIntegration — skills-format agents (Codex, Kimi, Agy)
# ---------------------------------------------------------------------------
@@ -713,9 +917,7 @@ class SkillsIntegration(IntegrationBase):
Raises ``ValueError`` when ``config`` or ``folder`` is missing.
"""
if not self.config:
raise ValueError(
f"{type(self).__name__}.config is not set."
)
raise ValueError(f"{type(self).__name__}.config is not set.")
folder = self.config.get("folder")
if not folder:
raise ValueError(

View File

@@ -1,21 +1,39 @@
"""Cursor IDE integration."""
"""Cursor IDE integration.
from ..base import MarkdownIntegration
Cursor Agent uses the ``.cursor/skills/speckit-<name>/SKILL.md`` layout.
Commands are deprecated; ``--skills`` defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class CursorAgentIntegration(MarkdownIntegration):
class CursorAgentIntegration(SkillsIntegration):
key = "cursor-agent"
config = {
"name": "Cursor",
"folder": ".cursor/",
"commands_subdir": "commands",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".cursor/commands",
"dir": ".cursor/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"extension": "/SKILL.md",
}
context_file = ".cursor/rules/specify-rules.mdc"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (recommended for Cursor)",
),
]

View File

@@ -0,0 +1,21 @@
"""Goose integration — Block's open source AI agent."""
from ..base import YamlIntegration
class GooseIntegration(YamlIntegration):
key = "goose"
config = {
"name": "Goose",
"folder": ".goose/",
"commands_subdir": "recipes",
"install_url": "https://block.github.io/goose/docs/getting-started/installation",
"requires_cli": True,
}
registrar_config = {
"dir": ".goose/recipes",
"format": "yaml",
"args": "{{args}}",
"extension": ".yaml",
}
context_file = "AGENTS.md"

View File

@@ -0,0 +1,33 @@
# update-context.ps1 — Goose integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
$ErrorActionPreference = 'Stop'
# Derive repo root from script location (walks up to find .specify/)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
# If git did not return a repo root, or the git root does not contain .specify,
# fall back to walking up from the script directory to find the initialized project root.
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1"
# Always delegate to the shared updater; fail clearly if it is unavailable.
if (-not (Test-Path $sharedScript)) {
Write-Error "Error: shared agent context updater not found: $sharedScript"
Write-Error "Goose integration requires support in scripts/powershell/update-agent-context.ps1."
exit 1
}
& $sharedScript -AgentType goose
exit $LASTEXITCODE

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# update-context.sh — Goose integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
set -euo pipefail
# Derive repo root from script location (walks up to find .specify/)
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh"
# Always delegate to the shared updater; fail clearly if it is unavailable.
if [ ! -x "$shared_script" ]; then
echo "Error: shared agent context updater not found or not executable:" >&2
echo " $shared_script" >&2
echo "Goose integration requires support in scripts/bash/update-agent-context.sh." >&2
exit 1
fi
exec "$shared_script" goose

View File

@@ -1587,6 +1587,16 @@ class PresetCatalog:
f"Preset '{pack_id}' not found in catalog"
)
# Bundled presets without a download URL must be installed locally
if pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
raise PresetError(
f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
f"Use 'specify preset add {pack_id}' to install from the bundled package, "
f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}"
)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
raise PresetError(

View File

@@ -84,7 +84,9 @@ class TomlIntegrationTests:
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.commands_dest(tmp_path)
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
assert expected_dir.exists(), (
f"Expected directory {expected_dir} was not created"
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
@@ -134,6 +136,12 @@ class TomlIntegrationTests:
# At least one file should contain {{args}} from the {ARGS} placeholder
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
assert has_args, "No TOML command file contains {{args}} placeholder"
has_dollar_args = any(
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
)
assert not has_dollar_args, (
"TOML command still contains $ARGUMENTS instead of {{args}}"
)
@pytest.mark.parametrize(
("frontmatter", "expected"),
@@ -156,19 +164,13 @@ class TomlIntegrationTests:
),
],
)
def test_toml_extract_description_supports_block_scalars(self, frontmatter, expected):
def test_toml_extract_description_supports_block_scalars(
self, frontmatter, expected
):
assert TomlIntegration._extract_description(frontmatter) == expected
def test_split_frontmatter_ignores_indented_delimiters(self):
content = (
"---\n"
"description: |\n"
" line one\n"
" ---\n"
" line two\n"
"---\n"
"Body\n"
)
content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n"
frontmatter, body = TomlIntegration._split_frontmatter(content)
@@ -205,7 +207,7 @@ class TomlIntegrationTests:
assert "---" not in parsed["prompt"]
def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch):
"""Multiline body ending with `"` must not produce `""""` (#2113)."""
"""Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113)."""
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
@@ -230,7 +232,9 @@ class TomlIntegrationTests:
assert '"""\n' in raw, "body must use multiline basic string"
parsed = tomllib.loads(raw)
assert parsed["prompt"].endswith('specified?"')
assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline"
assert not parsed["prompt"].endswith("\n"), (
"parsed value must not gain a trailing newline"
)
def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch):
"""Body containing `\"\"\"` and ending with `'` falls back to escaped basic string."""
@@ -254,11 +258,15 @@ class TomlIntegrationTests:
assert len(cmd_files) == 1
raw = cmd_files[0].read_text(encoding="utf-8")
assert "''''" not in raw, "literal string must not produce ambiguous closing quotes"
assert "''''" not in raw, (
"literal string must not produce ambiguous closing quotes"
)
parsed = tomllib.loads(raw)
assert parsed["prompt"].endswith("'single'")
assert '"""triple"""' in parsed["prompt"]
assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline"
assert not parsed["prompt"].endswith("\n"), (
"parsed value must not gain a trailing newline"
)
def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch):
"""Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline)."""
@@ -284,8 +292,9 @@ class TomlIntegrationTests:
raw = cmd_files[0].read_text(encoding="utf-8")
parsed = tomllib.loads(raw)
assert parsed["prompt"] == "Line one\nPlain body content"
assert raw.rstrip().endswith('content"""'), \
assert raw.rstrip().endswith('content"""'), (
"closing delimiter should be inline when body does not end with a quote"
)
def test_toml_is_valid(self, tmp_path):
"""Every generated TOML file must parse without errors."""
@@ -354,7 +363,14 @@ class TomlIntegrationTests:
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
sh = (
tmp_path
/ ".specify"
/ "integrations"
/ self.KEY
/ "scripts"
/ "update-context.sh"
)
assert os.access(sh, os.X_OK)
# -- CLI auto-promote -------------------------------------------------
@@ -369,10 +385,20 @@ class TomlIntegrationTests:
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
result = runner.invoke(
app,
[
"init",
"--here",
"--ai",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
@@ -390,13 +416,25 @@ class TomlIntegrationTests:
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
assert result.exit_code == 0, (
f"init --integration {self.KEY} failed: {result.output}"
)
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
@@ -406,8 +444,15 @@ class TomlIntegrationTests:
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"specify",
"tasks",
"taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
@@ -425,23 +470,38 @@ class TomlIntegrationTests:
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
# Framework files
files.append(f".specify/integration.json")
files.append(f".specify/init-options.json")
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(f".specify/integrations/speckit.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
"setup-plan.sh", "update-agent-context.sh"]:
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"update-agent-context.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
"setup-plan.ps1", "update-agent-context.ps1"]:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"update-agent-context.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in ["agent-file-template.md", "checklist-template.md",
"constitution-template.md", "plan-template.md",
"spec-template.md", "tasks-template.md"]:
for name in [
"agent-file-template.md",
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
@@ -457,15 +517,26 @@ class TomlIntegrationTests:
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--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}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file())
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
@@ -482,15 +553,26 @@ class TomlIntegrationTests:
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"ps",
"--no-git",
"--ignore-agent-tools",
],
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())
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"

View File

@@ -0,0 +1,459 @@
"""Reusable test mixin for standard YamlIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``YamlIntegrationTests``.
Mirrors ``TomlIntegrationTests`` closely — same test structure,
adapted for YAML recipe output format.
"""
import os
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import YamlIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class YamlIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".goose/"
COMMANDS_SUBDIR: str — e.g. "recipes"
REGISTRAR_DIR: str — e.g. ".goose/recipes"
CONTEXT_FILE: str — e.g. "AGENTS.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_yaml_integration(self):
assert isinstance(get_integration(self.KEY), YamlIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "yaml"
assert i.registrar_config["args"] == "{{args}}"
assert i.registrar_config["extension"] == ".yaml"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".yaml")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.commands_dest(tmp_path)
assert expected_dir.exists(), (
f"Expected directory {expected_dir} was not created"
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_templates_are_processed(self, tmp_path):
"""Command files must have placeholders replaced."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_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}}"
def test_yaml_has_title(self, tmp_path):
"""Every YAML recipe should have a title field."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "title:" in content, f"{f.name} missing title field"
def test_yaml_has_prompt(self, tmp_path):
"""Every YAML recipe should have a prompt block scalar."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "prompt: |" in content, f"{f.name} missing prompt block scalar"
def test_yaml_uses_correct_arg_placeholder(self, tmp_path):
"""YAML recipes must use {{args}} placeholder."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
assert has_args, "No YAML recipe contains {{args}} placeholder"
has_dollar_args = any(
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
)
assert not has_dollar_args, (
"YAML recipe still contains $ARGUMENTS instead of {{args}}"
)
def test_yaml_is_valid(self, tmp_path):
"""Every generated YAML file must parse without errors."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
# Strip trailing source comment before parsing
lines = content.split("\n")
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
try:
parsed = yaml.safe_load("\n".join(yaml_lines))
except Exception as exc:
raise AssertionError(f"{f.name} is not valid YAML: {exc}") from exc
assert "prompt" in parsed, f"{f.name} parsed YAML has no 'prompt' key"
assert "title" in parsed, f"{f.name} parsed YAML has no 'title' key"
def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch):
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
"description: Summary line one\n"
"scripts:\n"
" sh: scripts/bash/example.sh\n"
"---\n"
"Body line one\n"
"Body line two\n",
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) == 1
content = cmd_files[0].read_text(encoding="utf-8")
# Strip source comment for parsing
lines = content.split("\n")
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
parsed = yaml.safe_load("\n".join(yaml_lines))
assert "description:" not in parsed["prompt"]
assert "scripts:" not in parsed["prompt"]
assert "---" not in parsed["prompt"]
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
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"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
# -- Scripts ----------------------------------------------------------
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
def test_sh_script_is_executable(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
sh = (
tmp_path
/ ".specify"
/ "integrations"
/ self.KEY
/ "scripts"
/ "update-context.sh"
)
assert os.access(sh, os.X_OK)
# -- CLI auto-promote -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--ai",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, (
f"init --integration {self.KEY} failed: {result.output}"
)
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
commands = sorted(cmd_dir.glob("speckit.*.yaml"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"specify",
"tasks",
"taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the expected file list for this integration + script variant."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files (.yaml)
for stem in self.COMMAND_STEMS:
files.append(f"{cmd_dir}/speckit.{stem}.yaml")
# Integration scripts
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
# Framework files
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"update-agent-context.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"update-agent-context.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in [
"agent-file-template.md",
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--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}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"ps",
"--no-git",
"--ignore-agent-tools",
],
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 = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)

View File

@@ -1,11 +1,28 @@
"""Tests for CursorAgentIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
from .test_integration_base_skills import SkillsIntegrationTests
class TestCursorAgentIntegration(MarkdownIntegrationTests):
class TestCursorAgentIntegration(SkillsIntegrationTests):
KEY = "cursor-agent"
FOLDER = ".cursor/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".cursor/commands"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".cursor/skills"
CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
class TestCursorAgentAutoPromote:
"""--ai cursor-agent auto-promotes to integration path."""
def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path):
"""--ai cursor-agent should work the same as --integration cursor-agent."""
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", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -0,0 +1,11 @@
"""Tests for GooseIntegration."""
from .test_integration_base_yaml import YamlIntegrationTests
class TestGooseIntegration(YamlIntegrationTests):
KEY = "goose"
FOLDER = ".goose/"
COMMANDS_SUBDIR = "recipes"
REGISTRAR_DIR = ".goose/recipes"
CONTEXT_FILE = "AGENTS.md"

View File

@@ -50,16 +50,25 @@ class TestAgentConfigConsistency:
def test_devcontainer_kiro_installer_uses_pinned_checksum(self):
"""Devcontainer installer should always verify Kiro installer via pinned SHA256."""
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(encoding="utf-8")
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
encoding="utf-8"
)
assert 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' in post_create_text
assert (
'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"'
in post_create_text
)
assert "sha256sum -c -" in post_create_text
assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text
def test_agent_context_scripts_use_kiro_cli(self):
"""Agent context scripts should advertise kiro-cli and not legacy q agent key."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "kiro-cli" in bash_text
assert "kiro-cli" in pwsh_text
@@ -89,8 +98,12 @@ class TestAgentConfigConsistency:
def test_agent_context_scripts_include_tabnine(self):
"""Agent context scripts should support tabnine agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "tabnine" in bash_text
assert "TABNINE_FILE" in bash_text
@@ -121,7 +134,9 @@ class TestAgentConfigConsistency:
def test_kimi_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'kimi' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
ps_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
assert validate_set_match is not None
@@ -155,8 +170,12 @@ class TestAgentConfigConsistency:
def test_trae_in_agent_context_scripts(self):
"""Agent context scripts should support trae agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "trae" in bash_text
assert "TRAE_FILE" in bash_text
@@ -165,7 +184,9 @@ class TestAgentConfigConsistency:
def test_trae_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'trae' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
ps_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
assert validate_set_match is not None
@@ -200,7 +221,9 @@ class TestAgentConfigConsistency:
def test_pi_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'pi' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
ps_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
assert validate_set_match is not None
@@ -210,8 +233,12 @@ class TestAgentConfigConsistency:
def test_agent_context_scripts_include_pi(self):
"""Agent context scripts should support pi agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "pi" in bash_text
assert "Pi Coding Agent" in bash_text
@@ -242,8 +269,12 @@ class TestAgentConfigConsistency:
def test_iflow_in_agent_context_scripts(self):
"""Agent context scripts should support iflow agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "iflow" in bash_text
assert "IFLOW_FILE" in bash_text
@@ -253,3 +284,37 @@ class TestAgentConfigConsistency:
def test_ai_help_includes_iflow(self):
"""CLI help text for --ai should include iflow."""
assert "iflow" in AI_ASSISTANT_HELP
# --- Goose consistency checks ---
def test_goose_in_agent_config(self):
"""AGENT_CONFIG should include goose with correct folder and commands_subdir."""
assert "goose" in AGENT_CONFIG
assert AGENT_CONFIG["goose"]["folder"] == ".goose/"
assert AGENT_CONFIG["goose"]["commands_subdir"] == "recipes"
assert AGENT_CONFIG["goose"]["requires_cli"] is True
def test_goose_in_extension_registrar(self):
"""Extension command registrar should include goose targeting .goose/recipes."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "goose" in cfg
assert cfg["goose"]["dir"] == ".goose/recipes"
assert cfg["goose"]["format"] == "yaml"
assert cfg["goose"]["args"] == "{{args}}"
def test_goose_in_agent_context_scripts(self):
"""Agent context scripts should support goose agent type."""
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "goose" in bash_text
assert "goose" in pwsh_text
def test_ai_help_includes_goose(self):
"""CLI help text for --ai should include goose."""
assert "goose" in AI_ASSISTANT_HELP

View File

@@ -2995,6 +2995,122 @@ class TestExtensionAddCLI:
f"but was called with '{download_called_with[0]}'"
)
def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
"""extension add should give a clear error when a bundled extension is not found locally."""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app
runner = CliRunner()
# Create project structure
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".specify" / "extensions").mkdir(parents=True)
# Mock catalog that returns a bundled extension without download_url
mock_catalog = MagicMock()
mock_catalog.get_extension_info.return_value = {
"id": "git",
"name": "Git Branching Workflow",
"version": "1.0.0",
"description": "Git branching extension",
"bundled": True,
"_install_allowed": True,
}
mock_catalog.search.return_value = []
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
patch("specify_cli._locate_bundled_extension", return_value=None), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "git"],
catch_exceptions=True,
)
assert result.exit_code != 0
assert "bundled with spec-kit" in result.output
assert "reinstall" in result.output.lower()
class TestDownloadExtensionBundled:
"""Tests for download_extension handling of bundled extensions."""
def test_download_extension_raises_for_bundled(self, temp_dir):
"""download_extension should raise a clear error for bundled extensions without a URL."""
from unittest.mock import patch
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
bundled_ext_info = {
"name": "Git Branching Workflow",
"id": "git",
"version": "1.0.0",
"description": "Git workflow",
"bundled": True,
}
with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info):
with pytest.raises(ExtensionError, match="bundled with spec-kit"):
catalog.download_extension("git")
def test_download_extension_allows_bundled_with_url(self, temp_dir):
"""download_extension should allow bundled extensions that have a download_url (newer version)."""
from unittest.mock import patch, MagicMock
import urllib.request
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
bundled_with_url = {
"name": "Git Branching Workflow",
"id": "git",
"version": "2.0.0",
"description": "Git workflow",
"bundled": True,
"download_url": "https://example.com/git-2.0.0.zip",
}
mock_response = MagicMock()
mock_response.read.return_value = b"fake zip data"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \
patch.object(urllib.request, "urlopen", return_value=mock_response):
result = catalog.download_extension("git")
assert result.name == "git-2.0.0.zip"
def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir):
"""download_extension should raise 'no download URL' for non-bundled extensions without URL."""
from unittest.mock import patch
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
non_bundled_ext_info = {
"name": "Some Extension",
"id": "some-ext",
"version": "1.0.0",
"description": "Test",
}
with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info):
with pytest.raises(ExtensionError, match="has no download URL"):
catalog.download_extension("some-ext")
class TestExtensionUpdateCLI:
"""CLI integration tests for extension update command."""

View File

@@ -2865,3 +2865,182 @@ class TestPresetEnableDisable:
assert result.exit_code == 1
assert "corrupted state" in result.output.lower()
# ===== Lean Preset Tests =====
LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean"
LEAN_COMMAND_NAMES = [
"speckit.specify",
"speckit.plan",
"speckit.tasks",
"speckit.implement",
"speckit.constitution",
]
class TestLeanPreset:
"""Tests for the lean preset that ships with the repo."""
def test_lean_preset_exists(self):
"""Verify the lean preset directory and manifest exist."""
assert LEAN_PRESET_DIR.exists()
assert (LEAN_PRESET_DIR / "preset.yml").exists()
def test_lean_manifest_valid(self):
"""Verify the lean preset manifest is valid."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
assert manifest.id == "lean"
assert manifest.name == "Lean Workflow"
assert manifest.version == "1.0.0"
assert len(manifest.templates) == 5 # 5 commands
def test_lean_provides_core_workflow_commands(self):
"""Verify the lean preset provides overrides for core workflow commands."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
provided_names = {t["name"] for t in manifest.templates}
for name in LEAN_COMMAND_NAMES:
assert name in provided_names, f"Lean preset missing command: {name}"
def test_lean_command_files_exist(self):
"""Verify that all declared command files actually exist on disk."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
for tmpl in manifest.templates:
tmpl_path = LEAN_PRESET_DIR / tmpl["file"]
assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}"
def test_lean_commands_have_no_scripts(self):
"""Verify lean commands have no scripts or agent_scripts in frontmatter."""
from specify_cli.agents import CommandRegistrar
for name in LEAN_COMMAND_NAMES:
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
content = cmd_path.read_text()
frontmatter, _ = CommandRegistrar.parse_frontmatter(content)
assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter"
assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter"
def test_lean_commands_have_no_hooks(self):
"""Verify lean commands do not contain extension hook boilerplate."""
for name in LEAN_COMMAND_NAMES:
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
content = cmd_path.read_text()
assert "hooks." not in content, f"{name} should not reference extension hooks"
assert "extensions.yml" not in content, f"{name} should not reference extensions.yml"
def test_install_lean_preset(self, project_dir):
"""Test installing the lean preset from its directory."""
manager = PresetManager(project_dir)
manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
assert manifest.id == "lean"
assert manager.registry.is_installed("lean")
def test_lean_overrides_commands(self, project_dir):
"""Test that lean preset overrides are resolved correctly."""
manager = PresetManager(project_dir)
manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
resolver = PresetResolver(project_dir)
for name in LEAN_COMMAND_NAMES:
result = resolver.resolve(name, template_type="command")
assert result is not None, f"Lean override for {name} not resolved"
# ===== Bundled Preset Locator Tests =====
class TestBundledPresetLocator:
"""Tests for _locate_bundled_preset discovery function."""
def test_locate_bundled_lean_preset(self):
"""_locate_bundled_preset finds the lean preset."""
from specify_cli import _locate_bundled_preset
path = _locate_bundled_preset("lean")
assert path is not None
assert (path / "preset.yml").is_file()
def test_locate_bundled_preset_not_found(self):
"""_locate_bundled_preset returns None for nonexistent preset."""
from specify_cli import _locate_bundled_preset
path = _locate_bundled_preset("nonexistent-preset")
assert path is None
def test_locate_bundled_preset_rejects_invalid_id(self):
"""_locate_bundled_preset rejects IDs with invalid characters."""
from specify_cli import _locate_bundled_preset
assert _locate_bundled_preset("../escape") is None
assert _locate_bundled_preset("UPPERCASE") is None
assert _locate_bundled_preset("has spaces") is None
def test_bundled_preset_add_via_cli(self, project_dir):
"""Test that 'specify preset add lean' installs the bundled preset."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.get_speckit_version", return_value="0.6.0"):
result = runner.invoke(app, ["preset", "add", "lean"])
assert result.exit_code == 0, result.output
assert "Lean Workflow" in result.output
assert "installed" in result.output.lower()
def test_bundled_preset_in_catalog(self):
"""Verify the lean preset is listed in catalog.json with bundled marker."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
catalog = json.loads(catalog_path.read_text())
assert "lean" in catalog["presets"]
assert catalog["presets"]["lean"]["bundled"] is True
assert "download_url" not in catalog["presets"]["lean"]
def test_bundled_preset_download_raises_error(self, project_dir):
"""download_pack raises PresetError for bundled presets without download_url."""
catalog = PresetCatalog(project_dir)
catalog_data = {
"test-bundled": {
"name": "Test Bundled",
"version": "1.0.0",
"bundled": True,
}
}
from unittest.mock import patch
with patch.object(catalog, "_get_merged_packs", return_value=catalog_data):
with pytest.raises(PresetError, match="bundled with spec-kit"):
catalog.download_pack("test-bundled")
def test_bundled_preset_missing_locally_cli_error(self, project_dir):
"""CLI shows clear error when bundled preset cannot be found locally."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Patch _locate_bundled_preset to return None (simulating missing files)
# and mock the catalog to return a bundled entry for "lean"
fake_pack_info = {
"id": "lean",
"name": "Lean Workflow",
"version": "1.0.0",
"bundled": True,
"_install_allowed": True,
}
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli._locate_bundled_preset", return_value=None), \
patch("specify_cli.presets.PresetCatalog") as MockCatalog:
MockCatalog.return_value.get_pack_info.return_value = fake_pack_info
result = runner.invoke(app, ["preset", "add", "lean"])
# Should fail with a helpful error explaining this is a bundled preset
# and suggesting how to recover.
assert result.exit_code == 1
output = strip_ansi(result.output).lower()
assert "bundled" in output, result.output
assert "reinstall" in output, result.output