Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
a4db16dc21 chore: bump version to 0.6.0 2026-04-09 19:14:55 +00:00
34 changed files with 221 additions and 2192 deletions

View File

@@ -26,7 +26,6 @@ concurrency:
jobs:
# Build job
build:
if: github.repository == 'github/spec-kit'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -57,7 +56,6 @@ 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, YamlIntegration, SkillsIntegration
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, SkillsIntegration
├── manifest.py # IntegrationManifest (file tracking)
├── claude/ # Example: SkillsIntegration subclass
│ ├── __init__.py # ClaudeIntegration class
@@ -48,7 +48,6 @@ 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 |
@@ -344,82 +343,16 @@ 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,33 +2,6 @@
<!-- 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,10 +186,8 @@ 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) |
@@ -199,7 +197,6 @@ 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) |
@@ -214,7 +211,6 @@ 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) |
@@ -233,15 +229,11 @@ 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).
@@ -312,7 +304,6 @@ 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/) | ✅ | |
@@ -657,7 +648,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, 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:
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:
```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/skills/ # Cursor
ls -la .cursor/commands/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
```

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-13T14:39:00Z",
"updated_at": "2026-04-09T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -138,38 +138,6 @@
"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",
@@ -237,39 +205,6 @@
"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",
@@ -649,46 +584,6 @@
"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",
@@ -1135,38 +1030,6 @@
"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",
@@ -1265,8 +1128,8 @@
"id": "ralph",
"description": "Autonomous implementation loop using AI agent CLI.",
"author": "Rubiss",
"version": "1.0.1",
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip",
"version": "1.0.0",
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.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",
@@ -1299,7 +1162,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-04-12T19:00:00Z"
"updated_at": "2026-03-09T00:00:00Z"
},
"reconcile": {
"name": "Reconcile Extension",
@@ -1468,8 +1331,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.1",
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.1.zip",
"version": "1.0.0",
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.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",
@@ -1495,7 +1358,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z"
"updated_at": "2026-03-06T00:00:00Z"
},
"security-review": {
"name": "Security Review",
@@ -1591,39 +1454,6 @@
"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",
@@ -1686,36 +1516,6 @@
"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",
@@ -1791,38 +1591,6 @@
"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",
@@ -1860,8 +1628,8 @@
"id": "verify",
"description": "Post-implementation quality gate that validates implemented code against specification artifacts.",
"author": "ismaelJimenez",
"version": "1.0.3",
"download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.3.zip",
"version": "1.0.0",
"download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.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",
@@ -1885,7 +1653,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-03T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z"
"updated_at": "2026-03-03T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
@@ -1918,34 +1686,6 @@
"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",
@@ -1979,4 +1719,4 @@
"updated_at": "2026-04-09T00:00:00Z"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-06T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
"extensions": {
"git": {
@@ -10,13 +10,27 @@
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"bundled": true,
"download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip",
"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,22 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-03-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"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"
]
}
}
"presets": {}
}

View File

@@ -1,15 +0,0 @@
---
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

@@ -1,22 +0,0 @@
---
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

@@ -1,19 +0,0 @@
---
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

@@ -1,23 +0,0 @@
---
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

@@ -1,19 +0,0 @@
---
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

View File

