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
This commit is contained in:
Manfred Riem
2026-03-10 14:17:44 -05:00
parent 6003a232d8
commit abf4aebdb3
32 changed files with 4671 additions and 2458 deletions

View File

@@ -7,19 +7,22 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.1] - 2026-03-09
## [0.2.1] - 2026-03-10
### Added
- feat(templates): Pluggable template system with template packs, catalog, and resolver
- Template pack manifest (`template-pack.yml`) with validation for artifact, command, and script types
- `TemplatePackManifest`, `TemplatePackRegistry`, `TemplatePackManager`, `TemplateCatalog`, `TemplateResolver` classes in `src/specify_cli/templates.py`
- CLI commands: `specify template search`, `specify template add`, `specify template list`, `specify template remove`, `specify template resolve`
- `--template` option for `specify init` to install template packs during initialization
- 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 → packs → extensions → core
- Template catalog files (`templates/catalog.json`, `templates/catalog.community.json`)
- Template pack scaffold directory (`templates/template/`)
- 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
## [0.2.0] - 2026-03-09

157
presets/ARCHITECTURE.md Normal file
View 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
View 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
View 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.

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-09T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.json",
"template_packs": {}
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {}
}

25
presets/catalog.json Normal file
View File

@@ -0,0 +1,25 @@
{
"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": {
"self-test": {
"name": "Self-Test Preset",
"description": "A preset that overrides all core templates for testing purposes",
"author": "github",
"version": "1.0.0",
"repository": "https://github.com/github/spec-kit",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"templates": 6,
"commands": 1
},
"tags": ["testing", "self-test"],
"created_at": "2026-03-10T00:00:00Z",
"updated_at": "2026-03-10T00:00:00Z"
}
}
}

View 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

View 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.

View 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.

View 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"

View 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.
-->

View 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

View 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.

View 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"

View 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.

View 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

View 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

View 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

View File

@@ -1,6 +1,8 @@
# Feature Specification
# Feature Specification (Self-Test Preset)
> Replace this with your actual specification content.
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Overview

View 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 |

View File

