mirror of
https://github.com/github/spec-kit.git
synced 2026-03-16 18:33:07 +00:00
feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
* Initial plan * feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * test(templates): add comprehensive unit tests for template pack system Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat(presets): pluggable preset system with template/command overrides, catalog, and resolver - Rename 'template packs' to 'presets' to avoid naming collision with core templates - PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py - Extract CommandRegistrar to agents.py as shared infrastructure - CLI: specify preset list/add/remove/search/resolve/info - CLI: specify preset catalog list/add/remove - --preset option on specify init - Priority-based preset stacking (--priority, lower = higher precedence) - Command overrides registered into all detected agent directories (17+ agents) - Extension command safety: skip registration if target extension not installed - Multi-catalog support: env var, project config, user config, built-in defaults - resolve_template() / Resolve-Template in bash/PowerShell scripts - Self-test preset: overrides all 6 core templates + 1 command - Scaffold with 4 examples: core/extension template and command overrides - Preset catalog (catalog.json, catalog.community.json) - Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md - 110 preset tests, 253 total tests passing * feat(presets): propagate command overrides to skills via init-options - Add save_init_options() / load_init_options() helpers that persist CLI flags from 'specify init' to .specify/init-options.json - PresetManager._register_skills() overwrites SKILL.md files when --ai-skills was used during init and corresponding skill dirs exist - PresetManager._unregister_skills() restores core template content on preset removal - registered_skills stored in preset registry metadata - 8 new tests covering skill override, skip conditions, and restore * fix: address PR check failures (ruff F541, CodeQL URL substring) - Remove extraneous f-prefix from two f-strings without placeholders - Replace substring URL check in test with startswith/endswith assertions to satisfy CodeQL incomplete URL substring sanitization rule * fix: address Copilot PR review comments - Move save_init_options() before preset install so skills propagation works during 'specify init --preset --ai-skills' - Clean up downloaded ZIP after successful preset install during init - Validate --from URL scheme (require HTTPS, HTTP only for localhost) - Expose unregister_commands() on extensions.py CommandRegistrar wrapper instead of reaching into private _registrar field - Use _get_merged_packs() for search() and get_pack_info() so all active catalogs are searched, not just the highest-priority one - Fix fetch_catalog() cache to verify cached URL matches current URL - Fix PresetResolver: script resolution uses .sh extension, consistent file extensions throughout resolve(), and resolve_with_source() delegates to resolve() to honor template_type parameter - Fix bash common.sh: fall through to directory scan when python3 returns empty preset list - Fix PowerShell Resolve-Template: filter out dot-folders and sort extensions deterministically * fix: narrow empty except blocks and add explanatory comments * fix: address Copilot PR review comments (round 2) - Fix init --preset error masking: distinguish "not found" from real errors - Fix bash resolve_template: skip hidden dirs in extensions (match Python/PS) - Fix temp dir leaks in tests: use temp_dir fixture instead of mkdtemp - Fix self-test catalog entry: add note that it's local-only (no download_url) - Fix Windows path issue in resolve_with_source: use Path.relative_to() - Fix skill restore path: use project's .specify/templates/commands/ not source tree - Add encoding="utf-8" to all file read/write in agents.py - Update test to set up core command templates for skill restoration * fix: remove self-test from catalog.json (local-only preset) * fix: address Copilot PR review comments (round 3) - Fix PS Resolve-Template fallback to skip dot-prefixed dirs (.cache) - Rename _catalog to _catalog_name for consistency with extension system - Enforce install_allowed policy in CLI preset add and download_pack() - Fix shell injection: pass registry path via env var instead of string interpolation * fix: correct PresetError docstring from template to preset * Removed CHANGELOG requirement * Applying review recommendations * Applying review recommendations * Applying review recommendations * Applying review recommendations --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
This commit is contained in:
@@ -10,10 +10,6 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## General practices
|
|
||||||
|
|
||||||
- Any changes to `__init__.py` for the Specify CLI require a version rev in `pyproject.toml` and addition of entries to `CHANGELOG.md`.
|
|
||||||
|
|
||||||
## Adding New Agent Support
|
## Adding New Agent Support
|
||||||
|
|
||||||
This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow.
|
This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow.
|
||||||
|
|||||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -7,6 +7,27 @@ Recent changes to the Specify CLI and templates are documented here.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- feat(presets): Pluggable preset system with preset catalog and template resolver
|
||||||
|
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
|
||||||
|
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
|
||||||
|
- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info`
|
||||||
|
- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management
|
||||||
|
- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system
|
||||||
|
- `--preset` option for `specify init` to install presets during initialization
|
||||||
|
- Priority-based preset resolution: presets with lower priority number win (`--priority` flag)
|
||||||
|
- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts
|
||||||
|
- Template resolution priority stack: overrides → presets → extensions → core
|
||||||
|
- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`)
|
||||||
|
- Preset scaffold directory (`presets/scaffold/`)
|
||||||
|
- Scripts updated to use template resolution instead of hardcoded paths
|
||||||
|
- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init
|
||||||
|
- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations
|
||||||
|
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
|
||||||
|
|
||||||
## [0.2.1] - 2026-03-11
|
## [0.2.1] - 2026-03-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -51,13 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
||||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
||||||
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
|
|
||||||
|
|
||||||
## [0.2.0] - 2026-03-09
|
## [0.2.0] - 2026-03-09
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
157
presets/ARCHITECTURE.md
Normal file
157
presets/ARCHITECTURE.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Preset System Architecture
|
||||||
|
|
||||||
|
This document describes the internal architecture of the preset system — how template resolution, command registration, and catalog management work under the hood.
|
||||||
|
|
||||||
|
For usage instructions, see [README.md](README.md).
|
||||||
|
|
||||||
|
## Template Resolution
|
||||||
|
|
||||||
|
When Spec Kit needs a template (e.g. `spec-template`), the `PresetResolver` walks a priority stack and returns the first match:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["resolve_template('spec-template')"] --> B{Override exists?}
|
||||||
|
B -- Yes --> C[".specify/templates/overrides/spec-template.md"]
|
||||||
|
B -- No --> D{Preset provides it?}
|
||||||
|
D -- Yes --> E[".specify/presets/‹preset-id›/templates/spec-template.md"]
|
||||||
|
D -- No --> F{Extension provides it?}
|
||||||
|
F -- Yes --> G[".specify/extensions/‹ext-id›/templates/spec-template.md"]
|
||||||
|
F -- No --> H[".specify/templates/spec-template.md"]
|
||||||
|
|
||||||
|
E -- "multiple presets?" --> I["lowest priority number wins"]
|
||||||
|
I --> E
|
||||||
|
|
||||||
|
style C fill:#4caf50,color:#fff
|
||||||
|
style E fill:#2196f3,color:#fff
|
||||||
|
style G fill:#ff9800,color:#fff
|
||||||
|
style H fill:#9e9e9e,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
| Priority | Source | Path | Use case |
|
||||||
|
|----------|--------|------|----------|
|
||||||
|
| 1 (highest) | Override | `.specify/templates/overrides/` | One-off project-local tweaks |
|
||||||
|
| 2 | Preset | `.specify/presets/<id>/templates/` | Shareable, stackable customizations |
|
||||||
|
| 3 | Extension | `.specify/extensions/<id>/templates/` | Extension-provided templates |
|
||||||
|
| 4 (lowest) | Core | `.specify/templates/` | Shipped defaults |
|
||||||
|
|
||||||
|
When multiple presets are installed, they're sorted by their `priority` field (lower number = higher precedence). This is set via `--priority` on `specify preset add`.
|
||||||
|
|
||||||
|
The resolution is implemented three times to ensure consistency:
|
||||||
|
- **Python**: `PresetResolver` in `src/specify_cli/presets.py`
|
||||||
|
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
|
||||||
|
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`
|
||||||
|
|
||||||
|
## Command Registration
|
||||||
|
|
||||||
|
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["specify preset add my-preset"] --> B{Preset has type: command?}
|
||||||
|
B -- No --> Z["done (templates only)"]
|
||||||
|
B -- Yes --> C{Extension command?}
|
||||||
|
C -- "speckit.myext.cmd\n(3+ dot segments)" --> D{Extension installed?}
|
||||||
|
D -- No --> E["skip (extension not active)"]
|
||||||
|
D -- Yes --> F["register command"]
|
||||||
|
C -- "speckit.specify\n(core command)" --> F
|
||||||
|
F --> G["detect agent directories"]
|
||||||
|
G --> H[".claude/commands/"]
|
||||||
|
G --> I[".gemini/commands/"]
|
||||||
|
G --> J[".github/agents/"]
|
||||||
|
G --> K["... (17+ agents)"]
|
||||||
|
H --> L["write .md (Markdown format)"]
|
||||||
|
I --> M["write .toml (TOML format)"]
|
||||||
|
J --> N["write .agent.md + .prompt.md"]
|
||||||
|
|
||||||
|
style E fill:#ff5722,color:#fff
|
||||||
|
style L fill:#4caf50,color:#fff
|
||||||
|
style M fill:#4caf50,color:#fff
|
||||||
|
style N fill:#4caf50,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extension safety check
|
||||||
|
|
||||||
|
Command names follow the pattern `speckit.<ext-id>.<cmd-name>`. When a command has 3+ dot segments, the system extracts the extension ID and checks if `.specify/extensions/<ext-id>/` exists. If the extension isn't installed, the command is skipped — preventing orphan files referencing non-existent extensions.
|
||||||
|
|
||||||
|
Core commands (e.g. `speckit.specify`, with only 2 segments) are always registered.
|
||||||
|
|
||||||
|
### Agent format rendering
|
||||||
|
|
||||||
|
The `CommandRegistrar` renders commands differently per agent:
|
||||||
|
|
||||||
|
| Agent | Format | Extension | Arg placeholder |
|
||||||
|
|-------|--------|-----------|-----------------|
|
||||||
|
| Claude, Cursor, opencode, Windsurf, etc. | Markdown | `.md` | `$ARGUMENTS` |
|
||||||
|
| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` |
|
||||||
|
| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` |
|
||||||
|
|
||||||
|
### Cleanup on removal
|
||||||
|
|
||||||
|
When `specify preset remove` is called, the registered commands are read from the registry metadata and the corresponding files are deleted from each agent directory, including Copilot companion `.prompt.md` files.
|
||||||
|
|
||||||
|
## Catalog System
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["specify preset search"] --> B["PresetCatalog.get_active_catalogs()"]
|
||||||
|
B --> C{SPECKIT_PRESET_CATALOG_URL set?}
|
||||||
|
C -- Yes --> D["single custom catalog"]
|
||||||
|
C -- No --> E{.specify/preset-catalogs.yml exists?}
|
||||||
|
E -- Yes --> F["project-level catalog stack"]
|
||||||
|
E -- No --> G{"~/.specify/preset-catalogs.yml exists?"}
|
||||||
|
G -- Yes --> H["user-level catalog stack"]
|
||||||
|
G -- No --> I["built-in defaults"]
|
||||||
|
I --> J["default (install allowed)"]
|
||||||
|
I --> K["community (discovery only)"]
|
||||||
|
|
||||||
|
style D fill:#ff9800,color:#fff
|
||||||
|
style F fill:#2196f3,color:#fff
|
||||||
|
style H fill:#2196f3,color:#fff
|
||||||
|
style J fill:#4caf50,color:#fff
|
||||||
|
style K fill:#9e9e9e,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag.
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
presets/
|
||||||
|
├── ARCHITECTURE.md # This file
|
||||||
|
├── PUBLISHING.md # Guide for submitting presets to the catalog
|
||||||
|
├── README.md # User guide
|
||||||
|
├── catalog.json # Official preset catalog
|
||||||
|
├── catalog.community.json # Community preset catalog
|
||||||
|
├── scaffold/ # Scaffold for creating new presets
|
||||||
|
│ ├── preset.yml # Example manifest
|
||||||
|
│ ├── README.md # Guide for customizing the scaffold
|
||||||
|
│ ├── commands/
|
||||||
|
│ │ ├── speckit.specify.md # Core command override example
|
||||||
|
│ │ └── speckit.myext.myextcmd.md # Extension command override example
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── spec-template.md # Core template override example
|
||||||
|
│ └── myext-template.md # Extension template override example
|
||||||
|
└── self-test/ # Self-test preset (overrides all core templates)
|
||||||
|
├── preset.yml
|
||||||
|
├── commands/
|
||||||
|
│ └── speckit.specify.md
|
||||||
|
└── templates/
|
||||||
|
├── spec-template.md
|
||||||
|
├── plan-template.md
|
||||||
|
├── tasks-template.md
|
||||||
|
├── checklist-template.md
|
||||||
|
├── constitution-template.md
|
||||||
|
└── agent-file-template.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/specify_cli/
|
||||||
|
├── agents.py # CommandRegistrar — shared infrastructure for writing
|
||||||
|
│ # command files to agent directories
|
||||||
|
├── presets.py # PresetManifest, PresetRegistry, PresetManager,
|
||||||
|
│ # PresetCatalog, PresetCatalogEntry, PresetResolver
|
||||||
|
└── __init__.py # CLI commands: specify preset list/add/remove/search/
|
||||||
|
# resolve/info, specify preset catalog list/add/remove
|
||||||
|
```
|
||||||
295
presets/PUBLISHING.md
Normal file
295
presets/PUBLISHING.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Preset Publishing Guide
|
||||||
|
|
||||||
|
This guide explains how to publish your preset to the Spec Kit preset catalog, making it discoverable by `specify preset search`.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Prerequisites](#prerequisites)
|
||||||
|
2. [Prepare Your Preset](#prepare-your-preset)
|
||||||
|
3. [Submit to Catalog](#submit-to-catalog)
|
||||||
|
4. [Verification Process](#verification-process)
|
||||||
|
5. [Release Workflow](#release-workflow)
|
||||||
|
6. [Best Practices](#best-practices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before publishing a preset, ensure you have:
|
||||||
|
|
||||||
|
1. **Valid Preset**: A working preset with a valid `preset.yml` manifest
|
||||||
|
2. **Git Repository**: Preset hosted on GitHub (or other public git hosting)
|
||||||
|
3. **Documentation**: README.md with description and usage instructions
|
||||||
|
4. **License**: Open source license file (MIT, Apache 2.0, etc.)
|
||||||
|
5. **Versioning**: Semantic versioning (e.g., 1.0.0)
|
||||||
|
6. **Testing**: Preset tested on real projects with `specify preset add --dev`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prepare Your Preset
|
||||||
|
|
||||||
|
### 1. Preset Structure
|
||||||
|
|
||||||
|
Ensure your preset follows the standard structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
your-preset/
|
||||||
|
├── preset.yml # Required: Preset manifest
|
||||||
|
├── README.md # Required: Documentation
|
||||||
|
├── LICENSE # Required: License file
|
||||||
|
├── CHANGELOG.md # Recommended: Version history
|
||||||
|
│
|
||||||
|
├── templates/ # Template overrides
|
||||||
|
│ ├── spec-template.md
|
||||||
|
│ ├── plan-template.md
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── commands/ # Command overrides (optional)
|
||||||
|
└── speckit.specify.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Start from the [scaffold](scaffold/) if you're creating a new preset.
|
||||||
|
|
||||||
|
### 2. preset.yml Validation
|
||||||
|
|
||||||
|
Verify your manifest is valid:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schema_version: "1.0"
|
||||||
|
|
||||||
|
preset:
|
||||||
|
id: "your-preset" # Unique lowercase-hyphenated ID
|
||||||
|
name: "Your Preset Name" # Human-readable name
|
||||||
|
version: "1.0.0" # Semantic version
|
||||||
|
description: "Brief description (one sentence)"
|
||||||
|
author: "Your Name or Organization"
|
||||||
|
repository: "https://github.com/your-org/spec-kit-preset-your-preset"
|
||||||
|
license: "MIT"
|
||||||
|
|
||||||
|
requires:
|
||||||
|
speckit_version: ">=0.1.0" # Required spec-kit version
|
||||||
|
|
||||||
|
provides:
|
||||||
|
templates:
|
||||||
|
- type: "template"
|
||||||
|
name: "spec-template"
|
||||||
|
file: "templates/spec-template.md"
|
||||||
|
description: "Custom spec template"
|
||||||
|
replaces: "spec-template"
|
||||||
|
|
||||||
|
tags: # 2-5 relevant tags
|
||||||
|
- "category"
|
||||||
|
- "workflow"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Checklist**:
|
||||||
|
|
||||||
|
- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters)
|
||||||
|
- ✅ `version` follows semantic versioning (X.Y.Z)
|
||||||
|
- ✅ `description` is concise (under 200 characters)
|
||||||
|
- ✅ `repository` URL is valid and public
|
||||||
|
- ✅ All template and command files exist in the preset directory
|
||||||
|
- ✅ Template names are lowercase with hyphens only
|
||||||
|
- ✅ Command names use dot notation (e.g. `speckit.specify`)
|
||||||
|
- ✅ Tags are lowercase and descriptive
|
||||||
|
|
||||||
|
### 3. Test Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install from local directory
|
||||||
|
specify preset add --dev /path/to/your-preset
|
||||||
|
|
||||||
|
# Verify templates resolve from your preset
|
||||||
|
specify preset resolve spec-template
|
||||||
|
|
||||||
|
# Verify preset info
|
||||||
|
specify preset info your-preset
|
||||||
|
|
||||||
|
# List installed presets
|
||||||
|
specify preset list
|
||||||
|
|
||||||
|
# Remove when done testing
|
||||||
|
specify preset remove your-preset
|
||||||
|
```
|
||||||
|
|
||||||
|
If your preset includes command overrides, verify they appear in the agent directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Claude commands (if using Claude)
|
||||||
|
ls .claude/commands/speckit.*.md
|
||||||
|
|
||||||
|
# Check Copilot commands (if using Copilot)
|
||||||
|
ls .github/agents/speckit.*.agent.md
|
||||||
|
|
||||||
|
# Check Gemini commands (if using Gemini)
|
||||||
|
ls .gemini/commands/speckit.*.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create GitHub Release
|
||||||
|
|
||||||
|
Create a GitHub release for your preset version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tag the release
|
||||||
|
git tag v1.0.0
|
||||||
|
git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The release archive URL will be:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Test Installation from Archive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Submit to Catalog
|
||||||
|
|
||||||
|
### Understanding the Catalogs
|
||||||
|
|
||||||
|
Spec Kit uses a dual-catalog system:
|
||||||
|
|
||||||
|
- **`catalog.json`** — Official, verified presets (install allowed by default)
|
||||||
|
- **`catalog.community.json`** — Community-contributed presets (discovery only by default)
|
||||||
|
|
||||||
|
All community presets should be submitted to `catalog.community.json`.
|
||||||
|
|
||||||
|
### 1. Fork the spec-kit Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR-USERNAME/spec-kit.git
|
||||||
|
cd spec-kit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add Preset to Community Catalog
|
||||||
|
|
||||||
|
Edit `presets/catalog.community.json` and add your preset.
|
||||||
|
|
||||||
|
> **⚠️ Entries must be sorted alphabetically by preset ID.** Insert your preset in the correct position within the `"presets"` object.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"updated_at": "2026-03-10T00:00:00Z",
|
||||||
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||||
|
"presets": {
|
||||||
|
"your-preset": {
|
||||||
|
"name": "Your Preset Name",
|
||||||
|
"description": "Brief description of what your preset provides",
|
||||||
|
"author": "Your Name",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip",
|
||||||
|
"repository": "https://github.com/your-org/spec-kit-preset-your-preset",
|
||||||
|
"license": "MIT",
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.1.0"
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"templates": 3,
|
||||||
|
"commands": 1
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"category",
|
||||||
|
"workflow"
|
||||||
|
],
|
||||||
|
"created_at": "2026-03-10T00:00:00Z",
|
||||||
|
"updated_at": "2026-03-10T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Submit Pull Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b add-your-preset
|
||||||
|
git add presets/catalog.community.json
|
||||||
|
git commit -m "Add your-preset to community catalog
|
||||||
|
|
||||||
|
- Preset ID: your-preset
|
||||||
|
- Version: 1.0.0
|
||||||
|
- Author: Your Name
|
||||||
|
- Description: Brief description
|
||||||
|
"
|
||||||
|
git push origin add-your-preset
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pull Request Checklist**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Preset Submission
|
||||||
|
|
||||||
|
**Preset Name**: Your Preset Name
|
||||||
|
**Preset ID**: your-preset
|
||||||
|
**Version**: 1.0.0
|
||||||
|
**Repository**: https://github.com/your-org/spec-kit-preset-your-preset
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
- [ ] Valid preset.yml manifest
|
||||||
|
- [ ] README.md with description and usage
|
||||||
|
- [ ] LICENSE file included
|
||||||
|
- [ ] GitHub release created
|
||||||
|
- [ ] Preset tested with `specify preset add --dev`
|
||||||
|
- [ ] Templates resolve correctly (`specify preset resolve`)
|
||||||
|
- [ ] Commands register to agent directories (if applicable)
|
||||||
|
- [ ] Commands match template sections (command + template are coherent)
|
||||||
|
- [ ] Added to presets/catalog.community.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Process
|
||||||
|
|
||||||
|
After submission, maintainers will review:
|
||||||
|
|
||||||
|
1. **Manifest validation** — valid `preset.yml`, all files exist
|
||||||
|
2. **Template quality** — templates are useful and well-structured
|
||||||
|
3. **Command coherence** — commands reference sections that exist in templates
|
||||||
|
4. **Security** — no malicious content, safe file operations
|
||||||
|
5. **Documentation** — clear README explaining what the preset does
|
||||||
|
|
||||||
|
Once verified, `verified: true` is set and the preset appears in `specify preset search`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Workflow
|
||||||
|
|
||||||
|
When releasing a new version:
|
||||||
|
|
||||||
|
1. Update `version` in `preset.yml`
|
||||||
|
2. Update CHANGELOG.md
|
||||||
|
3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0`
|
||||||
|
4. Submit PR to update `version` and `download_url` in `presets/catalog.community.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Template Design
|
||||||
|
|
||||||
|
- **Keep sections clear** — use headings and placeholder text the LLM can replace
|
||||||
|
- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template
|
||||||
|
- **Document customization points** — use HTML comments to guide users on what to change
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean`
|
||||||
|
- Avoid generic names: `my-preset`, `custom`, `test`
|
||||||
|
|
||||||
|
### Stacking
|
||||||
|
|
||||||
|
- Design presets to work well when stacked with others
|
||||||
|
- Only override templates you need to change
|
||||||
|
- Document which templates and commands your preset modifies
|
||||||
|
|
||||||
|
### Command Overrides
|
||||||
|
|
||||||
|
- Only override commands when the workflow needs to change, not just the output format
|
||||||
|
- If you only need different template sections, a template override is sufficient
|
||||||
|
- Test command overrides with multiple agents (Claude, Gemini, Copilot)
|
||||||
115
presets/README.md
Normal file
115
presets/README.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Presets
|
||||||
|
|
||||||
|
Presets are stackable, priority-ordered collections of template and command overrides for Spec Kit. They let you customize both the artifacts produced by the Spec-Driven Development workflow (specs, plans, tasks, checklists, constitutions) and the commands that guide the LLM in creating them — without forking or modifying core files.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When Spec Kit needs a template (e.g. `spec-template`), it walks a resolution stack:
|
||||||
|
|
||||||
|
1. `.specify/templates/overrides/` — project-local one-off overrides
|
||||||
|
2. `.specify/presets/<preset-id>/templates/` — installed presets (sorted by priority)
|
||||||
|
3. `.specify/extensions/<ext-id>/templates/` — extension-provided templates
|
||||||
|
4. `.specify/templates/` — core templates shipped with Spec Kit
|
||||||
|
|
||||||
|
If no preset is installed, core templates are used — exactly the same behavior as before presets existed.
|
||||||
|
|
||||||
|
For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Command Overrides
|
||||||
|
|
||||||
|
Presets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions).
|
||||||
|
|
||||||
|
When a preset includes `type: "command"` entries, the commands are automatically registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Search available presets
|
||||||
|
specify preset search
|
||||||
|
|
||||||
|
# Install a preset from the catalog
|
||||||
|
specify preset add healthcare-compliance
|
||||||
|
|
||||||
|
# Install from a local directory (for development)
|
||||||
|
specify preset add --dev ./my-preset
|
||||||
|
|
||||||
|
# Install with a specific priority (lower = higher precedence)
|
||||||
|
specify preset add healthcare-compliance --priority 5
|
||||||
|
|
||||||
|
# List installed presets
|
||||||
|
specify preset list
|
||||||
|
|
||||||
|
# See which template a name resolves to
|
||||||
|
specify preset resolve spec-template
|
||||||
|
|
||||||
|
# Get detailed info about a preset
|
||||||
|
specify preset info healthcare-compliance
|
||||||
|
|
||||||
|
# Remove a preset
|
||||||
|
specify preset remove healthcare-compliance
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stacking Presets
|
||||||
|
|
||||||
|
Multiple presets can be installed simultaneously. The `--priority` flag controls which one wins when two presets provide the same template (lower number = higher precedence):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
specify preset add enterprise-safe --priority 10 # base layer
|
||||||
|
specify preset add healthcare-compliance --priority 5 # overrides enterprise-safe
|
||||||
|
specify preset add pm-workflow --priority 1 # overrides everything
|
||||||
|
```
|
||||||
|
|
||||||
|
Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
|
||||||
|
|
||||||
|
## Catalog Management
|
||||||
|
|
||||||
|
Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List active catalogs
|
||||||
|
specify preset catalog list
|
||||||
|
|
||||||
|
# Add a custom catalog
|
||||||
|
specify preset catalog add https://example.com/catalog.json --name my-org --install-allowed
|
||||||
|
|
||||||
|
# Remove a catalog
|
||||||
|
specify preset catalog remove my-org
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a Preset
|
||||||
|
|
||||||
|
See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset.
|
||||||
|
|
||||||
|
1. Copy `scaffold/` to a new directory
|
||||||
|
2. Edit `preset.yml` with your preset's metadata
|
||||||
|
3. Add or replace templates in `templates/`
|
||||||
|
4. Test locally with `specify preset add --dev .`
|
||||||
|
5. Verify with `specify preset resolve spec-template`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
| File | Scope | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `.specify/preset-catalogs.yml` | Project | Custom catalog stack for this project |
|
||||||
|
| `~/.specify/preset-catalogs.yml` | User | Custom catalog stack for all projects |
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
The following enhancements are under consideration for future releases:
|
||||||
|
|
||||||
|
- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:
|
||||||
|
|
||||||
|
| Type | `replace` | `prepend` | `append` | `wrap` |
|
||||||
|
|------|-----------|-----------|----------|--------|
|
||||||
|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||||
|
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
|
||||||
|
| **script** | ✓ (default) | — | — | ✓ |
|
||||||
|
|
||||||
|
For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.
|
||||||
|
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.
|
||||||
6
presets/catalog.community.json
Normal file
6
presets/catalog.community.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"updated_at": "2026-03-09T00:00:00Z",
|
||||||
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||||
|
"presets": {}
|
||||||
|
}
|
||||||
6
presets/catalog.json
Normal file
6
presets/catalog.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"updated_at": "2026-03-10T00:00:00Z",
|
||||||
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
|
||||||
|
"presets": {}
|
||||||
|
}
|
||||||
46
presets/scaffold/README.md
Normal file
46
presets/scaffold/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# My Preset
|
||||||
|
|
||||||
|
A custom preset for Spec Kit. Copy this directory and customize it to create your own.
|
||||||
|
|
||||||
|
## Templates Included
|
||||||
|
|
||||||
|
| Template | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `spec-template` | template | Custom feature specification template (overrides core and extensions) |
|
||||||
|
| `myext-template` | template | Override of the myext extension's report template |
|
||||||
|
| `speckit.specify` | command | Custom specification command (overrides core) |
|
||||||
|
| `speckit.myext.myextcmd` | command | Override of the myext extension's myextcmd command |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
1. Copy this directory: `cp -r presets/scaffold my-preset`
|
||||||
|
2. Edit `preset.yml` — set your preset's ID, name, description, and templates
|
||||||
|
3. Add or modify templates in `templates/`
|
||||||
|
4. Test locally: `specify preset add --dev ./my-preset`
|
||||||
|
5. Verify resolution: `specify preset resolve spec-template`
|
||||||
|
6. Remove when done testing: `specify preset remove my-preset`
|
||||||
|
|
||||||
|
## Manifest Reference (`preset.yml`)
|
||||||
|
|
||||||
|
Required fields:
|
||||||
|
- `schema_version` — always `"1.0"`
|
||||||
|
- `preset.id` — lowercase alphanumeric with hyphens
|
||||||
|
- `preset.name` — human-readable name
|
||||||
|
- `preset.version` — semantic version (e.g. `1.0.0`)
|
||||||
|
- `preset.description` — brief description
|
||||||
|
- `requires.speckit_version` — version constraint (e.g. `>=0.1.0`)
|
||||||
|
- `provides.templates` — list of templates with `type`, `name`, and `file`
|
||||||
|
|
||||||
|
## Template Types
|
||||||
|
|
||||||
|
- **template** — Document scaffolds (spec-template.md, plan-template.md, tasks-template.md, etc.)
|
||||||
|
- **command** — AI agent workflow prompts (e.g. speckit.specify, speckit.plan)
|
||||||
|
- **script** — Custom scripts (reserved for future use)
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
See the [Preset Publishing Guide](../PUBLISHING.md) for details on submitting to the catalog.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
20
presets/scaffold/commands/speckit.myext.myextcmd.md
Normal file
20
presets/scaffold/commands/speckit.myext.myextcmd.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
description: "Override of the myext extension's myextcmd command"
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Preset override for speckit.myext.myextcmd -->
|
||||||
|
|
||||||
|
You are following a customized version of the myext extension's myextcmd command.
|
||||||
|
|
||||||
|
When executing this command:
|
||||||
|
|
||||||
|
1. Read the user's input from $ARGUMENTS
|
||||||
|
2. Follow the standard myextcmd workflow
|
||||||
|
3. Additionally, apply the following customizations from this preset:
|
||||||
|
- Add compliance checks before proceeding
|
||||||
|
- Include audit trail entries in the output
|
||||||
|
|
||||||
|
> CUSTOMIZE: Replace the instructions above with your own.
|
||||||
|
> This file overrides the command that the "myext" extension provides.
|
||||||
|
> When this preset is installed, all agents (Claude, Gemini, Copilot, etc.)
|
||||||
|
> will use this version instead of the extension's original.
|
||||||
23
presets/scaffold/commands/speckit.specify.md
Normal file
23
presets/scaffold/commands/speckit.specify.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: "Create a feature specification (preset override)"
|
||||||
|
scripts:
|
||||||
|
sh: scripts/bash/create-new-feature.sh "{ARGS}"
|
||||||
|
ps: scripts/powershell/create-new-feature.ps1 "{ARGS}"
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
Given the feature description above:
|
||||||
|
|
||||||
|
1. **Create the feature branch** by running the script:
|
||||||
|
- Bash: `{SCRIPT} --json --short-name "<short-name>" "<description>"`
|
||||||
|
- The JSON output contains BRANCH_NAME and SPEC_FILE paths.
|
||||||
|
|
||||||
|
2. **Read the spec-template** to see the sections you need to fill.
|
||||||
|
|
||||||
|
3. **Write the specification** to SPEC_FILE, replacing the placeholders in each section
|
||||||
|
(Overview, Requirements, Acceptance Criteria) with details from the user's description.
|
||||||
91
presets/scaffold/preset.yml
Normal file
91
presets/scaffold/preset.yml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
schema_version: "1.0"
|
||||||
|
|
||||||
|
preset:
|
||||||
|
# CUSTOMIZE: Change 'my-preset' to your preset ID (lowercase, hyphen-separated)
|
||||||
|
id: "my-preset"
|
||||||
|
|
||||||
|
# CUSTOMIZE: Human-readable name for your preset
|
||||||
|
name: "My Preset"
|
||||||
|
|
||||||
|
# CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z)
|
||||||
|
version: "1.0.0"
|
||||||
|
|
||||||
|
# CUSTOMIZE: Brief description (under 200 characters)
|
||||||
|
description: "Brief description of what your preset provides"
|
||||||
|
|
||||||
|
# CUSTOMIZE: Your name or organization name
|
||||||
|
author: "Your Name"
|
||||||
|
|
||||||
|
# CUSTOMIZE: GitHub repository URL (create before publishing)
|
||||||
|
repository: "https://github.com/your-org/spec-kit-preset-my-preset"
|
||||||
|
|
||||||
|
# REVIEW: License (MIT is recommended for open source)
|
||||||
|
license: "MIT"
|
||||||
|
|
||||||
|
# Requirements for this preset
|
||||||
|
requires:
|
||||||
|
# CUSTOMIZE: Minimum spec-kit version required
|
||||||
|
speckit_version: ">=0.1.0"
|
||||||
|
|
||||||
|
# Templates provided by this preset
|
||||||
|
provides:
|
||||||
|
templates:
|
||||||
|
# CUSTOMIZE: Define your template overrides
|
||||||
|
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
|
||||||
|
- type: "template"
|
||||||
|
name: "spec-template"
|
||||||
|
file: "templates/spec-template.md"
|
||||||
|
description: "Custom feature specification template"
|
||||||
|
replaces: "spec-template" # Which core template this overrides (optional)
|
||||||
|
|
||||||
|
# ADD MORE TEMPLATES: Copy this block for each template
|
||||||
|
# - type: "template"
|
||||||
|
# name: "plan-template"
|
||||||
|
# file: "templates/plan-template.md"
|
||||||
|
# description: "Custom plan template"
|
||||||
|
# replaces: "plan-template"
|
||||||
|
|
||||||
|
# OVERRIDE EXTENSION TEMPLATES:
|
||||||
|
# Presets sit above extensions in the resolution stack, so you can
|
||||||
|
# override templates provided by any installed extension.
|
||||||
|
# For example, if the "myext" extension provides a spec-template,
|
||||||
|
# the preset's version above will take priority automatically.
|
||||||
|
|
||||||
|
# Override a template provided by the "myext" extension:
|
||||||
|
- type: "template"
|
||||||
|
name: "myext-template"
|
||||||
|
file: "templates/myext-template.md"
|
||||||
|
description: "Override myext's report template"
|
||||||
|
replaces: "myext-template"
|
||||||
|
|
||||||
|
# Command overrides (AI agent workflow prompts)
|
||||||
|
# Presets can override both core and extension commands.
|
||||||
|
# Commands are automatically registered into all detected agent
|
||||||
|
# directories (.claude/commands/, .gemini/commands/, etc.)
|
||||||
|
|
||||||
|
# Override a core command:
|
||||||
|
- type: "command"
|
||||||
|
name: "speckit.specify"
|
||||||
|
file: "commands/speckit.specify.md"
|
||||||
|
description: "Custom specification command"
|
||||||
|
replaces: "speckit.specify"
|
||||||
|
|
||||||
|
# Override an extension command (e.g. from the "myext" extension):
|
||||||
|
- type: "command"
|
||||||
|
name: "speckit.myext.myextcmd"
|
||||||
|
file: "commands/speckit.myext.myextcmd.md"
|
||||||
|
description: "Override myext's myextcmd command with custom workflow"
|
||||||
|
replaces: "speckit.myext.myextcmd"
|
||||||
|
|
||||||
|
# Script templates (reserved for future use)
|
||||||
|
# - type: "script"
|
||||||
|
# name: "create-new-feature"
|
||||||
|
# file: "scripts/bash/create-new-feature.sh"
|
||||||
|
# description: "Custom feature creation script"
|
||||||
|
# replaces: "create-new-feature"
|
||||||
|
|
||||||
|
# CUSTOMIZE: Add relevant tags (2-5 recommended)
|
||||||
|
# Used for discovery in catalog
|
||||||
|
tags:
|
||||||
|
- "example"
|
||||||
|
- "preset"
|
||||||
24
presets/scaffold/templates/myext-template.md
Normal file
24
presets/scaffold/templates/myext-template.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# MyExt Report
|
||||||
|
|
||||||
|
> This template overrides the one provided by the "myext" extension.
|
||||||
|
> Customize it to match your needs.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Brief summary of the report.
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
- Detail 1
|
||||||
|
- Detail 2
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
- [ ] Action 1
|
||||||
|
- [ ] Action 2
|
||||||
|
|
||||||
|
<!--
|
||||||
|
CUSTOMIZE: This template takes priority over the myext extension's
|
||||||
|
version of myext-template. The extension's original is still available
|
||||||
|
if you remove this preset.
|
||||||
|
-->
|
||||||
18
presets/scaffold/templates/spec-template.md
Normal file
18
presets/scaffold/templates/spec-template.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Feature Specification: [FEATURE NAME]
|
||||||
|
|
||||||
|
**Created**: [DATE]
|
||||||
|
**Status**: Draft
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[Brief description of the feature]
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- [ ] Requirement 1
|
||||||
|
- [ ] Requirement 2
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Criterion 1
|
||||||
|
- [ ] Criterion 2
|
||||||
15
presets/self-test/commands/speckit.specify.md
Normal file
15
presets/self-test/commands/speckit.specify.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: "Self-test override of the specify command"
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- preset:self-test -->
|
||||||
|
|
||||||
|
You are following the self-test preset's version of the specify command.
|
||||||
|
|
||||||
|
When creating a specification, follow this process:
|
||||||
|
|
||||||
|
1. Read the user's requirements from $ARGUMENTS
|
||||||
|
2. Create a specification document using the spec-template
|
||||||
|
3. Include all standard sections plus the self-test marker
|
||||||
|
|
||||||
|
> This command is provided by the self-test preset.
|
||||||
61
presets/self-test/preset.yml
Normal file
61
presets/self-test/preset.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
schema_version: "1.0"
|
||||||
|
|
||||||
|
preset:
|
||||||
|
id: "self-test"
|
||||||
|
name: "Self-Test Preset"
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "A preset that overrides all core templates for testing purposes"
|
||||||
|
author: "github"
|
||||||
|
repository: "https://github.com/github/spec-kit"
|
||||||
|
license: "MIT"
|
||||||
|
|
||||||
|
requires:
|
||||||
|
speckit_version: ">=0.1.0"
|
||||||
|
|
||||||
|
provides:
|
||||||
|
templates:
|
||||||
|
- type: "template"
|
||||||
|
name: "spec-template"
|
||||||
|
file: "templates/spec-template.md"
|
||||||
|
description: "Self-test spec template"
|
||||||
|
replaces: "spec-template"
|
||||||
|
|
||||||
|
- type: "template"
|
||||||
|
name: "plan-template"
|
||||||
|
file: "templates/plan-template.md"
|
||||||
|
description: "Self-test plan template"
|
||||||
|
replaces: "plan-template"
|
||||||
|
|
||||||
|
- type: "template"
|
||||||
|
name: "tasks-template"
|
||||||
|
file: "templates/tasks-template.md"
|
||||||
|
description: "Self-test tasks template"
|
||||||
|
replaces: "tasks-template"
|
||||||
|
|
||||||
|
- type: "template"
|
||||||
|
name: "checklist-template"
|
||||||
|
file: "templates/checklist-template.md"
|
||||||
|
description: "Self-test checklist template"
|
||||||
|
replaces: "checklist-template"
|
||||||
|
|
||||||
|
- type: "template"
|
||||||
|
name: "constitution-template"
|
||||||
|
file: "templates/constitution-template.md"
|
||||||
|
description: "Self-test constitution template"
|
||||||
|
replaces: "constitution-template"
|
||||||
|
|
||||||
|
- type: "template"
|
||||||
|
name: "agent-file-template"
|
||||||
|
file: "templates/agent-file-template.md"
|
||||||
|
description: "Self-test agent file template"
|
||||||
|
replaces: "agent-file-template"
|
||||||
|
|
||||||
|
- type: "command"
|
||||||
|
name: "speckit.specify"
|
||||||
|
file: "commands/speckit.specify.md"
|
||||||
|
description: "Self-test override of the specify command"
|
||||||
|
replaces: "speckit.specify"
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- "testing"
|
||||||
|
- "self-test"
|
||||||
9
presets/self-test/templates/agent-file-template.md
Normal file
9
presets/self-test/templates/agent-file-template.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Agent File (Self-Test Preset)
|
||||||
|
|
||||||
|
<!-- preset:self-test -->
|
||||||
|
|
||||||
|
> This template is provided by the self-test preset.
|
||||||
|
|
||||||
|
## Agent Instructions
|
||||||
|
|
||||||
|
Follow these guidelines when working on this project.
|
||||||
15
presets/self-test/templates/checklist-template.md
Normal file
15
presets/self-test/templates/checklist-template.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Checklist (Self-Test Preset)
|
||||||
|
|
||||||
|
<!-- preset:self-test -->
|
||||||
|
|
||||||
|
> This template is provided by the self-test preset.
|
||||||
|
|
||||||
|
## Pre-Implementation
|
||||||
|
|
||||||
|
- [ ] Spec reviewed
|
||||||
|
- [ ] Plan approved
|
||||||
|
|
||||||
|
## Post-Implementation
|
||||||
|
|
||||||
|
- [ ] Tests passing
|
||||||
|
- [ ] Documentation updated
|
||||||
15
presets/self-test/templates/constitution-template.md
Normal file
15
presets/self-test/templates/constitution-template.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Constitution (Self-Test Preset)
|
||||||
|
|
||||||
|
<!-- preset:self-test -->
|
||||||
|
|
||||||
|
> This template is provided by the self-test preset.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. Principle 1
|
||||||
|
2. Principle 2
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Guideline 1
|
||||||
|
- Guideline 2
|
||||||
22
presets/self-test/templates/plan-template.md
Normal file
22
presets/self-test/templates/plan-template.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Implementation Plan (Self-Test Preset)
|
||||||
|
|
||||||
|
<!-- preset:self-test -->
|
||||||
|
|
||||||
|
> This template is provided by the self-test preset.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Describe the implementation approach.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Step 1
|
||||||
|
2. Step 2
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Dependency 1
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Risk 1
|
||||||
23
presets/self-test/templates/spec-template.md
Normal file
23
presets/self-test/templates/spec-template.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Feature Specification (Self-Test Preset)
|
||||||
|
|
||||||
|
<!-- preset:self-test -->
|
||||||
|
|
||||||
|
> This template is provided by the self-test preset.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Brief description of the feature.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Requirement 1
|
||||||
|
- Requirement 2
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Describe the design approach.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Criterion 1
|
||||||
|
- [ ] Criterion 2
|
||||||
17
presets/self-test/templates/tasks-template.md
Normal file
17
presets/self-test/templates/tasks-template.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Tasks (Self-Test Preset)
|
||||||
|
|
||||||
|
<!-- preset:self-test -->
|
||||||
|
|
||||||
|
> This template is provided by the self-test preset.
|
||||||
|
|
||||||
|
## Task List
|
||||||
|
|
||||||
|
- [ ] Task 1
|
||||||
|
- [ ] Task 2
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
| Task | Estimate |
|
||||||
|
|------|----------|
|
||||||
|
| Task 1 | TBD |
|
||||||
|
| Task 2 | TBD |
|
||||||
@@ -175,3 +175,79 @@ json_escape() {
|
|||||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||||
|
|
||||||
|
# Resolve a template name to a file path using the priority stack:
|
||||||
|
# 1. .specify/templates/overrides/
|
||||||
|
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||||
|
# 3. .specify/extensions/<ext-id>/templates/
|
||||||
|
# 4. .specify/templates/ (core)
|
||||||
|
resolve_template() {
|
||||||
|
local template_name="$1"
|
||||||
|
local repo_root="$2"
|
||||||
|
local base="$repo_root/.specify/templates"
|
||||||
|
|
||||||
|
# Priority 1: Project overrides
|
||||||
|
local override="$base/overrides/${template_name}.md"
|
||||||
|
[ -f "$override" ] && echo "$override" && return 0
|
||||||
|
|
||||||
|
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||||
|
local presets_dir="$repo_root/.specify/presets"
|
||||||
|
if [ -d "$presets_dir" ]; then
|
||||||
|
local registry_file="$presets_dir/.registry"
|
||||||
|
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
|
# Read preset IDs sorted by priority (lower number = higher precedence)
|
||||||
|
local sorted_presets
|
||||||
|
sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||||
|
import json, sys, os
|
||||||
|
try:
|
||||||
|
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
presets = data.get('presets', {})
|
||||||
|
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
|
||||||
|
print(pid)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>/dev/null)
|
||||||
|
if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then
|
||||||
|
while IFS= read -r preset_id; do
|
||||||
|
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done <<< "$sorted_presets"
|
||||||
|
else
|
||||||
|
# python3 returned empty list — fall through to directory scan
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Fallback: alphabetical directory order (no python3 available)
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 3: Extension-provided templates
|
||||||
|
local ext_dir="$repo_root/.specify/extensions"
|
||||||
|
if [ -d "$ext_dir" ]; then
|
||||||
|
for ext in "$ext_dir"/*/; do
|
||||||
|
[ -d "$ext" ] || continue
|
||||||
|
# Skip hidden directories (e.g. .backup, .cache)
|
||||||
|
case "$(basename "$ext")" in .*) continue;; esac
|
||||||
|
local candidate="$ext/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 4: Core templates
|
||||||
|
local core="$base/${template_name}.md"
|
||||||
|
[ -f "$core" ] && echo "$core" && return 0
|
||||||
|
|
||||||
|
# Return success with empty output so callers using set -e don't abort;
|
||||||
|
# callers check [ -n "$TEMPLATE" ] to detect "not found".
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ json_escape() {
|
|||||||
# to searching for repository markers so the workflow still functions in repositories that
|
# to searching for repository markers so the workflow still functions in repositories that
|
||||||
# were initialised with --no-git.
|
# were initialised with --no-git.
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
@@ -307,9 +308,9 @@ fi
|
|||||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
||||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||||
|
|
||||||
# Inform the user how to persist the feature variable in their own shell
|
# Inform the user how to persist the feature variable in their own shell
|
||||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
|
|||||||
@@ -39,12 +39,12 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
|||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
# Copy plan template if it exists
|
# Copy plan template if it exists
|
||||||
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
|
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
|
||||||
if [[ -f "$TEMPLATE" ]]; then
|
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||||
echo "Copied plan template to $IMPL_PLAN"
|
echo "Copied plan template to $IMPL_PLAN"
|
||||||
else
|
else
|
||||||
echo "Warning: Plan template not found at $TEMPLATE"
|
echo "Warning: Plan template not found"
|
||||||
# Create a basic plan file if template doesn't exist
|
# Create a basic plan file if template doesn't exist
|
||||||
touch "$IMPL_PLAN"
|
touch "$IMPL_PLAN"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -135,3 +135,70 @@ function Test-DirHasFiles {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve a template name to a file path using the priority stack:
|
||||||
|
# 1. .specify/templates/overrides/
|
||||||
|
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||||
|
# 3. .specify/extensions/<ext-id>/templates/
|
||||||
|
# 4. .specify/templates/ (core)
|
||||||
|
function Resolve-Template {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||||
|
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
$base = Join-Path $RepoRoot '.specify/templates'
|
||||||
|
|
||||||
|
# Priority 1: Project overrides
|
||||||
|
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||||
|
if (Test-Path $override) { return $override }
|
||||||
|
|
||||||
|
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||||
|
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||||
|
if (Test-Path $presetsDir) {
|
||||||
|
$registryFile = Join-Path $presetsDir '.registry'
|
||||||
|
$sortedPresets = @()
|
||||||
|
if (Test-Path $registryFile) {
|
||||||
|
try {
|
||||||
|
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||||
|
$presets = $registryData.presets
|
||||||
|
if ($presets) {
|
||||||
|
$sortedPresets = $presets.PSObject.Properties |
|
||||||
|
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||||
|
ForEach-Object { $_.Name }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# Fallback: alphabetical directory order
|
||||||
|
$sortedPresets = @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sortedPresets.Count -gt 0) {
|
||||||
|
foreach ($presetId in $sortedPresets) {
|
||||||
|
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||||
|
if (Test-Path $candidate) { return $candidate }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
# Fallback: alphabetical directory order
|
||||||
|
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
|
||||||
|
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||||
|
if (Test-Path $candidate) { return $candidate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Priority 3: Extension-provided templates
|
||||||
|
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||||
|
if (Test-Path $extDir) {
|
||||||
|
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||||
|
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||||
|
if (Test-Path $candidate) { return $candidate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Priority 4: Core templates
|
||||||
|
$core = Join-Path $base "$TemplateName.md"
|
||||||
|
if (Test-Path $core) { return $core }
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ if (-not $fallbackRoot) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Load common functions (includes Resolve-Template)
|
||||||
|
. "$PSScriptRoot/common.ps1"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
@@ -276,9 +279,9 @@ if ($hasGit) {
|
|||||||
$featureDir = Join-Path $specsDir $branchName
|
$featureDir = Join-Path $specsDir $branchName
|
||||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||||
|
|
||||||
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
|
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||||
$specFile = Join-Path $featureDir 'spec.md'
|
$specFile = Join-Path $featureDir 'spec.md'
|
||||||
if (Test-Path $template) {
|
if ($template -and (Test-Path $template)) {
|
||||||
Copy-Item $template $specFile -Force
|
Copy-Item $template $specFile -Force
|
||||||
} else {
|
} else {
|
||||||
New-Item -ItemType File -Path $specFile | Out-Null
|
New-Item -ItemType File -Path $specFile | Out-Null
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GI
|
|||||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||||
|
|
||||||
# Copy plan template if it exists, otherwise note it or create empty file
|
# Copy plan template if it exists, otherwise note it or create empty file
|
||||||
$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
|
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||||
if (Test-Path $template) {
|
if ($template -and (Test-Path $template)) {
|
||||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||||
} else {
|
} else {
|
||||||
Write-Warning "Plan template not found at $template"
|
Write-Warning "Plan template not found"
|
||||||
# Create a basic plan file if template doesn't exist
|
# Create a basic plan file if template doesn't exist
|
||||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import shlex
|
|||||||
import json
|
import json
|
||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
import httpx
|
import httpx
|
||||||
@@ -1067,6 +1067,36 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
|
|||||||
else:
|
else:
|
||||||
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
|
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
INIT_OPTIONS_FILE = ".specify/init-options.json"
|
||||||
|
|
||||||
|
|
||||||
|
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||||
|
"""Persist the CLI options used during ``specify init``.
|
||||||
|
|
||||||
|
Writes a small JSON file to ``.specify/init-options.json`` so that
|
||||||
|
later operations (e.g. preset install) can adapt their behaviour
|
||||||
|
without scanning the filesystem.
|
||||||
|
"""
|
||||||
|
dest = project_path / INIT_OPTIONS_FILE
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_text(json.dumps(options, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||||
|
"""Load the init options previously saved by ``specify init``.
|
||||||
|
|
||||||
|
Returns an empty dict if the file does not exist or cannot be parsed.
|
||||||
|
"""
|
||||||
|
path = project_path / INIT_OPTIONS_FILE
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# Agent-specific skill directory overrides for agents whose skills directory
|
# Agent-specific skill directory overrides for agents whose skills directory
|
||||||
# doesn't follow the standard <agent_folder>/skills/ pattern
|
# doesn't follow the standard <agent_folder>/skills/ pattern
|
||||||
AGENT_SKILLS_DIR_OVERRIDES = {
|
AGENT_SKILLS_DIR_OVERRIDES = {
|
||||||
@@ -1300,6 +1330,7 @@ def init(
|
|||||||
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
||||||
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
||||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||||
|
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize a new Specify project from the latest template.
|
Initialize a new Specify project from the latest template.
|
||||||
@@ -1328,6 +1359,7 @@ def init(
|
|||||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||||
specify init --here --ai gemini --ai-skills
|
specify init --here --ai gemini --ai-skills
|
||||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||||
|
specify init my-project --ai claude --preset healthcare-compliance # With preset
|
||||||
"""
|
"""
|
||||||
|
|
||||||
show_banner()
|
show_banner()
|
||||||
@@ -1589,6 +1621,50 @@ def init(
|
|||||||
else:
|
else:
|
||||||
tracker.skip("git", "--no-git flag")
|
tracker.skip("git", "--no-git flag")
|
||||||
|
|
||||||
|
# Persist the CLI options so later operations (e.g. preset add)
|
||||||
|
# can adapt their behaviour without re-scanning the filesystem.
|
||||||
|
# Must be saved BEFORE preset install so _get_skills_dir() works.
|
||||||
|
save_init_options(project_path, {
|
||||||
|
"ai": selected_ai,
|
||||||
|
"ai_skills": ai_skills,
|
||||||
|
"ai_commands_dir": ai_commands_dir,
|
||||||
|
"here": here,
|
||||||
|
"preset": preset,
|
||||||
|
"script": selected_script,
|
||||||
|
"speckit_version": get_speckit_version(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Install preset if specified
|
||||||
|
if preset:
|
||||||
|
try:
|
||||||
|
from .presets import PresetManager, PresetCatalog, PresetError
|
||||||
|
preset_manager = PresetManager(project_path)
|
||||||
|
speckit_ver = get_speckit_version()
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
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:
|
||||||
|
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.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}")
|
||||||
|
|
||||||
tracker.complete("final", "project ready")
|
tracker.complete("final", "project ready")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tracker.error("final", str(e))
|
tracker.error("final", str(e))
|
||||||
@@ -1826,6 +1902,20 @@ catalog_app = typer.Typer(
|
|||||||
)
|
)
|
||||||
extension_app.add_typer(catalog_app, name="catalog")
|
extension_app.add_typer(catalog_app, name="catalog")
|
||||||
|
|
||||||
|
preset_app = typer.Typer(
|
||||||
|
name="preset",
|
||||||
|
help="Manage spec-kit presets",
|
||||||
|
add_completion=False,
|
||||||
|
)
|
||||||
|
app.add_typer(preset_app, name="preset")
|
||||||
|
|
||||||
|
preset_catalog_app = typer.Typer(
|
||||||
|
name="catalog",
|
||||||
|
help="Manage preset catalogs",
|
||||||
|
add_completion=False,
|
||||||
|
)
|
||||||
|
preset_app.add_typer(preset_catalog_app, name="catalog")
|
||||||
|
|
||||||
|
|
||||||
def get_speckit_version() -> str:
|
def get_speckit_version() -> str:
|
||||||
"""Get current spec-kit version."""
|
"""Get current spec-kit version."""
|
||||||
@@ -1848,6 +1938,490 @@ def get_speckit_version() -> str:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Preset Commands =====
|
||||||
|
|
||||||
|
|
||||||
|
@preset_app.command("list")
|
||||||
|
def preset_list():
|
||||||
|
"""List installed presets."""
|
||||||
|
from .presets import PresetManager
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
manager = PresetManager(project_root)
|
||||||
|
installed = manager.list_installed()
|
||||||
|
|
||||||
|
if not installed:
|
||||||
|
console.print("[yellow]No presets installed.[/yellow]")
|
||||||
|
console.print("\nInstall a preset with:")
|
||||||
|
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
|
||||||
|
for pack in installed:
|
||||||
|
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||||
|
pri = pack.get('priority', 10)
|
||||||
|
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
||||||
|
console.print(f" {pack['description']}")
|
||||||
|
if pack.get("tags"):
|
||||||
|
tags_str = ", ".join(pack["tags"])
|
||||||
|
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||||
|
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
@preset_app.command("add")
|
||||||
|
def preset_add(
|
||||||
|
pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
|
||||||
|
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
||||||
|
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
||||||
|
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||||
|
):
|
||||||
|
"""Install a preset."""
|
||||||
|
from .presets import (
|
||||||
|
PresetManager,
|
||||||
|
PresetCatalog,
|
||||||
|
PresetError,
|
||||||
|
PresetValidationError,
|
||||||
|
PresetCompatibilityError,
|
||||||
|
)
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
manager = PresetManager(project_root)
|
||||||
|
speckit_version = get_speckit_version()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if dev:
|
||||||
|
dev_path = Path(dev).resolve()
|
||||||
|
if not dev_path.exists():
|
||||||
|
console.print(f"[red]Error:[/red] Directory not found: {dev}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...")
|
||||||
|
manifest = manager.install_from_directory(dev_path, speckit_version, priority)
|
||||||
|
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||||
|
|
||||||
|
elif from_url:
|
||||||
|
# Validate URL scheme before downloading
|
||||||
|
from urllib.parse import urlparse as _urlparse
|
||||||
|
_parsed = _urlparse(from_url)
|
||||||
|
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||||
|
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
|
||||||
|
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
zip_path = Path(tmpdir) / "preset.zip"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||||
|
zip_path.write_bytes(response.read())
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||||
|
|
||||||
|
elif pack_id:
|
||||||
|
catalog = PresetCatalog(project_root)
|
||||||
|
pack_info = catalog.get_pack_info(pack_id)
|
||||||
|
|
||||||
|
if not pack_info:
|
||||||
|
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if not pack_info.get("_install_allowed", True):
|
||||||
|
catalog_name = pack_info.get("_catalog_name", "unknown")
|
||||||
|
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
|
||||||
|
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
zip_path = catalog.download_pack(pack_id)
|
||||||
|
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||||
|
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||||
|
finally:
|
||||||
|
if 'zip_path' in locals() and zip_path.exists():
|
||||||
|
zip_path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
except PresetCompatibilityError as e:
|
||||||
|
console.print(f"[red]Compatibility Error:[/red] {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
except PresetValidationError as e:
|
||||||
|
console.print(f"[red]Validation Error:[/red] {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
except PresetError as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@preset_app.command("remove")
|
||||||
|
def preset_remove(
|
||||||
|
pack_id: str = typer.Argument(..., help="Preset ID to remove"),
|
||||||
|
):
|
||||||
|
"""Remove an installed preset."""
|
||||||
|
from .presets import PresetManager
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
manager = PresetManager(project_root)
|
||||||
|
|
||||||
|
if not manager.registry.is_installed(pack_id):
|
||||||
|
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if manager.remove(pack_id):
|
||||||
|
console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@preset_app.command("search")
|
||||||
|
def preset_search(
|
||||||
|
query: str = typer.Argument(None, help="Search query"),
|
||||||
|
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
|
||||||
|
author: str = typer.Option(None, "--author", help="Filter by author"),
|
||||||
|
):
|
||||||
|
"""Search for presets in the catalog."""
|
||||||
|
from .presets import PresetCatalog, PresetError
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
catalog = PresetCatalog(project_root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = catalog.search(query=query, tag=tag, author=author)
|
||||||
|
except PresetError as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
console.print("[yellow]No presets found matching your criteria.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
|
||||||
|
for pack in results:
|
||||||
|
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
|
||||||
|
console.print(f" {pack.get('description', '')}")
|
||||||
|
if pack.get("tags"):
|
||||||
|
tags_str = ", ".join(pack["tags"])
|
||||||
|
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
@preset_app.command("resolve")
|
||||||
|
def preset_resolve(
|
||||||
|
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
|
||||||
|
):
|
||||||
|
"""Show which template will be resolved for a given name."""
|
||||||
|
from .presets import PresetResolver
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
resolver = PresetResolver(project_root)
|
||||||
|
result = resolver.resolve_with_source(template_name)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
||||||
|
console.print(f" [dim](from: {result['source']})[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
||||||
|
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
@preset_app.command("info")
|
||||||
|
def preset_info(
|
||||||
|
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
||||||
|
):
|
||||||
|
"""Show detailed information about a preset."""
|
||||||
|
from .presets import PresetCatalog, PresetManager, PresetError
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Check if installed locally first
|
||||||
|
manager = PresetManager(project_root)
|
||||||
|
local_pack = manager.get_pack(pack_id)
|
||||||
|
|
||||||
|
if local_pack:
|
||||||
|
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
|
||||||
|
console.print(f" ID: {local_pack.id}")
|
||||||
|
console.print(f" Version: {local_pack.version}")
|
||||||
|
console.print(f" Description: {local_pack.description}")
|
||||||
|
if local_pack.author:
|
||||||
|
console.print(f" Author: {local_pack.author}")
|
||||||
|
if local_pack.tags:
|
||||||
|
console.print(f" Tags: {', '.join(local_pack.tags)}")
|
||||||
|
console.print(f" Templates: {len(local_pack.templates)}")
|
||||||
|
for tmpl in local_pack.templates:
|
||||||
|
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
||||||
|
repo = local_pack.data.get("preset", {}).get("repository")
|
||||||
|
if repo:
|
||||||
|
console.print(f" Repository: {repo}")
|
||||||
|
license_val = local_pack.data.get("preset", {}).get("license")
|
||||||
|
if license_val:
|
||||||
|
console.print(f" License: {license_val}")
|
||||||
|
console.print("\n [green]Status: installed[/green]")
|
||||||
|
console.print()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fall back to catalog
|
||||||
|
catalog = PresetCatalog(project_root)
|
||||||
|
try:
|
||||||
|
pack_info = catalog.get_pack_info(pack_id)
|
||||||
|
except PresetError:
|
||||||
|
pack_info = None
|
||||||
|
|
||||||
|
if not pack_info:
|
||||||
|
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n")
|
||||||
|
console.print(f" ID: {pack_info['id']}")
|
||||||
|
console.print(f" Version: {pack_info.get('version', '?')}")
|
||||||
|
console.print(f" Description: {pack_info.get('description', '')}")
|
||||||
|
if pack_info.get("author"):
|
||||||
|
console.print(f" Author: {pack_info['author']}")
|
||||||
|
if pack_info.get("tags"):
|
||||||
|
console.print(f" Tags: {', '.join(pack_info['tags'])}")
|
||||||
|
if pack_info.get("repository"):
|
||||||
|
console.print(f" Repository: {pack_info['repository']}")
|
||||||
|
if pack_info.get("license"):
|
||||||
|
console.print(f" License: {pack_info['license']}")
|
||||||
|
console.print("\n [yellow]Status: not installed[/yellow]")
|
||||||
|
console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Preset Catalog Commands =====
|
||||||
|
|
||||||
|
|
||||||
|
@preset_catalog_app.command("list")
|
||||||
|
def preset_catalog_list():
|
||||||
|
"""List all active preset catalogs."""
|
||||||
|
from .presets import PresetCatalog, PresetValidationError
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
catalog = PresetCatalog(project_root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
active_catalogs = catalog.get_active_catalogs()
|
||||||
|
except PresetValidationError as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
|
||||||
|
for entry in active_catalogs:
|
||||||
|
install_str = (
|
||||||
|
"[green]install allowed[/green]"
|
||||||
|
if entry.install_allowed
|
||||||
|
else "[yellow]discovery only[/yellow]"
|
||||||
|
)
|
||||||
|
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
||||||
|
if entry.description:
|
||||||
|
console.print(f" {entry.description}")
|
||||||
|
console.print(f" URL: {entry.url}")
|
||||||
|
console.print(f" Install: {install_str}")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
config_path = project_root / ".specify" / "preset-catalogs.yml"
|
||||||
|
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
|
||||||
|
if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
|
||||||
|
console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
|
||||||
|
except PresetValidationError:
|
||||||
|
proj_loaded = False
|
||||||
|
if proj_loaded:
|
||||||
|
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
|
||||||
|
except PresetValidationError:
|
||||||
|
user_loaded = False
|
||||||
|
if user_loaded:
|
||||||
|
console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
|
||||||
|
else:
|
||||||
|
console.print("[dim]Using built-in default catalog stack.[/dim]")
|
||||||
|
console.print(
|
||||||
|
"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@preset_catalog_app.command("add")
|
||||||
|
def preset_catalog_add(
|
||||||
|
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
|
||||||
|
name: str = typer.Option(..., "--name", help="Catalog name"),
|
||||||
|
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
|
||||||
|
install_allowed: bool = typer.Option(
|
||||||
|
False, "--install-allowed/--no-install-allowed",
|
||||||
|
help="Allow presets from this catalog to be installed",
|
||||||
|
),
|
||||||
|
description: str = typer.Option("", "--description", help="Description of the catalog"),
|
||||||
|
):
|
||||||
|
"""Add a catalog to .specify/preset-catalogs.yml."""
|
||||||
|
from .presets import PresetCatalog, PresetValidationError
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Validate URL
|
||||||
|
tmp_catalog = PresetCatalog(project_root)
|
||||||
|
try:
|
||||||
|
tmp_catalog._validate_catalog_url(url)
|
||||||
|
except PresetValidationError as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
config_path = specify_dir / "preset-catalogs.yml"
|
||||||
|
|
||||||
|
# Load existing config
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
config = yaml.safe_load(config_path.read_text()) or {}
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
else:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
catalogs = config.get("catalogs", [])
|
||||||
|
if not isinstance(catalogs, list):
|
||||||
|
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
for existing in catalogs:
|
||||||
|
if isinstance(existing, dict) and existing.get("name") == name:
|
||||||
|
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
||||||
|
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
catalogs.append({
|
||||||
|
"name": name,
|
||||||
|
"url": url,
|
||||||
|
"priority": priority,
|
||||||
|
"install_allowed": install_allowed,
|
||||||
|
"description": description,
|
||||||
|
})
|
||||||
|
|
||||||
|
config["catalogs"] = catalogs
|
||||||
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
||||||
|
|
||||||
|
install_label = "install allowed" if install_allowed else "discovery only"
|
||||||
|
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||||
|
console.print(f" URL: {url}")
|
||||||
|
console.print(f" Priority: {priority}")
|
||||||
|
console.print(f"\nConfig saved to {config_path.relative_to(project_root)}")
|
||||||
|
|
||||||
|
|
||||||
|
@preset_catalog_app.command("remove")
|
||||||
|
def preset_catalog_remove(
|
||||||
|
name: str = typer.Argument(help="Catalog name to remove"),
|
||||||
|
):
|
||||||
|
"""Remove a catalog from .specify/preset-catalogs.yml."""
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
config_path = specify_dir / "preset-catalogs.yml"
|
||||||
|
if not config_path.exists():
|
||||||
|
console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = yaml.safe_load(config_path.read_text()) or {}
|
||||||
|
except Exception:
|
||||||
|
console.print("[red]Error:[/red] Failed to read preset catalog config.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
catalogs = config.get("catalogs", [])
|
||||||
|
if not isinstance(catalogs, list):
|
||||||
|
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
original_count = len(catalogs)
|
||||||
|
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
||||||
|
|
||||||
|
if len(catalogs) == original_count:
|
||||||
|
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
config["catalogs"] = catalogs
|
||||||
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||||
|
if not catalogs:
|
||||||
|
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Extension Commands =====
|
||||||
|
|
||||||
|
|
||||||
def _resolve_installed_extension(
|
def _resolve_installed_extension(
|
||||||
argument: str,
|
argument: str,
|
||||||
installed_extensions: list,
|
installed_extensions: list,
|
||||||
|
|||||||
422
src/specify_cli/agents.py
Normal file
422
src/specify_cli/agents.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""
|
||||||
|
Agent Command Registrar for Spec Kit
|
||||||
|
|
||||||
|
Shared infrastructure for registering commands with AI agents.
|
||||||
|
Used by both the extension system and the preset system to write
|
||||||
|
command files into agent-specific directories in the correct format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRegistrar:
|
||||||
|
"""Handles registration of commands with AI agents.
|
||||||
|
|
||||||
|
Supports writing command files in Markdown or TOML format to the
|
||||||
|
appropriate agent directory, with correct argument placeholders
|
||||||
|
and companion files (e.g. Copilot .prompt.md).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Agent configurations with directory, format, and argument placeholder
|
||||||
|
AGENT_CONFIGS = {
|
||||||
|
"claude": {
|
||||||
|
"dir": ".claude/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"dir": ".gemini/commands",
|
||||||
|
"format": "toml",
|
||||||
|
"args": "{{args}}",
|
||||||
|
"extension": ".toml"
|
||||||
|
},
|
||||||
|
"copilot": {
|
||||||
|
"dir": ".github/agents",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".agent.md"
|
||||||
|
},
|
||||||
|
"cursor": {
|
||||||
|
"dir": ".cursor/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"qwen": {
|
||||||
|
"dir": ".qwen/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"opencode": {
|
||||||
|
"dir": ".opencode/command",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"dir": ".codex/prompts",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"windsurf": {
|
||||||
|
"dir": ".windsurf/workflows",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"kilocode": {
|
||||||
|
"dir": ".kilocode/workflows",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"auggie": {
|
||||||
|
"dir": ".augment/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"roo": {
|
||||||
|
"dir": ".roo/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"codebuddy": {
|
||||||
|
"dir": ".codebuddy/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"qodercli": {
|
||||||
|
"dir": ".qoder/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"kiro-cli": {
|
||||||
|
"dir": ".kiro/prompts",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"amp": {
|
||||||
|
"dir": ".agents/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"shai": {
|
||||||
|
"dir": ".shai/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"tabnine": {
|
||||||
|
"dir": ".tabnine/agent/commands",
|
||||||
|
"format": "toml",
|
||||||
|
"args": "{{args}}",
|
||||||
|
"extension": ".toml"
|
||||||
|
},
|
||||||
|
"bob": {
|
||||||
|
"dir": ".bob/commands",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
|
"kimi": {
|
||||||
|
"dir": ".kimi/skills",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": "/SKILL.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||||
|
"""Parse YAML frontmatter from Markdown content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Markdown content with YAML frontmatter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (frontmatter_dict, body_content)
|
||||||
|
"""
|
||||||
|
if not content.startswith("---"):
|
||||||
|
return {}, content
|
||||||
|
|
||||||
|
# Find second ---
|
||||||
|
end_marker = content.find("---", 3)
|
||||||
|
if end_marker == -1:
|
||||||
|
return {}, content
|
||||||
|
|
||||||
|
frontmatter_str = content[3:end_marker].strip()
|
||||||
|
body = content[end_marker + 3:].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
frontmatter = yaml.safe_load(frontmatter_str) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
frontmatter = {}
|
||||||
|
|
||||||
|
return frontmatter, body
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render_frontmatter(fm: dict) -> str:
|
||||||
|
"""Render frontmatter dictionary as YAML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fm: Frontmatter dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
YAML-formatted frontmatter with delimiters
|
||||||
|
"""
|
||||||
|
if not fm:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
|
||||||
|
return f"---\n{yaml_str}---\n"
|
||||||
|
|
||||||
|
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||||
|
"""Adjust script paths from extension-relative to repo-relative.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frontmatter: Frontmatter dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified frontmatter with adjusted paths
|
||||||
|
"""
|
||||||
|
if "scripts" in frontmatter:
|
||||||
|
for key in frontmatter["scripts"]:
|
||||||
|
script_path = frontmatter["scripts"][key]
|
||||||
|
if script_path.startswith("../../scripts/"):
|
||||||
|
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
||||||
|
return frontmatter
|
||||||
|
|
||||||
|
def render_markdown_command(
|
||||||
|
self,
|
||||||
|
frontmatter: dict,
|
||||||
|
body: str,
|
||||||
|
source_id: str,
|
||||||
|
context_note: str = None
|
||||||
|
) -> str:
|
||||||
|
"""Render command in Markdown format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frontmatter: Command frontmatter
|
||||||
|
body: Command body content
|
||||||
|
source_id: Source identifier (extension or preset ID)
|
||||||
|
context_note: Custom context comment (default: <!-- Source: {source_id} -->)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted Markdown command file content
|
||||||
|
"""
|
||||||
|
if context_note is None:
|
||||||
|
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:
|
||||||
|
"""Render command in TOML format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frontmatter: Command frontmatter
|
||||||
|
body: Command body content
|
||||||
|
source_id: Source identifier (extension or preset ID)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted TOML command file content
|
||||||
|
"""
|
||||||
|
toml_lines = []
|
||||||
|
|
||||||
|
if "description" in frontmatter:
|
||||||
|
desc = frontmatter["description"].replace('"', '\\"')
|
||||||
|
toml_lines.append(f'description = "{desc}"')
|
||||||
|
toml_lines.append("")
|
||||||
|
|
||||||
|
toml_lines.append(f"# Source: {source_id}")
|
||||||
|
toml_lines.append("")
|
||||||
|
|
||||||
|
toml_lines.append('prompt = """')
|
||||||
|
toml_lines.append(body)
|
||||||
|
toml_lines.append('"""')
|
||||||
|
|
||||||
|
return "\n".join(toml_lines)
|
||||||
|
|
||||||
|
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||||
|
"""Convert argument placeholder format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Command content
|
||||||
|
from_placeholder: Source placeholder (e.g., "$ARGUMENTS")
|
||||||
|
to_placeholder: Target placeholder (e.g., "{{args}}")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Content with converted placeholders
|
||||||
|
"""
|
||||||
|
return content.replace(from_placeholder, to_placeholder)
|
||||||
|
|
||||||
|
def register_commands(
|
||||||
|
self,
|
||||||
|
agent_name: str,
|
||||||
|
commands: List[Dict[str, Any]],
|
||||||
|
source_id: str,
|
||||||
|
source_dir: Path,
|
||||||
|
project_root: Path,
|
||||||
|
context_note: str = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""Register commands for a specific agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: Agent name (claude, gemini, copilot, etc.)
|
||||||
|
commands: List of command info dicts with 'name', 'file', and optional 'aliases'
|
||||||
|
source_id: Identifier of the source (extension or preset ID)
|
||||||
|
source_dir: Directory containing command source files
|
||||||
|
project_root: Path to project root
|
||||||
|
context_note: Custom context comment for markdown output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of registered command names
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If agent is not supported
|
||||||
|
"""
|
||||||
|
if agent_name not in self.AGENT_CONFIGS:
|
||||||
|
raise ValueError(f"Unsupported agent: {agent_name}")
|
||||||
|
|
||||||
|
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||||
|
commands_dir = project_root / agent_config["dir"]
|
||||||
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
registered = []
|
||||||
|
|
||||||
|
for cmd_info in commands:
|
||||||
|
cmd_name = cmd_info["name"]
|
||||||
|
cmd_file = cmd_info["file"]
|
||||||
|
|
||||||
|
source_file = source_dir / cmd_file
|
||||||
|
if not source_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = source_file.read_text(encoding="utf-8")
|
||||||
|
frontmatter, body = self.parse_frontmatter(content)
|
||||||
|
|
||||||
|
frontmatter = self._adjust_script_paths(frontmatter)
|
||||||
|
|
||||||
|
body = self._convert_argument_placeholder(
|
||||||
|
body, "$ARGUMENTS", agent_config["args"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if agent_config["format"] == "markdown":
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||||
|
|
||||||
|
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||||
|
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest_file.write_text(output, encoding="utf-8")
|
||||||
|
|
||||||
|
if agent_name == "copilot":
|
||||||
|
self.write_copilot_prompt(project_root, cmd_name)
|
||||||
|
|
||||||
|
registered.append(cmd_name)
|
||||||
|
|
||||||
|
for alias in cmd_info.get("aliases", []):
|
||||||
|
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
||||||
|
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
alias_file.write_text(output, encoding="utf-8")
|
||||||
|
if agent_name == "copilot":
|
||||||
|
self.write_copilot_prompt(project_root, alias)
|
||||||
|
registered.append(alias)
|
||||||
|
|
||||||
|
return registered
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
|
||||||
|
"""Generate a companion .prompt.md file for a Copilot agent command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Path to project root
|
||||||
|
cmd_name: Command name (e.g. 'speckit.my-ext.example')
|
||||||
|
"""
|
||||||
|
prompts_dir = project_root / ".github" / "prompts"
|
||||||
|
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
|
||||||
|
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def register_commands_for_all_agents(
|
||||||
|
self,
|
||||||
|
commands: List[Dict[str, Any]],
|
||||||
|
source_id: str,
|
||||||
|
source_dir: Path,
|
||||||
|
project_root: Path,
|
||||||
|
context_note: str = None
|
||||||
|
) -> Dict[str, List[str]]:
|
||||||
|
"""Register commands for all detected agents in the project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
commands: List of command info dicts
|
||||||
|
source_id: Identifier of the source (extension or preset ID)
|
||||||
|
source_dir: Directory containing command source files
|
||||||
|
project_root: Path to project root
|
||||||
|
context_note: Custom context comment for markdown output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping agent names to list of registered commands
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||||
|
agent_dir = project_root / agent_config["dir"].split("/")[0]
|
||||||
|
|
||||||
|
if agent_dir.exists():
|
||||||
|
try:
|
||||||
|
registered = self.register_commands(
|
||||||
|
agent_name, commands, source_id, source_dir, project_root,
|
||||||
|
context_note=context_note
|
||||||
|
)
|
||||||
|
if registered:
|
||||||
|
results[agent_name] = registered
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def unregister_commands(
|
||||||
|
self,
|
||||||
|
registered_commands: Dict[str, List[str]],
|
||||||
|
project_root: Path
|
||||||
|
) -> None:
|
||||||
|
"""Remove previously registered command files from agent directories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
registered_commands: Dict mapping agent names to command name lists
|
||||||
|
project_root: Path to project root
|
||||||
|
"""
|
||||||
|
for agent_name, cmd_names in registered_commands.items():
|
||||||
|
if agent_name not in self.AGENT_CONFIGS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||||
|
commands_dir = project_root / agent_config["dir"]
|
||||||
|
|
||||||
|
for cmd_name in cmd_names:
|
||||||
|
cmd_file = commands_dir / f"{cmd_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"
|
||||||
|
if prompt_file.exists():
|
||||||
|
prompt_file.unlink()
|
||||||
@@ -578,23 +578,7 @@ class ExtensionManager:
|
|||||||
# Unregister commands from all AI agents
|
# Unregister commands from all AI agents
|
||||||
if registered_commands:
|
if registered_commands:
|
||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
for agent_name, cmd_names in registered_commands.items():
|
registrar.unregister_commands(registered_commands, self.project_root)
|
||||||
if agent_name not in registrar.AGENT_CONFIGS:
|
|
||||||
continue
|
|
||||||
|
|
||||||
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
|
||||||
commands_dir = self.project_root / agent_config["dir"]
|
|
||||||
|
|
||||||
for cmd_name in cmd_names:
|
|
||||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
|
||||||
if cmd_file.exists():
|
|
||||||
cmd_file.unlink()
|
|
||||||
|
|
||||||
# Also remove companion .prompt.md for Copilot
|
|
||||||
if agent_name == "copilot":
|
|
||||||
prompt_file = self.project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
|
||||||
if prompt_file.exists():
|
|
||||||
prompt_file.unlink()
|
|
||||||
|
|
||||||
if keep_config:
|
if keep_config:
|
||||||
# Preserve config files, only remove non-config files
|
# Preserve config files, only remove non-config files
|
||||||
@@ -718,255 +702,47 @@ def version_satisfies(current: str, required: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
class CommandRegistrar:
|
class CommandRegistrar:
|
||||||
"""Handles registration of extension commands with AI agents."""
|
"""Handles registration of extension commands with AI agents.
|
||||||
|
|
||||||
# Agent configurations with directory, format, and argument placeholder
|
This is a backward-compatible wrapper around the shared CommandRegistrar
|
||||||
AGENT_CONFIGS = {
|
in agents.py. Extension-specific methods accept ExtensionManifest objects
|
||||||
"claude": {
|
and delegate to the generic API.
|
||||||
"dir": ".claude/commands",
|
"""
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"gemini": {
|
|
||||||
"dir": ".gemini/commands",
|
|
||||||
"format": "toml",
|
|
||||||
"args": "{{args}}",
|
|
||||||
"extension": ".toml"
|
|
||||||
},
|
|
||||||
"copilot": {
|
|
||||||
"dir": ".github/agents",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".agent.md"
|
|
||||||
},
|
|
||||||
"cursor": {
|
|
||||||
"dir": ".cursor/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"qwen": {
|
|
||||||
"dir": ".qwen/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"opencode": {
|
|
||||||
"dir": ".opencode/command",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"codex": {
|
|
||||||
"dir": ".codex/prompts",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"windsurf": {
|
|
||||||
"dir": ".windsurf/workflows",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"kilocode": {
|
|
||||||
"dir": ".kilocode/rules",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"auggie": {
|
|
||||||
"dir": ".augment/rules",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"roo": {
|
|
||||||
"dir": ".roo/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"codebuddy": {
|
|
||||||
"dir": ".codebuddy/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"qodercli": {
|
|
||||||
"dir": ".qoder/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"kiro-cli": {
|
|
||||||
"dir": ".kiro/prompts",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"amp": {
|
|
||||||
"dir": ".agents/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"shai": {
|
|
||||||
"dir": ".shai/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"tabnine": {
|
|
||||||
"dir": ".tabnine/agent/commands",
|
|
||||||
"format": "toml",
|
|
||||||
"args": "{{args}}",
|
|
||||||
"extension": ".toml"
|
|
||||||
},
|
|
||||||
"bob": {
|
|
||||||
"dir": ".bob/commands",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
},
|
|
||||||
"kimi": {
|
|
||||||
"dir": ".kimi/skills",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": "/SKILL.md"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# Re-export AGENT_CONFIGS at class level for direct attribute access
|
||||||
|
from .agents import CommandRegistrar as _AgentRegistrar
|
||||||
|
AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
from .agents import CommandRegistrar as _Registrar
|
||||||
|
self._registrar = _Registrar()
|
||||||
|
|
||||||
|
# Delegate static/utility methods
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||||
"""Parse YAML frontmatter from Markdown content.
|
from .agents import CommandRegistrar as _Registrar
|
||||||
|
return _Registrar.parse_frontmatter(content)
|
||||||
Args:
|
|
||||||
content: Markdown content with YAML frontmatter
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (frontmatter_dict, body_content)
|
|
||||||
"""
|
|
||||||
if not content.startswith("---"):
|
|
||||||
return {}, content
|
|
||||||
|
|
||||||
# Find second ---
|
|
||||||
end_marker = content.find("---", 3)
|
|
||||||
if end_marker == -1:
|
|
||||||
return {}, content
|
|
||||||
|
|
||||||
frontmatter_str = content[3:end_marker].strip()
|
|
||||||
body = content[end_marker + 3:].strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
frontmatter = yaml.safe_load(frontmatter_str) or {}
|
|
||||||
except yaml.YAMLError:
|
|
||||||
frontmatter = {}
|
|
||||||
|
|
||||||
return frontmatter, body
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def render_frontmatter(fm: dict) -> str:
|
def render_frontmatter(fm: dict) -> str:
|
||||||
"""Render frontmatter dictionary as YAML.
|
from .agents import CommandRegistrar as _Registrar
|
||||||
|
return _Registrar.render_frontmatter(fm)
|
||||||
|
|
||||||
Args:
|
@staticmethod
|
||||||
fm: Frontmatter dictionary
|
def _write_copilot_prompt(project_root, cmd_name: str) -> None:
|
||||||
|
from .agents import CommandRegistrar as _Registrar
|
||||||
|
_Registrar.write_copilot_prompt(project_root, cmd_name)
|
||||||
|
|
||||||
Returns:
|
def _render_markdown_command(self, frontmatter, body, ext_id):
|
||||||
YAML-formatted frontmatter with delimiters
|
# Preserve extension-specific comment format for backward compatibility
|
||||||
"""
|
|
||||||
if not fm:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
|
|
||||||
return f"---\n{yaml_str}---\n"
|
|
||||||
|
|
||||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
|
||||||
"""Adjust script paths from extension-relative to repo-relative.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frontmatter: Frontmatter dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Modified frontmatter with adjusted paths
|
|
||||||
"""
|
|
||||||
if "scripts" in frontmatter:
|
|
||||||
for key in frontmatter["scripts"]:
|
|
||||||
script_path = frontmatter["scripts"][key]
|
|
||||||
if script_path.startswith("../../scripts/"):
|
|
||||||
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
|
||||||
return frontmatter
|
|
||||||
|
|
||||||
def _render_markdown_command(
|
|
||||||
self,
|
|
||||||
frontmatter: dict,
|
|
||||||
body: str,
|
|
||||||
ext_id: str
|
|
||||||
) -> str:
|
|
||||||
"""Render command in Markdown format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frontmatter: Command frontmatter
|
|
||||||
body: Command body content
|
|
||||||
ext_id: Extension ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted Markdown command file content
|
|
||||||
"""
|
|
||||||
context_note = f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n"
|
context_note = f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n"
|
||||||
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
|
return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||||
|
|
||||||
def _render_toml_command(
|
def _render_toml_command(self, frontmatter, body, ext_id):
|
||||||
self,
|
# Preserve extension-specific context comments for backward compatibility
|
||||||
frontmatter: dict,
|
base = self._registrar.render_toml_command(frontmatter, body, ext_id)
|
||||||
body: str,
|
context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n"
|
||||||
ext_id: str
|
return base.rstrip("\n") + "\n" + context_lines
|
||||||
) -> str:
|
|
||||||
"""Render command in TOML format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frontmatter: Command frontmatter
|
|
||||||
body: Command body content
|
|
||||||
ext_id: Extension ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted TOML command file content
|
|
||||||
"""
|
|
||||||
# TOML format for Gemini/Qwen
|
|
||||||
toml_lines = []
|
|
||||||
|
|
||||||
# Add description if present
|
|
||||||
if "description" in frontmatter:
|
|
||||||
# Escape quotes in description
|
|
||||||
desc = frontmatter["description"].replace('"', '\\"')
|
|
||||||
toml_lines.append(f'description = "{desc}"')
|
|
||||||
toml_lines.append("")
|
|
||||||
|
|
||||||
# Add extension context as comments
|
|
||||||
toml_lines.append(f"# Extension: {ext_id}")
|
|
||||||
toml_lines.append(f"# Config: .specify/extensions/{ext_id}/")
|
|
||||||
toml_lines.append("")
|
|
||||||
|
|
||||||
# Add prompt content
|
|
||||||
toml_lines.append('prompt = """')
|
|
||||||
toml_lines.append(body)
|
|
||||||
toml_lines.append('"""')
|
|
||||||
|
|
||||||
return "\n".join(toml_lines)
|
|
||||||
|
|
||||||
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
|
||||||
"""Convert argument placeholder format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: Command content
|
|
||||||
from_placeholder: Source placeholder (e.g., "$ARGUMENTS")
|
|
||||||
to_placeholder: Target placeholder (e.g., "{{args}}")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Content with converted placeholders
|
|
||||||
"""
|
|
||||||
return content.replace(from_placeholder, to_placeholder)
|
|
||||||
|
|
||||||
def register_commands_for_agent(
|
def register_commands_for_agent(
|
||||||
self,
|
self,
|
||||||
@@ -975,96 +751,14 @@ class CommandRegistrar:
|
|||||||
extension_dir: Path,
|
extension_dir: Path,
|
||||||
project_root: Path
|
project_root: Path
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Register extension commands for a specific agent.
|
"""Register extension commands for a specific agent."""
|
||||||
|
|
||||||
Args:
|
|
||||||
agent_name: Agent name (claude, gemini, copilot, etc.)
|
|
||||||
manifest: Extension manifest
|
|
||||||
extension_dir: Path to extension directory
|
|
||||||
project_root: Path to project root
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of registered command names
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ExtensionError: If agent is not supported
|
|
||||||
"""
|
|
||||||
if agent_name not in self.AGENT_CONFIGS:
|
if agent_name not in self.AGENT_CONFIGS:
|
||||||
raise ExtensionError(f"Unsupported agent: {agent_name}")
|
raise ExtensionError(f"Unsupported agent: {agent_name}")
|
||||||
|
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
return self._registrar.register_commands(
|
||||||
commands_dir = project_root / agent_config["dir"]
|
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
context_note=context_note
|
||||||
|
)
|
||||||
registered = []
|
|
||||||
|
|
||||||
for cmd_info in manifest.commands:
|
|
||||||
cmd_name = cmd_info["name"]
|
|
||||||
cmd_file = cmd_info["file"]
|
|
||||||
|
|
||||||
# Read source command file
|
|
||||||
source_file = extension_dir / cmd_file
|
|
||||||
if not source_file.exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
content = source_file.read_text()
|
|
||||||
frontmatter, body = self.parse_frontmatter(content)
|
|
||||||
|
|
||||||
# Adjust script paths
|
|
||||||
frontmatter = self._adjust_script_paths(frontmatter)
|
|
||||||
|
|
||||||
# Convert argument placeholders
|
|
||||||
body = self._convert_argument_placeholder(
|
|
||||||
body, "$ARGUMENTS", agent_config["args"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Render in agent-specific format
|
|
||||||
if agent_config["format"] == "markdown":
|
|
||||||
output = self._render_markdown_command(frontmatter, body, manifest.id)
|
|
||||||
elif agent_config["format"] == "toml":
|
|
||||||
output = self._render_toml_command(frontmatter, body, manifest.id)
|
|
||||||
else:
|
|
||||||
raise ExtensionError(f"Unsupported format: {agent_config['format']}")
|
|
||||||
|
|
||||||
# Write command file
|
|
||||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
|
||||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
dest_file.write_text(output)
|
|
||||||
|
|
||||||
# Generate companion .prompt.md for Copilot agents
|
|
||||||
if agent_name == "copilot":
|
|
||||||
self._write_copilot_prompt(project_root, cmd_name)
|
|
||||||
|
|
||||||
registered.append(cmd_name)
|
|
||||||
|
|
||||||
# Register aliases
|
|
||||||
for alias in cmd_info.get("aliases", []):
|
|
||||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
|
||||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
alias_file.write_text(output)
|
|
||||||
# Generate companion .prompt.md for alias too
|
|
||||||
if agent_name == "copilot":
|
|
||||||
self._write_copilot_prompt(project_root, alias)
|
|
||||||
registered.append(alias)
|
|
||||||
|
|
||||||
return registered
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
|
|
||||||
"""Generate a companion .prompt.md file for a Copilot agent command.
|
|
||||||
|
|
||||||
Copilot requires a .prompt.md file in .github/prompts/ that references
|
|
||||||
the corresponding .agent.md file in .github/agents/ via an ``agent:``
|
|
||||||
frontmatter field.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_root: Path to project root
|
|
||||||
cmd_name: Command name (used as the file stem, e.g. 'speckit.my-ext.example')
|
|
||||||
"""
|
|
||||||
prompts_dir = project_root / ".github" / "prompts"
|
|
||||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
|
|
||||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n")
|
|
||||||
|
|
||||||
def register_commands_for_all_agents(
|
def register_commands_for_all_agents(
|
||||||
self,
|
self,
|
||||||
@@ -1072,35 +766,20 @@ class CommandRegistrar:
|
|||||||
extension_dir: Path,
|
extension_dir: Path,
|
||||||
project_root: Path
|
project_root: Path
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Register extension commands for all detected agents.
|
"""Register extension commands for all detected agents."""
|
||||||
|
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||||
|
return self._registrar.register_commands_for_all_agents(
|
||||||
|
manifest.commands, manifest.id, extension_dir, project_root,
|
||||||
|
context_note=context_note
|
||||||
|
)
|
||||||
|
|
||||||
Args:
|
def unregister_commands(
|
||||||
manifest: Extension manifest
|
self,
|
||||||
extension_dir: Path to extension directory
|
registered_commands: Dict[str, List[str]],
|
||||||
project_root: Path to project root
|
project_root: Path
|
||||||
|
) -> None:
|
||||||
Returns:
|
"""Remove previously registered command files from agent directories."""
|
||||||
Dictionary mapping agent names to list of registered commands
|
self._registrar.unregister_commands(registered_commands, project_root)
|
||||||
"""
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
# Detect which agents are present in the project
|
|
||||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
|
||||||
agent_dir = project_root / agent_config["dir"].split("/")[0]
|
|
||||||
|
|
||||||
# Register if agent directory exists
|
|
||||||
if agent_dir.exists():
|
|
||||||
try:
|
|
||||||
registered = self.register_commands_for_agent(
|
|
||||||
agent_name, manifest, extension_dir, project_root
|
|
||||||
)
|
|
||||||
if registered:
|
|
||||||
results[agent_name] = registered
|
|
||||||
except ExtensionError:
|
|
||||||
# Skip agent on error
|
|
||||||
continue
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def register_commands_for_claude(
|
def register_commands_for_claude(
|
||||||
self,
|
self,
|
||||||
@@ -1108,16 +787,7 @@ class CommandRegistrar:
|
|||||||
extension_dir: Path,
|
extension_dir: Path,
|
||||||
project_root: Path
|
project_root: Path
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Register extension commands for Claude Code agent.
|
"""Register extension commands for Claude Code agent."""
|
||||||
|
|
||||||
Args:
|
|
||||||
manifest: Extension manifest
|
|
||||||
extension_dir: Path to extension directory
|
|
||||||
project_root: Path to project root
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of registered command names
|
|
||||||
"""
|
|
||||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1530
src/specify_cli/presets.py
Normal file
1530
src/specify_cli/presets.py
Normal file
File diff suppressed because it is too large
Load Diff
1712
tests/test_presets.py
Normal file
1712
tests/test_presets.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user