@@ -1,50 +0,0 @@
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.2"
version = "0.6.0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [
@@ -41,8 +41,6 @@ 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, Goose, 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, 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|goose|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|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, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid
# Amp, Kiro CLI, IBM Bob, Pi, and Forge 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,15 +710,12 @@ 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|goose|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|generic"
exit 1
;;
esac
@@ -762,7 +759,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/Goose" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge" || _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
@@ -803,7 +800,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|goose|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|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, goose, 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, 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','goose','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','generic')]
[string]$AgentType
)
@@ -68,7 +68,6 @@ $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'
@@ -418,9 +417,8 @@ 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|goose|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|generic'; return $false }
}
}
@@ -462,7 +460,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/Goose')) { $ok = $false }
if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge')) { $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 }
@@ -492,7 +490,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|goose|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|generic]'
}
function Main {

View File

@@ -621,31 +621,6 @@ 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,
@@ -1291,44 +1266,27 @@ def init(
preset_manager = PresetManager(project_path)
speckit_ver = get_speckit_version()
# Try local directory first, then bundled, then catalog
# Try local directory first, 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:
bundled_path = _locate_bundled_preset(preset)
if bundled_path:
preset_manager.install_from_directory(bundled_path, speckit_ver)
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.")
else:
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
elif pack_info.get("bundled") and not pack_info.get("download_url"):
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 = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
# Clean up downloaded ZIP to avoid cache accumulation
try:
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
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}")
except Exception as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
@@ -1380,7 +1338,7 @@ def init(
step_num = 2
# Determine skill display mode for the next-steps panel.
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
# Skills integrations (codex, kimi, agy, trae) should show skill invocation syntax.
from .integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
@@ -1389,8 +1347,7 @@ def init(
kimi_skill_mode = selected_ai == "kimi"
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
trae_skill_mode = selected_ai == "trae"
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (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
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode
if codex_skill_mode and not ai_skills:
# Integration path installed skills; show the helpful notice
@@ -1399,9 +1356,6 @@ 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:
@@ -1411,8 +1365,6 @@ 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:")
@@ -2182,50 +2134,28 @@ def preset_add(
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
elif pack_id:
# 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)
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)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
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)
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)
@@ -3071,7 +3001,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, REINSTALL_COMMAND
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
project_root = Path.cwd()
@@ -3173,19 +3103,6 @@ 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,7 +18,6 @@ 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":
@@ -76,7 +75,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 {}
@@ -101,9 +100,7 @@ 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:
@@ -149,16 +146,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.
@@ -175,7 +172,12 @@ 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:
@@ -190,7 +192,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("")
@@ -224,41 +226,6 @@ 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,
@@ -285,13 +252,9 @@ 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,
@@ -325,9 +288,7 @@ 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
@@ -351,9 +312,7 @@ 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:
@@ -375,9 +334,7 @@ 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)
@@ -385,9 +342,7 @@ 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:
@@ -401,16 +356,14 @@ 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}"
@@ -422,7 +375,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.
@@ -479,24 +432,12 @@ 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']}")
@@ -510,68 +451,34 @@ 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
)
elif agent_config["format"] == "yaml":
alias_output = self.render_yaml_command(
alias_frontmatter, body, source_id, alias
)
alias_output = self.render_toml_command(alias_frontmatter, body, source_id)
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']}"
)
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 = commands_dir / f"{alias_output_name}{agent_config['extension']}"
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(alias_output, encoding="utf-8")
if agent_name == "copilot":
@@ -599,7 +506,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.
@@ -622,12 +529,8 @@ 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
@@ -637,7 +540,9 @@ 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.
@@ -654,17 +559,13 @@ 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,8 +38,6 @@ _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.
@@ -1872,14 +1870,6 @@ 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")
@@ -2180,7 +2170,6 @@ 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:
@@ -2189,8 +2178,6 @@ 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,7 +36,6 @@ def get_integration(key: str) -> IntegrationBase | None:
# -- Register built-in integrations --------------------------------------
def _register_builtins() -> None:
"""Register all built-in integrations.
@@ -59,7 +58,6 @@ 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
@@ -89,7 +87,6 @@ def _register_builtins() -> None:
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
_register(GooseIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())

View File

@@ -28,7 +28,6 @@ if TYPE_CHECKING:
# IntegrationOption
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class IntegrationOption:
"""Declares an option that an integration accepts via ``--integration-options``.
@@ -52,7 +51,6 @@ class IntegrationOption:
# IntegrationBase — abstract base class
# ---------------------------------------------------------------------------
class IntegrationBase(ABC):
"""Abstract base class every integration must implement.
@@ -277,7 +275,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}`` and ``$ARGUMENTS`` with *arg_placeholder*
5. Replace ``{ARGS}`` with *arg_placeholder*
6. Replace ``__AGENT__`` with *agent_name*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
"""
@@ -350,9 +348,8 @@ class IntegrationBase(ABC):
output_lines.append(line)
content = "".join(output_lines)
# 5. Replace {ARGS} and $ARGUMENTS
# 5. Replace {ARGS}
content = content.replace("{ARGS}", arg_placeholder)
content = content.replace("$ARGUMENTS", arg_placeholder)
# 6. Replace __AGENT__
content = content.replace("__AGENT__", agent_name)
@@ -361,7 +358,6 @@ 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
@@ -437,7 +433,9 @@ 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,
@@ -454,7 +452,6 @@ class IntegrationBase(ABC):
# MarkdownIntegration — covers ~20 standard agents
# ---------------------------------------------------------------------------
class MarkdownIntegration(IntegrationBase):
"""Concrete base for integrations that use standard Markdown commands.
@@ -495,18 +492,12 @@ 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
@@ -521,7 +512,6 @@ class MarkdownIntegration(IntegrationBase):
# TomlIntegration — TOML-format agents (Gemini, Tabnine)
# ---------------------------------------------------------------------------
class TomlIntegration(IntegrationBase):
"""Concrete base for integrations that use TOML command format.
@@ -613,17 +603,13 @@ 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:
@@ -642,9 +628,7 @@ 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")
@@ -681,19 +665,13 @@ 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)
@@ -706,188 +684,6 @@ 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)
# ---------------------------------------------------------------------------
@@ -917,7 +713,9 @@ 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,39 +1,21 @@
"""Cursor IDE integration.
"""Cursor IDE integration."""
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
from ..base import MarkdownIntegration
class CursorAgentIntegration(SkillsIntegration):
class CursorAgentIntegration(MarkdownIntegration):
key = "cursor-agent"
config = {
"name": "Cursor",
"folder": ".cursor/",
"commands_subdir": "skills",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".cursor/skills",
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
"extension": ".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

@@ -1,21 +0,0 @@
"""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