@@ -156,7 +156,7 @@ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" |
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/templates/packs/<pack-id>/templates/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
resolve_template() {
@@ -168,14 +168,37 @@ resolve_template() {
local override="$base/overrides/${template_name}.md"
[ -f "$override" ] && echo "$override" && return 0
# Priority 2: Installed packs (by directory order)
local packs_dir="$base/packs"
if [ -d "$packs_dir" ]; then
for pack in "$packs_dir"/*/; do
[ -d "$pack" ] || continue
local candidate="$pack/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
# 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=$(python3 -c "
import json, sys
try:
data = json.load(open('$registry_file'))
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"
fi
else
# Fallback: alphabetical directory order
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

View File

@@ -137,7 +137,7 @@ function Test-DirHasFiles {
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/templates/packs/<pack-id>/templates/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
function Resolve-Template {
@@ -152,12 +152,37 @@ function Resolve-Template {
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) { return $override }
# Priority 2: Installed packs (by directory order)
$packsDir = Join-Path $base 'packs'
if (Test-Path $packsDir) {
foreach ($pack in Get-ChildItem -Path $packsDir -Directory -ErrorAction SilentlyContinue) {
$candidate = Join-Path $pack.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
# 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 ($_.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) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
}

View File

@@ -1272,7 +1272,7 @@ def init(
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)"),
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
template: str = typer.Option(None, "--template", help="Install a template pack during initialization (by pack ID)"),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
):
"""
Initialize a new Specify project from the latest template.
@@ -1301,7 +1301,7 @@ def init(
specify init my-project --ai claude --ai-skills # Install agent 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 claude --template healthcare-compliance # With template pack
specify init my-project --ai claude --preset healthcare-compliance # With preset
"""
show_banner()
@@ -1544,26 +1544,26 @@ def init(
else:
tracker.skip("git", "--no-git flag")
# Install template pack if specified
if template:
# Install preset if specified
if preset:
try:
from .templates import TemplatePackManager, TemplateCatalog, TemplateError
tmpl_manager = TemplatePackManager(project_path)
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(template).resolve()
if local_path.is_dir() and (local_path / "template-pack.yml").exists():
tmpl_manager.install_from_directory(local_path, speckit_ver)
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:
tmpl_catalog = TemplateCatalog(project_path)
preset_catalog = PresetCatalog(project_path)
try:
zip_path = tmpl_catalog.download_pack(template)
tmpl_manager.install_from_zip(zip_path, speckit_ver)
except TemplateError:
console.print(f"[yellow]Warning:[/yellow] Template pack '{template}' not found in catalog. Skipping.")
except Exception as tmpl_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install template pack: {tmpl_err}")
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
except PresetError:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
except Exception as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
tracker.complete("final", "project ready")
except Exception as e:
@@ -1802,12 +1802,19 @@ catalog_app = typer.Typer(
)
extension_app.add_typer(catalog_app, name="catalog")
template_app = typer.Typer(
name="template",
help="Manage spec-kit template packs",
preset_app = typer.Typer(
name="preset",
help="Manage spec-kit presets",
add_completion=False,
)
app.add_typer(template_app, name="template")
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:
@@ -1831,13 +1838,13 @@ def get_speckit_version() -> str:
return "unknown"
# ===== Template Pack Commands =====
# ===== Preset Commands =====
@template_app.command("list")
def template_list():
"""List installed template packs."""
from .templates import TemplatePackManager
@preset_app.command("list")
def preset_list():
"""List installed presets."""
from .presets import PresetManager
project_root = Path.cwd()
@@ -1847,19 +1854,20 @@ def template_list():
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
manager = TemplatePackManager(project_root)
manager = PresetManager(project_root)
installed = manager.list_installed()
if not installed:
console.print("[yellow]No template packs installed.[/yellow]")
console.print("\nInstall a template pack with:")
console.print(" [cyan]specify template add <pack-name>[/cyan]")
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 Template Packs:[/bold cyan]\n")
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]"
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']}{status}")
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"])
@@ -1868,19 +1876,20 @@ def template_list():
console.print()
@template_app.command("add")
def template_add(
pack_id: str = typer.Argument(None, help="Template pack ID to install from catalog"),
@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 template pack."""
from .templates import (
TemplatePackManager,
TemplateCatalog,
TemplateError,
TemplateValidationError,
TemplateCompatibilityError,
"""Install a preset."""
from .presets import (
PresetManager,
PresetCatalog,
PresetError,
PresetValidationError,
PresetCompatibilityError,
)
project_root = Path.cwd()
@@ -1891,7 +1900,7 @@ def template_add(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
manager = TemplatePackManager(project_root)
manager = PresetManager(project_root)
speckit_version = get_speckit_version()
try:
@@ -1901,18 +1910,18 @@ def template_add(
console.print(f"[red]Error:[/red] Directory not found: {dev}")
raise typer.Exit(1)
console.print(f"Installing template pack from [cyan]{dev_path}[/cyan]...")
manifest = manager.install_from_directory(dev_path, speckit_version)
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
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:
console.print(f"Installing template pack from [cyan]{from_url}[/cyan]...")
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) / "template-pack.zip"
zip_path = Path(tmpdir) / "preset.zip"
try:
with urllib.request.urlopen(from_url, timeout=60) as response:
zip_path.write_bytes(response.read())
@@ -1920,48 +1929,48 @@ def template_add(
console.print(f"[red]Error:[/red] Failed to download: {e}")
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
elif pack_id:
catalog = TemplateCatalog(project_root)
catalog = PresetCatalog(project_root)
pack_info = catalog.get_pack_info(pack_id)
if not pack_info:
console.print(f"[red]Error:[/red] Template pack '{pack_id}' not found in catalog")
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
raise typer.Exit(1)
console.print(f"Installing template pack [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
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)
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
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 template pack ID, --from URL, or --dev path")
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)
except TemplateCompatibilityError as e:
except PresetCompatibilityError as e:
console.print(f"[red]Compatibility Error:[/red] {e}")
raise typer.Exit(1)
except TemplateValidationError as e:
except PresetValidationError as e:
console.print(f"[red]Validation Error:[/red] {e}")
raise typer.Exit(1)
except TemplateError as e:
except PresetError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@template_app.command("remove")
def template_remove(
pack_id: str = typer.Argument(..., help="Template pack ID to remove"),
@preset_app.command("remove")
def preset_remove(
pack_id: str = typer.Argument(..., help="Preset ID to remove"),
):
"""Remove an installed template pack."""
from .templates import TemplatePackManager
"""Remove an installed preset."""
from .presets import PresetManager
project_root = Path.cwd()
@@ -1971,27 +1980,27 @@ def template_remove(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
manager = TemplatePackManager(project_root)
manager = PresetManager(project_root)
if not manager.registry.is_installed(pack_id):
console.print(f"[red]Error:[/red] Template pack '{pack_id}' is not installed")
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] Template pack '{pack_id}' removed successfully")
console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully")
else:
console.print(f"[red]Error:[/red] Failed to remove template pack '{pack_id}'")
console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'")
raise typer.Exit(1)
@template_app.command("search")
def template_search(
@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 template packs in the catalog."""
from .templates import TemplateCatalog, TemplateError
"""Search for presets in the catalog."""
from .presets import PresetCatalog, PresetError
project_root = Path.cwd()
@@ -2001,19 +2010,19 @@ def template_search(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
catalog = TemplateCatalog(project_root)
catalog = PresetCatalog(project_root)
try:
results = catalog.search(query=query, tag=tag, author=author)
except TemplateError as e:
except PresetError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
if not results:
console.print("[yellow]No template packs found matching your criteria.[/yellow]")
console.print("[yellow]No presets found matching your criteria.[/yellow]")
return
console.print(f"\n[bold cyan]Template Packs ({len(results)} found):[/bold cyan]\n")
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', '')}")
@@ -2023,12 +2032,12 @@ def template_search(
console.print()
@template_app.command("resolve")
def template_resolve(
@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 .templates import TemplateResolver
from .presets import PresetResolver
project_root = Path.cwd()
@@ -2038,7 +2047,7 @@ def template_resolve(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
resolver = TemplateResolver(project_root)
resolver = PresetResolver(project_root)
result = resolver.resolve_with_source(template_name)
if result:
@@ -2049,6 +2058,253 @@ def template_resolve(
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(f"\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(f"\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 =====

408
src/specify_cli/agents.py Normal file
View File

@@ -0,0 +1,408 @@
"""
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": "toml",
"args": "{{args}}",
"extension": ".toml"
},
"opencode": {
"dir": ".opencode/command",
"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/rules",
"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"
}
}
@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()
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.write_text(output)
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.write_text(output)
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")
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()

View File

@@ -455,23 +455,7 @@ class ExtensionManager:
# Unregister commands from all AI agents
if registered_commands:
registrar = CommandRegistrar()
for agent_name, cmd_names in registered_commands.items():
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()
registrar._registrar.unregister_commands(registered_commands, self.project_root)
if keep_config:
# Preserve config files, only remove non-config files
@@ -595,243 +579,44 @@ def version_satisfies(current: str, required: str) -> bool:
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
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": "toml",
"args": "{{args}}",
"extension": ".toml"
},
"opencode": {
"dir": ".opencode/command",
"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/rules",
"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"
}
}
This is a backward-compatible wrapper around the shared CommandRegistrar
in agents.py. Extension-specific methods accept ExtensionManifest objects
and delegate to the generic API.
"""
# Re-export AGENT_CONFIGS at class level for direct attribute access
from .agents import CommandRegistrar as _AgentRegistrar
AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS
def __init__(self):
from .agents import CommandRegistrar as _Registrar
self._registrar = _Registrar()
# Delegate static/utility methods
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""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
from .agents import CommandRegistrar as _Registrar
return _Registrar.parse_frontmatter(content)
@staticmethod
def render_frontmatter(fm: dict) -> str:
"""Render frontmatter dictionary as YAML.
from .agents import CommandRegistrar as _Registrar
return _Registrar.render_frontmatter(fm)
Args:
fm: Frontmatter dictionary
@staticmethod
def _write_copilot_prompt(project_root, cmd_name: str) -> None:
from .agents import CommandRegistrar as _Registrar
_Registrar.write_copilot_prompt(project_root, cmd_name)
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,
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
"""
def _render_markdown_command(self, frontmatter, body, ext_id):
# Preserve extension-specific comment format for backward compatibility
context_note = f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n"
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
def _render_toml_command(
self,
frontmatter: dict,
body: str,
ext_id: str
) -> 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 _render_toml_command(self, frontmatter, body, ext_id):
return self._registrar.render_toml_command(frontmatter, body, ext_id)
def register_commands_for_agent(
self,
@@ -840,94 +625,14 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path
) -> List[str]:
"""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
"""
"""Register extension commands for a specific agent."""
if agent_name not in self.AGENT_CONFIGS:
raise ExtensionError(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 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.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.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")
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
return self._registrar.register_commands(
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
context_note=context_note
)
def register_commands_for_all_agents(
self,
@@ -935,35 +640,12 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path
) -> Dict[str, List[str]]:
"""Register extension commands for all detected agents.
Args:
manifest: Extension manifest
extension_dir: Path to extension directory
project_root: Path to project root
Returns:
Dictionary mapping agent names to list of registered commands
"""
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
"""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
)
def register_commands_for_claude(
self,
@@ -971,16 +653,7 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path
) -> List[str]:
"""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
"""
"""Register extension commands for Claude Code agent."""
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)

1269
src/specify_cli/presets.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,938 +0,0 @@
"""
Template Pack Manager for Spec Kit
Handles installation, removal, and management of Spec Kit template packs.
Template packs are self-contained, versioned collections of templates
(artifact, command, and script templates) that can be installed to
customize the Spec-Driven Development workflow.
"""
import json
import hashlib
import os
import tempfile
import zipfile
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Dict, List, Any
from datetime import datetime, timezone
import re
import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
class TemplateError(Exception):
"""Base exception for template-related errors."""
pass
class TemplateValidationError(TemplateError):
"""Raised when template pack manifest validation fails."""
pass
class TemplateCompatibilityError(TemplateError):
"""Raised when template pack is incompatible with current environment."""
pass
VALID_TEMPLATE_TYPES = {"artifact", "command", "script"}
class TemplatePackManifest:
"""Represents and validates a template pack manifest (template-pack.yml)."""
SCHEMA_VERSION = "1.0"
REQUIRED_FIELDS = ["schema_version", "template_pack", "requires", "provides"]
def __init__(self, manifest_path: Path):
"""Load and validate template pack manifest.
Args:
manifest_path: Path to template-pack.yml file
Raises:
TemplateValidationError: If manifest is invalid
"""
self.path = manifest_path
self.data = self._load_yaml(manifest_path)
self._validate()
def _load_yaml(self, path: Path) -> dict:
"""Load YAML file safely."""
try:
with open(path, 'r') as f:
return yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise TemplateValidationError(f"Invalid YAML in {path}: {e}")
except FileNotFoundError:
raise TemplateValidationError(f"Manifest not found: {path}")
def _validate(self):
"""Validate manifest structure and required fields."""
# Check required top-level fields
for field in self.REQUIRED_FIELDS:
if field not in self.data:
raise TemplateValidationError(f"Missing required field: {field}")
# Validate schema version
if self.data["schema_version"] != self.SCHEMA_VERSION:
raise TemplateValidationError(
f"Unsupported schema version: {self.data['schema_version']} "
f"(expected {self.SCHEMA_VERSION})"
)
# Validate template_pack metadata
pack = self.data["template_pack"]
for field in ["id", "name", "version", "description"]:
if field not in pack:
raise TemplateValidationError(f"Missing template_pack.{field}")
# Validate pack ID format
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
raise TemplateValidationError(
f"Invalid template pack ID '{pack['id']}': "
"must be lowercase alphanumeric with hyphens only"
)
# Validate semantic version
try:
pkg_version.Version(pack["version"])
except pkg_version.InvalidVersion:
raise TemplateValidationError(f"Invalid version: {pack['version']}")
# Validate requires section
requires = self.data["requires"]
if "speckit_version" not in requires:
raise TemplateValidationError("Missing requires.speckit_version")
# Validate provides section
provides = self.data["provides"]
if "templates" not in provides or not provides["templates"]:
raise TemplateValidationError(
"Template pack must provide at least one template"
)
# Validate templates
for tmpl in provides["templates"]:
if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl:
raise TemplateValidationError(
"Template missing 'type', 'name', or 'file'"
)
if tmpl["type"] not in VALID_TEMPLATE_TYPES:
raise TemplateValidationError(
f"Invalid template type '{tmpl['type']}': "
f"must be one of {sorted(VALID_TEMPLATE_TYPES)}"
)
# Validate template name format
if not re.match(r'^[a-z0-9-]+$', tmpl["name"]):
raise TemplateValidationError(
f"Invalid template name '{tmpl['name']}': "
"must be lowercase alphanumeric with hyphens only"
)
@property
def id(self) -> str:
"""Get template pack ID."""
return self.data["template_pack"]["id"]
@property
def name(self) -> str:
"""Get template pack name."""
return self.data["template_pack"]["name"]
@property
def version(self) -> str:
"""Get template pack version."""
return self.data["template_pack"]["version"]
@property
def description(self) -> str:
"""Get template pack description."""
return self.data["template_pack"]["description"]
@property
def author(self) -> str:
"""Get template pack author."""
return self.data["template_pack"].get("author", "")
@property
def requires_speckit_version(self) -> str:
"""Get required spec-kit version range."""
return self.data["requires"]["speckit_version"]
@property
def templates(self) -> List[Dict[str, Any]]:
"""Get list of provided templates."""
return self.data["provides"]["templates"]
@property
def tags(self) -> List[str]:
"""Get template pack tags."""
return self.data.get("tags", [])
def get_hash(self) -> str:
"""Calculate SHA256 hash of manifest file."""
with open(self.path, 'rb') as f:
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
class TemplatePackRegistry:
"""Manages the registry of installed template packs."""
REGISTRY_FILE = ".registry"
SCHEMA_VERSION = "1.0"
def __init__(self, packs_dir: Path):
"""Initialize registry.
Args:
packs_dir: Path to .specify/templates/packs/ directory
"""
self.packs_dir = packs_dir
self.registry_path = packs_dir / self.REGISTRY_FILE
self.data = self._load()
def _load(self) -> dict:
"""Load registry from disk."""
if not self.registry_path.exists():
return {
"schema_version": self.SCHEMA_VERSION,
"template_packs": {}
}
try:
with open(self.registry_path, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
return {
"schema_version": self.SCHEMA_VERSION,
"template_packs": {}
}
def _save(self):
"""Save registry to disk."""
self.packs_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, 'w') as f:
json.dump(self.data, f, indent=2)
def add(self, pack_id: str, metadata: dict):
"""Add template pack to registry.
Args:
pack_id: Template pack ID
metadata: Pack metadata (version, source, etc.)
"""
self.data["template_packs"][pack_id] = {
**metadata,
"installed_at": datetime.now(timezone.utc).isoformat()
}
self._save()
def remove(self, pack_id: str):
"""Remove template pack from registry.
Args:
pack_id: Template pack ID
"""
if pack_id in self.data["template_packs"]:
del self.data["template_packs"][pack_id]
self._save()
def get(self, pack_id: str) -> Optional[dict]:
"""Get template pack metadata from registry.
Args:
pack_id: Template pack ID
Returns:
Pack metadata or None if not found
"""
return self.data["template_packs"].get(pack_id)
def list(self) -> Dict[str, dict]:
"""Get all installed template packs.
Returns:
Dictionary of pack_id -> metadata
"""
return self.data["template_packs"]
def is_installed(self, pack_id: str) -> bool:
"""Check if template pack is installed.
Args:
pack_id: Template pack ID
Returns:
True if pack is installed
"""
return pack_id in self.data["template_packs"]
class TemplatePackManager:
"""Manages template pack lifecycle: installation, removal, updates."""
def __init__(self, project_root: Path):
"""Initialize template pack manager.
Args:
project_root: Path to project root directory
"""
self.project_root = project_root
self.templates_dir = project_root / ".specify" / "templates"
self.packs_dir = self.templates_dir / "packs"
self.registry = TemplatePackRegistry(self.packs_dir)
def check_compatibility(
self,
manifest: TemplatePackManifest,
speckit_version: str
) -> bool:
"""Check if template pack is compatible with current spec-kit version.
Args:
manifest: Template pack manifest
speckit_version: Current spec-kit version
Returns:
True if compatible
Raises:
TemplateCompatibilityError: If pack is incompatible
"""
required = manifest.requires_speckit_version
current = pkg_version.Version(speckit_version)
try:
specifier = SpecifierSet(required)
if current not in specifier:
raise TemplateCompatibilityError(
f"Template pack requires spec-kit {required}, "
f"but {speckit_version} is installed.\n"
f"Upgrade spec-kit with: uv tool install specify-cli --force"
)
except InvalidSpecifier:
raise TemplateCompatibilityError(
f"Invalid version specifier: {required}"
)
return True
def install_from_directory(
self,
source_dir: Path,
speckit_version: str,
) -> TemplatePackManifest:
"""Install template pack from a local directory.
Args:
source_dir: Path to template pack directory
speckit_version: Current spec-kit version
Returns:
Installed template pack manifest
Raises:
TemplateValidationError: If manifest is invalid
TemplateCompatibilityError: If pack is incompatible
"""
manifest_path = source_dir / "template-pack.yml"
manifest = TemplatePackManifest(manifest_path)
self.check_compatibility(manifest, speckit_version)
if self.registry.is_installed(manifest.id):
raise TemplateError(
f"Template pack '{manifest.id}' is already installed. "
f"Use 'specify template remove {manifest.id}' first."
)
dest_dir = self.packs_dir / manifest.id
if dest_dir.exists():
shutil.rmtree(dest_dir)
shutil.copytree(source_dir, dest_dir)
self.registry.add(manifest.id, {
"version": manifest.version,
"source": "local",
"manifest_hash": manifest.get_hash(),
"enabled": True,
})
return manifest
def install_from_zip(
self,
zip_path: Path,
speckit_version: str
) -> TemplatePackManifest:
"""Install template pack from ZIP file.
Args:
zip_path: Path to template pack ZIP file
speckit_version: Current spec-kit version
Returns:
Installed template pack manifest
Raises:
TemplateValidationError: If manifest is invalid
TemplateCompatibilityError: If pack is incompatible
"""
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
with zipfile.ZipFile(zip_path, 'r') as zf:
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise TemplateValidationError(
f"Unsafe path in ZIP archive: {member} "
"(potential path traversal)"
)
zf.extractall(temp_path)
pack_dir = temp_path
manifest_path = pack_dir / "template-pack.yml"
if not manifest_path.exists():
subdirs = [d for d in temp_path.iterdir() if d.is_dir()]
if len(subdirs) == 1:
pack_dir = subdirs[0]
manifest_path = pack_dir / "template-pack.yml"
if not manifest_path.exists():
raise TemplateValidationError(
"No template-pack.yml found in ZIP file"
)
return self.install_from_directory(pack_dir, speckit_version)
def remove(self, pack_id: str) -> bool:
"""Remove an installed template pack.
Args:
pack_id: Template pack ID
Returns:
True if pack was removed
"""
if not self.registry.is_installed(pack_id):
return False
pack_dir = self.packs_dir / pack_id
if pack_dir.exists():
shutil.rmtree(pack_dir)
self.registry.remove(pack_id)
return True
def list_installed(self) -> List[Dict[str, Any]]:
"""List all installed template packs with metadata.
Returns:
List of template pack metadata dictionaries
"""
result = []
for pack_id, metadata in self.registry.list().items():
pack_dir = self.packs_dir / pack_id
manifest_path = pack_dir / "template-pack.yml"
try:
manifest = TemplatePackManifest(manifest_path)
result.append({
"id": pack_id,
"name": manifest.name,
"version": metadata["version"],
"description": manifest.description,
"enabled": metadata.get("enabled", True),
"installed_at": metadata.get("installed_at"),
"template_count": len(manifest.templates),
"tags": manifest.tags,
})
except TemplateValidationError:
result.append({
"id": pack_id,
"name": pack_id,
"version": metadata.get("version", "unknown"),
"description": "⚠️ Corrupted template pack",
"enabled": False,
"installed_at": metadata.get("installed_at"),
"template_count": 0,
"tags": [],
})
return result
def get_pack(self, pack_id: str) -> Optional[TemplatePackManifest]:
"""Get manifest for an installed template pack.
Args:
pack_id: Template pack ID
Returns:
Template pack manifest or None if not installed
"""
if not self.registry.is_installed(pack_id):
return None
pack_dir = self.packs_dir / pack_id
manifest_path = pack_dir / "template-pack.yml"
try:
return TemplatePackManifest(manifest_path)
except TemplateValidationError:
return None
class TemplateCatalog:
"""Manages template pack catalog fetching, caching, and searching."""
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.json"
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.community.json"
CACHE_DURATION = 3600 # 1 hour in seconds
def __init__(self, project_root: Path):
"""Initialize template catalog manager.
Args:
project_root: Root directory of the spec-kit project
"""
self.project_root = project_root
self.templates_dir = project_root / ".specify" / "templates"
self.cache_dir = self.templates_dir / "packs" / ".cache"
self.cache_file = self.cache_dir / "catalog.json"
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
def _validate_catalog_url(self, url: str) -> None:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
Args:
url: URL to validate
Raises:
TemplateValidationError: If URL is invalid or uses non-HTTPS scheme
"""
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise TemplateValidationError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise TemplateValidationError(
"Catalog URL must be a valid URL with a host."
)
def get_catalog_url(self) -> str:
"""Get the primary catalog URL.
Returns:
URL of the primary catalog
"""
env_value = os.environ.get("SPECKIT_TEMPLATE_CATALOG_URL")
if env_value:
catalog_url = env_value.strip()
self._validate_catalog_url(catalog_url)
return catalog_url
return self.DEFAULT_CATALOG_URL
def is_cache_valid(self) -> bool:
"""Check if cached catalog is still valid.
Returns:
True if cache exists and is within cache duration
"""
if not self.cache_file.exists() or not self.cache_metadata_file.exists():
return False
try:
metadata = json.loads(self.cache_metadata_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age_seconds = (
datetime.now(timezone.utc) - cached_at
).total_seconds()
return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
return False
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
"""Fetch template pack catalog from URL or cache.
Args:
force_refresh: If True, bypass cache and fetch from network
Returns:
Catalog data dictionary
Raises:
TemplateError: If catalog cannot be fetched
"""
if not force_refresh and self.is_cache_valid():
try:
return json.loads(self.cache_file.read_text())
except json.JSONDecodeError:
pass
catalog_url = self.get_catalog_url()
try:
import urllib.request
import urllib.error
with urllib.request.urlopen(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read())
if (
"schema_version" not in catalog_data
or "template_packs" not in catalog_data
):
raise TemplateError("Invalid template catalog format")
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog_url,
}
self.cache_metadata_file.write_text(
json.dumps(metadata, indent=2)
)
return catalog_data
except (ImportError, Exception) as e:
if isinstance(e, TemplateError):
raise
raise TemplateError(
f"Failed to fetch template catalog from {catalog_url}: {e}"
)
def search(
self,
query: Optional[str] = None,
tag: Optional[str] = None,
author: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Search catalog for template packs.
Args:
query: Search query (searches name, description, tags)
tag: Filter by specific tag
author: Filter by author name
Returns:
List of matching template pack metadata
"""
try:
catalog_data = self.fetch_catalog()
except TemplateError:
return []
results = []
packs = catalog_data.get("template_packs", {})
for pack_id, pack_data in packs.items():
if author and pack_data.get("author", "").lower() != author.lower():
continue
if tag and tag.lower() not in [
t.lower() for t in pack_data.get("tags", [])
]:
continue
if query:
query_lower = query.lower()
searchable_text = " ".join(
[
pack_data.get("name", ""),
pack_data.get("description", ""),
pack_id,
]
+ pack_data.get("tags", [])
).lower()
if query_lower not in searchable_text:
continue
results.append({**pack_data, "id": pack_id})
return results
def get_pack_info(
self, pack_id: str
) -> Optional[Dict[str, Any]]:
"""Get detailed information about a specific template pack.
Args:
pack_id: ID of the template pack
Returns:
Pack metadata or None if not found
"""
try:
catalog_data = self.fetch_catalog()
except TemplateError:
return None
packs = catalog_data.get("template_packs", {})
if pack_id in packs:
return {**packs[pack_id], "id": pack_id}
return None
def download_pack(
self, pack_id: str, target_dir: Optional[Path] = None
) -> Path:
"""Download template pack ZIP from catalog.
Args:
pack_id: ID of the template pack to download
target_dir: Directory to save ZIP file (defaults to cache directory)
Returns:
Path to downloaded ZIP file
Raises:
TemplateError: If pack not found or download fails
"""
import urllib.request
import urllib.error
pack_info = self.get_pack_info(pack_id)
if not pack_info:
raise TemplateError(
f"Template pack '{pack_id}' not found in catalog"
)
download_url = pack_info.get("download_url")
if not download_url:
raise TemplateError(
f"Template pack '{pack_id}' has no download URL"
)
from urllib.parse import urlparse
parsed = urlparse(download_url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise TemplateError(
f"Template pack download URL must use HTTPS: {download_url}"
)
if target_dir is None:
target_dir = self.cache_dir / "downloads"
target_dir.mkdir(parents=True, exist_ok=True)
version = pack_info.get("version", "unknown")
zip_filename = f"{pack_id}-{version}.zip"
zip_path = target_dir / zip_filename
try:
with urllib.request.urlopen(download_url, timeout=60) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)
return zip_path
except urllib.error.URLError as e:
raise TemplateError(
f"Failed to download template pack from {download_url}: {e}"
)
except IOError as e:
raise TemplateError(f"Failed to save template pack ZIP: {e}")
def clear_cache(self):
"""Clear the catalog cache."""
if self.cache_file.exists():
self.cache_file.unlink()
if self.cache_metadata_file.exists():
self.cache_metadata_file.unlink()
class TemplateResolver:
"""Resolves template names to file paths using a priority stack.
Resolution order:
1. .specify/templates/overrides/ - Project-local overrides
2. .specify/templates/packs/<pack-id>/ - Installed template packs
3. .specify/extensions/<ext-id>/templates/ - Extension-provided templates
4. .specify/templates/ - Core templates (shipped with Spec Kit)
"""
def __init__(self, project_root: Path):
"""Initialize template resolver.
Args:
project_root: Path to project root directory
"""
self.project_root = project_root
self.templates_dir = project_root / ".specify" / "templates"
self.packs_dir = self.templates_dir / "packs"
self.overrides_dir = self.templates_dir / "overrides"
self.extensions_dir = project_root / ".specify" / "extensions"
def resolve(
self,
template_name: str,
template_type: str = "artifact",
) -> Optional[Path]:
"""Resolve a template name to its file path.
Walks the priority stack and returns the first match.
Args:
template_name: Template name (e.g., "spec-template")
template_type: Template type ("artifact", "command", or "script")
Returns:
Path to the resolved template file, or None if not found
"""
# Determine subdirectory based on template type
if template_type == "artifact":
subdirs = ["templates", ""]
elif template_type == "command":
subdirs = ["commands"]
elif template_type == "script":
subdirs = ["scripts"]
else:
subdirs = [""]
# Priority 1: Project-local overrides
for subdir in subdirs:
if template_type == "script":
override = self.overrides_dir / "scripts" / f"{template_name}.sh"
elif subdir:
override = self.overrides_dir / f"{template_name}.md"
else:
override = self.overrides_dir / f"{template_name}.md"
if override.exists():
return override
# Priority 2: Installed packs (by registry order)
if self.packs_dir.exists():
registry = TemplatePackRegistry(self.packs_dir)
for pack_id in registry.list():
pack_dir = self.packs_dir / pack_id
for subdir in subdirs:
if subdir:
candidate = (
pack_dir / subdir / f"{template_name}.md"
)
else:
candidate = pack_dir / f"{template_name}.md"
if candidate.exists():
return candidate
# Priority 3: Extension-provided templates
if self.extensions_dir.exists():
for ext_dir in sorted(self.extensions_dir.iterdir()):
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
continue
for subdir in subdirs:
if subdir:
candidate = (
ext_dir / "templates" / f"{template_name}.md"
)
else:
candidate = (
ext_dir / "templates" / f"{template_name}.md"
)
if candidate.exists():
return candidate
# Priority 4: Core templates
if template_type == "artifact":
core = self.templates_dir / f"{template_name}.md"
if core.exists():
return core
elif template_type == "command":
core = self.templates_dir / "commands" / f"{template_name}.md"
if core.exists():
return core
return None
def resolve_with_source(
self,
template_name: str,
template_type: str = "artifact",
) -> Optional[Dict[str, str]]:
"""Resolve a template name and return source attribution.
Args:
template_name: Template name (e.g., "spec-template")
template_type: Template type ("artifact", "command", or "script")
Returns:
Dictionary with 'path' and 'source' keys, or None if not found
"""
# Priority 1: Project-local overrides
override = self.overrides_dir / f"{template_name}.md"
if override.exists():
return {"path": str(override), "source": "project override"}
# Priority 2: Installed packs
if self.packs_dir.exists():
registry = TemplatePackRegistry(self.packs_dir)
for pack_id in registry.list():
pack_dir = self.packs_dir / pack_id
# Check templates/ subdirectory first, then root
for subdir in ["templates", "commands", "scripts", ""]:
if subdir:
candidate = (
pack_dir / subdir / f"{template_name}.md"
)
else:
candidate = pack_dir / f"{template_name}.md"
if candidate.exists():
meta = registry.get(pack_id)
version = meta.get("version", "?") if meta else "?"
return {
"path": str(candidate),
"source": f"{pack_id} v{version}",
}
# Priority 3: Extension-provided templates
if self.extensions_dir.exists():
for ext_dir in sorted(self.extensions_dir.iterdir()):
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
continue
candidate = ext_dir / "templates" / f"{template_name}.md"
if candidate.exists():
return {
"path": str(candidate),
"source": f"extension:{ext_dir.name}",
}
# Priority 4: Core templates
core = self.templates_dir / f"{template_name}.md"
if core.exists():
return {"path": str(core), "source": "core"}
# Also check commands subdirectory for core
core_cmd = self.templates_dir / "commands" / f"{template_name}.md"
if core_cmd.exists():
return {"path": str(core_cmd), "source": "core"}
return None

View File

@@ -1,6 +0,0 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-09T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.community.json",
"template_packs": {}
}

View File

@@ -1,49 +0,0 @@
# My Template Pack
A custom template pack for Spec Kit.
## Overview
This template pack provides customized artifact templates for your development workflow.
## Templates Included
| Template | Type | Description |
|----------|------|-------------|
| `spec-template` | artifact | Custom feature specification template |
## Installation
```bash
# Install from local directory (during development)
specify template add --dev /path/to/this/directory
# Install from catalog (after publishing)
specify template add my-template-pack
```
## Usage
Once installed, templates are automatically resolved by the Spec Kit scripts.
When you run `specify specify` or create a new feature, your custom templates
will be used instead of the core defaults.
## Template Types
- **artifact** — Document scaffolds (spec.md, plan.md, tasks.md, etc.)
- **command** — AI agent prompts (the files in `.claude/commands/`, etc.)
- **script** — Custom scripts that replace core scripts
## Development
1. Edit templates in the `templates/` directory
2. Test with: `specify template add --dev .`
3. Verify with: `specify template resolve spec-template`
## Publishing
See the [Template Publishing Guide](../../docs/TEMPLATE-PUBLISHING-GUIDE.md) for details.
## License
MIT

View File

@@ -1,66 +0,0 @@
schema_version: "1.0"
template_pack:
# CUSTOMIZE: Change 'my-template-pack' to your template pack ID (lowercase, hyphen-separated)
id: "my-template-pack"
# CUSTOMIZE: Human-readable name for your template pack
name: "My Template Pack"
# 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 template pack 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-templates-my-pack"
# REVIEW: License (MIT is recommended for open source)
license: "MIT"
# Requirements for this template pack
requires:
# CUSTOMIZE: Minimum spec-kit version required
speckit_version: ">=0.1.0"
# Templates provided by this pack
provides:
templates:
# CUSTOMIZE: Define your artifact templates
# Artifact templates are document scaffolds (spec.md, plan.md, etc.)
- type: "artifact"
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: "artifact"
# name: "plan-template"
# file: "templates/plan-template.md"
# description: "Custom plan template"
# replaces: "plan-template"
# Command templates (AI agent prompts)
# - type: "command"
# name: "specify"
# file: "commands/specify.md"
# description: "Custom specification command"
# replaces: "specify"
# Script templates
# - 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"
- "template"

1568
tests/test_presets.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,923 +0,0 @@
"""
Unit tests for the template pack system.
Tests cover:
- Template pack manifest validation
- Template pack registry operations
- Template pack manager installation/removal
- Template catalog search
- Template resolver priority stack
- Extension-provided templates
"""
import pytest
import json
import tempfile
import shutil
import zipfile
from pathlib import Path
from datetime import datetime, timezone
import yaml
from specify_cli.templates import (
TemplatePackManifest,
TemplatePackRegistry,
TemplatePackManager,
TemplateCatalog,
TemplateResolver,
TemplateError,
TemplateValidationError,
TemplateCompatibilityError,
VALID_TEMPLATE_TYPES,
)
# ===== Fixtures =====
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
tmpdir = tempfile.mkdtemp()
yield Path(tmpdir)
shutil.rmtree(tmpdir)
@pytest.fixture
def valid_pack_data():
"""Valid template pack manifest data."""
return {
"schema_version": "1.0",
"template_pack": {
"id": "test-pack",
"name": "Test Template Pack",
"version": "1.0.0",
"description": "A test template pack",
"author": "Test Author",
"repository": "https://github.com/test/test-pack",
"license": "MIT",
},
"requires": {
"speckit_version": ">=0.1.0",
},
"provides": {
"templates": [
{
"type": "artifact",
"name": "spec-template",
"file": "templates/spec-template.md",
"description": "Custom spec template",
"replaces": "spec-template",
}
]
},
"tags": ["testing", "example"],
}
@pytest.fixture
def pack_dir(temp_dir, valid_pack_data):
"""Create a complete template pack directory structure."""
p_dir = temp_dir / "test-pack"
p_dir.mkdir()
# Write manifest
manifest_path = p_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
# Create templates directory
templates_dir = p_dir / "templates"
templates_dir.mkdir()
# Write template file
tmpl_file = templates_dir / "spec-template.md"
tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n")
return p_dir
@pytest.fixture
def project_dir(temp_dir):
"""Create a mock spec-kit project directory."""
proj_dir = temp_dir / "project"
proj_dir.mkdir()
# Create .specify directory
specify_dir = proj_dir / ".specify"
specify_dir.mkdir()
# Create templates directory with core templates
templates_dir = specify_dir / "templates"
templates_dir.mkdir()
# Create core spec-template
core_spec = templates_dir / "spec-template.md"
core_spec.write_text("# Core Spec Template\n")
# Create core plan-template
core_plan = templates_dir / "plan-template.md"
core_plan.write_text("# Core Plan Template\n")
# Create commands subdirectory
commands_dir = templates_dir / "commands"
commands_dir.mkdir()
return proj_dir
# ===== TemplatePackManifest Tests =====
class TestTemplatePackManifest:
"""Test TemplatePackManifest validation and parsing."""
def test_valid_manifest(self, pack_dir):
"""Test loading a valid manifest."""
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
assert manifest.id == "test-pack"
assert manifest.name == "Test Template Pack"
assert manifest.version == "1.0.0"
assert manifest.description == "A test template pack"
assert manifest.author == "Test Author"
assert manifest.requires_speckit_version == ">=0.1.0"
assert len(manifest.templates) == 1
assert manifest.tags == ["testing", "example"]
def test_missing_manifest(self, temp_dir):
"""Test that missing manifest raises error."""
with pytest.raises(TemplateValidationError, match="Manifest not found"):
TemplatePackManifest(temp_dir / "nonexistent.yml")
def test_invalid_yaml(self, temp_dir):
"""Test that invalid YAML raises error."""
bad_file = temp_dir / "bad.yml"
bad_file.write_text(": invalid: yaml: {{{")
with pytest.raises(TemplateValidationError, match="Invalid YAML"):
TemplatePackManifest(bad_file)
def test_missing_schema_version(self, temp_dir, valid_pack_data):
"""Test missing schema_version field."""
del valid_pack_data["schema_version"]
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="Missing required field: schema_version"):
TemplatePackManifest(manifest_path)
def test_wrong_schema_version(self, temp_dir, valid_pack_data):
"""Test unsupported schema version."""
valid_pack_data["schema_version"] = "2.0"
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="Unsupported schema version"):
TemplatePackManifest(manifest_path)
def test_missing_pack_id(self, temp_dir, valid_pack_data):
"""Test missing template_pack.id field."""
del valid_pack_data["template_pack"]["id"]
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="Missing template_pack.id"):
TemplatePackManifest(manifest_path)
def test_invalid_pack_id_format(self, temp_dir, valid_pack_data):
"""Test invalid pack ID format."""
valid_pack_data["template_pack"]["id"] = "Invalid_ID"
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="Invalid template pack ID"):
TemplatePackManifest(manifest_path)
def test_invalid_version(self, temp_dir, valid_pack_data):
"""Test invalid semantic version."""
valid_pack_data["template_pack"]["version"] = "not-a-version"
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="Invalid version"):
TemplatePackManifest(manifest_path)
def test_missing_speckit_version(self, temp_dir, valid_pack_data):
"""Test missing requires.speckit_version."""
del valid_pack_data["requires"]["speckit_version"]
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="Missing requires.speckit_version"):
TemplatePackManifest(manifest_path)
def test_no_templates_provided(self, temp_dir, valid_pack_data):
"""Test pack with no templates."""
valid_pack_data["provides"]["templates"] = []
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="must provide at least one template"):
TemplatePackManifest(manifest_path)
def test_invalid_template_type(self, temp_dir, valid_pack_data):
"""Test template with invalid type."""
valid_pack_data["provides"]["templates"][0]["type"] = "invalid"
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="Invalid template type"):
TemplatePackManifest(manifest_path)
def test_valid_template_types(self):
"""Test that all expected template types are valid."""
assert "artifact" in VALID_TEMPLATE_TYPES
assert "command" in VALID_TEMPLATE_TYPES
assert "script" in VALID_TEMPLATE_TYPES
def test_template_missing_required_fields(self, temp_dir, valid_pack_data):
"""Test template missing required fields."""
valid_pack_data["provides"]["templates"] = [{"type": "artifact"}]
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="missing 'type', 'name', or 'file'"):
TemplatePackManifest(manifest_path)
def test_invalid_template_name_format(self, temp_dir, valid_pack_data):
"""Test template with invalid name format."""
valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name"
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(TemplateValidationError, match="Invalid template name"):
TemplatePackManifest(manifest_path)
def test_get_hash(self, pack_dir):
"""Test manifest hash calculation."""
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
hash_val = manifest.get_hash()
assert hash_val.startswith("sha256:")
assert len(hash_val) > 10
def test_multiple_templates(self, temp_dir, valid_pack_data):
"""Test pack with multiple templates of different types."""
valid_pack_data["provides"]["templates"] = [
{"type": "artifact", "name": "spec-template", "file": "templates/spec-template.md"},
{"type": "artifact", "name": "plan-template", "file": "templates/plan-template.md"},
{"type": "command", "name": "specify", "file": "commands/specify.md"},
{"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"},
]
manifest_path = temp_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
manifest = TemplatePackManifest(manifest_path)
assert len(manifest.templates) == 4
# ===== TemplatePackRegistry Tests =====
class TestTemplatePackRegistry:
"""Test TemplatePackRegistry operations."""
def test_empty_registry(self, temp_dir):
"""Test empty registry initialization."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = TemplatePackRegistry(packs_dir)
assert registry.list() == {}
assert not registry.is_installed("test-pack")
def test_add_and_get(self, temp_dir):
"""Test adding and retrieving a pack."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = TemplatePackRegistry(packs_dir)
registry.add("test-pack", {"version": "1.0.0", "source": "local"})
assert registry.is_installed("test-pack")
metadata = registry.get("test-pack")
assert metadata is not None
assert metadata["version"] == "1.0.0"
assert "installed_at" in metadata
def test_remove(self, temp_dir):
"""Test removing a pack."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = TemplatePackRegistry(packs_dir)
registry.add("test-pack", {"version": "1.0.0"})
assert registry.is_installed("test-pack")
registry.remove("test-pack")
assert not registry.is_installed("test-pack")
def test_remove_nonexistent(self, temp_dir):
"""Test removing a pack that doesn't exist."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = TemplatePackRegistry(packs_dir)
registry.remove("nonexistent") # Should not raise
def test_list(self, temp_dir):
"""Test listing all packs."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = TemplatePackRegistry(packs_dir)
registry.add("pack-a", {"version": "1.0.0"})
registry.add("pack-b", {"version": "2.0.0"})
all_packs = registry.list()
assert len(all_packs) == 2
assert "pack-a" in all_packs
assert "pack-b" in all_packs
def test_persistence(self, temp_dir):
"""Test that registry data persists across instances."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
# Add with first instance
registry1 = TemplatePackRegistry(packs_dir)
registry1.add("test-pack", {"version": "1.0.0"})
# Load with second instance
registry2 = TemplatePackRegistry(packs_dir)
assert registry2.is_installed("test-pack")
def test_corrupted_registry(self, temp_dir):
"""Test recovery from corrupted registry file."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry_file = packs_dir / ".registry"
registry_file.write_text("not valid json{{{")
registry = TemplatePackRegistry(packs_dir)
assert registry.list() == {}
def test_get_nonexistent(self, temp_dir):
"""Test getting a nonexistent pack."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = TemplatePackRegistry(packs_dir)
assert registry.get("nonexistent") is None
# ===== TemplatePackManager Tests =====
class TestTemplatePackManager:
"""Test TemplatePackManager installation and removal."""
def test_install_from_directory(self, project_dir, pack_dir):
"""Test installing a template pack from a directory."""
manager = TemplatePackManager(project_dir)
manifest = manager.install_from_directory(pack_dir, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
# Verify files are copied
installed_dir = project_dir / ".specify" / "templates" / "packs" / "test-pack"
assert installed_dir.exists()
assert (installed_dir / "template-pack.yml").exists()
assert (installed_dir / "templates" / "spec-template.md").exists()
def test_install_already_installed(self, project_dir, pack_dir):
"""Test installing an already-installed pack raises error."""
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
with pytest.raises(TemplateError, match="already installed"):
manager.install_from_directory(pack_dir, "0.1.5")
def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data):
"""Test installing an incompatible pack raises error."""
valid_pack_data["requires"]["speckit_version"] = ">=99.0.0"
incompat_dir = temp_dir / "incompat-pack"
incompat_dir.mkdir()
manifest_path = incompat_dir / "template-pack.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
(incompat_dir / "templates").mkdir()
(incompat_dir / "templates" / "spec-template.md").write_text("test")
manager = TemplatePackManager(project_dir)
with pytest.raises(TemplateCompatibilityError):
manager.install_from_directory(incompat_dir, "0.1.5")
def test_install_from_zip(self, project_dir, pack_dir, temp_dir):
"""Test installing from a ZIP file."""
zip_path = temp_dir / "test-pack.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
for file_path in pack_dir.rglob('*'):
if file_path.is_file():
arcname = file_path.relative_to(pack_dir)
zf.write(file_path, arcname)
manager = TemplatePackManager(project_dir)
manifest = manager.install_from_zip(zip_path, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):
"""Test installing from ZIP with nested directory."""
zip_path = temp_dir / "test-pack.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
for file_path in pack_dir.rglob('*'):
if file_path.is_file():
arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir)
zf.write(file_path, arcname)
manager = TemplatePackManager(project_dir)
manifest = manager.install_from_zip(zip_path, "0.1.5")
assert manifest.id == "test-pack"
def test_install_from_zip_no_manifest(self, project_dir, temp_dir):
"""Test installing from ZIP without manifest raises error."""
zip_path = temp_dir / "bad.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
zf.writestr("readme.txt", "no manifest here")
manager = TemplatePackManager(project_dir)
with pytest.raises(TemplateValidationError, match="No template-pack.yml found"):
manager.install_from_zip(zip_path, "0.1.5")
def test_remove(self, project_dir, pack_dir):
"""Test removing a template pack."""
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
assert manager.registry.is_installed("test-pack")
result = manager.remove("test-pack")
assert result is True
assert not manager.registry.is_installed("test-pack")
installed_dir = project_dir / ".specify" / "templates" / "packs" / "test-pack"
assert not installed_dir.exists()
def test_remove_nonexistent(self, project_dir):
"""Test removing a pack that doesn't exist."""
manager = TemplatePackManager(project_dir)
result = manager.remove("nonexistent")
assert result is False
def test_list_installed(self, project_dir, pack_dir):
"""Test listing installed packs."""
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["id"] == "test-pack"
assert installed[0]["name"] == "Test Template Pack"
assert installed[0]["version"] == "1.0.0"
assert installed[0]["template_count"] == 1
def test_list_installed_empty(self, project_dir):
"""Test listing when no packs installed."""
manager = TemplatePackManager(project_dir)
assert manager.list_installed() == []
def test_get_pack(self, project_dir, pack_dir):
"""Test getting a specific installed pack."""
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
pack = manager.get_pack("test-pack")
assert pack is not None
assert pack.id == "test-pack"
def test_get_pack_not_installed(self, project_dir):
"""Test getting a non-installed pack returns None."""
manager = TemplatePackManager(project_dir)
assert manager.get_pack("nonexistent") is None
def test_check_compatibility_valid(self, pack_dir):
"""Test compatibility check with valid version."""
manager = TemplatePackManager(Path(tempfile.mkdtemp()))
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
assert manager.check_compatibility(manifest, "0.1.5") is True
def test_check_compatibility_invalid(self, pack_dir):
"""Test compatibility check with invalid specifier."""
manager = TemplatePackManager(Path(tempfile.mkdtemp()))
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
manifest.data["requires"]["speckit_version"] = "not-a-specifier"
with pytest.raises(TemplateCompatibilityError, match="Invalid version specifier"):
manager.check_compatibility(manifest, "0.1.5")
# ===== TemplateResolver Tests =====
class TestTemplateResolver:
"""Test TemplateResolver priority stack."""
def test_resolve_core_template(self, project_dir):
"""Test resolving a core template."""
resolver = TemplateResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert result.name == "spec-template.md"
assert "Core Spec Template" in result.read_text()
def test_resolve_nonexistent(self, project_dir):
"""Test resolving a nonexistent template returns None."""
resolver = TemplateResolver(project_dir)
result = resolver.resolve("nonexistent-template")
assert result is None
def test_resolve_override_takes_priority(self, project_dir):
"""Test that project overrides take priority over core."""
# Create override
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
override = overrides_dir / "spec-template.md"
override.write_text("# Override Spec Template\n")
resolver = TemplateResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Override Spec Template" in result.read_text()
def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir):
"""Test that installed packs take priority over core templates."""
# Install the pack
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = TemplateResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Custom Spec Template" in result.read_text()
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
"""Test that overrides take priority over installed packs."""
# Install the pack
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
# Create override
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
override = overrides_dir / "spec-template.md"
override.write_text("# Override Spec Template\n")
resolver = TemplateResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Override Spec Template" in result.read_text()
def test_resolve_extension_provided_templates(self, project_dir):
"""Test resolving templates provided by extensions."""
# Create extension with templates
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "custom-template.md"
ext_template.write_text("# Extension Custom Template\n")
resolver = TemplateResolver(project_dir)
result = resolver.resolve("custom-template")
assert result is not None
assert "Extension Custom Template" in result.read_text()
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
"""Test that pack templates take priority over extension templates."""
# Create extension with templates
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "spec-template.md"
ext_template.write_text("# Extension Spec Template\n")
# Install a pack with the same template
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = TemplateResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
# Pack should win over extension
assert "Custom Spec Template" in result.read_text()
def test_resolve_with_source_core(self, project_dir):
"""Test resolve_with_source for core template."""
resolver = TemplateResolver(project_dir)
result = resolver.resolve_with_source("spec-template")
assert result is not None
assert result["source"] == "core"
assert "spec-template.md" in result["path"]
def test_resolve_with_source_override(self, project_dir):
"""Test resolve_with_source for override template."""
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
override = overrides_dir / "spec-template.md"
override.write_text("# Override\n")
resolver = TemplateResolver(project_dir)
result = resolver.resolve_with_source("spec-template")
assert result is not None
assert result["source"] == "project override"
def test_resolve_with_source_pack(self, project_dir, pack_dir):
"""Test resolve_with_source for pack template."""
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = TemplateResolver(project_dir)
result = resolver.resolve_with_source("spec-template")
assert result is not None
assert "test-pack" in result["source"]
assert "v1.0.0" in result["source"]
def test_resolve_with_source_extension(self, project_dir):
"""Test resolve_with_source for extension-provided template."""
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "unique-template.md"
ext_template.write_text("# Unique\n")
resolver = TemplateResolver(project_dir)
result = resolver.resolve_with_source("unique-template")
assert result is not None
assert result["source"] == "extension:my-ext"
def test_resolve_with_source_not_found(self, project_dir):
"""Test resolve_with_source for nonexistent template."""
resolver = TemplateResolver(project_dir)
result = resolver.resolve_with_source("nonexistent")
assert result is None
def test_resolve_skips_hidden_extension_dirs(self, project_dir):
"""Test that hidden directories in extensions are skipped."""
ext_dir = project_dir / ".specify" / "extensions" / ".backup"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "hidden-template.md"
ext_template.write_text("# Hidden\n")
resolver = TemplateResolver(project_dir)
result = resolver.resolve("hidden-template")
assert result is None
# ===== TemplateCatalog Tests =====
class TestTemplateCatalog:
"""Test template catalog functionality."""
def test_default_catalog_url(self, project_dir):
"""Test default catalog URL."""
catalog = TemplateCatalog(project_dir)
assert "githubusercontent.com" in catalog.DEFAULT_CATALOG_URL
assert "templates/catalog.json" in catalog.DEFAULT_CATALOG_URL
def test_community_catalog_url(self, project_dir):
"""Test community catalog URL."""
catalog = TemplateCatalog(project_dir)
assert "templates/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL
def test_cache_validation_no_cache(self, project_dir):
"""Test cache validation when no cache exists."""
catalog = TemplateCatalog(project_dir)
assert catalog.is_cache_valid() is False
def test_cache_validation_valid(self, project_dir):
"""Test cache validation with valid cache."""
catalog = TemplateCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps({
"schema_version": "1.0",
"template_packs": {},
}))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
}))
assert catalog.is_cache_valid() is True
def test_cache_validation_expired(self, project_dir):
"""Test cache validation with expired cache."""
catalog = TemplateCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps({
"schema_version": "1.0",
"template_packs": {},
}))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": "2020-01-01T00:00:00+00:00",
}))
assert catalog.is_cache_valid() is False
def test_cache_validation_corrupted(self, project_dir):
"""Test cache validation with corrupted metadata."""
catalog = TemplateCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("not json")
catalog.cache_metadata_file.write_text("not json")
assert catalog.is_cache_valid() is False
def test_clear_cache(self, project_dir):
"""Test clearing the cache."""
catalog = TemplateCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}")
catalog.cache_metadata_file.write_text("{}")
catalog.clear_cache()
assert not catalog.cache_file.exists()
assert not catalog.cache_metadata_file.exists()
def test_search_with_cached_data(self, project_dir):
"""Test search with cached catalog data."""
catalog = TemplateCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog_data = {
"schema_version": "1.0",
"template_packs": {
"safe-agile": {
"name": "SAFe Agile Templates",
"description": "SAFe-aligned templates",
"author": "agile-community",
"version": "1.0.0",
"tags": ["safe", "agile"],
},
"healthcare": {
"name": "Healthcare Compliance",
"description": "HIPAA-compliant templates",
"author": "healthcare-org",
"version": "1.0.0",
"tags": ["healthcare", "hipaa"],
},
}
}
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
}))
# Search by query
results = catalog.search(query="agile")
assert len(results) == 1
assert results[0]["id"] == "safe-agile"
# Search by tag
results = catalog.search(tag="hipaa")
assert len(results) == 1
assert results[0]["id"] == "healthcare"
# Search by author
results = catalog.search(author="agile-community")
assert len(results) == 1
# Search all
results = catalog.search()
assert len(results) == 2
def test_get_pack_info(self, project_dir):
"""Test getting info for a specific pack."""
catalog = TemplateCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog_data = {
"schema_version": "1.0",
"template_packs": {
"test-pack": {
"name": "Test Pack",
"version": "1.0.0",
},
}
}
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
}))
info = catalog.get_pack_info("test-pack")
assert info is not None
assert info["name"] == "Test Pack"
assert info["id"] == "test-pack"
assert catalog.get_pack_info("nonexistent") is None
def test_validate_catalog_url_https(self, project_dir):
"""Test that HTTPS URLs are accepted."""
catalog = TemplateCatalog(project_dir)
catalog._validate_catalog_url("https://example.com/catalog.json")
def test_validate_catalog_url_http_rejected(self, project_dir):
"""Test that HTTP URLs are rejected."""
catalog = TemplateCatalog(project_dir)
with pytest.raises(TemplateValidationError, match="must use HTTPS"):
catalog._validate_catalog_url("http://example.com/catalog.json")
def test_validate_catalog_url_localhost_http_allowed(self, project_dir):
"""Test that HTTP is allowed for localhost."""
catalog = TemplateCatalog(project_dir)
catalog._validate_catalog_url("http://localhost:8080/catalog.json")
catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json")
def test_env_var_catalog_url(self, project_dir, monkeypatch):
"""Test catalog URL from environment variable."""
monkeypatch.setenv("SPECKIT_TEMPLATE_CATALOG_URL", "https://custom.example.com/catalog.json")
catalog = TemplateCatalog(project_dir)
assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json"
# ===== Integration Tests =====
class TestIntegration:
"""Integration tests for complete template pack workflows."""
def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir):
"""Test complete lifecycle: install → resolve → remove."""
# Install
manager = TemplatePackManager(project_dir)
manifest = manager.install_from_directory(pack_dir, "0.1.5")
assert manifest.id == "test-pack"
# Resolve — pack template should win over core
resolver = TemplateResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Custom Spec Template" in result.read_text()
# Remove
manager.remove("test-pack")
# Resolve — should fall back to core
result = resolver.resolve("spec-template")
assert result is not None
assert "Core Spec Template" in result.read_text()
def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir):
"""Test the full priority stack: override > pack > extension > core."""
resolver = TemplateResolver(project_dir)
# Core should resolve
result = resolver.resolve_with_source("spec-template")
assert result["source"] == "core"
# Add extension template
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
result = resolver.resolve_with_source("spec-template")
assert result["source"] == "extension:my-ext"
# Install pack — should win over extension
manager = TemplatePackManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
result = resolver.resolve_with_source("spec-template")
assert "test-pack" in result["source"]
# Add override — should win over pack
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
(overrides_dir / "spec-template.md").write_text("# Override\n")
result = resolver.resolve_with_source("spec-template")
assert result["source"] == "project override"
def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir):
"""Test installing from ZIP and then resolving."""
# Create ZIP
zip_path = temp_dir / "test-pack.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
for file_path in pack_dir.rglob('*'):
if file_path.is_file():
arcname = file_path.relative_to(pack_dir)
zf.write(file_path, arcname)
# Install
manager = TemplatePackManager(project_dir)
manager.install_from_zip(zip_path, "0.1.5")
# Resolve
resolver = TemplateResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Custom Spec Template" in result.read_text()