mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 10:53:08 +00:00
Compare commits
7 Commits
f5f8311415
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d4f63298 | ||
|
|
990a1513c2 | ||
|
|
9a82f098e8 | ||
|
|
5c77268136 | ||
|
|
ad591607ea | ||
|
|
50c605ed5f | ||
|
|
623cd79b38 |
57
CHANGELOG.md
57
CHANGELOG.md
@@ -7,63 +7,6 @@ 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-10
|
||||
|
||||
### Added
|
||||
|
||||
- feat(presets): Pluggable preset system with preset catalog and template resolver
|
||||
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
|
||||
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
|
||||
- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info`
|
||||
- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management
|
||||
- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system
|
||||
- `--preset` option for `specify init` to install presets during initialization
|
||||
- Priority-based preset resolution: presets with lower priority number win (`--priority` flag)
|
||||
- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts
|
||||
- Template resolution priority stack: overrides → presets → extensions → core
|
||||
- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`)
|
||||
- Preset scaffold directory (`presets/scaffold/`)
|
||||
- Scripts updated to use template resolution instead of hardcoded paths
|
||||
- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init
|
||||
- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations
|
||||
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
|
||||
|
||||
## [0.2.0] - 2026-03-09
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: sync agent list comments with actual supported agents (#1785)
|
||||
- feat(extensions): support multiple active catalogs simultaneously (#1720)
|
||||
- Pavel/add tabnine cli support (#1503)
|
||||
- Add Understanding extension to community catalog (#1778)
|
||||
- Add ralph extension to community catalog (#1780)
|
||||
- Update README with project initialization instructions (#1772)
|
||||
- feat: add review extension to community catalog (#1775)
|
||||
- Add fleet extension to community catalog (#1771)
|
||||
- Integration of Mistral vibe support into speckit (#1725)
|
||||
- fix: Remove duplicate options in specify.md (#1765)
|
||||
- fix: use global branch numbering instead of per-short-name detection (#1757)
|
||||
- Add Community Walkthroughs section to README (#1766)
|
||||
- feat(extensions): add Jira Integration to community catalog (#1764)
|
||||
- Add Azure DevOps Integration extension to community catalog (#1734)
|
||||
- Fix docs: update Antigravity link and add initialization example (#1748)
|
||||
- fix: wire after_tasks and after_implement hook events into command templates (#1702)
|
||||
- make c ignores consistent with c++ (#1747)
|
||||
- chore: bump version to 0.1.13 (#1746)
|
||||
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
|
||||
- feat: add verify extension to community catalog (#1726)
|
||||
- Add Retrospective Extension to community catalog README table (#1741)
|
||||
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
|
||||
- fix: correct Copilot extension command registration (#1724)
|
||||
- fix(implement): remove Makefile from C ignore patterns (#1558)
|
||||
- Add sync extension to community catalog (#1728)
|
||||
- fix(checklist): clarify file handling behavior for append vs create (#1556)
|
||||
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
|
||||
- chore: bump version to 0.1.12 (#1737)
|
||||
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
|
||||
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
||||
|
||||
## [0.1.14] - 2026-03-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -421,7 +421,7 @@ specify init . --force --ai claude
|
||||
specify init --here --force --ai claude
|
||||
```
|
||||
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, or Kiro CLI installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --ai claude --ignore-agent-tools
|
||||
|
||||
@@ -332,67 +332,6 @@ echo "$config"
|
||||
|
||||
---
|
||||
|
||||
## Excluding Files with `.extensionignore`
|
||||
|
||||
Extension authors can create a `.extensionignore` file in the extension root to exclude files and folders from being copied when a user installs the extension with `specify extension add`. This is useful for keeping development-only files (tests, CI configs, docs source, etc.) out of the installed copy.
|
||||
|
||||
### Format
|
||||
|
||||
The file uses `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library:
|
||||
|
||||
- Blank lines are ignored
|
||||
- Lines starting with `#` are comments
|
||||
- `*` matches anything **except** `/` (does not cross directory boundaries)
|
||||
- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`)
|
||||
- `?` matches any single character except `/`
|
||||
- A trailing `/` restricts a pattern to directories only
|
||||
- Patterns containing `/` (other than a trailing slash) are anchored to the extension root
|
||||
- Patterns without `/` match at any depth in the tree
|
||||
- `!` negates a previously excluded pattern (re-includes a file)
|
||||
- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility
|
||||
- The `.extensionignore` file itself is always excluded automatically
|
||||
|
||||
### Example
|
||||
|
||||
```gitignore
|
||||
# .extensionignore
|
||||
|
||||
# Development files
|
||||
tests/
|
||||
.github/
|
||||
.gitignore
|
||||
|
||||
# Build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
dist/
|
||||
|
||||
# Documentation source (keep only the built README)
|
||||
docs/
|
||||
CONTRIBUTING.md
|
||||
```
|
||||
|
||||
### Pattern Matching
|
||||
|
||||
| Pattern | Matches | Does NOT match |
|
||||
|---------|---------|----------------|
|
||||
| `*.pyc` | Any `.pyc` file in any directory | — |
|
||||
| `tests/` | The `tests` directory (and all its contents) | A file named `tests` |
|
||||
| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) |
|
||||
| `.env` | The `.env` file at any level | — |
|
||||
| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — |
|
||||
| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — |
|
||||
|
||||
### Unsupported Features
|
||||
|
||||
The following `.gitignore` features are **not applicable** in this context:
|
||||
|
||||
- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories)
|
||||
- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here
|
||||
- **Negation inside excluded directories**: Because file copying uses `shutil.copytree`, excluding a directory prevents recursion into it entirely. A negation pattern cannot re-include a file inside a directory that was itself excluded. For example, the combination `tests/` followed by `!tests/important.py` will **not** preserve `tests/important.py` — the `tests/` directory is skipped at the root level and its contents are never evaluated. To work around this, exclude the directory's contents individually instead of the directory itself (e.g., `tests/*.pyc` and `tests/.cache/` rather than `tests/`).
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Extension ID
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,295 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,115 +0,0 @@
|
||||
# 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.
|
||||
@@ -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/presets/catalog.community.json",
|
||||
"presets": {}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,91 +0,0 @@
|
||||
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"
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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.
|
||||
-->
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,61 +0,0 @@
|
||||
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"
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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
|
||||
@@ -1,23 +0,0 @@
|
||||
# Feature Specification (Self-Test Preset)
|
||||
|
||||
<!-- preset:self-test -->
|
||||
|
||||
> This template is provided by the self-test preset.
|
||||
|
||||
## Overview
|
||||
|
||||
Brief description of the feature.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Requirement 1
|
||||
- Requirement 2
|
||||
|
||||
## Design
|
||||
|
||||
Describe the design approach.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.2.1"
|
||||
version = "0.1.14"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -13,7 +13,6 @@ dependencies = [
|
||||
"truststore>=0.10.4",
|
||||
"pyyaml>=6.0",
|
||||
"packaging>=23.0",
|
||||
"pathspec>=0.12.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -154,77 +154,3 @@ EOF
|
||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
# 3. .specify/extensions/<ext-id>/templates/
|
||||
# 4. .specify/templates/ (core)
|
||||
resolve_template() {
|
||||
local template_name="$1"
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
# Priority 1: Project overrides
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
[ -f "$override" ] && echo "$override" && return 0
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
# Read preset IDs sorted by priority (lower number = higher precedence)
|
||||
local sorted_presets
|
||||
sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||
import json, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null)
|
||||
if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then
|
||||
while IFS= read -r preset_id; do
|
||||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done <<< "$sorted_presets"
|
||||
else
|
||||
# python3 returned empty list — fall through to directory scan
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
else
|
||||
# Fallback: alphabetical directory order (no python3 available)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
# Skip hidden directories (e.g. .backup, .cache)
|
||||
case "$(basename "$ext")" in .*) continue;; esac
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates
|
||||
local core="$base/${template_name}.md"
|
||||
[ -f "$core" ] && echo "$core" && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,6 @@ clean_branch_name() {
|
||||
# to searching for repository markers so the workflow still functions in repositories that
|
||||
# were initialised with --no-git.
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
@@ -297,9 +296,9 @@ fi
|
||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
export SPECIFY_FEATURE="$BRANCH_NAME"
|
||||
|
||||
@@ -37,12 +37,12 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
# Copy plan template if it exists
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
|
||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
|
||||
if [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
else
|
||||
echo "Warning: Plan template not found"
|
||||
echo "Warning: Plan template not found at $TEMPLATE"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Antigravity or Generic
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe or Antigravity
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
|
||||
@@ -135,70 +135,3 @@ function Test-DirHasFiles {
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
# 3. .specify/extensions/<ext-id>/templates/
|
||||
# 4. .specify/templates/ (core)
|
||||
function Resolve-Template {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Priority 1: Project overrides
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) { return $override }
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Sort-Object { if ($_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
} catch {
|
||||
# Fallback: alphabetical directory order
|
||||
$sortedPresets = @()
|
||||
}
|
||||
}
|
||||
|
||||
if ($sortedPresets.Count -gt 0) {
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) { return $core }
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
|
||||
@@ -141,9 +141,6 @@ if (-not $fallbackRoot) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Load common functions (includes Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
try {
|
||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
@@ -279,9 +276,9 @@ if ($hasGit) {
|
||||
$featureDir = Join-Path $specsDir $branchName
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
if ($template -and (Test-Path $template)) {
|
||||
if (Test-Path $template) {
|
||||
Copy-Item $template $specFile -Force
|
||||
} else {
|
||||
New-Item -ItemType File -Path $specFile | Out-Null
|
||||
|
||||
@@ -32,12 +32,12 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GI
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
# Copy plan template if it exists, otherwise note it or create empty file
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
|
||||
if (Test-Path $template) {
|
||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
} else {
|
||||
Write-Warning "Plan template not found"
|
||||
Write-Warning "Plan template not found at $template"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
|
||||
2. Plan Data Extraction
|
||||
3. Agent File Management (create from template or update existing)
|
||||
4. Content Generation (technology stack, recent changes, timestamp)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, generic)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli)
|
||||
|
||||
.PARAMETER AgentType
|
||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||
|
||||
@@ -34,7 +34,7 @@ import shlex
|
||||
import json
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import typer
|
||||
import httpx
|
||||
@@ -1060,36 +1060,6 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
|
||||
else:
|
||||
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
|
||||
|
||||
|
||||
INIT_OPTIONS_FILE = ".specify/init-options.json"
|
||||
|
||||
|
||||
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||
"""Persist the CLI options used during ``specify init``.
|
||||
|
||||
Writes a small JSON file to ``.specify/init-options.json`` so that
|
||||
later operations (e.g. preset install) can adapt their behaviour
|
||||
without scanning the filesystem.
|
||||
"""
|
||||
dest = project_path / INIT_OPTIONS_FILE
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(json.dumps(options, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
"""Load the init options previously saved by ``specify init``.
|
||||
|
||||
Returns an empty dict if the file does not exist or cannot be parsed.
|
||||
"""
|
||||
path = project_path / INIT_OPTIONS_FILE
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
# Agent-specific skill directory overrides for agents whose skills directory
|
||||
# doesn't follow the standard <agent_folder>/skills/ pattern
|
||||
AGENT_SKILLS_DIR_OVERRIDES = {
|
||||
@@ -1302,7 +1272,6 @@ 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)"),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project from the latest template.
|
||||
@@ -1331,7 +1300,6 @@ 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 --preset healthcare-compliance # With preset
|
||||
"""
|
||||
|
||||
show_banner()
|
||||
@@ -1574,50 +1542,6 @@ def init(
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
# Persist the CLI options so later operations (e.g. preset add)
|
||||
# can adapt their behaviour without re-scanning the filesystem.
|
||||
# Must be saved BEFORE preset install so _get_skills_dir() works.
|
||||
save_init_options(project_path, {
|
||||
"ai": selected_ai,
|
||||
"ai_skills": ai_skills,
|
||||
"ai_commands_dir": ai_commands_dir,
|
||||
"here": here,
|
||||
"preset": preset,
|
||||
"script": selected_script,
|
||||
"speckit_version": get_speckit_version(),
|
||||
})
|
||||
|
||||
# Install preset if specified
|
||||
if preset:
|
||||
try:
|
||||
from .presets import PresetManager, PresetCatalog, PresetError
|
||||
preset_manager = PresetManager(project_path)
|
||||
speckit_ver = get_speckit_version()
|
||||
|
||||
# Try local directory first, then catalog
|
||||
local_path = Path(preset).resolve()
|
||||
if local_path.is_dir() and (local_path / "preset.yml").exists():
|
||||
preset_manager.install_from_directory(local_path, speckit_ver)
|
||||
else:
|
||||
preset_catalog = PresetCatalog(project_path)
|
||||
pack_info = preset_catalog.get_pack_info(preset)
|
||||
if not pack_info:
|
||||
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
|
||||
else:
|
||||
try:
|
||||
zip_path = preset_catalog.download_pack(preset)
|
||||
preset_manager.install_from_zip(zip_path, speckit_ver)
|
||||
# Clean up downloaded ZIP to avoid cache accumulation
|
||||
try:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
# Best-effort cleanup; failure to delete is non-fatal
|
||||
pass
|
||||
except PresetError as preset_err:
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
|
||||
except Exception as preset_err:
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except Exception as e:
|
||||
tracker.error("final", str(e))
|
||||
@@ -1855,20 +1779,6 @@ catalog_app = typer.Typer(
|
||||
)
|
||||
extension_app.add_typer(catalog_app, name="catalog")
|
||||
|
||||
preset_app = typer.Typer(
|
||||
name="preset",
|
||||
help="Manage spec-kit presets",
|
||||
add_completion=False,
|
||||
)
|
||||
app.add_typer(preset_app, name="preset")
|
||||
|
||||
preset_catalog_app = typer.Typer(
|
||||
name="catalog",
|
||||
help="Manage preset catalogs",
|
||||
add_completion=False,
|
||||
)
|
||||
preset_app.add_typer(preset_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def get_speckit_version() -> str:
|
||||
"""Get current spec-kit version."""
|
||||
@@ -1891,490 +1801,6 @@ def get_speckit_version() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ===== Preset Commands =====
|
||||
|
||||
|
||||
@preset_app.command("list")
|
||||
def preset_list():
|
||||
"""List installed presets."""
|
||||
from .presets import PresetManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = PresetManager(project_root)
|
||||
installed = manager.list_installed()
|
||||
|
||||
if not installed:
|
||||
console.print("[yellow]No presets installed.[/yellow]")
|
||||
console.print("\nInstall a preset with:")
|
||||
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
|
||||
for pack in installed:
|
||||
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||
pri = pack.get('priority', 10)
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
||||
console.print(f" {pack['description']}")
|
||||
if pack.get("tags"):
|
||||
tags_str = ", ".join(pack["tags"])
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@preset_app.command("add")
|
||||
def preset_add(
|
||||
pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
|
||||
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
||||
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||
):
|
||||
"""Install a preset."""
|
||||
from .presets import (
|
||||
PresetManager,
|
||||
PresetCatalog,
|
||||
PresetError,
|
||||
PresetValidationError,
|
||||
PresetCompatibilityError,
|
||||
)
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = PresetManager(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
try:
|
||||
if dev:
|
||||
dev_path = Path(dev).resolve()
|
||||
if not dev_path.exists():
|
||||
console.print(f"[red]Error:[/red] Directory not found: {dev}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...")
|
||||
manifest = manager.install_from_directory(dev_path, speckit_version, priority)
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
|
||||
elif from_url:
|
||||
# Validate URL scheme before downloading
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
_parsed = _urlparse(from_url)
|
||||
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
|
||||
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
try:
|
||||
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||
zip_path.write_bytes(response.read())
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
|
||||
elif pack_id:
|
||||
catalog = PresetCatalog(project_root)
|
||||
pack_info = catalog.get_pack_info(pack_id)
|
||||
|
||||
if not pack_info:
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not pack_info.get("_install_allowed", True):
|
||||
catalog_name = pack_info.get("_catalog_name", "unknown")
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
|
||||
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
|
||||
|
||||
try:
|
||||
zip_path = catalog.download_pack(pack_id)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
finally:
|
||||
if 'zip_path' in locals() and zip_path.exists():
|
||||
zip_path.unlink(missing_ok=True)
|
||||
else:
|
||||
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except PresetCompatibilityError as e:
|
||||
console.print(f"[red]Compatibility Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Validation Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except PresetError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@preset_app.command("remove")
|
||||
def preset_remove(
|
||||
pack_id: str = typer.Argument(..., help="Preset ID to remove"),
|
||||
):
|
||||
"""Remove an installed preset."""
|
||||
from .presets import PresetManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = PresetManager(project_root)
|
||||
|
||||
if not manager.registry.is_installed(pack_id):
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if manager.remove(pack_id):
|
||||
console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@preset_app.command("search")
|
||||
def preset_search(
|
||||
query: str = typer.Argument(None, help="Search query"),
|
||||
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
|
||||
author: str = typer.Option(None, "--author", help="Filter by author"),
|
||||
):
|
||||
"""Search for presets in the catalog."""
|
||||
from .presets import PresetCatalog, PresetError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalog = PresetCatalog(project_root)
|
||||
|
||||
try:
|
||||
results = catalog.search(query=query, tag=tag, author=author)
|
||||
except PresetError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not results:
|
||||
console.print("[yellow]No presets found matching your criteria.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
|
||||
for pack in results:
|
||||
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
|
||||
console.print(f" {pack.get('description', '')}")
|
||||
if pack.get("tags"):
|
||||
tags_str = ", ".join(pack["tags"])
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@preset_app.command("resolve")
|
||||
def preset_resolve(
|
||||
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
|
||||
):
|
||||
"""Show which template will be resolved for a given name."""
|
||||
from .presets import PresetResolver
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
resolver = PresetResolver(project_root)
|
||||
result = resolver.resolve_with_source(template_name)
|
||||
|
||||
if result:
|
||||
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
||||
console.print(f" [dim](from: {result['source']})[/dim]")
|
||||
else:
|
||||
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
||||
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
||||
|
||||
|
||||
@preset_app.command("info")
|
||||
def preset_info(
|
||||
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
||||
):
|
||||
"""Show detailed information about a preset."""
|
||||
from .presets import PresetCatalog, PresetManager, PresetError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Check if installed locally first
|
||||
manager = PresetManager(project_root)
|
||||
local_pack = manager.get_pack(pack_id)
|
||||
|
||||
if local_pack:
|
||||
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
|
||||
console.print(f" ID: {local_pack.id}")
|
||||
console.print(f" Version: {local_pack.version}")
|
||||
console.print(f" Description: {local_pack.description}")
|
||||
if local_pack.author:
|
||||
console.print(f" Author: {local_pack.author}")
|
||||
if local_pack.tags:
|
||||
console.print(f" Tags: {', '.join(local_pack.tags)}")
|
||||
console.print(f" Templates: {len(local_pack.templates)}")
|
||||
for tmpl in local_pack.templates:
|
||||
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
||||
repo = local_pack.data.get("preset", {}).get("repository")
|
||||
if repo:
|
||||
console.print(f" Repository: {repo}")
|
||||
license_val = local_pack.data.get("preset", {}).get("license")
|
||||
if license_val:
|
||||
console.print(f" License: {license_val}")
|
||||
console.print("\n [green]Status: installed[/green]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Fall back to catalog
|
||||
catalog = PresetCatalog(project_root)
|
||||
try:
|
||||
pack_info = catalog.get_pack_info(pack_id)
|
||||
except PresetError:
|
||||
pack_info = None
|
||||
|
||||
if not pack_info:
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n")
|
||||
console.print(f" ID: {pack_info['id']}")
|
||||
console.print(f" Version: {pack_info.get('version', '?')}")
|
||||
console.print(f" Description: {pack_info.get('description', '')}")
|
||||
if pack_info.get("author"):
|
||||
console.print(f" Author: {pack_info['author']}")
|
||||
if pack_info.get("tags"):
|
||||
console.print(f" Tags: {', '.join(pack_info['tags'])}")
|
||||
if pack_info.get("repository"):
|
||||
console.print(f" Repository: {pack_info['repository']}")
|
||||
if pack_info.get("license"):
|
||||
console.print(f" License: {pack_info['license']}")
|
||||
console.print("\n [yellow]Status: not installed[/yellow]")
|
||||
console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
# ===== Preset Catalog Commands =====
|
||||
|
||||
|
||||
@preset_catalog_app.command("list")
|
||||
def preset_catalog_list():
|
||||
"""List all active preset catalogs."""
|
||||
from .presets import PresetCatalog, PresetValidationError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalog = PresetCatalog(project_root)
|
||||
|
||||
try:
|
||||
active_catalogs = catalog.get_active_catalogs()
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
|
||||
for entry in active_catalogs:
|
||||
install_str = (
|
||||
"[green]install allowed[/green]"
|
||||
if entry.install_allowed
|
||||
else "[yellow]discovery only[/yellow]"
|
||||
)
|
||||
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
||||
if entry.description:
|
||||
console.print(f" {entry.description}")
|
||||
console.print(f" URL: {entry.url}")
|
||||
console.print(f" Install: {install_str}")
|
||||
console.print()
|
||||
|
||||
config_path = project_root / ".specify" / "preset-catalogs.yml"
|
||||
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
|
||||
if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
|
||||
console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]")
|
||||
else:
|
||||
try:
|
||||
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
|
||||
except PresetValidationError:
|
||||
proj_loaded = False
|
||||
if proj_loaded:
|
||||
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
|
||||
else:
|
||||
try:
|
||||
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
|
||||
except PresetValidationError:
|
||||
user_loaded = False
|
||||
if user_loaded:
|
||||
console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
|
||||
else:
|
||||
console.print("[dim]Using built-in default catalog stack.[/dim]")
|
||||
console.print(
|
||||
"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@preset_catalog_app.command("add")
|
||||
def preset_catalog_add(
|
||||
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
|
||||
name: str = typer.Option(..., "--name", help="Catalog name"),
|
||||
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
|
||||
install_allowed: bool = typer.Option(
|
||||
False, "--install-allowed/--no-install-allowed",
|
||||
help="Allow presets from this catalog to be installed",
|
||||
),
|
||||
description: str = typer.Option("", "--description", help="Description of the catalog"),
|
||||
):
|
||||
"""Add a catalog to .specify/preset-catalogs.yml."""
|
||||
from .presets import PresetCatalog, PresetValidationError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate URL
|
||||
tmp_catalog = PresetCatalog(project_root)
|
||||
try:
|
||||
tmp_catalog._validate_catalog_url(url)
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_path = specify_dir / "preset-catalogs.yml"
|
||||
|
||||
# Load existing config
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
config = {}
|
||||
|
||||
catalogs = config.get("catalogs", [])
|
||||
if not isinstance(catalogs, list):
|
||||
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Check for duplicate name
|
||||
for existing in catalogs:
|
||||
if isinstance(existing, dict) and existing.get("name") == name:
|
||||
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
||||
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalogs.append({
|
||||
"name": name,
|
||||
"url": url,
|
||||
"priority": priority,
|
||||
"install_allowed": install_allowed,
|
||||
"description": description,
|
||||
})
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
||||
|
||||
install_label = "install allowed" if install_allowed else "discovery only"
|
||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||
console.print(f" URL: {url}")
|
||||
console.print(f" Priority: {priority}")
|
||||
console.print(f"\nConfig saved to {config_path.relative_to(project_root)}")
|
||||
|
||||
|
||||
@preset_catalog_app.command("remove")
|
||||
def preset_catalog_remove(
|
||||
name: str = typer.Argument(help="Catalog name to remove"),
|
||||
):
|
||||
"""Remove a catalog from .specify/preset-catalogs.yml."""
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_path = specify_dir / "preset-catalogs.yml"
|
||||
if not config_path.exists():
|
||||
console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
except Exception:
|
||||
console.print("[red]Error:[/red] Failed to read preset catalog config.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalogs = config.get("catalogs", [])
|
||||
if not isinstance(catalogs, list):
|
||||
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||
raise typer.Exit(1)
|
||||
original_count = len(catalogs)
|
||||
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
||||
|
||||
if len(catalogs) == original_count:
|
||||
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
||||
|
||||
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||
if not catalogs:
|
||||
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
||||
|
||||
|
||||
# ===== Extension Commands =====
|
||||
|
||||
|
||||
@extension_app.command("list")
|
||||
def extension_list(
|
||||
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
},
|
||||
"codex": {
|
||||
"dir": ".codex/prompts",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"windsurf": {
|
||||
"dir": ".windsurf/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kilocode": {
|
||||
"dir": ".kilocode/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"auggie": {
|
||||
"dir": ".augment/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"roo": {
|
||||
"dir": ".roo/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(encoding="utf-8")
|
||||
frontmatter, body = self.parse_frontmatter(content)
|
||||
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
|
||||
if agent_config["format"] == "markdown":
|
||||
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||
elif agent_config["format"] == "toml":
|
||||
output = self.render_toml_command(frontmatter, body, source_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
dest_file.write_text(output, encoding="utf-8")
|
||||
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, cmd_name)
|
||||
|
||||
registered.append(cmd_name)
|
||||
|
||||
for alias in cmd_info.get("aliases", []):
|
||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
||||
alias_file.write_text(output, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, alias)
|
||||
registered.append(alias)
|
||||
|
||||
return registered
|
||||
|
||||
@staticmethod
|
||||
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
|
||||
"""Generate a companion .prompt.md file for a Copilot agent command.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root
|
||||
cmd_name: Command name (e.g. 'speckit.my-ext.example')
|
||||
"""
|
||||
prompts_dir = project_root / ".github" / "prompts"
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
|
||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
self,
|
||||
commands: List[Dict[str, Any]],
|
||||
source_id: str,
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: str = None
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all detected agents in the project.
|
||||
|
||||
Args:
|
||||
commands: List of command info dicts
|
||||
source_id: Identifier of the source (extension or preset ID)
|
||||
source_dir: Directory containing command source files
|
||||
project_root: Path to project root
|
||||
context_note: Custom context comment for markdown output
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
agent_dir = project_root / agent_config["dir"].split("/")[0]
|
||||
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name, commands, source_id, source_dir, project_root,
|
||||
context_note=context_note
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
def unregister_commands(
|
||||
self,
|
||||
registered_commands: Dict[str, List[str]],
|
||||
project_root: Path
|
||||
) -> None:
|
||||
"""Remove previously registered command files from agent directories.
|
||||
|
||||
Args:
|
||||
registered_commands: Dict mapping agent names to command name lists
|
||||
project_root: Path to project root
|
||||
"""
|
||||
for agent_name, cmd_names in registered_commands.items():
|
||||
if agent_name not in self.AGENT_CONFIGS:
|
||||
continue
|
||||
|
||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
|
||||
if agent_name == "copilot":
|
||||
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||
if prompt_file.exists():
|
||||
prompt_file.unlink()
|
||||
@@ -14,12 +14,10 @@ import zipfile
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any, Callable, Set
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
import pathspec
|
||||
|
||||
import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
@@ -282,70 +280,6 @@ class ExtensionManager:
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
self.registry = ExtensionRegistry(self.extensions_dir)
|
||||
|
||||
@staticmethod
|
||||
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
||||
"""Load .extensionignore and return an ignore function for shutil.copytree.
|
||||
|
||||
The .extensionignore file uses .gitignore-compatible patterns (one per line).
|
||||
Lines starting with '#' are comments. Blank lines are ignored.
|
||||
The .extensionignore file itself is always excluded.
|
||||
|
||||
Pattern semantics mirror .gitignore:
|
||||
- '*' matches anything except '/'
|
||||
- '**' matches zero or more directories
|
||||
- '?' matches any single character except '/'
|
||||
- Trailing '/' restricts a pattern to directories only
|
||||
- Patterns with '/' (other than trailing) are anchored to the root
|
||||
- '!' negates a previously excluded pattern
|
||||
|
||||
Args:
|
||||
source_dir: Path to the extension source directory
|
||||
|
||||
Returns:
|
||||
An ignore function compatible with shutil.copytree, or None
|
||||
if no .extensionignore file exists.
|
||||
"""
|
||||
ignore_file = source_dir / ".extensionignore"
|
||||
if not ignore_file.exists():
|
||||
return None
|
||||
|
||||
lines: List[str] = ignore_file.read_text().splitlines()
|
||||
|
||||
# Normalise backslashes in patterns so Windows-authored files work
|
||||
normalised: List[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
normalised.append(stripped.replace("\\", "/"))
|
||||
else:
|
||||
# Preserve blanks/comments so pathspec line numbers stay stable
|
||||
normalised.append(line)
|
||||
|
||||
# Always ignore the .extensionignore file itself
|
||||
normalised.append(".extensionignore")
|
||||
|
||||
spec = pathspec.GitIgnoreSpec.from_lines(normalised)
|
||||
|
||||
def _ignore(directory: str, entries: List[str]) -> Set[str]:
|
||||
ignored: Set[str] = set()
|
||||
rel_dir = Path(directory).relative_to(source_dir)
|
||||
for entry in entries:
|
||||
rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry
|
||||
# Normalise to forward slashes for consistent matching
|
||||
rel_path_fwd = rel_path.replace("\\", "/")
|
||||
|
||||
entry_full = Path(directory) / entry
|
||||
if entry_full.is_dir():
|
||||
# Append '/' so directory-only patterns (e.g. tests/) match
|
||||
if spec.match_file(rel_path_fwd + "/"):
|
||||
ignored.add(entry)
|
||||
else:
|
||||
if spec.match_file(rel_path_fwd):
|
||||
ignored.add(entry)
|
||||
return ignored
|
||||
|
||||
return _ignore
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
manifest: ExtensionManifest,
|
||||
@@ -419,8 +353,7 @@ class ExtensionManager:
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
|
||||
ignore_fn = self._load_extensionignore(source_dir)
|
||||
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
|
||||
# Register commands with AI agents
|
||||
registered_commands = {}
|
||||
@@ -522,7 +455,23 @@ class ExtensionManager:
|
||||
# Unregister commands from all AI agents
|
||||
if registered_commands:
|
||||
registrar = CommandRegistrar()
|
||||
registrar.unregister_commands(registered_commands, self.project_root)
|
||||
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()
|
||||
|
||||
if keep_config:
|
||||
# Preserve config files, only remove non-config files
|
||||
@@ -646,44 +595,243 @@ 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."""
|
||||
|
||||
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.
|
||||
"""
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
|
||||
# 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]:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
return _Registrar.parse_frontmatter(content)
|
||||
"""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:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
return _Registrar.render_frontmatter(fm)
|
||||
"""Render frontmatter dictionary as YAML.
|
||||
|
||||
@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)
|
||||
Args:
|
||||
fm: Frontmatter dictionary
|
||||
|
||||
def _render_markdown_command(self, frontmatter, body, ext_id):
|
||||
# Preserve extension-specific comment format for backward compatibility
|
||||
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
|
||||
"""
|
||||
context_note = f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n"
|
||||
return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
|
||||
def _render_toml_command(self, frontmatter, body, ext_id):
|
||||
return self._registrar.render_toml_command(frontmatter, body, ext_id)
|
||||
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 register_commands_for_agent(
|
||||
self,
|
||||
@@ -692,14 +840,94 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> List[str]:
|
||||
"""Register extension commands for a specific agent."""
|
||||
"""Register extension commands for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_name: Agent name (claude, gemini, copilot, etc.)
|
||||
manifest: Extension manifest
|
||||
extension_dir: Path to extension directory
|
||||
project_root: Path to project root
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
|
||||
Raises:
|
||||
ExtensionError: If agent is not supported
|
||||
"""
|
||||
if agent_name not in self.AGENT_CONFIGS:
|
||||
raise ExtensionError(f"Unsupported agent: {agent_name}")
|
||||
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
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
self,
|
||||
@@ -707,20 +935,35 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> Dict[str, List[str]]:
|
||||
"""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
|
||||
)
|
||||
"""Register extension commands for all detected agents.
|
||||
|
||||
def unregister_commands(
|
||||
self,
|
||||
registered_commands: Dict[str, List[str]],
|
||||
project_root: Path
|
||||
) -> None:
|
||||
"""Remove previously registered command files from agent directories."""
|
||||
self._registrar.unregister_commands(registered_commands, project_root)
|
||||
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
|
||||
|
||||
def register_commands_for_claude(
|
||||
self,
|
||||
@@ -728,7 +971,16 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> List[str]:
|
||||
"""Register extension commands for Claude Code agent."""
|
||||
"""Register extension commands for Claude Code agent.
|
||||
|
||||
Args:
|
||||
manifest: Extension manifest
|
||||
extension_dir: Path to extension directory
|
||||
project_root: Path to project root
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
"""
|
||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,13 +28,6 @@ class TestAgentConfigConsistency:
|
||||
assert cfg["kiro-cli"]["dir"] == ".kiro/prompts"
|
||||
assert "q" not in cfg
|
||||
|
||||
def test_extension_registrar_includes_codex(self):
|
||||
"""Extension command registrar should include codex targeting .codex/prompts."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "codex" in cfg
|
||||
assert cfg["codex"]["dir"] == ".codex/prompts"
|
||||
|
||||
def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):
|
||||
"""Bash and PowerShell release scripts should agree on agent key set for Kiro."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
|
||||
@@ -407,11 +407,6 @@ class TestCommandRegistrar:
|
||||
assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts"
|
||||
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
def test_codex_agent_config_present(self):
|
||||
"""Codex should be mapped to .codex/prompts."""
|
||||
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts"
|
||||
|
||||
def test_parse_frontmatter_valid(self):
|
||||
"""Test parsing valid YAML frontmatter."""
|
||||
content = """---
|
||||
@@ -1603,343 +1598,3 @@ class TestCatalogStack:
|
||||
assert len(results) == 1
|
||||
assert results[0]["_catalog_name"] == "org"
|
||||
assert results[0]["_install_allowed"] is True
|
||||
|
||||
|
||||
class TestExtensionIgnore:
|
||||
"""Test .extensionignore support during extension installation."""
|
||||
|
||||
def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None):
|
||||
"""Helper to create an extension directory with optional extra files and .extensionignore."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ignored-ext"
|
||||
ext_dir.mkdir()
|
||||
|
||||
# Write manifest
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
# Create commands directory with a command file
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
(commands_dir / "hello.md").write_text(
|
||||
"---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n"
|
||||
)
|
||||
|
||||
# Create any extra files/dirs
|
||||
if extra_files:
|
||||
for rel_path, content in extra_files.items():
|
||||
p = ext_dir / rel_path
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if content is None:
|
||||
# Create directory
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
p.write_text(content)
|
||||
|
||||
# Write .extensionignore
|
||||
if ignore_content is not None:
|
||||
(ext_dir / ".extensionignore").write_text(ignore_content)
|
||||
|
||||
return ext_dir
|
||||
|
||||
def test_no_extensionignore(self, temp_dir, valid_manifest_data):
|
||||
"""Without .extensionignore, all files are copied."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"},
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "tests" / "test_foo.py").exists()
|
||||
|
||||
def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data):
|
||||
"""Files matching .extensionignore patterns are excluded."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"README.md": "# Hello",
|
||||
"tests/test_foo.py": "pass",
|
||||
"tests/test_bar.py": "pass",
|
||||
".github/workflows/ci.yml": "on: push",
|
||||
},
|
||||
ignore_content="tests/\n.github/\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# Included
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "extension.yml").exists()
|
||||
assert (dest / "commands" / "hello.md").exists()
|
||||
# Excluded
|
||||
assert not (dest / "tests").exists()
|
||||
assert not (dest / ".github").exists()
|
||||
|
||||
def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data):
|
||||
"""Glob patterns like *.pyc are respected."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"README.md": "# Hello",
|
||||
"helpers.pyc": b"\x00".decode("latin-1"),
|
||||
"commands/cache.pyc": b"\x00".decode("latin-1"),
|
||||
},
|
||||
ignore_content="*.pyc\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "README.md").exists()
|
||||
assert not (dest / "helpers.pyc").exists()
|
||||
assert not (dest / "commands" / "cache.pyc").exists()
|
||||
|
||||
def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data):
|
||||
"""Comments and blank lines in .extensionignore are ignored."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello", "notes.txt": "some notes"},
|
||||
ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "README.md").exists()
|
||||
assert not (dest / "notes.txt").exists()
|
||||
|
||||
def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data):
|
||||
""".extensionignore is never copied to the destination."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
ignore_content="# nothing special here\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "extension.yml").exists()
|
||||
assert not (dest / ".extensionignore").exists()
|
||||
|
||||
def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data):
|
||||
"""Patterns matching relative paths work correctly."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/guide.md": "# Guide",
|
||||
"docs/internal/draft.md": "draft",
|
||||
"README.md": "# Hello",
|
||||
},
|
||||
ignore_content="docs/internal/draft.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "docs" / "guide.md").exists()
|
||||
assert not (dest / "docs" / "internal" / "draft.md").exists()
|
||||
|
||||
def test_extensionignore_dotdot_pattern_is_noop(self, temp_dir, valid_manifest_data):
|
||||
"""Patterns with '..' should not escape the extension root."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello"},
|
||||
ignore_content="../sibling/\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# Everything should still be copied — the '..' pattern matches nothing inside
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "extension.yml").exists()
|
||||
assert (dest / "commands" / "hello.md").exists()
|
||||
|
||||
def test_extensionignore_absolute_path_pattern_is_noop(self, temp_dir, valid_manifest_data):
|
||||
"""Absolute path patterns should not match anything."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello", "passwd": "sensitive"},
|
||||
ignore_content="/etc/passwd\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "passwd").exists()
|
||||
|
||||
def test_extensionignore_empty_file(self, temp_dir, valid_manifest_data):
|
||||
"""An empty .extensionignore should exclude only itself."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={"README.md": "# Hello", "notes.txt": "notes"},
|
||||
ignore_content="",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "README.md").exists()
|
||||
assert (dest / "notes.txt").exists()
|
||||
assert (dest / "extension.yml").exists()
|
||||
# .extensionignore itself is still excluded
|
||||
assert not (dest / ".extensionignore").exists()
|
||||
|
||||
def test_extensionignore_windows_backslash_patterns(self, temp_dir, valid_manifest_data):
|
||||
"""Backslash patterns (Windows-style) are normalised to forward slashes."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/internal/draft.md": "draft",
|
||||
"docs/guide.md": "# Guide",
|
||||
},
|
||||
ignore_content="docs\\internal\\draft.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert (dest / "docs" / "guide.md").exists()
|
||||
assert not (dest / "docs" / "internal" / "draft.md").exists()
|
||||
|
||||
def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data):
|
||||
"""'*' should NOT match across directory boundaries (gitignore semantics)."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/api.draft.md": "draft",
|
||||
"docs/sub/api.draft.md": "nested draft",
|
||||
},
|
||||
ignore_content="docs/*.draft.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# docs/*.draft.md should only match directly inside docs/, NOT subdirs
|
||||
assert not (dest / "docs" / "api.draft.md").exists()
|
||||
assert (dest / "docs" / "sub" / "api.draft.md").exists()
|
||||
|
||||
def test_extensionignore_doublestar_crosses_directories(self, temp_dir, valid_manifest_data):
|
||||
"""'**' should match across directory boundaries."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/api.draft.md": "draft",
|
||||
"docs/sub/api.draft.md": "nested draft",
|
||||
"docs/guide.md": "guide",
|
||||
},
|
||||
ignore_content="docs/**/*.draft.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert not (dest / "docs" / "api.draft.md").exists()
|
||||
assert not (dest / "docs" / "sub" / "api.draft.md").exists()
|
||||
assert (dest / "docs" / "guide.md").exists()
|
||||
|
||||
def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data):
|
||||
"""'!' negation re-includes a previously excluded file."""
|
||||
ext_dir = self._make_extension(
|
||||
temp_dir,
|
||||
valid_manifest_data,
|
||||
extra_files={
|
||||
"docs/guide.md": "# Guide",
|
||||
"docs/internal.md": "internal",
|
||||
"docs/api.md": "api",
|
||||
},
|
||||
ignore_content="docs/*.md\n!docs/api.md\n",
|
||||
)
|
||||
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
|
||||
manager = ExtensionManager(proj_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||
# docs/*.md excludes all .md in docs, but !docs/api.md re-includes it
|
||||
assert not (dest / "docs" / "guide.md").exists()
|
||||
assert not (dest / "docs" / "internal.md").exists()
|
||||
assert (dest / "docs" / "api.md").exists()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user