@@ -1,33 +0,0 @@
# 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

@@ -1,38 +0,0 @@
#!/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,16 +1587,6 @@ 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,9 +84,7 @@ 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:
@@ -136,12 +134,6 @@ 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"),
@@ -164,13 +156,19 @@ 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 = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n"
content = (
"---\n"
"description: |\n"
" line one\n"
" ---\n"
" line two\n"
"---\n"
"Body\n"
)
frontmatter, body = TomlIntegration._split_frontmatter(content)
@@ -207,7 +205,7 @@ class TomlIntegrationTests:
assert "---" not in parsed["prompt"]
def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch):
"""Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113)."""
"""Multiline body ending with `"` must not produce `""""` (#2113)."""
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
@@ -232,9 +230,7 @@ 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."""
@@ -258,15 +254,11 @@ 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)."""
@@ -292,9 +284,8 @@ 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."""
@@ -363,14 +354,7 @@ 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 -------------------------------------------------
@@ -385,20 +369,10 @@ 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}"
@@ -416,25 +390,13 @@ 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"
@@ -444,15 +406,8 @@ 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]:
@@ -470,38 +425,23 @@ class TomlIntegrationTests:
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/integration.json")
files.append(f".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(f".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")
@@ -517,26 +457,15 @@ 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"
@@ -553,26 +482,15 @@ 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

@@ -1,459 +0,0 @@
"""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,28 +1,11 @@
"""Tests for CursorAgentIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestCursorAgentIntegration(SkillsIntegrationTests):
class TestCursorAgentIntegration(MarkdownIntegrationTests):
KEY = "cursor-agent"
FOLDER = ".cursor/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".cursor/skills"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".cursor/commands"
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

@@ -1,11 +0,0 @@
"""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,25 +50,16 @@ 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
@@ -98,12 +89,8 @@ 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
@@ -134,9 +121,7 @@ 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
@@ -170,12 +155,8 @@ 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
@@ -184,9 +165,7 @@ 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
@@ -221,9 +200,7 @@ 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
@@ -233,12 +210,8 @@ 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
@@ -269,12 +242,8 @@ 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
@@ -284,37 +253,3 @@ 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,122 +2995,6 @@ 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,182 +2865,3 @@ 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