mirror of
https://github.com/github/spec-kit.git
synced 2026-03-21 04:43:08 +00:00
Compare commits
22 Commits
v0.1.13
...
6003a232d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6003a232d8 | ||
|
|
2e8a4d6432 | ||
|
|
65ecaa9fe4 | ||
|
|
5c0bedb410 | ||
|
|
d92798d5b0 | ||
|
|
ee922cbde9 | ||
|
|
1df24f1953 | ||
|
|
3033834d64 | ||
|
|
4b00078907 | ||
|
|
2d72f85790 | ||
|
|
855ac838b8 | ||
|
|
a8ec87e3c2 | ||
|
|
9d6c05ad5b | ||
|
|
3ef12cae3e | ||
|
|
8618d0a53e | ||
|
|
71e6b4da4a | ||
|
|
ad74334a85 | ||
|
|
8c3982d65b | ||
|
|
13dec1de05 | ||
|
|
d0a112c60f | ||
|
|
c84756b7f3 | ||
|
|
524affca79 |
@@ -46,12 +46,16 @@ gh release create "$VERSION" \
|
||||
.genreleases/spec-kit-template-amp-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-shai-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-shai-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-tabnine-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-tabnine-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-kiro-cli-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-kiro-cli-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-agy-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-agy-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-bob-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-bob-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-vibe-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
|
||||
--title "Spec Kit Templates - $VERSION_NO_V" \
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
.PARAMETER Agents
|
||||
Comma or space separated subset of agents to build (default: all)
|
||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, agy, generic
|
||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, generic
|
||||
|
||||
.PARAMETER Scripts
|
||||
Comma or space separated subset of script types to build (default: both)
|
||||
@@ -351,6 +351,12 @@ function Build-Variant {
|
||||
$cmdDir = Join-Path $baseDir ".shai/commands"
|
||||
Generate-Commands -Agent 'shai' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'tabnine' {
|
||||
$cmdDir = Join-Path $baseDir ".tabnine/agent/commands"
|
||||
Generate-Commands -Agent 'tabnine' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
$tabnineTemplate = Join-Path 'agent_templates' 'tabnine/TABNINE.md'
|
||||
if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') }
|
||||
}
|
||||
'agy' {
|
||||
$cmdDir = Join-Path $baseDir ".agent/workflows"
|
||||
Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
@@ -359,6 +365,10 @@ function Build-Variant {
|
||||
$cmdDir = Join-Path $baseDir ".speckit/commands"
|
||||
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'vibe' {
|
||||
$cmdDir = Join-Path $baseDir ".vibe/prompts"
|
||||
Generate-Commands -Agent 'vibe' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
default {
|
||||
throw "Unsupported agent '$Agent'."
|
||||
}
|
||||
@@ -371,7 +381,7 @@ function Build-Variant {
|
||||
}
|
||||
|
||||
# Define all agents and scripts
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'agy', 'generic')
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'generic')
|
||||
$AllScripts = @('sh', 'ps')
|
||||
|
||||
function Normalize-List {
|
||||
|
||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
||||
# Version argument should include leading 'v'.
|
||||
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai kiro-cli agy bob qodercli generic (default: all)
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli generic (default: all)
|
||||
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
||||
# Examples:
|
||||
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
||||
@@ -155,7 +155,7 @@ build_variant() {
|
||||
|
||||
# NOTE: We substitute {ARGS} internally. Outward tokens differ intentionally:
|
||||
# * Markdown/prompt (claude, copilot, cursor-agent, opencode): $ARGUMENTS
|
||||
# * TOML (gemini, qwen): {{args}}
|
||||
# * TOML (gemini, qwen, tabnine): {{args}}
|
||||
# This keeps formats readable without extra abstraction.
|
||||
|
||||
case $agent in
|
||||
@@ -212,6 +212,10 @@ build_variant() {
|
||||
shai)
|
||||
mkdir -p "$base_dir/.shai/commands"
|
||||
generate_commands shai md "\$ARGUMENTS" "$base_dir/.shai/commands" "$script" ;;
|
||||
tabnine)
|
||||
mkdir -p "$base_dir/.tabnine/agent/commands"
|
||||
generate_commands tabnine toml "{{args}}" "$base_dir/.tabnine/agent/commands" "$script"
|
||||
[[ -f agent_templates/tabnine/TABNINE.md ]] && cp agent_templates/tabnine/TABNINE.md "$base_dir/TABNINE.md" ;;
|
||||
kiro-cli)
|
||||
mkdir -p "$base_dir/.kiro/prompts"
|
||||
generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;;
|
||||
@@ -221,6 +225,9 @@ build_variant() {
|
||||
bob)
|
||||
mkdir -p "$base_dir/.bob/commands"
|
||||
generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;;
|
||||
vibe)
|
||||
mkdir -p "$base_dir/.vibe/prompts"
|
||||
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
||||
generic)
|
||||
mkdir -p "$base_dir/.speckit/commands"
|
||||
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
||||
@@ -230,7 +237,7 @@ build_variant() {
|
||||
}
|
||||
|
||||
# Determine agent list
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai kiro-cli agy bob qodercli generic)
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli generic)
|
||||
ALL_SCRIPTS=(sh ps)
|
||||
|
||||
norm_list() {
|
||||
|
||||
@@ -47,6 +47,7 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
||||
| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI |
|
||||
| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI |
|
||||
| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI |
|
||||
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
|
||||
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
||||
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
||||
|
||||
@@ -322,6 +323,7 @@ Require a command-line tool to be installed:
|
||||
- **Qoder CLI**: `qodercli` CLI
|
||||
- **Amp**: `amp` CLI
|
||||
- **SHAI**: `shai` CLI
|
||||
- **Tabnine CLI**: `tabnine` CLI
|
||||
|
||||
### IDE-Based Agents
|
||||
|
||||
@@ -360,7 +362,7 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders.
|
||||
|
||||
### TOML Format
|
||||
|
||||
Used by: Gemini, Qwen
|
||||
Used by: Gemini, Qwen, Tabnine
|
||||
|
||||
```toml
|
||||
description = "Command description"
|
||||
|
||||
91
CHANGELOG.md
91
CHANGELOG.md
@@ -7,6 +7,97 @@ Recent changes to the Specify CLI and templates are documented here.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.2.1] - 2026-03-09
|
||||
|
||||
### Added
|
||||
|
||||
- feat(templates): Pluggable template system with template packs, catalog, and resolver
|
||||
- Template pack manifest (`template-pack.yml`) with validation for artifact, command, and script types
|
||||
- `TemplatePackManifest`, `TemplatePackRegistry`, `TemplatePackManager`, `TemplateCatalog`, `TemplateResolver` classes in `src/specify_cli/templates.py`
|
||||
- CLI commands: `specify template search`, `specify template add`, `specify template list`, `specify template remove`, `specify template resolve`
|
||||
- `--template` option for `specify init` to install template packs during initialization
|
||||
- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts
|
||||
- Template resolution priority stack: overrides → packs → extensions → core
|
||||
- Template catalog files (`templates/catalog.json`, `templates/catalog.community.json`)
|
||||
- Template pack scaffold directory (`templates/template/`)
|
||||
- Scripts updated to use template resolution instead of hardcoded paths
|
||||
|
||||
## [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
|
||||
|
||||
- feat: add Tabnine CLI agent support
|
||||
- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack
|
||||
- New `specify extension catalog list` command lists all active catalogs with name, URL, priority, and `install_allowed` status
|
||||
- New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management
|
||||
- Default built-in stack includes `catalog.json` (default, installable) and `catalog.community.json` (community, discovery only) — community extensions are now surfaced in search results out of the box
|
||||
- `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog
|
||||
- `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly
|
||||
- Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence
|
||||
- `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog)
|
||||
- All catalog URLs require HTTPS (HTTP allowed for localhost development)
|
||||
- New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation
|
||||
- Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog
|
||||
- Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)
|
||||
- 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement
|
||||
- Updated RFC, Extension User Guide, and Extension API Reference documentation
|
||||
|
||||
## [0.1.13] - 2026-03-03
|
||||
|
||||
### Changed
|
||||
|
||||
- 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.13] - 2026-03-03
|
||||
|
||||
### Fixed
|
||||
|
||||
33
README.md
33
README.md
@@ -22,6 +22,7 @@
|
||||
- [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development)
|
||||
- [⚡ Get Started](#-get-started)
|
||||
- [📽️ Video Overview](#️-video-overview)
|
||||
- [🚶 Community Walkthroughs](#-community-walkthroughs)
|
||||
- [🤖 Supported AI Agents](#-supported-ai-agents)
|
||||
- [🔧 Specify CLI Reference](#-specify-cli-reference)
|
||||
- [📚 Core Philosophy](#-core-philosophy)
|
||||
@@ -79,7 +80,13 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki
|
||||
Run directly without installing:
|
||||
|
||||
```bash
|
||||
# Create new project
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
||||
|
||||
# Or initialize in existing project
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init . --ai claude
|
||||
# or
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai claude
|
||||
```
|
||||
|
||||
**Benefits of persistent installation:**
|
||||
@@ -139,6 +146,16 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
|
||||
|
||||
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
|
||||
|
||||
## 🚶 Community Walkthroughs
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||
|
||||
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
|
||||
|
||||
- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.
|
||||
|
||||
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
|
||||
|
||||
## 🤖 Supported AI Agents
|
||||
|
||||
| Agent | Support | Notes |
|
||||
@@ -160,8 +177,10 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
|
||||
| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | |
|
||||
| [Roo Code](https://roocode.com/) | ✅ | |
|
||||
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | |
|
||||
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | |
|
||||
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | |
|
||||
| [Windsurf](https://windsurf.com/) | ✅ | |
|
||||
| [Antigravity (agy)](https://agy.ai/) | ✅ | |
|
||||
| [Antigravity (agy)](https://antigravity.google/) | ✅ | |
|
||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||
|
||||
## 🔧 Specify CLI Reference
|
||||
@@ -173,14 +192,14 @@ The `specify` command supports the following options:
|
||||
| Command | Description |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `init` | Initialize a new Specify project from the latest template |
|
||||
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`) |
|
||||
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`) |
|
||||
|
||||
### `specify init` Arguments & Options
|
||||
|
||||
| Argument/Option | Type | Description |
|
||||
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
|
||||
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, or `generic` (requires `--ai-commands-dir`) |
|
||||
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, or `generic` (requires `--ai-commands-dir`) |
|
||||
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
|
||||
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
|
||||
@@ -219,9 +238,15 @@ specify init my-project --ai amp
|
||||
# Initialize with SHAI support
|
||||
specify init my-project --ai shai
|
||||
|
||||
# Initialize with Mistral Vibe support
|
||||
specify init my-project --ai vibe
|
||||
|
||||
# Initialize with IBM Bob support
|
||||
specify init my-project --ai bob
|
||||
|
||||
# Initialize with Antigravity support
|
||||
specify init my-project --ai agy
|
||||
|
||||
# Initialize with an unsupported agent (generic / bring your own agent)
|
||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/
|
||||
|
||||
@@ -396,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, 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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --ai claude --ignore-agent-tools
|
||||
|
||||
@@ -243,6 +243,34 @@ manager.check_compatibility(
|
||||
) # Raises: CompatibilityError if incompatible
|
||||
```
|
||||
|
||||
### CatalogEntry
|
||||
|
||||
**Module**: `specify_cli.extensions`
|
||||
|
||||
Represents a single catalog in the active catalog stack.
|
||||
|
||||
```python
|
||||
from specify_cli.extensions import CatalogEntry
|
||||
|
||||
entry = CatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Built-in catalog of installable extensions",
|
||||
)
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `url` | `str` | Catalog URL (must use HTTPS, or HTTP for localhost) |
|
||||
| `name` | `str` | Human-readable catalog name |
|
||||
| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) |
|
||||
| `install_allowed` | `bool` | Whether extensions from this catalog can be installed |
|
||||
| `description` | `str` | Optional human-readable description of the catalog (default: empty) |
|
||||
|
||||
### ExtensionCatalog
|
||||
|
||||
**Module**: `specify_cli.extensions`
|
||||
@@ -253,30 +281,67 @@ from specify_cli.extensions import ExtensionCatalog
|
||||
catalog = ExtensionCatalog(project_root)
|
||||
```
|
||||
|
||||
**Class attributes**:
|
||||
|
||||
```python
|
||||
ExtensionCatalog.DEFAULT_CATALOG_URL # default catalog URL
|
||||
ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL
|
||||
```
|
||||
|
||||
**Methods**:
|
||||
|
||||
```python
|
||||
# Fetch catalog
|
||||
# Get the ordered list of active catalogs
|
||||
entries = catalog.get_active_catalogs() # List[CatalogEntry]
|
||||
|
||||
# Fetch catalog (primary catalog, backward compat)
|
||||
catalog_data = catalog.fetch_catalog(force_refresh: bool = False) # Dict
|
||||
|
||||
# Search extensions
|
||||
# Search extensions across all active catalogs
|
||||
# Each result includes _catalog_name and _install_allowed
|
||||
results = catalog.search(
|
||||
query: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
verified_only: bool = False
|
||||
) # Returns: List[Dict]
|
||||
) # Returns: List[Dict] — each dict includes _catalog_name, _install_allowed
|
||||
|
||||
# Get extension info
|
||||
# Get extension info (searches all active catalogs)
|
||||
# Returns None if not found; includes _catalog_name and _install_allowed
|
||||
ext_info = catalog.get_extension_info(extension_id: str) # Optional[Dict]
|
||||
|
||||
# Check cache validity
|
||||
# Check cache validity (primary catalog)
|
||||
is_valid = catalog.is_cache_valid() # bool
|
||||
|
||||
# Clear cache
|
||||
# Clear all catalog caches
|
||||
catalog.clear_cache()
|
||||
```
|
||||
|
||||
**Result annotation fields**:
|
||||
|
||||
Each extension dict returned by `search()` and `get_extension_info()` includes:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `_catalog_name` | `str` | Name of the source catalog |
|
||||
| `_install_allowed` | `bool` | Whether installation is allowed from this catalog |
|
||||
|
||||
**Catalog config file** (`.specify/extension-catalogs.yml`):
|
||||
|
||||
```yaml
|
||||
catalogs:
|
||||
- name: "default"
|
||||
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
priority: 1
|
||||
install_allowed: true
|
||||
description: "Built-in catalog of installable extensions"
|
||||
- name: "community"
|
||||
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
|
||||
priority: 2
|
||||
install_allowed: false
|
||||
description: "Community-contributed extensions (discovery only)"
|
||||
```
|
||||
|
||||
### HookExecutor
|
||||
|
||||
**Module**: `specify_cli.extensions`
|
||||
@@ -543,6 +608,39 @@ EXECUTE_COMMAND: {command}
|
||||
|
||||
**Output**: List of installed extensions with metadata
|
||||
|
||||
### extension catalog list
|
||||
|
||||
**Usage**: `specify extension catalog list`
|
||||
|
||||
Lists all active catalogs in the current catalog stack, showing name, description, URL, priority, and `install_allowed` status.
|
||||
|
||||
### extension catalog add
|
||||
|
||||
**Usage**: `specify extension catalog add URL [OPTIONS]`
|
||||
|
||||
**Options**:
|
||||
|
||||
- `--name NAME` - Catalog name (required)
|
||||
- `--priority INT` - Priority (lower = higher priority, default: 10)
|
||||
- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false)
|
||||
- `--description TEXT` - Optional description of the catalog
|
||||
|
||||
**Arguments**:
|
||||
|
||||
- `URL` - Catalog URL (must use HTTPS)
|
||||
|
||||
Adds a catalog entry to `.specify/extension-catalogs.yml`.
|
||||
|
||||
### extension catalog remove
|
||||
|
||||
**Usage**: `specify extension catalog remove NAME`
|
||||
|
||||
**Arguments**:
|
||||
|
||||
- `NAME` - Catalog name to remove
|
||||
|
||||
Removes a catalog entry from `.specify/extension-catalogs.yml`.
|
||||
|
||||
### extension add
|
||||
|
||||
**Usage**: `specify extension add EXTENSION [OPTIONS]`
|
||||
@@ -551,13 +649,13 @@ EXECUTE_COMMAND: {command}
|
||||
|
||||
- `--from URL` - Install from custom URL
|
||||
- `--dev PATH` - Install from local directory
|
||||
- `--version VERSION` - Install specific version
|
||||
- `--no-register` - Skip command registration
|
||||
|
||||
**Arguments**:
|
||||
|
||||
- `EXTENSION` - Extension name or URL
|
||||
|
||||
**Note**: Extensions from catalogs with `install_allowed: false` cannot be installed via this command.
|
||||
|
||||
### extension remove
|
||||
|
||||
**Usage**: `specify extension remove EXTENSION [OPTIONS]`
|
||||
@@ -575,6 +673,8 @@ EXECUTE_COMMAND: {command}
|
||||
|
||||
**Usage**: `specify extension search [QUERY] [OPTIONS]`
|
||||
|
||||
Searches all active catalogs simultaneously. Results include source catalog name and install_allowed status.
|
||||
|
||||
**Options**:
|
||||
|
||||
- `--tag TAG` - Filter by tag
|
||||
@@ -589,6 +689,8 @@ EXECUTE_COMMAND: {command}
|
||||
|
||||
**Usage**: `specify extension info EXTENSION`
|
||||
|
||||
Shows source catalog and install_allowed status.
|
||||
|
||||
**Arguments**:
|
||||
|
||||
- `EXTENSION` - Extension ID
|
||||
|
||||
@@ -76,7 +76,7 @@ vim .specify/extensions/jira/jira-config.yml
|
||||
|
||||
## Finding Extensions
|
||||
|
||||
**Note**: By default, `specify extension search` uses your organization's catalog (`catalog.json`). If the catalog is empty, you won't see any results. See [Extension Catalogs](#extension-catalogs) to learn how to populate your catalog from the community reference catalog.
|
||||
`specify extension search` searches **all active catalogs** simultaneously, including the community catalog by default. Results are annotated with their source catalog and install status.
|
||||
|
||||
### Browse All Extensions
|
||||
|
||||
@@ -84,7 +84,7 @@ vim .specify/extensions/jira/jira-config.yml
|
||||
specify extension search
|
||||
```
|
||||
|
||||
Shows all extensions in your organization's catalog.
|
||||
Shows all extensions across all active catalogs (default and community by default).
|
||||
|
||||
### Search by Keyword
|
||||
|
||||
@@ -402,13 +402,13 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`),
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SPECKIT_CATALOG_URL` | Override the extension catalog URL | GitHub-hosted catalog |
|
||||
| `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack |
|
||||
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None |
|
||||
|
||||
#### Example: Using a custom catalog for testing
|
||||
|
||||
```bash
|
||||
# Point to a local or alternative catalog
|
||||
# Point to a local or alternative catalog (replaces the full stack)
|
||||
export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json"
|
||||
|
||||
# Or use a staging catalog
|
||||
@@ -419,13 +419,76 @@ export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json"
|
||||
|
||||
## Extension Catalogs
|
||||
|
||||
For information about how Spec Kit's dual-catalog system works (`catalog.json` vs `catalog.community.json`), see the main [Extensions README](README.md#extension-catalogs).
|
||||
Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simultaneously. By default, two catalogs are active:
|
||||
|
||||
| Priority | Catalog | Install Allowed | Purpose |
|
||||
|----------|---------|-----------------|---------|
|
||||
| 1 | `catalog.json` (default) | ✅ Yes | Curated extensions available for installation |
|
||||
| 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions |
|
||||
|
||||
### Listing Active Catalogs
|
||||
|
||||
```bash
|
||||
specify extension catalog list
|
||||
```
|
||||
|
||||
### Adding a Catalog (Project-scoped)
|
||||
|
||||
```bash
|
||||
# Add an internal catalog that allows installs
|
||||
specify extension catalog add \
|
||||
--name "internal" \
|
||||
--priority 2 \
|
||||
--install-allowed \
|
||||
https://internal.company.com/spec-kit/catalog.json
|
||||
|
||||
# Add a discovery-only catalog
|
||||
specify extension catalog add \
|
||||
--name "partner" \
|
||||
--priority 5 \
|
||||
https://partner.example.com/spec-kit/catalog.json
|
||||
```
|
||||
|
||||
This creates or updates `.specify/extension-catalogs.yml`.
|
||||
|
||||
### Removing a Catalog
|
||||
|
||||
```bash
|
||||
specify extension catalog remove internal
|
||||
```
|
||||
|
||||
### Manual Config File
|
||||
|
||||
You can also edit `.specify/extension-catalogs.yml` directly:
|
||||
|
||||
```yaml
|
||||
catalogs:
|
||||
- name: "default"
|
||||
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
priority: 1
|
||||
install_allowed: true
|
||||
description: "Built-in catalog of installable extensions"
|
||||
|
||||
- name: "internal"
|
||||
url: "https://internal.company.com/spec-kit/catalog.json"
|
||||
priority: 2
|
||||
install_allowed: true
|
||||
description: "Internal company extensions"
|
||||
|
||||
- name: "community"
|
||||
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
|
||||
priority: 3
|
||||
install_allowed: false
|
||||
description: "Community-contributed extensions (discovery only)"
|
||||
```
|
||||
|
||||
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when it contains one or more catalog entries. An empty `catalogs: []` list falls back to built-in defaults.
|
||||
|
||||
## Organization Catalog Customization
|
||||
|
||||
### Why Customize Your Catalog
|
||||
|
||||
Organizations customize their `catalog.json` to:
|
||||
Organizations customize their catalogs to:
|
||||
|
||||
- **Control available extensions** - Curate which extensions your team can install
|
||||
- **Host private extensions** - Internal tools that shouldn't be public
|
||||
@@ -503,24 +566,40 @@ Options for hosting your catalog:
|
||||
|
||||
#### 3. Configure Your Environment
|
||||
|
||||
##### Option A: Environment variable (recommended for CI/CD)
|
||||
##### Option A: Catalog stack config file (recommended)
|
||||
|
||||
Add to `.specify/extension-catalogs.yml` in your project:
|
||||
|
||||
```yaml
|
||||
catalogs:
|
||||
- name: "my-org"
|
||||
url: "https://your-org.com/spec-kit/catalog.json"
|
||||
priority: 1
|
||||
install_allowed: true
|
||||
```
|
||||
|
||||
Or use the CLI:
|
||||
|
||||
```bash
|
||||
specify extension catalog add \
|
||||
--name "my-org" \
|
||||
--install-allowed \
|
||||
https://your-org.com/spec-kit/catalog.json
|
||||
```
|
||||
|
||||
##### Option B: Environment variable (recommended for CI/CD, single-catalog)
|
||||
|
||||
```bash
|
||||
# In ~/.bashrc, ~/.zshrc, or CI pipeline
|
||||
export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json"
|
||||
```
|
||||
|
||||
##### Option B: Per-project configuration
|
||||
|
||||
Create `.env` or set in your shell before running spec-kit commands:
|
||||
|
||||
```bash
|
||||
SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" specify extension search
|
||||
```
|
||||
|
||||
#### 4. Verify Configuration
|
||||
|
||||
```bash
|
||||
# List active catalogs
|
||||
specify extension catalog list
|
||||
|
||||
# Search should now show your catalog's extensions
|
||||
specify extension search
|
||||
|
||||
|
||||
@@ -72,9 +72,15 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
|
||||
| Extension | Purpose | URL |
|
||||
|-----------|---------|-----|
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | [understanding](https://github.com/Testimonial/understanding) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
|
||||
|
||||
@@ -868,7 +868,7 @@ Spec Kit uses two catalog files with different purposes:
|
||||
|
||||
- **Purpose**: Organization's curated catalog of approved extensions
|
||||
- **Default State**: Empty by design - users populate with extensions they trust
|
||||
- **Usage**: Default catalog used by `specify extension` CLI commands
|
||||
- **Usage**: Primary catalog (priority 1, `install_allowed: true`) in the default stack
|
||||
- **Control**: Organizations maintain their own fork/version for their teams
|
||||
|
||||
#### Community Reference Catalog (`catalog.community.json`)
|
||||
@@ -879,16 +879,16 @@ Spec Kit uses two catalog files with different purposes:
|
||||
- **Verification**: Community extensions may have `verified: false` initially
|
||||
- **Status**: Active - open for community contributions
|
||||
- **Submission**: Via Pull Request following the Extension Publishing Guide
|
||||
- **Usage**: Browse to discover extensions, then copy to your `catalog.json`
|
||||
- **Usage**: Secondary catalog (priority 2, `install_allowed: false`) in the default stack — discovery only
|
||||
|
||||
**How It Works:**
|
||||
**How It Works (default stack):**
|
||||
|
||||
1. **Discover**: Browse `catalog.community.json` to find available extensions
|
||||
2. **Review**: Evaluate extensions for security, quality, and organizational fit
|
||||
3. **Curate**: Copy approved extension entries from community catalog to your `catalog.json`
|
||||
4. **Install**: Use `specify extension add <name>` (pulls from your curated catalog)
|
||||
1. **Discover**: `specify extension search` searches both catalogs — community extensions appear automatically
|
||||
2. **Review**: Evaluate community extensions for security, quality, and organizational fit
|
||||
3. **Curate**: Copy approved entries from community catalog to your `catalog.json`, or add to `.specify/extension-catalogs.yml` with `install_allowed: true`
|
||||
4. **Install**: Use `specify extension add <name>` — only allowed from `install_allowed: true` catalogs
|
||||
|
||||
This approach gives organizations full control over which extensions are available to their teams while maintaining a shared community resource for discovery.
|
||||
This approach gives organizations full control over which extensions can be installed while still providing community discoverability out of the box.
|
||||
|
||||
### Catalog Format
|
||||
|
||||
@@ -961,30 +961,92 @@ specify extension info jira
|
||||
|
||||
### Custom Catalogs
|
||||
|
||||
**⚠️ FUTURE FEATURE - NOT YET IMPLEMENTED**
|
||||
Spec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to maintain their own org-approved extensions alongside an internal catalog and community discovery, all at once.
|
||||
|
||||
The following catalog management commands are proposed design concepts but are not yet available in the current implementation:
|
||||
#### Catalog Stack Resolution
|
||||
|
||||
```bash
|
||||
# Add custom catalog (FUTURE - NOT AVAILABLE)
|
||||
specify extension add-catalog https://internal.company.com/spec-kit/catalog.json
|
||||
The active catalog stack is resolved in this order (first match wins):
|
||||
|
||||
# Set as default (FUTURE - NOT AVAILABLE)
|
||||
specify extension set-catalog --default https://internal.company.com/spec-kit/catalog.json
|
||||
1. **`SPECKIT_CATALOG_URL` environment variable** — single catalog replacing all defaults (backward compat)
|
||||
2. **Project-level `.specify/extension-catalogs.yml`** — full control for the project
|
||||
3. **User-level `~/.specify/extension-catalogs.yml`** — personal defaults
|
||||
4. **Built-in default stack** — `catalog.json` (install_allowed: true) + `catalog.community.json` (install_allowed: false)
|
||||
|
||||
# List catalogs (FUTURE - NOT AVAILABLE)
|
||||
specify extension catalogs
|
||||
#### Default Built-in Stack
|
||||
|
||||
When no config file exists, the CLI uses:
|
||||
|
||||
| Priority | Catalog | install_allowed | Purpose |
|
||||
|----------|---------|-----------------|---------|
|
||||
| 1 | `catalog.json` (default) | `true` | Curated extensions available for installation |
|
||||
| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install |
|
||||
|
||||
This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to entries from catalogs with `install_allowed: true`.
|
||||
|
||||
#### `.specify/extension-catalogs.yml` Config File
|
||||
|
||||
```yaml
|
||||
catalogs:
|
||||
- name: "default"
|
||||
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
priority: 1 # Highest — only approved entries can be installed
|
||||
install_allowed: true
|
||||
description: "Built-in catalog of installable extensions"
|
||||
|
||||
- name: "internal"
|
||||
url: "https://internal.company.com/spec-kit/catalog.json"
|
||||
priority: 2
|
||||
install_allowed: true
|
||||
description: "Internal company extensions"
|
||||
|
||||
- name: "community"
|
||||
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
|
||||
priority: 3 # Lowest — discovery only, not installable
|
||||
install_allowed: false
|
||||
description: "Community-contributed extensions (discovery only)"
|
||||
```
|
||||
|
||||
**Proposed catalog priority** (future design):
|
||||
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present with one or more catalog entries, it takes full control and the built-in defaults are not applied. An empty `catalogs: []` list is treated the same as no config file, falling back to defaults.
|
||||
|
||||
1. Project-specific catalog (`.specify/extension-catalogs.yml`) - *not implemented*
|
||||
2. User-level catalog (`~/.specify/extension-catalogs.yml`) - *not implemented*
|
||||
3. Default GitHub catalog
|
||||
#### Catalog CLI Commands
|
||||
|
||||
#### Current Implementation: SPECKIT_CATALOG_URL
|
||||
```bash
|
||||
# List active catalogs with name, URL, priority, and install_allowed
|
||||
specify extension catalog list
|
||||
|
||||
**The currently available method** for using custom catalogs is the `SPECKIT_CATALOG_URL` environment variable:
|
||||
# Add a catalog (project-scoped)
|
||||
specify extension catalog add --name "internal" --install-allowed \
|
||||
https://internal.company.com/spec-kit/catalog.json
|
||||
|
||||
# Add a discovery-only catalog
|
||||
specify extension catalog add --name "community" \
|
||||
https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json
|
||||
|
||||
# Remove a catalog
|
||||
specify extension catalog remove internal
|
||||
|
||||
# Show which catalog an extension came from
|
||||
specify extension info jira
|
||||
# → Source catalog: default
|
||||
```
|
||||
|
||||
#### Merge Conflict Resolution
|
||||
|
||||
When the same extension `id` appears in multiple catalogs, the higher-priority (lower priority number) catalog wins. Extensions from lower-priority catalogs with the same `id` are ignored.
|
||||
|
||||
#### `install_allowed: false` Behavior
|
||||
|
||||
Extensions from discovery-only catalogs are shown in `specify extension search` results but cannot be installed directly:
|
||||
|
||||
```
|
||||
⚠ 'linear' is available in the 'community' catalog but installation is not allowed from that catalog.
|
||||
|
||||
To enable installation, add 'linear' to an approved catalog (install_allowed: true) in .specify/extension-catalogs.yml.
|
||||
```
|
||||
|
||||
#### `SPECKIT_CATALOG_URL` (Backward Compatibility)
|
||||
|
||||
The `SPECKIT_CATALOG_URL` environment variable still works — it is treated as a single `install_allowed: true` catalog, **replacing both defaults** for full backward compatibility:
|
||||
|
||||
```bash
|
||||
# Point to your organization's catalog
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-03-09T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"azure-devops": {
|
||||
"name": "Azure DevOps Integration",
|
||||
"id": "azure-devops",
|
||||
"description": "Sync user stories and tasks to Azure DevOps work items using OAuth authentication.",
|
||||
"author": "pragya247",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/pragya247/spec-kit-azure-devops/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/pragya247/spec-kit-azure-devops",
|
||||
"homepage": "https://github.com/pragya247/spec-kit-azure-devops",
|
||||
"documentation": "https://github.com/pragya247/spec-kit-azure-devops/blob/main/README.md",
|
||||
"changelog": "https://github.com/pragya247/spec-kit-azure-devops/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "az",
|
||||
"version": ">=2.0.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"azure",
|
||||
"devops",
|
||||
"project-management",
|
||||
"work-items",
|
||||
"issue-tracking"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-03-03T00:00:00Z"
|
||||
},
|
||||
"cleanup": {
|
||||
"name": "Cleanup Extension",
|
||||
"id": "cleanup",
|
||||
@@ -22,13 +61,112 @@
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": ["quality", "tech-debt", "review", "cleanup", "scout-rule"],
|
||||
"tags": [
|
||||
"quality",
|
||||
"tech-debt",
|
||||
"review",
|
||||
"cleanup",
|
||||
"scout-rule"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-02-22T00:00:00Z",
|
||||
"updated_at": "2026-02-22T00:00:00Z"
|
||||
},
|
||||
"fleet": {
|
||||
"name": "Fleet Orchestrator",
|
||||
"id": "fleet",
|
||||
"description": "Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases.",
|
||||
"author": "sharathsatish",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/sharathsatish/spec-kit-fleet",
|
||||
"homepage": "https://github.com/sharathsatish/spec-kit-fleet",
|
||||
"documentation": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/README.md",
|
||||
"changelog": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": ["orchestration", "workflow", "human-in-the-loop", "parallel"],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-03-06T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Integration",
|
||||
"id": "jira",
|
||||
"description": "Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support.",
|
||||
"author": "mbachorik",
|
||||
"version": "2.1.0",
|
||||
"download_url": "https://github.com/mbachorik/spec-kit-jira/archive/refs/tags/v2.1.0.zip",
|
||||
"repository": "https://github.com/mbachorik/spec-kit-jira",
|
||||
"homepage": "https://github.com/mbachorik/spec-kit-jira",
|
||||
"documentation": "https://github.com/mbachorik/spec-kit-jira/blob/main/README.md",
|
||||
"changelog": "https://github.com/mbachorik/spec-kit-jira/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"issue-tracking",
|
||||
"jira",
|
||||
"atlassian",
|
||||
"project-management"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-05T00:00:00Z",
|
||||
"updated_at": "2026-03-05T00:00:00Z"
|
||||
},
|
||||
"ralph": {
|
||||
"name": "Ralph Loop",
|
||||
"id": "ralph",
|
||||
"description": "Autonomous implementation loop using AI agent CLI.",
|
||||
"author": "Rubiss",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Rubiss/spec-kit-ralph",
|
||||
"homepage": "https://github.com/Rubiss/spec-kit-ralph",
|
||||
"documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md",
|
||||
"changelog": "https://github.com/Rubiss/spec-kit-ralph/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "copilot",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": ["implementation", "automation", "loop", "copilot"],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-09T00:00:00Z",
|
||||
"updated_at": "2026-03-09T00:00:00Z"
|
||||
},
|
||||
"retrospective": {
|
||||
"name": "Retrospective Extension",
|
||||
"id": "retrospective",
|
||||
@@ -48,13 +186,45 @@
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": ["retrospective", "spec-drift", "quality", "analysis", "governance"],
|
||||
"tags": [
|
||||
"retrospective",
|
||||
"spec-drift",
|
||||
"quality",
|
||||
"analysis",
|
||||
"governance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-02-24T00:00:00Z",
|
||||
"updated_at": "2026-02-24T00:00:00Z"
|
||||
},
|
||||
"review": {
|
||||
"name": "Review Extension",
|
||||
"id": "review",
|
||||
"description": "Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.",
|
||||
"author": "ismaelJimenez",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/ismaelJimenez/spec-kit-review",
|
||||
"homepage": "https://github.com/ismaelJimenez/spec-kit-review",
|
||||
"documentation": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md",
|
||||
"changelog": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 7,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": ["code-review", "quality", "review", "testing", "error-handling", "type-design", "simplification"],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-03-06T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
"id": "sync",
|
||||
@@ -74,13 +244,60 @@
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": ["sync", "drift", "validation", "bidirectional", "backfill"],
|
||||
"tags": [
|
||||
"sync",
|
||||
"drift",
|
||||
"validation",
|
||||
"bidirectional",
|
||||
"backfill"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-02T00:00:00Z",
|
||||
"updated_at": "2026-03-02T00:00:00Z"
|
||||
},
|
||||
"understanding": {
|
||||
"name": "Understanding",
|
||||
"id": "understanding",
|
||||
"description": "Automated requirements quality analysis — validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.",
|
||||
"author": "Ladislav Bihari",
|
||||
"version": "3.4.0",
|
||||
"download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
|
||||
"repository": "https://github.com/Testimonial/understanding",
|
||||
"homepage": "https://github.com/Testimonial/understanding",
|
||||
"documentation": "https://github.com/Testimonial/understanding/blob/main/extension/README.md",
|
||||
"changelog": "https://github.com/Testimonial/understanding/blob/main/extension/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "understanding",
|
||||
"version": ">=3.4.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"metrics",
|
||||
"requirements",
|
||||
"validation",
|
||||
"readability",
|
||||
"IEEE-830",
|
||||
"ISO-29148"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-07T00:00:00Z",
|
||||
"updated_at": "2026-03-07T00:00:00Z"
|
||||
},
|
||||
"v-model": {
|
||||
"name": "V-Model Extension Pack",
|
||||
"id": "v-model",
|
||||
@@ -100,7 +317,13 @@
|
||||
"commands": 9,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": ["v-model", "traceability", "testing", "compliance", "safety-critical"],
|
||||
"tags": [
|
||||
"v-model",
|
||||
"traceability",
|
||||
"testing",
|
||||
"compliance",
|
||||
"safety-critical"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
@@ -126,7 +349,13 @@
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": ["verification", "quality-gate", "implementation", "spec-adherence", "compliance"],
|
||||
"tags": [
|
||||
"verification",
|
||||
"quality-gate",
|
||||
"implementation",
|
||||
"spec-adherence",
|
||||
"compliance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.1.13"
|
||||
version = "0.2.1"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -154,3 +154,44 @@ 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/templates/packs/<pack-id>/templates/
|
||||
# 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 packs (by directory order)
|
||||
local packs_dir="$base/packs"
|
||||
if [ -d "$packs_dir" ]; then
|
||||
for pack in "$packs_dir"/*/; do
|
||||
[ -d "$pack" ] || continue
|
||||
local candidate="$pack/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
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
|
||||
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,6 +166,7 @@ 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)
|
||||
@@ -296,9 +297,9 @@ fi
|
||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
|
||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
|
||||
# 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="$REPO_ROOT/.specify/templates/plan-template.md"
|
||||
if [[ -f "$TEMPLATE" ]]; then
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
|
||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
else
|
||||
echo "Warning: Plan template not found at $TEMPLATE"
|
||||
echo "Warning: Plan template not found"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Kiro CLI, or Antigravity
|
||||
# - 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
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
@@ -73,9 +73,11 @@ CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
||||
QODER_FILE="$REPO_ROOT/QODER.md"
|
||||
AMP_FILE="$REPO_ROOT/AGENTS.md"
|
||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||
TABNINE_FILE="$REPO_ROOT/TABNINE.md"
|
||||
KIRO_FILE="$REPO_ROOT/AGENTS.md"
|
||||
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$REPO_ROOT/AGENTS.md"
|
||||
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
|
||||
|
||||
# Template file
|
||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||
@@ -648,6 +650,9 @@ update_specific_agent() {
|
||||
shai)
|
||||
update_agent_file "$SHAI_FILE" "SHAI"
|
||||
;;
|
||||
tabnine)
|
||||
update_agent_file "$TABNINE_FILE" "Tabnine CLI"
|
||||
;;
|
||||
kiro-cli)
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI"
|
||||
;;
|
||||
@@ -657,12 +662,15 @@ update_specific_agent() {
|
||||
bob)
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
;;
|
||||
vibe)
|
||||
update_agent_file "$VIBE_FILE" "Mistral Vibe"
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -732,6 +740,11 @@ update_all_existing_agents() {
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$TABNINE_FILE" ]]; then
|
||||
update_agent_file "$TABNINE_FILE" "Tabnine CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$QODER_FILE" ]]; then
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
found_agent=true
|
||||
@@ -750,6 +763,11 @@ update_all_existing_agents() {
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$VIBE_FILE" ]]; then
|
||||
update_agent_file "$VIBE_FILE" "Mistral Vibe"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
# If no agent files exist, create a default Claude file
|
||||
if [[ "$found_agent" == false ]]; then
|
||||
@@ -774,8 +792,7 @@ print_summary() {
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli]"
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -135,3 +135,45 @@ function Test-DirHasFiles {
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/templates/packs/<pack-id>/templates/
|
||||
# 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 packs (by directory order)
|
||||
$packsDir = Join-Path $base 'packs'
|
||||
if (Test-Path $packsDir) {
|
||||
foreach ($pack in Get-ChildItem -Path $packsDir -Directory -ErrorAction SilentlyContinue) {
|
||||
$candidate = Join-Path $pack.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) { return $core }
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,9 @@ if (-not $fallbackRoot) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Load common functions (includes Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
try {
|
||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
@@ -276,9 +279,9 @@ if ($hasGit) {
|
||||
$featureDir = Join-Path $specsDir $branchName
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
|
||||
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
if (Test-Path $template) {
|
||||
if ($template -and (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 = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
|
||||
if (Test-Path $template) {
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (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 at $template"
|
||||
Write-Warning "Plan template not found"
|
||||
# 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, kiro-cli, agy, bob, qodercli)
|
||||
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)
|
||||
|
||||
.PARAMETER AgentType
|
||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
|
||||
#>
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','kiro-cli','agy','bob','qodercli','generic')]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','generic')]
|
||||
[string]$AgentType
|
||||
)
|
||||
|
||||
@@ -58,9 +58,11 @@ $CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md'
|
||||
$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md'
|
||||
$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md'
|
||||
$TABNINE_FILE = Join-Path $REPO_ROOT 'TABNINE.md'
|
||||
$KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
|
||||
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
|
||||
|
||||
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
|
||||
|
||||
@@ -399,11 +401,13 @@ function Update-SpecificAgent {
|
||||
'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' }
|
||||
'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' }
|
||||
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
|
||||
'tabnine' { Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI' }
|
||||
'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' }
|
||||
'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' }
|
||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
|
||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic'; return $false }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic'; return $false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,9 +427,11 @@ function Update-AllExistingAgents {
|
||||
if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $TABNINE_FILE) { if (-not (Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }
|
||||
if (-not $found) {
|
||||
Write-Info 'No existing agent files found, creating default Claude file...'
|
||||
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
|
||||
@@ -440,7 +446,7 @@ function Print-Summary {
|
||||
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
|
||||
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
|
||||
Write-Host ''
|
||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic]'
|
||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]'
|
||||
}
|
||||
|
||||
function Main {
|
||||
|
||||
@@ -237,6 +237,13 @@ AGENT_CONFIG = {
|
||||
"install_url": "https://github.com/ovh/shai",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"tabnine": {
|
||||
"name": "Tabnine CLI",
|
||||
"folder": ".tabnine/agent/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"agy": {
|
||||
"name": "Antigravity",
|
||||
"folder": ".agent/",
|
||||
@@ -251,6 +258,13 @@ AGENT_CONFIG = {
|
||||
"install_url": None, # IDE-based
|
||||
"requires_cli": False,
|
||||
},
|
||||
"vibe": {
|
||||
"name": "Mistral Vibe",
|
||||
"folder": ".vibe/",
|
||||
"commands_subdir": "prompts",
|
||||
"install_url": "https://github.com/mistralai/mistral-vibe",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"generic": {
|
||||
"name": "Generic (bring your own agent)",
|
||||
"folder": None, # Set dynamically via --ai-commands-dir
|
||||
@@ -1117,7 +1131,7 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
|
||||
# Fallback: try the repo-relative path (for running from source checkout)
|
||||
# This also covers agents whose extracted commands are in a different
|
||||
# format (e.g. gemini uses .toml, not .md).
|
||||
# format (e.g. gemini/tabnine use .toml, not .md).
|
||||
script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/
|
||||
fallback_dir = script_dir / "templates" / "commands"
|
||||
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
|
||||
@@ -1258,6 +1272,7 @@ def init(
|
||||
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
||||
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
template: str = typer.Option(None, "--template", help="Install a template pack during initialization (by pack ID)"),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project from the latest template.
|
||||
@@ -1280,11 +1295,13 @@ def init(
|
||||
specify init --here --ai claude # Alternative syntax for current directory
|
||||
specify init --here --ai codex
|
||||
specify init --here --ai codebuddy
|
||||
specify init --here --ai vibe # Initialize with Mistral Vibe support
|
||||
specify init --here
|
||||
specify init --here --force # Skip confirmation when current directory not empty
|
||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||
specify init --here --ai gemini --ai-skills
|
||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||
specify init my-project --ai claude --template healthcare-compliance # With template pack
|
||||
"""
|
||||
|
||||
show_banner()
|
||||
@@ -1527,6 +1544,27 @@ def init(
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
# Install template pack if specified
|
||||
if template:
|
||||
try:
|
||||
from .templates import TemplatePackManager, TemplateCatalog, TemplateError
|
||||
tmpl_manager = TemplatePackManager(project_path)
|
||||
speckit_ver = get_speckit_version()
|
||||
|
||||
# Try local directory first, then catalog
|
||||
local_path = Path(template).resolve()
|
||||
if local_path.is_dir() and (local_path / "template-pack.yml").exists():
|
||||
tmpl_manager.install_from_directory(local_path, speckit_ver)
|
||||
else:
|
||||
tmpl_catalog = TemplateCatalog(project_path)
|
||||
try:
|
||||
zip_path = tmpl_catalog.download_pack(template)
|
||||
tmpl_manager.install_from_zip(zip_path, speckit_ver)
|
||||
except TemplateError:
|
||||
console.print(f"[yellow]Warning:[/yellow] Template pack '{template}' not found in catalog. Skipping.")
|
||||
except Exception as tmpl_err:
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to install template pack: {tmpl_err}")
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except Exception as e:
|
||||
tracker.error("final", str(e))
|
||||
@@ -1757,6 +1795,20 @@ extension_app = typer.Typer(
|
||||
)
|
||||
app.add_typer(extension_app, name="extension")
|
||||
|
||||
catalog_app = typer.Typer(
|
||||
name="catalog",
|
||||
help="Manage extension catalogs",
|
||||
add_completion=False,
|
||||
)
|
||||
extension_app.add_typer(catalog_app, name="catalog")
|
||||
|
||||
template_app = typer.Typer(
|
||||
name="template",
|
||||
help="Manage spec-kit template packs",
|
||||
add_completion=False,
|
||||
)
|
||||
app.add_typer(template_app, name="template")
|
||||
|
||||
|
||||
def get_speckit_version() -> str:
|
||||
"""Get current spec-kit version."""
|
||||
@@ -1779,6 +1831,227 @@ def get_speckit_version() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ===== Template Pack Commands =====
|
||||
|
||||
|
||||
@template_app.command("list")
|
||||
def template_list():
|
||||
"""List installed template packs."""
|
||||
from .templates import TemplatePackManager
|
||||
|
||||
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 = TemplatePackManager(project_root)
|
||||
installed = manager.list_installed()
|
||||
|
||||
if not installed:
|
||||
console.print("[yellow]No template packs installed.[/yellow]")
|
||||
console.print("\nInstall a template pack with:")
|
||||
console.print(" [cyan]specify template add <pack-name>[/cyan]")
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Installed Template Packs:[/bold cyan]\n")
|
||||
for pack in installed:
|
||||
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status}")
|
||||
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()
|
||||
|
||||
|
||||
@template_app.command("add")
|
||||
def template_add(
|
||||
pack_id: str = typer.Argument(None, help="Template pack 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)"),
|
||||
):
|
||||
"""Install a template pack."""
|
||||
from .templates import (
|
||||
TemplatePackManager,
|
||||
TemplateCatalog,
|
||||
TemplateError,
|
||||
TemplateValidationError,
|
||||
TemplateCompatibilityError,
|
||||
)
|
||||
|
||||
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 = TemplatePackManager(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 template pack from [cyan]{dev_path}[/cyan]...")
|
||||
manifest = manager.install_from_directory(dev_path, speckit_version)
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
|
||||
elif from_url:
|
||||
console.print(f"Installing template pack from [cyan]{from_url}[/cyan]...")
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "template-pack.zip"
|
||||
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)
|
||||
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
|
||||
elif pack_id:
|
||||
catalog = TemplateCatalog(project_root)
|
||||
pack_info = catalog.get_pack_info(pack_id)
|
||||
|
||||
if not pack_info:
|
||||
console.print(f"[red]Error:[/red] Template pack '{pack_id}' not found in catalog")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing template pack [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
|
||||
|
||||
try:
|
||||
zip_path = catalog.download_pack(pack_id)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
finally:
|
||||
if 'zip_path' in locals() and zip_path.exists():
|
||||
zip_path.unlink(missing_ok=True)
|
||||
else:
|
||||
console.print("[red]Error:[/red] Specify a template pack ID, --from URL, or --dev path")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except TemplateCompatibilityError as e:
|
||||
console.print(f"[red]Compatibility Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except TemplateValidationError as e:
|
||||
console.print(f"[red]Validation Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except TemplateError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@template_app.command("remove")
|
||||
def template_remove(
|
||||
pack_id: str = typer.Argument(..., help="Template pack ID to remove"),
|
||||
):
|
||||
"""Remove an installed template pack."""
|
||||
from .templates import TemplatePackManager
|
||||
|
||||
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 = TemplatePackManager(project_root)
|
||||
|
||||
if not manager.registry.is_installed(pack_id):
|
||||
console.print(f"[red]Error:[/red] Template pack '{pack_id}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if manager.remove(pack_id):
|
||||
console.print(f"[green]✓[/green] Template pack '{pack_id}' removed successfully")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Failed to remove template pack '{pack_id}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@template_app.command("search")
|
||||
def template_search(
|
||||
query: str = typer.Argument(None, help="Search query"),
|
||||
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
|
||||
author: str = typer.Option(None, "--author", help="Filter by author"),
|
||||
):
|
||||
"""Search for template packs in the catalog."""
|
||||
from .templates import TemplateCatalog, TemplateError
|
||||
|
||||
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 = TemplateCatalog(project_root)
|
||||
|
||||
try:
|
||||
results = catalog.search(query=query, tag=tag, author=author)
|
||||
except TemplateError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not results:
|
||||
console.print("[yellow]No template packs found matching your criteria.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold cyan]Template Packs ({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()
|
||||
|
||||
|
||||
@template_app.command("resolve")
|
||||
def template_resolve(
|
||||
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
|
||||
):
|
||||
"""Show which template will be resolved for a given name."""
|
||||
from .templates import TemplateResolver
|
||||
|
||||
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 = TemplateResolver(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]")
|
||||
|
||||
|
||||
# ===== Extension Commands =====
|
||||
|
||||
|
||||
@extension_app.command("list")
|
||||
def extension_list(
|
||||
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
||||
@@ -1822,6 +2095,181 @@ def extension_list(
|
||||
console.print(" [cyan]specify extension add <name>[/cyan]")
|
||||
|
||||
|
||||
@catalog_app.command("list")
|
||||
def catalog_list():
|
||||
"""List all active extension catalogs."""
|
||||
from .extensions import ExtensionCatalog, ValidationError
|
||||
|
||||
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 = ExtensionCatalog(project_root)
|
||||
|
||||
try:
|
||||
active_catalogs = catalog.get_active_catalogs()
|
||||
except ValidationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("\n[bold cyan]Active Extension 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" / "extension-catalogs.yml"
|
||||
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
|
||||
if os.environ.get("SPECKIT_CATALOG_URL"):
|
||||
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
|
||||
else:
|
||||
try:
|
||||
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
|
||||
except ValidationError:
|
||||
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 ValidationError:
|
||||
user_loaded = False
|
||||
if user_loaded:
|
||||
console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]")
|
||||
else:
|
||||
console.print("[dim]Using built-in default catalog stack.[/dim]")
|
||||
console.print(
|
||||
"[dim]Add .specify/extension-catalogs.yml to customize.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@catalog_app.command("add")
|
||||
def 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 extensions from this catalog to be installed",
|
||||
),
|
||||
description: str = typer.Option("", "--description", help="Description of the catalog"),
|
||||
):
|
||||
"""Add a catalog to .specify/extension-catalogs.yml."""
|
||||
from .extensions import ExtensionCatalog, ValidationError
|
||||
|
||||
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 = ExtensionCatalog(project_root)
|
||||
try:
|
||||
tmp_catalog._validate_catalog_url(url)
|
||||
except ValidationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_path = specify_dir / "extension-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 extension 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)}")
|
||||
|
||||
|
||||
@catalog_app.command("remove")
|
||||
def catalog_remove(
|
||||
name: str = typer.Argument(help="Catalog name to remove"),
|
||||
):
|
||||
"""Remove a catalog from .specify/extension-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 / "extension-catalogs.yml"
|
||||
if not config_path.exists():
|
||||
console.print("[red]Error:[/red] No 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 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_app.command("add")
|
||||
def extension_add(
|
||||
extension: str = typer.Argument(help="Extension name or path"),
|
||||
@@ -1910,6 +2358,19 @@ def extension_add(
|
||||
console.print(" specify extension search")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Enforce install_allowed policy
|
||||
if not ext_info.get("_install_allowed", True):
|
||||
catalog_name = ext_info.get("_catalog_name", "community")
|
||||
console.print(
|
||||
f"[red]Error:[/red] '{extension}' is available in the "
|
||||
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
|
||||
)
|
||||
console.print(
|
||||
f"\nTo enable installation, add '{extension}' to an approved catalog "
|
||||
f"(install_allowed: true) in .specify/extension-catalogs.yml."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Download extension ZIP
|
||||
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
|
||||
zip_path = catalog.download_extension(extension)
|
||||
@@ -2054,6 +2515,15 @@ def extension_search(
|
||||
tags_str = ", ".join(ext['tags'])
|
||||
console.print(f" [dim]Tags:[/dim] {tags_str}")
|
||||
|
||||
# Source catalog
|
||||
catalog_name = ext.get("_catalog_name", "")
|
||||
install_allowed = ext.get("_install_allowed", True)
|
||||
if catalog_name:
|
||||
if install_allowed:
|
||||
console.print(f" [dim]Catalog:[/dim] {catalog_name}")
|
||||
else:
|
||||
console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]")
|
||||
|
||||
# Stats
|
||||
stats = []
|
||||
if ext.get('downloads') is not None:
|
||||
@@ -2067,8 +2537,15 @@ def extension_search(
|
||||
if ext.get('repository'):
|
||||
console.print(f" [dim]Repository:[/dim] {ext['repository']}")
|
||||
|
||||
# Install command
|
||||
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
|
||||
# Install command (show warning if not installable)
|
||||
if install_allowed:
|
||||
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
|
||||
else:
|
||||
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
|
||||
console.print(
|
||||
f" Add to an approved catalog with install_allowed: true, "
|
||||
f"or install from a ZIP URL: specify extension add {ext['id']} --from <zip-url>"
|
||||
)
|
||||
console.print()
|
||||
|
||||
except ExtensionError as e:
|
||||
@@ -2117,6 +2594,12 @@ def extension_info(
|
||||
# Author and License
|
||||
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
|
||||
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
|
||||
|
||||
# Source catalog
|
||||
if ext_info.get("_catalog_name"):
|
||||
install_allowed = ext_info.get("_install_allowed", True)
|
||||
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
|
||||
console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}")
|
||||
console.print()
|
||||
|
||||
# Requirements
|
||||
@@ -2173,12 +2656,21 @@ def extension_info(
|
||||
|
||||
# Installation status and command
|
||||
is_installed = manager.registry.is_installed(ext_info['id'])
|
||||
install_allowed = ext_info.get("_install_allowed", True)
|
||||
if is_installed:
|
||||
console.print("[green]✓ Installed[/green]")
|
||||
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
||||
else:
|
||||
elif install_allowed:
|
||||
console.print("[yellow]Not installed[/yellow]")
|
||||
console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}")
|
||||
else:
|
||||
catalog_name = ext_info.get("_catalog_name", "community")
|
||||
console.print("[yellow]Not installed[/yellow]")
|
||||
console.print(
|
||||
f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog "
|
||||
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
|
||||
f"with install_allowed: true to enable installation."
|
||||
)
|
||||
|
||||
except ExtensionError as e:
|
||||
console.print(f"\n[red]Error:[/red] {e}")
|
||||
|
||||
@@ -8,9 +8,11 @@ without bloating the core framework.
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timezone
|
||||
@@ -36,6 +38,16 @@ class CompatibilityError(ExtensionError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogEntry:
|
||||
"""Represents a single catalog entry in the catalog stack."""
|
||||
url: str
|
||||
name: str
|
||||
priority: int
|
||||
install_allowed: bool
|
||||
description: str = ""
|
||||
|
||||
|
||||
class ExtensionManifest:
|
||||
"""Represents and validates an extension manifest (extension.yml)."""
|
||||
|
||||
@@ -677,6 +689,12 @@ class CommandRegistrar:
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"tabnine": {
|
||||
"dir": ".tabnine/agent/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"bob": {
|
||||
"dir": ".bob/commands",
|
||||
"format": "markdown",
|
||||
@@ -970,6 +988,7 @@ class ExtensionCatalog:
|
||||
"""Manages extension catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
|
||||
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
@@ -984,43 +1003,109 @@ class ExtensionCatalog:
|
||||
self.cache_file = self.cache_dir / "catalog.json"
|
||||
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
||||
|
||||
def get_catalog_url(self) -> str:
|
||||
"""Get catalog URL from config or use default.
|
||||
def _validate_catalog_url(self, url: str) -> None:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
||||
|
||||
Checks in order:
|
||||
1. SPECKIT_CATALOG_URL environment variable
|
||||
2. Default catalog URL
|
||||
|
||||
Returns:
|
||||
URL to fetch catalog from
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Raises:
|
||||
ValidationError: If custom URL is invalid (non-HTTPS)
|
||||
ValidationError: If URL is invalid or uses non-HTTPS scheme
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Environment variable override (useful for testing)
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ValidationError(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
raise ValidationError("Catalog URL must be a valid URL with a host.")
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to extension-catalogs.yml
|
||||
|
||||
Returns:
|
||||
Ordered list of CatalogEntry objects, or None if file doesn't exist
|
||||
or contains no valid catalog entries.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any catalog entry has an invalid URL,
|
||||
the file cannot be parsed, or a priority value is invalid.
|
||||
"""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text()) or {}
|
||||
except (yaml.YAMLError, OSError) as e:
|
||||
raise ValidationError(
|
||||
f"Failed to read catalog config {config_path}: {e}"
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
return None
|
||||
if not isinstance(catalogs_data, list):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
|
||||
)
|
||||
entries: List[CatalogEntry] = []
|
||||
for idx, item in enumerate(catalogs_data):
|
||||
if not isinstance(item, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
|
||||
)
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
|
||||
else:
|
||||
install_allowed = bool(raw_install)
|
||||
entries.append(CatalogEntry(
|
||||
url=url,
|
||||
name=str(item.get("name", f"catalog-{idx + 1}")),
|
||||
priority=priority,
|
||||
install_allowed=install_allowed,
|
||||
description=str(item.get("description", "")),
|
||||
))
|
||||
entries.sort(key=lambda e: e.priority)
|
||||
return entries if entries else None
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
"""Get the ordered list of active catalogs.
|
||||
|
||||
Resolution order:
|
||||
1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults
|
||||
2. Project-level .specify/extension-catalogs.yml
|
||||
3. User-level ~/.specify/extension-catalogs.yml
|
||||
4. Built-in default stack (default + community)
|
||||
|
||||
Returns:
|
||||
List of CatalogEntry objects sorted by priority (ascending)
|
||||
|
||||
Raises:
|
||||
ValidationError: If a catalog URL is invalid
|
||||
"""
|
||||
import sys
|
||||
|
||||
# 1. SPECKIT_CATALOG_URL env var replaces all defaults for backward compat
|
||||
if env_value := os.environ.get("SPECKIT_CATALOG_URL"):
|
||||
catalog_url = env_value.strip()
|
||||
parsed = urlparse(catalog_url)
|
||||
|
||||
# Require HTTPS for security (prevent man-in-the-middle attacks)
|
||||
# Allow http://localhost for local development/testing
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ValidationError(
|
||||
f"Invalid SPECKIT_CATALOG_URL: must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
|
||||
if not parsed.netloc:
|
||||
raise ValidationError(
|
||||
"Invalid SPECKIT_CATALOG_URL: must be a valid URL with a host."
|
||||
)
|
||||
|
||||
# Warn users when using a non-default catalog (once per instance)
|
||||
self._validate_catalog_url(catalog_url)
|
||||
if catalog_url != self.DEFAULT_CATALOG_URL:
|
||||
if not getattr(self, "_non_default_catalog_warning_shown", False):
|
||||
print(
|
||||
@@ -1029,11 +1114,163 @@ class ExtensionCatalog:
|
||||
file=sys.stderr,
|
||||
)
|
||||
self._non_default_catalog_warning_shown = True
|
||||
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
|
||||
|
||||
return catalog_url
|
||||
# 2. Project-level config overrides all defaults
|
||||
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
|
||||
catalogs = self._load_catalog_config(project_config_path)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
# TODO: Support custom catalogs from .specify/extension-catalogs.yml
|
||||
return self.DEFAULT_CATALOG_URL
|
||||
# 3. User-level config
|
||||
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
|
||||
catalogs = self._load_catalog_config(user_config_path)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
# 4. Built-in default stack
|
||||
return [
|
||||
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
|
||||
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
|
||||
]
|
||||
|
||||
def get_catalog_url(self) -> str:
|
||||
"""Get the primary catalog URL.
|
||||
|
||||
Returns the URL of the highest-priority catalog. Kept for backward
|
||||
compatibility. Use get_active_catalogs() for full multi-catalog support.
|
||||
|
||||
Returns:
|
||||
URL of the primary catalog
|
||||
|
||||
Raises:
|
||||
ValidationError: If a catalog URL is invalid
|
||||
"""
|
||||
active = self.get_active_catalogs()
|
||||
return active[0].url if active else self.DEFAULT_CATALOG_URL
|
||||
|
||||
def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""Fetch a single catalog with per-URL caching.
|
||||
|
||||
For the DEFAULT_CATALOG_URL, uses legacy cache files (self.cache_file /
|
||||
self.cache_metadata_file) for backward compatibility. For all other URLs,
|
||||
uses URL-hash-based cache files in self.cache_dir.
|
||||
|
||||
Args:
|
||||
entry: CatalogEntry describing the catalog to fetch
|
||||
force_refresh: If True, bypass cache
|
||||
|
||||
Returns:
|
||||
Catalog data dictionary
|
||||
|
||||
Raises:
|
||||
ExtensionError: If catalog cannot be fetched or has invalid format
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Determine cache file paths (backward compat for default catalog)
|
||||
if entry.url == self.DEFAULT_CATALOG_URL:
|
||||
cache_file = self.cache_file
|
||||
cache_meta_file = self.cache_metadata_file
|
||||
is_valid = not force_refresh and self.is_cache_valid()
|
||||
else:
|
||||
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
|
||||
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
|
||||
cache_meta_file = self.cache_dir / f"catalog-{url_hash}-metadata.json"
|
||||
is_valid = False
|
||||
if not force_refresh and cache_file.exists() and cache_meta_file.exists():
|
||||
try:
|
||||
metadata = json.loads(cache_meta_file.read_text())
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
is_valid = age < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
# If metadata is invalid or missing expected fields, treat cache as invalid
|
||||
pass
|
||||
|
||||
# Use cache if valid
|
||||
if is_valid:
|
||||
try:
|
||||
return json.loads(cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fetch from network
|
||||
try:
|
||||
with urllib.request.urlopen(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError(f"Invalid catalog format from {entry.url}")
|
||||
|
||||
# Save to cache
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
cache_meta_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}, indent=2))
|
||||
|
||||
return catalog_data
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise ExtensionError(f"Failed to fetch catalog from {entry.url}: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}")
|
||||
|
||||
def _get_merged_extensions(self, force_refresh: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Fetch and merge extensions from all active catalogs.
|
||||
|
||||
Higher-priority (lower priority number) catalogs win on conflicts
|
||||
(same extension id in two catalogs). Each extension dict is annotated with:
|
||||
- _catalog_name: name of the source catalog
|
||||
- _install_allowed: whether installation is allowed from this catalog
|
||||
|
||||
Catalogs that fail to fetch are skipped. Raises ExtensionError only if
|
||||
ALL catalogs fail.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, bypass all caches
|
||||
|
||||
Returns:
|
||||
List of merged extension dicts
|
||||
|
||||
Raises:
|
||||
ExtensionError: If all catalogs fail to fetch
|
||||
"""
|
||||
import sys
|
||||
|
||||
active_catalogs = self.get_active_catalogs()
|
||||
merged: Dict[str, Dict[str, Any]] = {}
|
||||
any_success = False
|
||||
|
||||
for catalog_entry in active_catalogs:
|
||||
try:
|
||||
catalog_data = self._fetch_single_catalog(catalog_entry, force_refresh)
|
||||
any_success = True
|
||||
except ExtensionError as e:
|
||||
print(
|
||||
f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
|
||||
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
|
||||
if ext_id not in merged: # Higher-priority catalog wins
|
||||
merged[ext_id] = {
|
||||
**ext_data,
|
||||
"id": ext_id,
|
||||
"_catalog_name": catalog_entry.name,
|
||||
"_install_allowed": catalog_entry.install_allowed,
|
||||
}
|
||||
|
||||
if not any_success and active_catalogs:
|
||||
raise ExtensionError("Failed to fetch any extension catalog")
|
||||
|
||||
return list(merged.values())
|
||||
|
||||
def is_cache_valid(self) -> bool:
|
||||
"""Check if cached catalog is still valid.
|
||||
@@ -1047,9 +1284,11 @@ class ExtensionCatalog:
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError):
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -1110,7 +1349,7 @@ class ExtensionCatalog:
|
||||
author: Optional[str] = None,
|
||||
verified_only: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search catalog for extensions.
|
||||
"""Search catalog for extensions across all active catalogs.
|
||||
|
||||
Args:
|
||||
query: Search query (searches name, description, tags)
|
||||
@@ -1119,14 +1358,16 @@ class ExtensionCatalog:
|
||||
verified_only: If True, show only verified extensions
|
||||
|
||||
Returns:
|
||||
List of matching extension metadata
|
||||
List of matching extension metadata, each annotated with
|
||||
``_catalog_name`` and ``_install_allowed`` from its source catalog.
|
||||
"""
|
||||
catalog = self.fetch_catalog()
|
||||
extensions = catalog.get("extensions", {})
|
||||
all_extensions = self._get_merged_extensions()
|
||||
|
||||
results = []
|
||||
|
||||
for ext_id, ext_data in extensions.items():
|
||||
for ext_data in all_extensions:
|
||||
ext_id = ext_data["id"]
|
||||
|
||||
# Apply filters
|
||||
if verified_only and not ext_data.get("verified", False):
|
||||
continue
|
||||
@@ -1152,25 +1393,26 @@ class ExtensionCatalog:
|
||||
if query_lower not in searchable_text:
|
||||
continue
|
||||
|
||||
results.append({"id": ext_id, **ext_data})
|
||||
results.append(ext_data)
|
||||
|
||||
return results
|
||||
|
||||
def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed information about a specific extension.
|
||||
|
||||
Searches all active catalogs in priority order.
|
||||
|
||||
Args:
|
||||
extension_id: ID of the extension
|
||||
|
||||
Returns:
|
||||
Extension metadata or None if not found
|
||||
Extension metadata (annotated with ``_catalog_name`` and
|
||||
``_install_allowed``) or None if not found.
|
||||
"""
|
||||
catalog = self.fetch_catalog()
|
||||
extensions = catalog.get("extensions", {})
|
||||
|
||||
if extension_id in extensions:
|
||||
return {"id": extension_id, **extensions[extension_id]}
|
||||
|
||||
all_extensions = self._get_merged_extensions()
|
||||
for ext_data in all_extensions:
|
||||
if ext_data["id"] == extension_id:
|
||||
return ext_data
|
||||
return None
|
||||
|
||||
def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path:
|
||||
@@ -1230,11 +1472,18 @@ class ExtensionCatalog:
|
||||
raise ExtensionError(f"Failed to save extension ZIP: {e}")
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the catalog cache."""
|
||||
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
|
||||
if self.cache_file.exists():
|
||||
self.cache_file.unlink()
|
||||
if self.cache_metadata_file.exists():
|
||||
self.cache_metadata_file.unlink()
|
||||
# Also clear any per-URL hash-based cache files
|
||||
if self.cache_dir.exists():
|
||||
for extra_cache in self.cache_dir.glob("catalog-*.json"):
|
||||
if extra_cache != self.cache_file:
|
||||
extra_cache.unlink(missing_ok=True)
|
||||
for extra_meta in self.cache_dir.glob("catalog-*-metadata.json"):
|
||||
extra_meta.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
|
||||
938
src/specify_cli/templates.py
Normal file
938
src/specify_cli/templates.py
Normal file
@@ -0,0 +1,938 @@
|
||||
"""
|
||||
Template Pack Manager for Spec Kit
|
||||
|
||||
Handles installation, removal, and management of Spec Kit template packs.
|
||||
Template packs are self-contained, versioned collections of templates
|
||||
(artifact, command, and script templates) that can be installed to
|
||||
customize the Spec-Driven Development workflow.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
|
||||
class TemplateError(Exception):
|
||||
"""Base exception for template-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateValidationError(TemplateError):
|
||||
"""Raised when template pack manifest validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateCompatibilityError(TemplateError):
|
||||
"""Raised when template pack is incompatible with current environment."""
|
||||
pass
|
||||
|
||||
|
||||
VALID_TEMPLATE_TYPES = {"artifact", "command", "script"}
|
||||
|
||||
|
||||
class TemplatePackManifest:
|
||||
"""Represents and validates a template pack manifest (template-pack.yml)."""
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
REQUIRED_FIELDS = ["schema_version", "template_pack", "requires", "provides"]
|
||||
|
||||
def __init__(self, manifest_path: Path):
|
||||
"""Load and validate template pack manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to template-pack.yml file
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
"""
|
||||
self.path = manifest_path
|
||||
self.data = self._load_yaml(manifest_path)
|
||||
self._validate()
|
||||
|
||||
def _load_yaml(self, path: Path) -> dict:
|
||||
"""Load YAML file safely."""
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise TemplateValidationError(f"Invalid YAML in {path}: {e}")
|
||||
except FileNotFoundError:
|
||||
raise TemplateValidationError(f"Manifest not found: {path}")
|
||||
|
||||
def _validate(self):
|
||||
"""Validate manifest structure and required fields."""
|
||||
# Check required top-level fields
|
||||
for field in self.REQUIRED_FIELDS:
|
||||
if field not in self.data:
|
||||
raise TemplateValidationError(f"Missing required field: {field}")
|
||||
|
||||
# Validate schema version
|
||||
if self.data["schema_version"] != self.SCHEMA_VERSION:
|
||||
raise TemplateValidationError(
|
||||
f"Unsupported schema version: {self.data['schema_version']} "
|
||||
f"(expected {self.SCHEMA_VERSION})"
|
||||
)
|
||||
|
||||
# Validate template_pack metadata
|
||||
pack = self.data["template_pack"]
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
if field not in pack:
|
||||
raise TemplateValidationError(f"Missing template_pack.{field}")
|
||||
|
||||
# Validate pack ID format
|
||||
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template pack ID '{pack['id']}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
)
|
||||
|
||||
# Validate semantic version
|
||||
try:
|
||||
pkg_version.Version(pack["version"])
|
||||
except pkg_version.InvalidVersion:
|
||||
raise TemplateValidationError(f"Invalid version: {pack['version']}")
|
||||
|
||||
# Validate requires section
|
||||
requires = self.data["requires"]
|
||||
if "speckit_version" not in requires:
|
||||
raise TemplateValidationError("Missing requires.speckit_version")
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
if "templates" not in provides or not provides["templates"]:
|
||||
raise TemplateValidationError(
|
||||
"Template pack must provide at least one template"
|
||||
)
|
||||
|
||||
# Validate templates
|
||||
for tmpl in provides["templates"]:
|
||||
if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl:
|
||||
raise TemplateValidationError(
|
||||
"Template missing 'type', 'name', or 'file'"
|
||||
)
|
||||
|
||||
if tmpl["type"] not in VALID_TEMPLATE_TYPES:
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template type '{tmpl['type']}': "
|
||||
f"must be one of {sorted(VALID_TEMPLATE_TYPES)}"
|
||||
)
|
||||
|
||||
# Validate template name format
|
||||
if not re.match(r'^[a-z0-9-]+$', tmpl["name"]):
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template name '{tmpl['name']}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Get template pack ID."""
|
||||
return self.data["template_pack"]["id"]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get template pack name."""
|
||||
return self.data["template_pack"]["name"]
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Get template pack version."""
|
||||
return self.data["template_pack"]["version"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Get template pack description."""
|
||||
return self.data["template_pack"]["description"]
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
"""Get template pack author."""
|
||||
return self.data["template_pack"].get("author", "")
|
||||
|
||||
@property
|
||||
def requires_speckit_version(self) -> str:
|
||||
"""Get required spec-kit version range."""
|
||||
return self.data["requires"]["speckit_version"]
|
||||
|
||||
@property
|
||||
def templates(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of provided templates."""
|
||||
return self.data["provides"]["templates"]
|
||||
|
||||
@property
|
||||
def tags(self) -> List[str]:
|
||||
"""Get template pack tags."""
|
||||
return self.data.get("tags", [])
|
||||
|
||||
def get_hash(self) -> str:
|
||||
"""Calculate SHA256 hash of manifest file."""
|
||||
with open(self.path, 'rb') as f:
|
||||
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
|
||||
|
||||
|
||||
class TemplatePackRegistry:
|
||||
"""Manages the registry of installed template packs."""
|
||||
|
||||
REGISTRY_FILE = ".registry"
|
||||
SCHEMA_VERSION = "1.0"
|
||||
|
||||
def __init__(self, packs_dir: Path):
|
||||
"""Initialize registry.
|
||||
|
||||
Args:
|
||||
packs_dir: Path to .specify/templates/packs/ directory
|
||||
"""
|
||||
self.packs_dir = packs_dir
|
||||
self.registry_path = packs_dir / self.REGISTRY_FILE
|
||||
self.data = self._load()
|
||||
|
||||
def _load(self) -> dict:
|
||||
"""Load registry from disk."""
|
||||
if not self.registry_path.exists():
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"template_packs": {}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"template_packs": {}
|
||||
}
|
||||
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.packs_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, 'w') as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, pack_id: str, metadata: dict):
|
||||
"""Add template pack to registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
metadata: Pack metadata (version, source, etc.)
|
||||
"""
|
||||
self.data["template_packs"][pack_id] = {
|
||||
**metadata,
|
||||
"installed_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
self._save()
|
||||
|
||||
def remove(self, pack_id: str):
|
||||
"""Remove template pack from registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
"""
|
||||
if pack_id in self.data["template_packs"]:
|
||||
del self.data["template_packs"][pack_id]
|
||||
self._save()
|
||||
|
||||
def get(self, pack_id: str) -> Optional[dict]:
|
||||
"""Get template pack metadata from registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
Pack metadata or None if not found
|
||||
"""
|
||||
return self.data["template_packs"].get(pack_id)
|
||||
|
||||
def list(self) -> Dict[str, dict]:
|
||||
"""Get all installed template packs.
|
||||
|
||||
Returns:
|
||||
Dictionary of pack_id -> metadata
|
||||
"""
|
||||
return self.data["template_packs"]
|
||||
|
||||
def is_installed(self, pack_id: str) -> bool:
|
||||
"""Check if template pack is installed.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
True if pack is installed
|
||||
"""
|
||||
return pack_id in self.data["template_packs"]
|
||||
|
||||
|
||||
class TemplatePackManager:
|
||||
"""Manages template pack lifecycle: installation, removal, updates."""
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template pack manager.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root directory
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.packs_dir = self.templates_dir / "packs"
|
||||
self.registry = TemplatePackRegistry(self.packs_dir)
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
manifest: TemplatePackManifest,
|
||||
speckit_version: str
|
||||
) -> bool:
|
||||
"""Check if template pack is compatible with current spec-kit version.
|
||||
|
||||
Args:
|
||||
manifest: Template pack manifest
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
True if compatible
|
||||
|
||||
Raises:
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
current = pkg_version.Version(speckit_version)
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(required)
|
||||
if current not in specifier:
|
||||
raise TemplateCompatibilityError(
|
||||
f"Template pack requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: uv tool install specify-cli --force"
|
||||
)
|
||||
except InvalidSpecifier:
|
||||
raise TemplateCompatibilityError(
|
||||
f"Invalid version specifier: {required}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def install_from_directory(
|
||||
self,
|
||||
source_dir: Path,
|
||||
speckit_version: str,
|
||||
) -> TemplatePackManifest:
|
||||
"""Install template pack from a local directory.
|
||||
|
||||
Args:
|
||||
source_dir: Path to template pack directory
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
Installed template pack manifest
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
manifest_path = source_dir / "template-pack.yml"
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
|
||||
self.check_compatibility(manifest, speckit_version)
|
||||
|
||||
if self.registry.is_installed(manifest.id):
|
||||
raise TemplateError(
|
||||
f"Template pack '{manifest.id}' is already installed. "
|
||||
f"Use 'specify template remove {manifest.id}' first."
|
||||
)
|
||||
|
||||
dest_dir = self.packs_dir / manifest.id
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
|
||||
self.registry.add(manifest.id, {
|
||||
"version": manifest.version,
|
||||
"source": "local",
|
||||
"manifest_hash": manifest.get_hash(),
|
||||
"enabled": True,
|
||||
})
|
||||
|
||||
return manifest
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str
|
||||
) -> TemplatePackManifest:
|
||||
"""Install template pack from ZIP file.
|
||||
|
||||
Args:
|
||||
zip_path: Path to template pack ZIP file
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
Installed template pack manifest
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
temp_path_resolved = temp_path.resolve()
|
||||
for member in zf.namelist():
|
||||
member_path = (temp_path / member).resolve()
|
||||
try:
|
||||
member_path.relative_to(temp_path_resolved)
|
||||
except ValueError:
|
||||
raise TemplateValidationError(
|
||||
f"Unsafe path in ZIP archive: {member} "
|
||||
"(potential path traversal)"
|
||||
)
|
||||
zf.extractall(temp_path)
|
||||
|
||||
pack_dir = temp_path
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
if not manifest_path.exists():
|
||||
subdirs = [d for d in temp_path.iterdir() if d.is_dir()]
|
||||
if len(subdirs) == 1:
|
||||
pack_dir = subdirs[0]
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise TemplateValidationError(
|
||||
"No template-pack.yml found in ZIP file"
|
||||
)
|
||||
|
||||
return self.install_from_directory(pack_dir, speckit_version)
|
||||
|
||||
def remove(self, pack_id: str) -> bool:
|
||||
"""Remove an installed template pack.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
True if pack was removed
|
||||
"""
|
||||
if not self.registry.is_installed(pack_id):
|
||||
return False
|
||||
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
if pack_dir.exists():
|
||||
shutil.rmtree(pack_dir)
|
||||
|
||||
self.registry.remove(pack_id)
|
||||
return True
|
||||
|
||||
def list_installed(self) -> List[Dict[str, Any]]:
|
||||
"""List all installed template packs with metadata.
|
||||
|
||||
Returns:
|
||||
List of template pack metadata dictionaries
|
||||
"""
|
||||
result = []
|
||||
|
||||
for pack_id, metadata in self.registry.list().items():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
try:
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
result.append({
|
||||
"id": pack_id,
|
||||
"name": manifest.name,
|
||||
"version": metadata["version"],
|
||||
"description": manifest.description,
|
||||
"enabled": metadata.get("enabled", True),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"template_count": len(manifest.templates),
|
||||
"tags": manifest.tags,
|
||||
})
|
||||
except TemplateValidationError:
|
||||
result.append({
|
||||
"id": pack_id,
|
||||
"name": pack_id,
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": "⚠️ Corrupted template pack",
|
||||
"enabled": False,
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"template_count": 0,
|
||||
"tags": [],
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_pack(self, pack_id: str) -> Optional[TemplatePackManifest]:
|
||||
"""Get manifest for an installed template pack.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
Template pack manifest or None if not installed
|
||||
"""
|
||||
if not self.registry.is_installed(pack_id):
|
||||
return None
|
||||
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
try:
|
||||
return TemplatePackManifest(manifest_path)
|
||||
except TemplateValidationError:
|
||||
return None
|
||||
|
||||
|
||||
class TemplateCatalog:
|
||||
"""Manages template pack catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.json"
|
||||
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.community.json"
|
||||
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template catalog manager.
|
||||
|
||||
Args:
|
||||
project_root: Root directory of the spec-kit project
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.cache_dir = self.templates_dir / "packs" / ".cache"
|
||||
self.cache_file = self.cache_dir / "catalog.json"
|
||||
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
||||
|
||||
def _validate_catalog_url(self, url: str) -> None:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If URL is invalid or uses non-HTTPS scheme
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise TemplateValidationError(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
raise TemplateValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
|
||||
def get_catalog_url(self) -> str:
|
||||
"""Get the primary catalog URL.
|
||||
|
||||
Returns:
|
||||
URL of the primary catalog
|
||||
"""
|
||||
env_value = os.environ.get("SPECKIT_TEMPLATE_CATALOG_URL")
|
||||
if env_value:
|
||||
catalog_url = env_value.strip()
|
||||
self._validate_catalog_url(catalog_url)
|
||||
return catalog_url
|
||||
return self.DEFAULT_CATALOG_URL
|
||||
|
||||
def is_cache_valid(self) -> bool:
|
||||
"""Check if cached catalog is still valid.
|
||||
|
||||
Returns:
|
||||
True if cache exists and is within cache duration
|
||||
"""
|
||||
if not self.cache_file.exists() or not self.cache_metadata_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age_seconds = (
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""Fetch template pack catalog from URL or cache.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, bypass cache and fetch from network
|
||||
|
||||
Returns:
|
||||
Catalog data dictionary
|
||||
|
||||
Raises:
|
||||
TemplateError: If catalog cannot be fetched
|
||||
"""
|
||||
if not force_refresh and self.is_cache_valid():
|
||||
try:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
with urllib.request.urlopen(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "template_packs" not in catalog_data
|
||||
):
|
||||
raise TemplateError("Invalid template catalog format")
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2)
|
||||
)
|
||||
|
||||
return catalog_data
|
||||
|
||||
except (ImportError, Exception) as e:
|
||||
if isinstance(e, TemplateError):
|
||||
raise
|
||||
raise TemplateError(
|
||||
f"Failed to fetch template catalog from {catalog_url}: {e}"
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search catalog for template packs.
|
||||
|
||||
Args:
|
||||
query: Search query (searches name, description, tags)
|
||||
tag: Filter by specific tag
|
||||
author: Filter by author name
|
||||
|
||||
Returns:
|
||||
List of matching template pack metadata
|
||||
"""
|
||||
try:
|
||||
catalog_data = self.fetch_catalog()
|
||||
except TemplateError:
|
||||
return []
|
||||
|
||||
results = []
|
||||
packs = catalog_data.get("template_packs", {})
|
||||
|
||||
for pack_id, pack_data in packs.items():
|
||||
if author and pack_data.get("author", "").lower() != author.lower():
|
||||
continue
|
||||
|
||||
if tag and tag.lower() not in [
|
||||
t.lower() for t in pack_data.get("tags", [])
|
||||
]:
|
||||
continue
|
||||
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
searchable_text = " ".join(
|
||||
[
|
||||
pack_data.get("name", ""),
|
||||
pack_data.get("description", ""),
|
||||
pack_id,
|
||||
]
|
||||
+ pack_data.get("tags", [])
|
||||
).lower()
|
||||
|
||||
if query_lower not in searchable_text:
|
||||
continue
|
||||
|
||||
results.append({**pack_data, "id": pack_id})
|
||||
|
||||
return results
|
||||
|
||||
def get_pack_info(
|
||||
self, pack_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed information about a specific template pack.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the template pack
|
||||
|
||||
Returns:
|
||||
Pack metadata or None if not found
|
||||
"""
|
||||
try:
|
||||
catalog_data = self.fetch_catalog()
|
||||
except TemplateError:
|
||||
return None
|
||||
|
||||
packs = catalog_data.get("template_packs", {})
|
||||
if pack_id in packs:
|
||||
return {**packs[pack_id], "id": pack_id}
|
||||
return None
|
||||
|
||||
def download_pack(
|
||||
self, pack_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download template pack ZIP from catalog.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the template pack to download
|
||||
target_dir: Directory to save ZIP file (defaults to cache directory)
|
||||
|
||||
Returns:
|
||||
Path to downloaded ZIP file
|
||||
|
||||
Raises:
|
||||
TemplateError: If pack not found or download fails
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
pack_info = self.get_pack_info(pack_id)
|
||||
if not pack_info:
|
||||
raise TemplateError(
|
||||
f"Template pack '{pack_id}' not found in catalog"
|
||||
)
|
||||
|
||||
download_url = pack_info.get("download_url")
|
||||
if not download_url:
|
||||
raise TemplateError(
|
||||
f"Template pack '{pack_id}' has no download URL"
|
||||
)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise TemplateError(
|
||||
f"Template pack download URL must use HTTPS: {download_url}"
|
||||
)
|
||||
|
||||
if target_dir is None:
|
||||
target_dir = self.cache_dir / "downloads"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
version = pack_info.get("version", "unknown")
|
||||
zip_filename = f"{pack_id}-{version}.zip"
|
||||
zip_path = target_dir / zip_filename
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(download_url, timeout=60) as response:
|
||||
zip_data = response.read()
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise TemplateError(
|
||||
f"Failed to download template pack from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise TemplateError(f"Failed to save template pack ZIP: {e}")
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the catalog cache."""
|
||||
if self.cache_file.exists():
|
||||
self.cache_file.unlink()
|
||||
if self.cache_metadata_file.exists():
|
||||
self.cache_metadata_file.unlink()
|
||||
|
||||
|
||||
class TemplateResolver:
|
||||
"""Resolves template names to file paths using a priority stack.
|
||||
|
||||
Resolution order:
|
||||
1. .specify/templates/overrides/ - Project-local overrides
|
||||
2. .specify/templates/packs/<pack-id>/ - Installed template packs
|
||||
3. .specify/extensions/<ext-id>/templates/ - Extension-provided templates
|
||||
4. .specify/templates/ - Core templates (shipped with Spec Kit)
|
||||
"""
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template resolver.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root directory
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.packs_dir = self.templates_dir / "packs"
|
||||
self.overrides_dir = self.templates_dir / "overrides"
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
template_name: str,
|
||||
template_type: str = "artifact",
|
||||
) -> Optional[Path]:
|
||||
"""Resolve a template name to its file path.
|
||||
|
||||
Walks the priority stack and returns the first match.
|
||||
|
||||
Args:
|
||||
template_name: Template name (e.g., "spec-template")
|
||||
template_type: Template type ("artifact", "command", or "script")
|
||||
|
||||
Returns:
|
||||
Path to the resolved template file, or None if not found
|
||||
"""
|
||||
# Determine subdirectory based on template type
|
||||
if template_type == "artifact":
|
||||
subdirs = ["templates", ""]
|
||||
elif template_type == "command":
|
||||
subdirs = ["commands"]
|
||||
elif template_type == "script":
|
||||
subdirs = ["scripts"]
|
||||
else:
|
||||
subdirs = [""]
|
||||
|
||||
# Priority 1: Project-local overrides
|
||||
for subdir in subdirs:
|
||||
if template_type == "script":
|
||||
override = self.overrides_dir / "scripts" / f"{template_name}.sh"
|
||||
elif subdir:
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
else:
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
if override.exists():
|
||||
return override
|
||||
|
||||
# Priority 2: Installed packs (by registry order)
|
||||
if self.packs_dir.exists():
|
||||
registry = TemplatePackRegistry(self.packs_dir)
|
||||
for pack_id in registry.list():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = (
|
||||
pack_dir / subdir / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = pack_dir / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
if self.extensions_dir.exists():
|
||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = (
|
||||
ext_dir / "templates" / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = (
|
||||
ext_dir / "templates" / f"{template_name}.md"
|
||||
)
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Priority 4: Core templates
|
||||
if template_type == "artifact":
|
||||
core = self.templates_dir / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return core
|
||||
elif template_type == "command":
|
||||
core = self.templates_dir / "commands" / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return core
|
||||
|
||||
return None
|
||||
|
||||
def resolve_with_source(
|
||||
self,
|
||||
template_name: str,
|
||||
template_type: str = "artifact",
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Resolve a template name and return source attribution.
|
||||
|
||||
Args:
|
||||
template_name: Template name (e.g., "spec-template")
|
||||
template_type: Template type ("artifact", "command", or "script")
|
||||
|
||||
Returns:
|
||||
Dictionary with 'path' and 'source' keys, or None if not found
|
||||
"""
|
||||
# Priority 1: Project-local overrides
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
if override.exists():
|
||||
return {"path": str(override), "source": "project override"}
|
||||
|
||||
# Priority 2: Installed packs
|
||||
if self.packs_dir.exists():
|
||||
registry = TemplatePackRegistry(self.packs_dir)
|
||||
for pack_id in registry.list():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
# Check templates/ subdirectory first, then root
|
||||
for subdir in ["templates", "commands", "scripts", ""]:
|
||||
if subdir:
|
||||
candidate = (
|
||||
pack_dir / subdir / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = pack_dir / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
meta = registry.get(pack_id)
|
||||
version = meta.get("version", "?") if meta else "?"
|
||||
return {
|
||||
"path": str(candidate),
|
||||
"source": f"{pack_id} v{version}",
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
if self.extensions_dir.exists():
|
||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
candidate = ext_dir / "templates" / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
return {
|
||||
"path": str(candidate),
|
||||
"source": f"extension:{ext_dir.name}",
|
||||
}
|
||||
|
||||
# Priority 4: Core templates
|
||||
core = self.templates_dir / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return {"path": str(core), "source": "core"}
|
||||
|
||||
# Also check commands subdirectory for core
|
||||
core_cmd = self.templates_dir / "commands" / f"{template_name}.md"
|
||||
if core_cmd.exists():
|
||||
return {"path": str(core_cmd), "source": "core"}
|
||||
|
||||
return None
|
||||
6
templates/catalog.community.json
Normal file
6
templates/catalog.community.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-09T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.community.json",
|
||||
"template_packs": {}
|
||||
}
|
||||
6
templates/catalog.json
Normal file
6
templates/catalog.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-09T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.json",
|
||||
"template_packs": {}
|
||||
}
|
||||
@@ -13,6 +13,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before implementation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter to only hooks where `enabled: true`
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
@@ -88,7 +122,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
@@ -136,3 +170,32 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
|
||||
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter to only hooks where `enabled: true`
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -9,8 +9,8 @@ handoffs:
|
||||
prompt: Clarify specification requirements
|
||||
send: true
|
||||
scripts:
|
||||
sh: scripts/bash/create-new-feature.sh --json "{ARGS}"
|
||||
ps: scripts/powershell/create-new-feature.ps1 -Json "{ARGS}"
|
||||
sh: scripts/bash/create-new-feature.sh "{ARGS}"
|
||||
ps: scripts/powershell/create-new-feature.ps1 "{ARGS}"
|
||||
---
|
||||
|
||||
## User Input
|
||||
@@ -39,33 +39,14 @@ Given that feature description, do this:
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Check for existing branches before creating new one**:
|
||||
2. **Create the feature branch** by running the script with `--short-name` (and `--json`), and do NOT pass `--number` (the script auto-detects the next globally available number across all branches and spec directories):
|
||||
|
||||
a. First, fetch all remote branches to ensure we have the latest information:
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
```
|
||||
|
||||
b. Find the highest feature number across all sources for the short-name:
|
||||
- Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-<short-name>$'`
|
||||
- Local branches: `git branch | grep -E '^[* ]*[0-9]+-<short-name>$'`
|
||||
- Specs directories: Check for directories matching `specs/[0-9]+-<short-name>`
|
||||
|
||||
c. Determine the next available number:
|
||||
- Extract all numbers from all three sources
|
||||
- Find the highest number N
|
||||
- Use N+1 for the new branch number
|
||||
|
||||
d. Run the script `{SCRIPT}` with the calculated number and short-name:
|
||||
- Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description
|
||||
- Bash example: `{SCRIPT} --json --number 5 --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `{SCRIPT} -Json -Number 5 -ShortName "user-auth" "Add user authentication"`
|
||||
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Check all three sources (remote branches, local branches, specs directories) to find the highest number
|
||||
- Only match branches/directories with the exact short-name pattern
|
||||
- If no existing branches/directories found with this short-name, start with number 1
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
|
||||
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
||||
|
||||
@@ -22,6 +22,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter to only hooks where `enabled: true`
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
@@ -63,6 +97,35 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter to only hooks where `enabled: true`
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
Context for task generation: {ARGS}
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
49
templates/template/README.md
Normal file
49
templates/template/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# My Template Pack
|
||||
|
||||
A custom template pack for Spec Kit.
|
||||
|
||||
## Overview
|
||||
|
||||
This template pack provides customized artifact templates for your development workflow.
|
||||
|
||||
## Templates Included
|
||||
|
||||
| Template | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `spec-template` | artifact | Custom feature specification template |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install from local directory (during development)
|
||||
specify template add --dev /path/to/this/directory
|
||||
|
||||
# Install from catalog (after publishing)
|
||||
specify template add my-template-pack
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, templates are automatically resolved by the Spec Kit scripts.
|
||||
When you run `specify specify` or create a new feature, your custom templates
|
||||
will be used instead of the core defaults.
|
||||
|
||||
## Template Types
|
||||
|
||||
- **artifact** — Document scaffolds (spec.md, plan.md, tasks.md, etc.)
|
||||
- **command** — AI agent prompts (the files in `.claude/commands/`, etc.)
|
||||
- **script** — Custom scripts that replace core scripts
|
||||
|
||||
## Development
|
||||
|
||||
1. Edit templates in the `templates/` directory
|
||||
2. Test with: `specify template add --dev .`
|
||||
3. Verify with: `specify template resolve spec-template`
|
||||
|
||||
## Publishing
|
||||
|
||||
See the [Template Publishing Guide](../../docs/TEMPLATE-PUBLISHING-GUIDE.md) for details.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
66
templates/template/template-pack.yml
Normal file
66
templates/template/template-pack.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
template_pack:
|
||||
# CUSTOMIZE: Change 'my-template-pack' to your template pack ID (lowercase, hyphen-separated)
|
||||
id: "my-template-pack"
|
||||
|
||||
# CUSTOMIZE: Human-readable name for your template pack
|
||||
name: "My Template Pack"
|
||||
|
||||
# CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z)
|
||||
version: "1.0.0"
|
||||
|
||||
# CUSTOMIZE: Brief description (under 200 characters)
|
||||
description: "Brief description of what your template pack provides"
|
||||
|
||||
# CUSTOMIZE: Your name or organization name
|
||||
author: "Your Name"
|
||||
|
||||
# CUSTOMIZE: GitHub repository URL (create before publishing)
|
||||
repository: "https://github.com/your-org/spec-kit-templates-my-pack"
|
||||
|
||||
# REVIEW: License (MIT is recommended for open source)
|
||||
license: "MIT"
|
||||
|
||||
# Requirements for this template pack
|
||||
requires:
|
||||
# CUSTOMIZE: Minimum spec-kit version required
|
||||
speckit_version: ">=0.1.0"
|
||||
|
||||
# Templates provided by this pack
|
||||
provides:
|
||||
templates:
|
||||
# CUSTOMIZE: Define your artifact templates
|
||||
# Artifact templates are document scaffolds (spec.md, plan.md, etc.)
|
||||
- type: "artifact"
|
||||
name: "spec-template"
|
||||
file: "templates/spec-template.md"
|
||||
description: "Custom feature specification template"
|
||||
replaces: "spec-template" # Which core template this overrides (optional)
|
||||
|
||||
# ADD MORE TEMPLATES: Copy this block for each template
|
||||
# - type: "artifact"
|
||||
# name: "plan-template"
|
||||
# file: "templates/plan-template.md"
|
||||
# description: "Custom plan template"
|
||||
# replaces: "plan-template"
|
||||
|
||||
# Command templates (AI agent prompts)
|
||||
# - type: "command"
|
||||
# name: "specify"
|
||||
# file: "commands/specify.md"
|
||||
# description: "Custom specification command"
|
||||
# replaces: "specify"
|
||||
|
||||
# Script templates
|
||||
# - type: "script"
|
||||
# name: "create-new-feature"
|
||||
# file: "scripts/bash/create-new-feature.sh"
|
||||
# description: "Custom feature creation script"
|
||||
# replaces: "create-new-feature"
|
||||
|
||||
# CUSTOMIZE: Add relevant tags (2-5 recommended)
|
||||
# Used for discovery in catalog
|
||||
tags:
|
||||
- "example"
|
||||
- "template"
|
||||
21
templates/template/templates/spec-template.md
Normal file
21
templates/template/templates/spec-template.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Feature Specification
|
||||
|
||||
> Replace this with your actual specification content.
|
||||
|
||||
## Overview
|
||||
|
||||
Brief description of the feature.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Requirement 1
|
||||
- Requirement 2
|
||||
|
||||
## Design
|
||||
|
||||
Describe the design approach.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
34
tests/hooks/.specify/extensions.yml
Normal file
34
tests/hooks/.specify/extensions.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
hooks:
|
||||
before_implement:
|
||||
- id: pre_test
|
||||
enabled: true
|
||||
optional: false
|
||||
extension: "test-extension"
|
||||
command: "pre_implement_test"
|
||||
description: "Test before implement hook execution"
|
||||
|
||||
after_implement:
|
||||
- id: post_test
|
||||
enabled: true
|
||||
optional: true
|
||||
extension: "test-extension"
|
||||
command: "post_implement_test"
|
||||
description: "Test after implement hook execution"
|
||||
prompt: "Would you like to run the post-implement test?"
|
||||
|
||||
before_tasks:
|
||||
- id: pre_tasks_test
|
||||
enabled: true
|
||||
optional: false
|
||||
extension: "test-extension"
|
||||
command: "pre_tasks_test"
|
||||
description: "Test before tasks hook execution"
|
||||
|
||||
after_tasks:
|
||||
- id: post_tasks_test
|
||||
enabled: true
|
||||
optional: true
|
||||
extension: "test-extension"
|
||||
command: "post_tasks_test"
|
||||
description: "Test after tasks hook execution"
|
||||
prompt: "Would you like to run the post-tasks test?"
|
||||
30
tests/hooks/TESTING.md
Normal file
30
tests/hooks/TESTING.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Testing Extension Hooks
|
||||
|
||||
This directory contains a mock project to verify that LLM agents correctly identify and execute hook commands defined in `.specify/extensions.yml`.
|
||||
|
||||
## Test 1: Testing `before_tasks` and `after_tasks`
|
||||
|
||||
1. Open a chat with an LLM (like GitHub Copilot) in this project.
|
||||
2. Ask it to generate tasks for the current directory:
|
||||
> "Please follow `/speckit.tasks` for the `./tests/hooks` directory."
|
||||
3. **Expected Behavior**:
|
||||
- Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`.
|
||||
- It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`.
|
||||
- It should then proceed to read the `.md` docs and produce a `tasks.md`.
|
||||
- After generation, it should output the optional `after_tasks` hook (`post_tasks_test`) block, asking if you want to run it.
|
||||
|
||||
## Test 2: Testing `before_implement` and `after_implement`
|
||||
|
||||
*(Requires `tasks.md` from Test 1 to exist)*
|
||||
|
||||
1. In the same (or new) chat, ask the LLM to implement the tasks:
|
||||
> "Please follow `/speckit.implement` for the `./tests/hooks` directory."
|
||||
2. **Expected Behavior**:
|
||||
- The LLM should first check for `before_implement` hooks.
|
||||
- It should state it is executing `EXECUTE_COMMAND: pre_implement_test` BEFORE doing any actual task execution.
|
||||
- It should evaluate the checklists and execute the code writing tasks.
|
||||
- Upon completion, it should output the optional `after_implement` hook (`post_implement_test`) block.
|
||||
|
||||
## How it works
|
||||
|
||||
The templates for these commands in `templates/commands/tasks.md` and `templates/commands/implement.md` contains strict ordered lists. The new `before_*` hooks are explicitly formulated in a **Pre-Execution Checks** section prior to the outline to ensure they're evaluated first without breaking template step numbers.
|
||||
3
tests/hooks/plan.md
Normal file
3
tests/hooks/plan.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Test Setup for Hooks
|
||||
|
||||
This feature is designed to test if LLMs correctly invoke Spec Kit extensions hooks when generating tasks and implementing code.
|
||||
1
tests/hooks/spec.md
Normal file
1
tests/hooks/spec.md
Normal file
@@ -0,0 +1 @@
|
||||
- **User Story 1:** I want a test script that prints "Hello hooks!".
|
||||
1
tests/hooks/tasks.md
Normal file
1
tests/hooks/tasks.md
Normal file
@@ -0,0 +1 @@
|
||||
- [ ] T001 [US1] Create script that prints 'Hello hooks!' in hello.py
|
||||
@@ -97,3 +97,70 @@ class TestAgentConfigConsistency:
|
||||
assert "kiro-cli" in pwsh_text
|
||||
assert "Amazon Q Developer CLI" not in bash_text
|
||||
assert "Amazon Q Developer CLI" not in pwsh_text
|
||||
|
||||
# --- Tabnine CLI consistency checks ---
|
||||
|
||||
def test_runtime_config_includes_tabnine(self):
|
||||
"""AGENT_CONFIG should include tabnine with correct folder and subdir."""
|
||||
assert "tabnine" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["tabnine"]["folder"] == ".tabnine/agent/"
|
||||
assert AGENT_CONFIG["tabnine"]["commands_subdir"] == "commands"
|
||||
assert AGENT_CONFIG["tabnine"]["requires_cli"] is True
|
||||
assert AGENT_CONFIG["tabnine"]["install_url"] is not None
|
||||
|
||||
def test_extension_registrar_includes_tabnine(self):
|
||||
"""CommandRegistrar.AGENT_CONFIGS should include tabnine with correct TOML config."""
|
||||
from specify_cli.extensions import CommandRegistrar
|
||||
|
||||
assert "tabnine" in CommandRegistrar.AGENT_CONFIGS
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS["tabnine"]
|
||||
assert cfg["dir"] == ".tabnine/agent/commands"
|
||||
assert cfg["format"] == "toml"
|
||||
assert cfg["args"] == "{{args}}"
|
||||
assert cfg["extension"] == ".toml"
|
||||
|
||||
def test_release_agent_lists_include_tabnine(self):
|
||||
"""Bash and PowerShell release scripts should include tabnine in agent lists."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||
|
||||
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
|
||||
assert sh_match is not None
|
||||
sh_agents = sh_match.group(1).split()
|
||||
|
||||
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
|
||||
assert ps_match is not None
|
||||
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
|
||||
|
||||
assert "tabnine" in sh_agents
|
||||
assert "tabnine" in ps_agents
|
||||
|
||||
def test_release_scripts_generate_tabnine_toml_commands(self):
|
||||
"""Release scripts should generate TOML commands for tabnine in .tabnine/agent/commands."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||
|
||||
assert ".tabnine/agent/commands" in sh_text
|
||||
assert ".tabnine/agent/commands" in ps_text
|
||||
assert re.search(r"'tabnine'\s*\{.*?\.tabnine/agent/commands", ps_text, re.S) is not None
|
||||
|
||||
def test_github_release_includes_tabnine_packages(self):
|
||||
"""GitHub release script should include tabnine template packages."""
|
||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "spec-kit-template-tabnine-sh-" in gh_release_text
|
||||
assert "spec-kit-template-tabnine-ps-" in gh_release_text
|
||||
|
||||
def test_agent_context_scripts_include_tabnine(self):
|
||||
"""Agent context scripts should support tabnine agent type."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
|
||||
assert "tabnine" in bash_text
|
||||
assert "TABNINE_FILE" in bash_text
|
||||
assert "tabnine" in pwsh_text
|
||||
assert "TABNINE_FILE" in pwsh_text
|
||||
|
||||
def test_ai_help_includes_tabnine(self):
|
||||
"""CLI help text for --ai should include tabnine."""
|
||||
assert "tabnine" in AI_ASSISTANT_HELP
|
||||
|
||||
@@ -147,6 +147,11 @@ class TestGetSkillsDir:
|
||||
result = _get_skills_dir(project_dir, "gemini")
|
||||
assert result == project_dir / ".gemini" / "skills"
|
||||
|
||||
def test_tabnine_skills_dir(self, project_dir):
|
||||
"""Tabnine should use .tabnine/agent/skills/."""
|
||||
result = _get_skills_dir(project_dir, "tabnine")
|
||||
assert result == project_dir / ".tabnine" / "agent" / "skills"
|
||||
|
||||
def test_copilot_skills_dir(self, project_dir):
|
||||
"""Copilot should use .github/skills/."""
|
||||
result = _get_skills_dir(project_dir, "copilot")
|
||||
|
||||
@@ -6,6 +6,7 @@ Tests cover:
|
||||
- Extension registry operations
|
||||
- Extension manager installation/removal
|
||||
- Command registration
|
||||
- Catalog stack (multi-catalog support)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +17,7 @@ from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from specify_cli.extensions import (
|
||||
CatalogEntry,
|
||||
ExtensionManifest,
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
@@ -880,10 +882,29 @@ class TestExtensionCatalog:
|
||||
|
||||
def test_search_all_extensions(self, temp_dir):
|
||||
"""Test searching all extensions without filters."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
# Use a single-catalog config so community extensions don't interfere
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "test-catalog",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Create mock catalog
|
||||
@@ -929,10 +950,29 @@ class TestExtensionCatalog:
|
||||
|
||||
def test_search_by_query(self, temp_dir):
|
||||
"""Test searching by query text."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
# Use a single-catalog config so community extensions don't interfere
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "test-catalog",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Create mock catalog
|
||||
@@ -974,10 +1014,29 @@ class TestExtensionCatalog:
|
||||
|
||||
def test_search_by_tag(self, temp_dir):
|
||||
"""Test searching by tag."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
# Use a single-catalog config so community extensions don't interfere
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "test-catalog",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Create mock catalog
|
||||
@@ -1026,10 +1085,29 @@ class TestExtensionCatalog:
|
||||
|
||||
def test_search_verified_only(self, temp_dir):
|
||||
"""Test searching verified extensions only."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
# Use a single-catalog config so community extensions don't interfere
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "test-catalog",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Create mock catalog
|
||||
@@ -1071,10 +1149,29 @@ class TestExtensionCatalog:
|
||||
|
||||
def test_get_extension_info(self, temp_dir):
|
||||
"""Test getting specific extension info."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
# Use a single-catalog config so community extensions don't interfere
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "test-catalog",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Create mock catalog
|
||||
@@ -1133,3 +1230,371 @@ class TestExtensionCatalog:
|
||||
|
||||
assert not catalog.cache_file.exists()
|
||||
assert not catalog.cache_metadata_file.exists()
|
||||
|
||||
|
||||
# ===== CatalogEntry Tests =====
|
||||
|
||||
class TestCatalogEntry:
|
||||
"""Test CatalogEntry dataclass."""
|
||||
|
||||
def test_catalog_entry_creation(self):
|
||||
"""Test creating a CatalogEntry."""
|
||||
entry = CatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
assert entry.url == "https://example.com/catalog.json"
|
||||
assert entry.name == "test"
|
||||
assert entry.priority == 1
|
||||
assert entry.install_allowed is True
|
||||
|
||||
|
||||
# ===== Catalog Stack Tests =====
|
||||
|
||||
class TestCatalogStack:
|
||||
"""Test multi-catalog stack support."""
|
||||
|
||||
def _make_project(self, temp_dir: Path) -> Path:
|
||||
"""Create a minimal spec-kit project directory."""
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
return project_dir
|
||||
|
||||
def _write_catalog_config(self, project_dir: Path, catalogs: list) -> None:
|
||||
"""Write extension-catalogs.yml to project .specify dir."""
|
||||
import yaml as yaml_module
|
||||
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump({"catalogs": catalogs}, f)
|
||||
|
||||
def _write_valid_cache(
|
||||
self, catalog: ExtensionCatalog, extensions: dict, url: str = "http://test.com"
|
||||
) -> None:
|
||||
"""Populate the primary cache file with mock extension data."""
|
||||
catalog_data = {"schema_version": "1.0", "extensions": extensions}
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": url,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# --- get_active_catalogs ---
|
||||
|
||||
def test_default_stack(self, temp_dir):
|
||||
"""Default stack includes default and community catalogs."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
entries = catalog.get_active_catalogs()
|
||||
|
||||
assert len(entries) == 2
|
||||
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
|
||||
assert entries[0].name == "default"
|
||||
assert entries[0].priority == 1
|
||||
assert entries[0].install_allowed is True
|
||||
assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL
|
||||
assert entries[1].name == "community"
|
||||
assert entries[1].priority == 2
|
||||
assert entries[1].install_allowed is False
|
||||
|
||||
def test_env_var_overrides_default_stack(self, temp_dir, monkeypatch):
|
||||
"""SPECKIT_CATALOG_URL replaces the entire default stack."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
custom_url = "https://example.com/catalog.json"
|
||||
monkeypatch.setenv("SPECKIT_CATALOG_URL", custom_url)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
entries = catalog.get_active_catalogs()
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0].url == custom_url
|
||||
assert entries[0].install_allowed is True
|
||||
|
||||
def test_env_var_invalid_url_raises(self, temp_dir, monkeypatch):
|
||||
"""SPECKIT_CATALOG_URL with http:// (non-localhost) raises ValidationError."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
monkeypatch.setenv("SPECKIT_CATALOG_URL", "http://example.com/catalog.json")
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
with pytest.raises(ValidationError, match="HTTPS"):
|
||||
catalog.get_active_catalogs()
|
||||
|
||||
def test_project_config_overrides_defaults(self, temp_dir):
|
||||
"""Project-level extension-catalogs.yml overrides default stack."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
self._write_catalog_config(
|
||||
project_dir,
|
||||
[
|
||||
{
|
||||
"name": "custom",
|
||||
"url": "https://example.com/catalog.json",
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
entries = catalog.get_active_catalogs()
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0].url == "https://example.com/catalog.json"
|
||||
assert entries[0].name == "custom"
|
||||
|
||||
def test_project_config_sorted_by_priority(self, temp_dir):
|
||||
"""Catalog entries are sorted by priority (ascending)."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
self._write_catalog_config(
|
||||
project_dir,
|
||||
[
|
||||
{
|
||||
"name": "secondary",
|
||||
"url": "https://example.com/secondary.json",
|
||||
"priority": 5,
|
||||
"install_allowed": False,
|
||||
},
|
||||
{
|
||||
"name": "primary",
|
||||
"url": "https://example.com/primary.json",
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
entries = catalog.get_active_catalogs()
|
||||
|
||||
assert len(entries) == 2
|
||||
assert entries[0].name == "primary"
|
||||
assert entries[1].name == "secondary"
|
||||
|
||||
def test_project_config_invalid_url_raises(self, temp_dir):
|
||||
"""Project config with HTTP (non-localhost) URL raises ValidationError."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
self._write_catalog_config(
|
||||
project_dir,
|
||||
[
|
||||
{
|
||||
"name": "bad",
|
||||
"url": "http://example.com/catalog.json",
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
with pytest.raises(ValidationError, match="HTTPS"):
|
||||
catalog.get_active_catalogs()
|
||||
|
||||
def test_empty_project_config_falls_back_to_defaults(self, temp_dir):
|
||||
"""Empty catalogs list in config falls back to default stack."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump({"catalogs": []}, f)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
entries = catalog.get_active_catalogs()
|
||||
|
||||
# Falls back to default stack
|
||||
assert len(entries) == 2
|
||||
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
|
||||
|
||||
# --- _load_catalog_config ---
|
||||
|
||||
def test_load_catalog_config_missing_file(self, temp_dir):
|
||||
"""Returns None when config file doesn't exist."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
result = catalog._load_catalog_config(project_dir / ".specify" / "nonexistent.yml")
|
||||
assert result is None
|
||||
|
||||
def test_load_catalog_config_localhost_allowed(self, temp_dir):
|
||||
"""Localhost HTTP URLs are allowed in config."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
self._write_catalog_config(
|
||||
project_dir,
|
||||
[
|
||||
{
|
||||
"name": "local",
|
||||
"url": "http://localhost:8000/catalog.json",
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
entries = catalog.get_active_catalogs()
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0].url == "http://localhost:8000/catalog.json"
|
||||
|
||||
# --- Merge conflict resolution ---
|
||||
|
||||
def test_merge_conflict_higher_priority_wins(self, temp_dir):
|
||||
"""When same extension id is in two catalogs, higher priority wins."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
|
||||
# Write project config with two catalogs
|
||||
self._write_catalog_config(
|
||||
project_dir,
|
||||
[
|
||||
{
|
||||
"name": "primary",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
},
|
||||
{
|
||||
"name": "secondary",
|
||||
"url": ExtensionCatalog.COMMUNITY_CATALOG_URL,
|
||||
"priority": 2,
|
||||
"install_allowed": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Write primary cache with jira v2.0.0
|
||||
primary_data = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"jira": {
|
||||
"name": "Jira Integration",
|
||||
"id": "jira",
|
||||
"version": "2.0.0",
|
||||
"description": "Primary Jira",
|
||||
}
|
||||
},
|
||||
}
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text(json.dumps(primary_data))
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": "http://test.com"})
|
||||
)
|
||||
|
||||
# Write secondary cache (URL-hash-based) with jira v1.0.0 (should lose)
|
||||
import hashlib
|
||||
|
||||
url_hash = hashlib.sha256(ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()).hexdigest()[:16]
|
||||
secondary_cache = catalog.cache_dir / f"catalog-{url_hash}.json"
|
||||
secondary_meta = catalog.cache_dir / f"catalog-{url_hash}-metadata.json"
|
||||
secondary_data = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"jira": {
|
||||
"name": "Jira Integration Community",
|
||||
"id": "jira",
|
||||
"version": "1.0.0",
|
||||
"description": "Community Jira",
|
||||
},
|
||||
"linear": {
|
||||
"name": "Linear",
|
||||
"id": "linear",
|
||||
"version": "0.9.0",
|
||||
"description": "Linear from secondary",
|
||||
},
|
||||
},
|
||||
}
|
||||
secondary_cache.write_text(json.dumps(secondary_data))
|
||||
secondary_meta.write_text(
|
||||
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": ExtensionCatalog.COMMUNITY_CATALOG_URL})
|
||||
)
|
||||
|
||||
results = catalog.search()
|
||||
jira_results = [r for r in results if r["id"] == "jira"]
|
||||
assert len(jira_results) == 1
|
||||
# Primary catalog wins
|
||||
assert jira_results[0]["version"] == "2.0.0"
|
||||
assert jira_results[0]["_catalog_name"] == "primary"
|
||||
assert jira_results[0]["_install_allowed"] is True
|
||||
|
||||
# linear comes from secondary
|
||||
linear_results = [r for r in results if r["id"] == "linear"]
|
||||
assert len(linear_results) == 1
|
||||
assert linear_results[0]["_catalog_name"] == "secondary"
|
||||
assert linear_results[0]["_install_allowed"] is False
|
||||
|
||||
def test_install_allowed_false_from_get_extension_info(self, temp_dir):
|
||||
"""get_extension_info includes _install_allowed from source catalog."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
|
||||
# Single catalog that is install_allowed=False
|
||||
self._write_catalog_config(
|
||||
project_dir,
|
||||
[
|
||||
{
|
||||
"name": "discovery",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
self._write_valid_cache(
|
||||
catalog,
|
||||
{
|
||||
"jira": {
|
||||
"name": "Jira Integration",
|
||||
"id": "jira",
|
||||
"version": "1.0.0",
|
||||
"description": "Jira integration",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
info = catalog.get_extension_info("jira")
|
||||
assert info is not None
|
||||
assert info["_install_allowed"] is False
|
||||
assert info["_catalog_name"] == "discovery"
|
||||
|
||||
def test_search_results_include_catalog_metadata(self, temp_dir):
|
||||
"""Search results include _catalog_name and _install_allowed."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
self._write_catalog_config(
|
||||
project_dir,
|
||||
[
|
||||
{
|
||||
"name": "org",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
self._write_valid_cache(
|
||||
catalog,
|
||||
{
|
||||
"jira": {
|
||||
"name": "Jira Integration",
|
||||
"id": "jira",
|
||||
"version": "1.0.0",
|
||||
"description": "Jira integration",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
results = catalog.search()
|
||||
assert len(results) == 1
|
||||
assert results[0]["_catalog_name"] == "org"
|
||||
assert results[0]["_install_allowed"] is True
|
||||
|
||||
923
tests/test_templates.py
Normal file
923
tests/test_templates.py
Normal file
@@ -0,0 +1,923 @@
|
||||
"""
|
||||
Unit tests for the template pack system.
|
||||
|
||||
Tests cover:
|
||||
- Template pack manifest validation
|
||||
- Template pack registry operations
|
||||
- Template pack manager installation/removal
|
||||
- Template catalog search
|
||||
- Template resolver priority stack
|
||||
- Extension-provided templates
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.templates import (
|
||||
TemplatePackManifest,
|
||||
TemplatePackRegistry,
|
||||
TemplatePackManager,
|
||||
TemplateCatalog,
|
||||
TemplateResolver,
|
||||
TemplateError,
|
||||
TemplateValidationError,
|
||||
TemplateCompatibilityError,
|
||||
VALID_TEMPLATE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
# ===== Fixtures =====
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
yield Path(tmpdir)
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_pack_data():
|
||||
"""Valid template pack manifest data."""
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"template_pack": {
|
||||
"id": "test-pack",
|
||||
"name": "Test Template Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "A test template pack",
|
||||
"author": "Test Author",
|
||||
"repository": "https://github.com/test/test-pack",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "artifact",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
"description": "Custom spec template",
|
||||
"replaces": "spec-template",
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["testing", "example"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pack_dir(temp_dir, valid_pack_data):
|
||||
"""Create a complete template pack directory structure."""
|
||||
p_dir = temp_dir / "test-pack"
|
||||
p_dir.mkdir()
|
||||
|
||||
# Write manifest
|
||||
manifest_path = p_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
|
||||
# Create templates directory
|
||||
templates_dir = p_dir / "templates"
|
||||
templates_dir.mkdir()
|
||||
|
||||
# Write template file
|
||||
tmpl_file = templates_dir / "spec-template.md"
|
||||
tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n")
|
||||
|
||||
return p_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir):
|
||||
"""Create a mock spec-kit project directory."""
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
|
||||
# Create .specify directory
|
||||
specify_dir = proj_dir / ".specify"
|
||||
specify_dir.mkdir()
|
||||
|
||||
# Create templates directory with core templates
|
||||
templates_dir = specify_dir / "templates"
|
||||
templates_dir.mkdir()
|
||||
|
||||
# Create core spec-template
|
||||
core_spec = templates_dir / "spec-template.md"
|
||||
core_spec.write_text("# Core Spec Template\n")
|
||||
|
||||
# Create core plan-template
|
||||
core_plan = templates_dir / "plan-template.md"
|
||||
core_plan.write_text("# Core Plan Template\n")
|
||||
|
||||
# Create commands subdirectory
|
||||
commands_dir = templates_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
|
||||
return proj_dir
|
||||
|
||||
|
||||
# ===== TemplatePackManifest Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackManifest:
|
||||
"""Test TemplatePackManifest validation and parsing."""
|
||||
|
||||
def test_valid_manifest(self, pack_dir):
|
||||
"""Test loading a valid manifest."""
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
assert manifest.id == "test-pack"
|
||||
assert manifest.name == "Test Template Pack"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.description == "A test template pack"
|
||||
assert manifest.author == "Test Author"
|
||||
assert manifest.requires_speckit_version == ">=0.1.0"
|
||||
assert len(manifest.templates) == 1
|
||||
assert manifest.tags == ["testing", "example"]
|
||||
|
||||
def test_missing_manifest(self, temp_dir):
|
||||
"""Test that missing manifest raises error."""
|
||||
with pytest.raises(TemplateValidationError, match="Manifest not found"):
|
||||
TemplatePackManifest(temp_dir / "nonexistent.yml")
|
||||
|
||||
def test_invalid_yaml(self, temp_dir):
|
||||
"""Test that invalid YAML raises error."""
|
||||
bad_file = temp_dir / "bad.yml"
|
||||
bad_file.write_text(": invalid: yaml: {{{")
|
||||
with pytest.raises(TemplateValidationError, match="Invalid YAML"):
|
||||
TemplatePackManifest(bad_file)
|
||||
|
||||
def test_missing_schema_version(self, temp_dir, valid_pack_data):
|
||||
"""Test missing schema_version field."""
|
||||
del valid_pack_data["schema_version"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing required field: schema_version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_wrong_schema_version(self, temp_dir, valid_pack_data):
|
||||
"""Test unsupported schema version."""
|
||||
valid_pack_data["schema_version"] = "2.0"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Unsupported schema version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_missing_pack_id(self, temp_dir, valid_pack_data):
|
||||
"""Test missing template_pack.id field."""
|
||||
del valid_pack_data["template_pack"]["id"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing template_pack.id"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_pack_id_format(self, temp_dir, valid_pack_data):
|
||||
"""Test invalid pack ID format."""
|
||||
valid_pack_data["template_pack"]["id"] = "Invalid_ID"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template pack ID"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_version(self, temp_dir, valid_pack_data):
|
||||
"""Test invalid semantic version."""
|
||||
valid_pack_data["template_pack"]["version"] = "not-a-version"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_missing_speckit_version(self, temp_dir, valid_pack_data):
|
||||
"""Test missing requires.speckit_version."""
|
||||
del valid_pack_data["requires"]["speckit_version"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing requires.speckit_version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_no_templates_provided(self, temp_dir, valid_pack_data):
|
||||
"""Test pack with no templates."""
|
||||
valid_pack_data["provides"]["templates"] = []
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="must provide at least one template"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_template_type(self, temp_dir, valid_pack_data):
|
||||
"""Test template with invalid type."""
|
||||
valid_pack_data["provides"]["templates"][0]["type"] = "invalid"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template type"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_valid_template_types(self):
|
||||
"""Test that all expected template types are valid."""
|
||||
assert "artifact" in VALID_TEMPLATE_TYPES
|
||||
assert "command" in VALID_TEMPLATE_TYPES
|
||||
assert "script" in VALID_TEMPLATE_TYPES
|
||||
|
||||
def test_template_missing_required_fields(self, temp_dir, valid_pack_data):
|
||||
"""Test template missing required fields."""
|
||||
valid_pack_data["provides"]["templates"] = [{"type": "artifact"}]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="missing 'type', 'name', or 'file'"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_template_name_format(self, temp_dir, valid_pack_data):
|
||||
"""Test template with invalid name format."""
|
||||
valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template name"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_get_hash(self, pack_dir):
|
||||
"""Test manifest hash calculation."""
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
hash_val = manifest.get_hash()
|
||||
assert hash_val.startswith("sha256:")
|
||||
assert len(hash_val) > 10
|
||||
|
||||
def test_multiple_templates(self, temp_dir, valid_pack_data):
|
||||
"""Test pack with multiple templates of different types."""
|
||||
valid_pack_data["provides"]["templates"] = [
|
||||
{"type": "artifact", "name": "spec-template", "file": "templates/spec-template.md"},
|
||||
{"type": "artifact", "name": "plan-template", "file": "templates/plan-template.md"},
|
||||
{"type": "command", "name": "specify", "file": "commands/specify.md"},
|
||||
{"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"},
|
||||
]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
assert len(manifest.templates) == 4
|
||||
|
||||
|
||||
# ===== TemplatePackRegistry Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackRegistry:
|
||||
"""Test TemplatePackRegistry operations."""
|
||||
|
||||
def test_empty_registry(self, temp_dir):
|
||||
"""Test empty registry initialization."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.list() == {}
|
||||
assert not registry.is_installed("test-pack")
|
||||
|
||||
def test_add_and_get(self, temp_dir):
|
||||
"""Test adding and retrieving a pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("test-pack", {"version": "1.0.0", "source": "local"})
|
||||
assert registry.is_installed("test-pack")
|
||||
|
||||
metadata = registry.get("test-pack")
|
||||
assert metadata is not None
|
||||
assert metadata["version"] == "1.0.0"
|
||||
assert "installed_at" in metadata
|
||||
|
||||
def test_remove(self, temp_dir):
|
||||
"""Test removing a pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("test-pack", {"version": "1.0.0"})
|
||||
assert registry.is_installed("test-pack")
|
||||
|
||||
registry.remove("test-pack")
|
||||
assert not registry.is_installed("test-pack")
|
||||
|
||||
def test_remove_nonexistent(self, temp_dir):
|
||||
"""Test removing a pack that doesn't exist."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
registry.remove("nonexistent") # Should not raise
|
||||
|
||||
def test_list(self, temp_dir):
|
||||
"""Test listing all packs."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("pack-a", {"version": "1.0.0"})
|
||||
registry.add("pack-b", {"version": "2.0.0"})
|
||||
|
||||
all_packs = registry.list()
|
||||
assert len(all_packs) == 2
|
||||
assert "pack-a" in all_packs
|
||||
assert "pack-b" in all_packs
|
||||
|
||||
def test_persistence(self, temp_dir):
|
||||
"""Test that registry data persists across instances."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
|
||||
# Add with first instance
|
||||
registry1 = TemplatePackRegistry(packs_dir)
|
||||
registry1.add("test-pack", {"version": "1.0.0"})
|
||||
|
||||
# Load with second instance
|
||||
registry2 = TemplatePackRegistry(packs_dir)
|
||||
assert registry2.is_installed("test-pack")
|
||||
|
||||
def test_corrupted_registry(self, temp_dir):
|
||||
"""Test recovery from corrupted registry file."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
|
||||
registry_file = packs_dir / ".registry"
|
||||
registry_file.write_text("not valid json{{{")
|
||||
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.list() == {}
|
||||
|
||||
def test_get_nonexistent(self, temp_dir):
|
||||
"""Test getting a nonexistent pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
|
||||
# ===== TemplatePackManager Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackManager:
|
||||
"""Test TemplatePackManager installation and removal."""
|
||||
|
||||
def test_install_from_directory(self, project_dir, pack_dir):
|
||||
"""Test installing a template pack from a directory."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
# Verify files are copied
|
||||
installed_dir = project_dir / ".specify" / "templates" / "packs" / "test-pack"
|
||||
assert installed_dir.exists()
|
||||
assert (installed_dir / "template-pack.yml").exists()
|
||||
assert (installed_dir / "templates" / "spec-template.md").exists()
|
||||
|
||||
def test_install_already_installed(self, project_dir, pack_dir):
|
||||
"""Test installing an already-installed pack raises error."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
with pytest.raises(TemplateError, match="already installed"):
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data):
|
||||
"""Test installing an incompatible pack raises error."""
|
||||
valid_pack_data["requires"]["speckit_version"] = ">=99.0.0"
|
||||
incompat_dir = temp_dir / "incompat-pack"
|
||||
incompat_dir.mkdir()
|
||||
manifest_path = incompat_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
(incompat_dir / "templates").mkdir()
|
||||
(incompat_dir / "templates" / "spec-template.md").write_text("test")
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
with pytest.raises(TemplateCompatibilityError):
|
||||
manager.install_from_directory(incompat_dir, "0.1.5")
|
||||
|
||||
def test_install_from_zip(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from a ZIP file."""
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_zip(zip_path, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from ZIP with nested directory."""
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_zip(zip_path, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
|
||||
def test_install_from_zip_no_manifest(self, project_dir, temp_dir):
|
||||
"""Test installing from ZIP without manifest raises error."""
|
||||
zip_path = temp_dir / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
zf.writestr("readme.txt", "no manifest here")
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
with pytest.raises(TemplateValidationError, match="No template-pack.yml found"):
|
||||
manager.install_from_zip(zip_path, "0.1.5")
|
||||
|
||||
def test_remove(self, project_dir, pack_dir):
|
||||
"""Test removing a template pack."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
result = manager.remove("test-pack")
|
||||
assert result is True
|
||||
assert not manager.registry.is_installed("test-pack")
|
||||
|
||||
installed_dir = project_dir / ".specify" / "templates" / "packs" / "test-pack"
|
||||
assert not installed_dir.exists()
|
||||
|
||||
def test_remove_nonexistent(self, project_dir):
|
||||
"""Test removing a pack that doesn't exist."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
result = manager.remove("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_list_installed(self, project_dir, pack_dir):
|
||||
"""Test listing installed packs."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
installed = manager.list_installed()
|
||||
assert len(installed) == 1
|
||||
assert installed[0]["id"] == "test-pack"
|
||||
assert installed[0]["name"] == "Test Template Pack"
|
||||
assert installed[0]["version"] == "1.0.0"
|
||||
assert installed[0]["template_count"] == 1
|
||||
|
||||
def test_list_installed_empty(self, project_dir):
|
||||
"""Test listing when no packs installed."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
assert manager.list_installed() == []
|
||||
|
||||
def test_get_pack(self, project_dir, pack_dir):
|
||||
"""Test getting a specific installed pack."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
pack = manager.get_pack("test-pack")
|
||||
assert pack is not None
|
||||
assert pack.id == "test-pack"
|
||||
|
||||
def test_get_pack_not_installed(self, project_dir):
|
||||
"""Test getting a non-installed pack returns None."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
assert manager.get_pack("nonexistent") is None
|
||||
|
||||
def test_check_compatibility_valid(self, pack_dir):
|
||||
"""Test compatibility check with valid version."""
|
||||
manager = TemplatePackManager(Path(tempfile.mkdtemp()))
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
assert manager.check_compatibility(manifest, "0.1.5") is True
|
||||
|
||||
def test_check_compatibility_invalid(self, pack_dir):
|
||||
"""Test compatibility check with invalid specifier."""
|
||||
manager = TemplatePackManager(Path(tempfile.mkdtemp()))
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
manifest.data["requires"]["speckit_version"] = "not-a-specifier"
|
||||
with pytest.raises(TemplateCompatibilityError, match="Invalid version specifier"):
|
||||
manager.check_compatibility(manifest, "0.1.5")
|
||||
|
||||
|
||||
# ===== TemplateResolver Tests =====
|
||||
|
||||
|
||||
class TestTemplateResolver:
|
||||
"""Test TemplateResolver priority stack."""
|
||||
|
||||
def test_resolve_core_template(self, project_dir):
|
||||
"""Test resolving a core template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert result.name == "spec-template.md"
|
||||
assert "Core Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_nonexistent(self, project_dir):
|
||||
"""Test resolving a nonexistent template returns None."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("nonexistent-template")
|
||||
assert result is None
|
||||
|
||||
def test_resolve_override_takes_priority(self, project_dir):
|
||||
"""Test that project overrides take priority over core."""
|
||||
# Create override
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override Spec Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Override Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir):
|
||||
"""Test that installed packs take priority over core templates."""
|
||||
# Install the pack
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
|
||||
"""Test that overrides take priority over installed packs."""
|
||||
# Install the pack
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
# Create override
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override Spec Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Override Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_extension_provided_templates(self, project_dir):
|
||||
"""Test resolving templates provided by extensions."""
|
||||
# Create extension with templates
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "custom-template.md"
|
||||
ext_template.write_text("# Extension Custom Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("custom-template")
|
||||
assert result is not None
|
||||
assert "Extension Custom Template" in result.read_text()
|
||||
|
||||
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
|
||||
"""Test that pack templates take priority over extension templates."""
|
||||
# Create extension with templates
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "spec-template.md"
|
||||
ext_template.write_text("# Extension Spec Template\n")
|
||||
|
||||
# Install a pack with the same template
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
# Pack should win over extension
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_with_source_core(self, project_dir):
|
||||
"""Test resolve_with_source for core template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "core"
|
||||
assert "spec-template.md" in result["path"]
|
||||
|
||||
def test_resolve_with_source_override(self, project_dir):
|
||||
"""Test resolve_with_source for override template."""
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "project override"
|
||||
|
||||
def test_resolve_with_source_pack(self, project_dir, pack_dir):
|
||||
"""Test resolve_with_source for pack template."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert "test-pack" in result["source"]
|
||||
assert "v1.0.0" in result["source"]
|
||||
|
||||
def test_resolve_with_source_extension(self, project_dir):
|
||||
"""Test resolve_with_source for extension-provided template."""
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "unique-template.md"
|
||||
ext_template.write_text("# Unique\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("unique-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "extension:my-ext"
|
||||
|
||||
def test_resolve_with_source_not_found(self, project_dir):
|
||||
"""Test resolve_with_source for nonexistent template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_resolve_skips_hidden_extension_dirs(self, project_dir):
|
||||
"""Test that hidden directories in extensions are skipped."""
|
||||
ext_dir = project_dir / ".specify" / "extensions" / ".backup"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "hidden-template.md"
|
||||
ext_template.write_text("# Hidden\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("hidden-template")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===== TemplateCatalog Tests =====
|
||||
|
||||
|
||||
class TestTemplateCatalog:
|
||||
"""Test template catalog functionality."""
|
||||
|
||||
def test_default_catalog_url(self, project_dir):
|
||||
"""Test default catalog URL."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert "githubusercontent.com" in catalog.DEFAULT_CATALOG_URL
|
||||
assert "templates/catalog.json" in catalog.DEFAULT_CATALOG_URL
|
||||
|
||||
def test_community_catalog_url(self, project_dir):
|
||||
"""Test community catalog URL."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert "templates/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL
|
||||
|
||||
def test_cache_validation_no_cache(self, project_dir):
|
||||
"""Test cache validation when no cache exists."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_cache_validation_valid(self, project_dir):
|
||||
"""Test cache validation with valid cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text(json.dumps({
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {},
|
||||
}))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
assert catalog.is_cache_valid() is True
|
||||
|
||||
def test_cache_validation_expired(self, project_dir):
|
||||
"""Test cache validation with expired cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text(json.dumps({
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {},
|
||||
}))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": "2020-01-01T00:00:00+00:00",
|
||||
}))
|
||||
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_cache_validation_corrupted(self, project_dir):
|
||||
"""Test cache validation with corrupted metadata."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text("not json")
|
||||
catalog.cache_metadata_file.write_text("not json")
|
||||
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_clear_cache(self, project_dir):
|
||||
"""Test clearing the cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}")
|
||||
catalog.cache_metadata_file.write_text("{}")
|
||||
|
||||
catalog.clear_cache()
|
||||
|
||||
assert not catalog.cache_file.exists()
|
||||
assert not catalog.cache_metadata_file.exists()
|
||||
|
||||
def test_search_with_cached_data(self, project_dir):
|
||||
"""Test search with cached catalog data."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {
|
||||
"safe-agile": {
|
||||
"name": "SAFe Agile Templates",
|
||||
"description": "SAFe-aligned templates",
|
||||
"author": "agile-community",
|
||||
"version": "1.0.0",
|
||||
"tags": ["safe", "agile"],
|
||||
},
|
||||
"healthcare": {
|
||||
"name": "Healthcare Compliance",
|
||||
"description": "HIPAA-compliant templates",
|
||||
"author": "healthcare-org",
|
||||
"version": "1.0.0",
|
||||
"tags": ["healthcare", "hipaa"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
# Search by query
|
||||
results = catalog.search(query="agile")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "safe-agile"
|
||||
|
||||
# Search by tag
|
||||
results = catalog.search(tag="hipaa")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "healthcare"
|
||||
|
||||
# Search by author
|
||||
results = catalog.search(author="agile-community")
|
||||
assert len(results) == 1
|
||||
|
||||
# Search all
|
||||
results = catalog.search()
|
||||
assert len(results) == 2
|
||||
|
||||
def test_get_pack_info(self, project_dir):
|
||||
"""Test getting info for a specific pack."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {
|
||||
"test-pack": {
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
info = catalog.get_pack_info("test-pack")
|
||||
assert info is not None
|
||||
assert info["name"] == "Test Pack"
|
||||
assert info["id"] == "test-pack"
|
||||
|
||||
assert catalog.get_pack_info("nonexistent") is None
|
||||
|
||||
def test_validate_catalog_url_https(self, project_dir):
|
||||
"""Test that HTTPS URLs are accepted."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog._validate_catalog_url("https://example.com/catalog.json")
|
||||
|
||||
def test_validate_catalog_url_http_rejected(self, project_dir):
|
||||
"""Test that HTTP URLs are rejected."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
with pytest.raises(TemplateValidationError, match="must use HTTPS"):
|
||||
catalog._validate_catalog_url("http://example.com/catalog.json")
|
||||
|
||||
def test_validate_catalog_url_localhost_http_allowed(self, project_dir):
|
||||
"""Test that HTTP is allowed for localhost."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog._validate_catalog_url("http://localhost:8080/catalog.json")
|
||||
catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json")
|
||||
|
||||
def test_env_var_catalog_url(self, project_dir, monkeypatch):
|
||||
"""Test catalog URL from environment variable."""
|
||||
monkeypatch.setenv("SPECKIT_TEMPLATE_CATALOG_URL", "https://custom.example.com/catalog.json")
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json"
|
||||
|
||||
|
||||
# ===== Integration Tests =====
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for complete template pack workflows."""
|
||||
|
||||
def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir):
|
||||
"""Test complete lifecycle: install → resolve → remove."""
|
||||
# Install
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_directory(pack_dir, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
|
||||
# Resolve — pack template should win over core
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
# Remove
|
||||
manager.remove("test-pack")
|
||||
|
||||
# Resolve — should fall back to core
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Core Spec Template" in result.read_text()
|
||||
|
||||
def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir):
|
||||
"""Test the full priority stack: override > pack > extension > core."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
|
||||
# Core should resolve
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "core"
|
||||
|
||||
# Add extension template
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "extension:my-ext"
|
||||
|
||||
# Install pack — should win over extension
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert "test-pack" in result["source"]
|
||||
|
||||
# Add override — should win over pack
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
(overrides_dir / "spec-template.md").write_text("# Override\n")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "project override"
|
||||
|
||||
def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from ZIP and then resolving."""
|
||||
# Create ZIP
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
# Install
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_zip(zip_path, "0.1.5")
|
||||
|
||||
# Resolve
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
Reference in New Issue
Block a user