diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0fa5d9..0e1d48a2 100644 --- a/CHANGELOG.md +++ b/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/), 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 diff --git a/pyproject.toml b/pyproject.toml index e7806959..04b0a338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] 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)." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 2c3165e4..ec40e2d4 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -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//templates/ +# 3. .specify/extensions//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 +} + diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 54697024..d7f707e8 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -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" diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index d01c6d6c..21182ed2 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -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 diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be2735..1725c628 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -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//templates/ +# 3. .specify/extensions//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 +} + diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 8f88b6c5..42e97c07 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -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 diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index d0ed582f..ee09094b 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -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 } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 209632a7..9c55722a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1272,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. @@ -1300,6 +1301,7 @@ def init( specify init my-project --ai claude --ai-skills # Install agent skills specify init --here --ai gemini --ai-skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent + specify init my-project --ai claude --template healthcare-compliance # With template pack """ show_banner() @@ -1542,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)) @@ -1779,6 +1802,13 @@ catalog_app = typer.Typer( ) 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.""" @@ -1801,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 [/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"), diff --git a/src/specify_cli/templates.py b/src/specify_cli/templates.py new file mode 100644 index 00000000..53b2fdf4 --- /dev/null +++ b/src/specify_cli/templates.py @@ -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// - Installed template packs + 3. .specify/extensions//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 diff --git a/templates/catalog.community.json b/templates/catalog.community.json new file mode 100644 index 00000000..e9de491b --- /dev/null +++ b/templates/catalog.community.json @@ -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": {} +} diff --git a/templates/catalog.json b/templates/catalog.json new file mode 100644 index 00000000..948b89a8 --- /dev/null +++ b/templates/catalog.json @@ -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": {} +} diff --git a/templates/template/README.md b/templates/template/README.md new file mode 100644 index 00000000..08b67aca --- /dev/null +++ b/templates/template/README.md @@ -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 diff --git a/templates/template/template-pack.yml b/templates/template/template-pack.yml new file mode 100644 index 00000000..3662f181 --- /dev/null +++ b/templates/template/template-pack.yml @@ -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" diff --git a/templates/template/templates/spec-template.md b/templates/template/templates/spec-template.md new file mode 100644 index 00000000..9f1a2b50 --- /dev/null +++ b/templates/template/templates/spec-template.md @@ -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