mirror of
https://github.com/github/spec-kit.git
synced 2026-03-19 20:03:07 +00:00
feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
This commit is contained in:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -7,6 +7,21 @@ Recent changes to the Specify CLI and templates are documented here.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
## [0.2.0] - 2026-03-09
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -154,3 +154,44 @@ EOF
|
|||||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||||
|
|
||||||
|
# Resolve a template name to a file path using the priority stack:
|
||||||
|
# 1. .specify/templates/overrides/
|
||||||
|
# 2. .specify/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
|
# to searching for repository markers so the workflow still functions in repositories that
|
||||||
# were initialised with --no-git.
|
# were initialised with --no-git.
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
@@ -296,9 +297,9 @@ fi
|
|||||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
||||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||||
|
|
||||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||||
export SPECIFY_FEATURE="$BRANCH_NAME"
|
export SPECIFY_FEATURE="$BRANCH_NAME"
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
|||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
# Copy plan template if it exists
|
# Copy plan template if it exists
|
||||||
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
|
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
|
||||||
if [[ -f "$TEMPLATE" ]]; then
|
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||||
echo "Copied plan template to $IMPL_PLAN"
|
echo "Copied plan template to $IMPL_PLAN"
|
||||||
else
|
else
|
||||||
echo "Warning: Plan template not found at $TEMPLATE"
|
echo "Warning: Plan template not found"
|
||||||
# Create a basic plan file if template doesn't exist
|
# Create a basic plan file if template doesn't exist
|
||||||
touch "$IMPL_PLAN"
|
touch "$IMPL_PLAN"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -135,3 +135,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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Load common functions (includes Resolve-Template)
|
||||||
|
. "$PSScriptRoot/common.ps1"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
@@ -276,9 +279,9 @@ if ($hasGit) {
|
|||||||
$featureDir = Join-Path $specsDir $branchName
|
$featureDir = Join-Path $specsDir $branchName
|
||||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||||
|
|
||||||
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
|
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||||
$specFile = Join-Path $featureDir 'spec.md'
|
$specFile = Join-Path $featureDir 'spec.md'
|
||||||
if (Test-Path $template) {
|
if ($template -and (Test-Path $template)) {
|
||||||
Copy-Item $template $specFile -Force
|
Copy-Item $template $specFile -Force
|
||||||
} else {
|
} else {
|
||||||
New-Item -ItemType File -Path $specFile | Out-Null
|
New-Item -ItemType File -Path $specFile | Out-Null
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GI
|
|||||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||||
|
|
||||||
# Copy plan template if it exists, otherwise note it or create empty file
|
# Copy plan template if it exists, otherwise note it or create empty file
|
||||||
$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
|
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||||
if (Test-Path $template) {
|
if ($template -and (Test-Path $template)) {
|
||||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||||
} else {
|
} else {
|
||||||
Write-Warning "Plan template not found at $template"
|
Write-Warning "Plan template not found"
|
||||||
# Create a basic plan file if template doesn't exist
|
# Create a basic plan file if template doesn't exist
|
||||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1272,6 +1272,7 @@ def init(
|
|||||||
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
||||||
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
||||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||||
|
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.
|
Initialize a new Specify project from the latest template.
|
||||||
@@ -1300,6 +1301,7 @@ def init(
|
|||||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||||
specify init --here --ai gemini --ai-skills
|
specify init --here --ai gemini --ai-skills
|
||||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||||
|
specify init my-project --ai claude --template healthcare-compliance # With template pack
|
||||||
"""
|
"""
|
||||||
|
|
||||||
show_banner()
|
show_banner()
|
||||||
@@ -1542,6 +1544,27 @@ def init(
|
|||||||
else:
|
else:
|
||||||
tracker.skip("git", "--no-git flag")
|
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")
|
tracker.complete("final", "project ready")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tracker.error("final", str(e))
|
tracker.error("final", str(e))
|
||||||
@@ -1779,6 +1802,13 @@ catalog_app = typer.Typer(
|
|||||||
)
|
)
|
||||||
extension_app.add_typer(catalog_app, name="catalog")
|
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:
|
def get_speckit_version() -> str:
|
||||||
"""Get current spec-kit version."""
|
"""Get current spec-kit version."""
|
||||||
@@ -1801,6 +1831,227 @@ def get_speckit_version() -> str:
|
|||||||
return "unknown"
|
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")
|
@extension_app.command("list")
|
||||||
def extension_list(
|
def extension_list(
|
||||||
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
||||||
|
|||||||
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": {}
|
||||||
|
}
|
||||||
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
|
||||||
Reference in New Issue
Block a user