mirror of
https://github.com/github/spec-kit.git
synced 2026-03-19 20:03:07 +00:00
feat(presets): pluggable preset system with template/command overrides, catalog, and resolver
- Rename 'template packs' to 'presets' to avoid naming collision with core templates - PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py - Extract CommandRegistrar to agents.py as shared infrastructure - CLI: specify preset list/add/remove/search/resolve/info - CLI: specify preset catalog list/add/remove - --preset option on specify init - Priority-based preset stacking (--priority, lower = higher precedence) - Command overrides registered into all detected agent directories (17+ agents) - Extension command safety: skip registration if target extension not installed - Multi-catalog support: env var, project config, user config, built-in defaults - resolve_template() / Resolve-Template in bash/PowerShell scripts - Self-test preset: overrides all 6 core templates + 1 command - Scaffold with 4 examples: core/extension template and command overrides - Preset catalog (catalog.json, catalog.community.json) - Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md - 110 preset tests, 253 total tests passing
This commit is contained in:
@@ -1272,7 +1272,7 @@ def init(
|
||||
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
||||
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
template: str = typer.Option(None, "--template", help="Install a template pack during initialization (by pack ID)"),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project from the latest template.
|
||||
@@ -1301,7 +1301,7 @@ def init(
|
||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||
specify init --here --ai gemini --ai-skills
|
||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||
specify init my-project --ai claude --template healthcare-compliance # With template pack
|
||||
specify init my-project --ai claude --preset healthcare-compliance # With preset
|
||||
"""
|
||||
|
||||
show_banner()
|
||||
@@ -1544,26 +1544,26 @@ def init(
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
# Install template pack if specified
|
||||
if template:
|
||||
# Install preset if specified
|
||||
if preset:
|
||||
try:
|
||||
from .templates import TemplatePackManager, TemplateCatalog, TemplateError
|
||||
tmpl_manager = TemplatePackManager(project_path)
|
||||
from .presets import PresetManager, PresetCatalog, PresetError
|
||||
preset_manager = PresetManager(project_path)
|
||||
speckit_ver = get_speckit_version()
|
||||
|
||||
# Try local directory first, then catalog
|
||||
local_path = Path(template).resolve()
|
||||
if local_path.is_dir() and (local_path / "template-pack.yml").exists():
|
||||
tmpl_manager.install_from_directory(local_path, speckit_ver)
|
||||
local_path = Path(preset).resolve()
|
||||
if local_path.is_dir() and (local_path / "preset.yml").exists():
|
||||
preset_manager.install_from_directory(local_path, speckit_ver)
|
||||
else:
|
||||
tmpl_catalog = TemplateCatalog(project_path)
|
||||
preset_catalog = PresetCatalog(project_path)
|
||||
try:
|
||||
zip_path = tmpl_catalog.download_pack(template)
|
||||
tmpl_manager.install_from_zip(zip_path, speckit_ver)
|
||||
except TemplateError:
|
||||
console.print(f"[yellow]Warning:[/yellow] Template pack '{template}' not found in catalog. Skipping.")
|
||||
except Exception as tmpl_err:
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to install template pack: {tmpl_err}")
|
||||
zip_path = preset_catalog.download_pack(preset)
|
||||
preset_manager.install_from_zip(zip_path, speckit_ver)
|
||||
except PresetError:
|
||||
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
|
||||
except Exception as preset_err:
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except Exception as e:
|
||||
@@ -1802,12 +1802,19 @@ catalog_app = typer.Typer(
|
||||
)
|
||||
extension_app.add_typer(catalog_app, name="catalog")
|
||||
|
||||
template_app = typer.Typer(
|
||||
name="template",
|
||||
help="Manage spec-kit template packs",
|
||||
preset_app = typer.Typer(
|
||||
name="preset",
|
||||
help="Manage spec-kit presets",
|
||||
add_completion=False,
|
||||
)
|
||||
app.add_typer(template_app, name="template")
|
||||
app.add_typer(preset_app, name="preset")
|
||||
|
||||
preset_catalog_app = typer.Typer(
|
||||
name="catalog",
|
||||
help="Manage preset catalogs",
|
||||
add_completion=False,
|
||||
)
|
||||
preset_app.add_typer(preset_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def get_speckit_version() -> str:
|
||||
@@ -1831,13 +1838,13 @@ def get_speckit_version() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ===== Template Pack Commands =====
|
||||
# ===== Preset Commands =====
|
||||
|
||||
|
||||
@template_app.command("list")
|
||||
def template_list():
|
||||
"""List installed template packs."""
|
||||
from .templates import TemplatePackManager
|
||||
@preset_app.command("list")
|
||||
def preset_list():
|
||||
"""List installed presets."""
|
||||
from .presets import PresetManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
@@ -1847,19 +1854,20 @@ def template_list():
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = TemplatePackManager(project_root)
|
||||
manager = PresetManager(project_root)
|
||||
installed = manager.list_installed()
|
||||
|
||||
if not installed:
|
||||
console.print("[yellow]No template packs installed.[/yellow]")
|
||||
console.print("\nInstall a template pack with:")
|
||||
console.print(" [cyan]specify template add <pack-name>[/cyan]")
|
||||
console.print("[yellow]No presets installed.[/yellow]")
|
||||
console.print("\nInstall a preset with:")
|
||||
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Installed Template Packs:[/bold cyan]\n")
|
||||
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
|
||||
for pack in installed:
|
||||
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status}")
|
||||
pri = pack.get('priority', 10)
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
||||
console.print(f" {pack['description']}")
|
||||
if pack.get("tags"):
|
||||
tags_str = ", ".join(pack["tags"])
|
||||
@@ -1868,19 +1876,20 @@ def template_list():
|
||||
console.print()
|
||||
|
||||
|
||||
@template_app.command("add")
|
||||
def template_add(
|
||||
pack_id: str = typer.Argument(None, help="Template pack ID to install from catalog"),
|
||||
@preset_app.command("add")
|
||||
def preset_add(
|
||||
pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
|
||||
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
||||
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||
):
|
||||
"""Install a template pack."""
|
||||
from .templates import (
|
||||
TemplatePackManager,
|
||||
TemplateCatalog,
|
||||
TemplateError,
|
||||
TemplateValidationError,
|
||||
TemplateCompatibilityError,
|
||||
"""Install a preset."""
|
||||
from .presets import (
|
||||
PresetManager,
|
||||
PresetCatalog,
|
||||
PresetError,
|
||||
PresetValidationError,
|
||||
PresetCompatibilityError,
|
||||
)
|
||||
|
||||
project_root = Path.cwd()
|
||||
@@ -1891,7 +1900,7 @@ def template_add(
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = TemplatePackManager(project_root)
|
||||
manager = PresetManager(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
try:
|
||||
@@ -1901,18 +1910,18 @@ def template_add(
|
||||
console.print(f"[red]Error:[/red] Directory not found: {dev}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing template pack from [cyan]{dev_path}[/cyan]...")
|
||||
manifest = manager.install_from_directory(dev_path, speckit_version)
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...")
|
||||
manifest = manager.install_from_directory(dev_path, speckit_version, priority)
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
|
||||
elif from_url:
|
||||
console.print(f"Installing template pack from [cyan]{from_url}[/cyan]...")
|
||||
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "template-pack.zip"
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
try:
|
||||
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||
zip_path.write_bytes(response.read())
|
||||
@@ -1920,48 +1929,48 @@ def template_add(
|
||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
|
||||
elif pack_id:
|
||||
catalog = TemplateCatalog(project_root)
|
||||
catalog = PresetCatalog(project_root)
|
||||
pack_info = catalog.get_pack_info(pack_id)
|
||||
|
||||
if not pack_info:
|
||||
console.print(f"[red]Error:[/red] Template pack '{pack_id}' not found in catalog")
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing template pack [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
|
||||
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
|
||||
|
||||
try:
|
||||
zip_path = catalog.download_pack(pack_id)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
console.print(f"[green]✓[/green] Template pack '{manifest.name}' v{manifest.version} installed successfully")
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
finally:
|
||||
if 'zip_path' in locals() and zip_path.exists():
|
||||
zip_path.unlink(missing_ok=True)
|
||||
else:
|
||||
console.print("[red]Error:[/red] Specify a template pack ID, --from URL, or --dev path")
|
||||
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except TemplateCompatibilityError as e:
|
||||
except PresetCompatibilityError as e:
|
||||
console.print(f"[red]Compatibility Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except TemplateValidationError as e:
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Validation Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
except TemplateError as e:
|
||||
except PresetError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@template_app.command("remove")
|
||||
def template_remove(
|
||||
pack_id: str = typer.Argument(..., help="Template pack ID to remove"),
|
||||
@preset_app.command("remove")
|
||||
def preset_remove(
|
||||
pack_id: str = typer.Argument(..., help="Preset ID to remove"),
|
||||
):
|
||||
"""Remove an installed template pack."""
|
||||
from .templates import TemplatePackManager
|
||||
"""Remove an installed preset."""
|
||||
from .presets import PresetManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
@@ -1971,27 +1980,27 @@ def template_remove(
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = TemplatePackManager(project_root)
|
||||
manager = PresetManager(project_root)
|
||||
|
||||
if not manager.registry.is_installed(pack_id):
|
||||
console.print(f"[red]Error:[/red] Template pack '{pack_id}' is not installed")
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if manager.remove(pack_id):
|
||||
console.print(f"[green]✓[/green] Template pack '{pack_id}' removed successfully")
|
||||
console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Failed to remove template pack '{pack_id}'")
|
||||
console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@template_app.command("search")
|
||||
def template_search(
|
||||
@preset_app.command("search")
|
||||
def preset_search(
|
||||
query: str = typer.Argument(None, help="Search query"),
|
||||
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
|
||||
author: str = typer.Option(None, "--author", help="Filter by author"),
|
||||
):
|
||||
"""Search for template packs in the catalog."""
|
||||
from .templates import TemplateCatalog, TemplateError
|
||||
"""Search for presets in the catalog."""
|
||||
from .presets import PresetCatalog, PresetError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
@@ -2001,19 +2010,19 @@ def template_search(
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalog = TemplateCatalog(project_root)
|
||||
catalog = PresetCatalog(project_root)
|
||||
|
||||
try:
|
||||
results = catalog.search(query=query, tag=tag, author=author)
|
||||
except TemplateError as e:
|
||||
except PresetError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not results:
|
||||
console.print("[yellow]No template packs found matching your criteria.[/yellow]")
|
||||
console.print("[yellow]No presets found matching your criteria.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold cyan]Template Packs ({len(results)} found):[/bold cyan]\n")
|
||||
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
|
||||
for pack in results:
|
||||
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
|
||||
console.print(f" {pack.get('description', '')}")
|
||||
@@ -2023,12 +2032,12 @@ def template_search(
|
||||
console.print()
|
||||
|
||||
|
||||
@template_app.command("resolve")
|
||||
def template_resolve(
|
||||
@preset_app.command("resolve")
|
||||
def preset_resolve(
|
||||
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
|
||||
):
|
||||
"""Show which template will be resolved for a given name."""
|
||||
from .templates import TemplateResolver
|
||||
from .presets import PresetResolver
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
@@ -2038,7 +2047,7 @@ def template_resolve(
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
resolver = TemplateResolver(project_root)
|
||||
resolver = PresetResolver(project_root)
|
||||
result = resolver.resolve_with_source(template_name)
|
||||
|
||||
if result:
|
||||
@@ -2049,6 +2058,253 @@ def template_resolve(
|
||||
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
||||
|
||||
|
||||
@preset_app.command("info")
|
||||
def preset_info(
|
||||
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
||||
):
|
||||
"""Show detailed information about a preset."""
|
||||
from .presets import PresetCatalog, PresetManager, PresetError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Check if installed locally first
|
||||
manager = PresetManager(project_root)
|
||||
local_pack = manager.get_pack(pack_id)
|
||||
|
||||
if local_pack:
|
||||
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
|
||||
console.print(f" ID: {local_pack.id}")
|
||||
console.print(f" Version: {local_pack.version}")
|
||||
console.print(f" Description: {local_pack.description}")
|
||||
if local_pack.author:
|
||||
console.print(f" Author: {local_pack.author}")
|
||||
if local_pack.tags:
|
||||
console.print(f" Tags: {', '.join(local_pack.tags)}")
|
||||
console.print(f" Templates: {len(local_pack.templates)}")
|
||||
for tmpl in local_pack.templates:
|
||||
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
||||
repo = local_pack.data.get("preset", {}).get("repository")
|
||||
if repo:
|
||||
console.print(f" Repository: {repo}")
|
||||
license_val = local_pack.data.get("preset", {}).get("license")
|
||||
if license_val:
|
||||
console.print(f" License: {license_val}")
|
||||
console.print(f"\n [green]Status: installed[/green]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Fall back to catalog
|
||||
catalog = PresetCatalog(project_root)
|
||||
try:
|
||||
pack_info = catalog.get_pack_info(pack_id)
|
||||
except PresetError:
|
||||
pack_info = None
|
||||
|
||||
if not pack_info:
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n")
|
||||
console.print(f" ID: {pack_info['id']}")
|
||||
console.print(f" Version: {pack_info.get('version', '?')}")
|
||||
console.print(f" Description: {pack_info.get('description', '')}")
|
||||
if pack_info.get("author"):
|
||||
console.print(f" Author: {pack_info['author']}")
|
||||
if pack_info.get("tags"):
|
||||
console.print(f" Tags: {', '.join(pack_info['tags'])}")
|
||||
if pack_info.get("repository"):
|
||||
console.print(f" Repository: {pack_info['repository']}")
|
||||
if pack_info.get("license"):
|
||||
console.print(f" License: {pack_info['license']}")
|
||||
console.print(f"\n [yellow]Status: not installed[/yellow]")
|
||||
console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
# ===== Preset Catalog Commands =====
|
||||
|
||||
|
||||
@preset_catalog_app.command("list")
|
||||
def preset_catalog_list():
|
||||
"""List all active preset catalogs."""
|
||||
from .presets import PresetCatalog, PresetValidationError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalog = PresetCatalog(project_root)
|
||||
|
||||
try:
|
||||
active_catalogs = catalog.get_active_catalogs()
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
|
||||
for entry in active_catalogs:
|
||||
install_str = (
|
||||
"[green]install allowed[/green]"
|
||||
if entry.install_allowed
|
||||
else "[yellow]discovery only[/yellow]"
|
||||
)
|
||||
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
||||
if entry.description:
|
||||
console.print(f" {entry.description}")
|
||||
console.print(f" URL: {entry.url}")
|
||||
console.print(f" Install: {install_str}")
|
||||
console.print()
|
||||
|
||||
config_path = project_root / ".specify" / "preset-catalogs.yml"
|
||||
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
|
||||
if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
|
||||
console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]")
|
||||
else:
|
||||
try:
|
||||
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
|
||||
except PresetValidationError:
|
||||
proj_loaded = False
|
||||
if proj_loaded:
|
||||
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
|
||||
else:
|
||||
try:
|
||||
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
|
||||
except PresetValidationError:
|
||||
user_loaded = False
|
||||
if user_loaded:
|
||||
console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
|
||||
else:
|
||||
console.print("[dim]Using built-in default catalog stack.[/dim]")
|
||||
console.print(
|
||||
"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@preset_catalog_app.command("add")
|
||||
def preset_catalog_add(
|
||||
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
|
||||
name: str = typer.Option(..., "--name", help="Catalog name"),
|
||||
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
|
||||
install_allowed: bool = typer.Option(
|
||||
False, "--install-allowed/--no-install-allowed",
|
||||
help="Allow presets from this catalog to be installed",
|
||||
),
|
||||
description: str = typer.Option("", "--description", help="Description of the catalog"),
|
||||
):
|
||||
"""Add a catalog to .specify/preset-catalogs.yml."""
|
||||
from .presets import PresetCatalog, PresetValidationError
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate URL
|
||||
tmp_catalog = PresetCatalog(project_root)
|
||||
try:
|
||||
tmp_catalog._validate_catalog_url(url)
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_path = specify_dir / "preset-catalogs.yml"
|
||||
|
||||
# Load existing config
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
config = {}
|
||||
|
||||
catalogs = config.get("catalogs", [])
|
||||
if not isinstance(catalogs, list):
|
||||
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Check for duplicate name
|
||||
for existing in catalogs:
|
||||
if isinstance(existing, dict) and existing.get("name") == name:
|
||||
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
||||
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalogs.append({
|
||||
"name": name,
|
||||
"url": url,
|
||||
"priority": priority,
|
||||
"install_allowed": install_allowed,
|
||||
"description": description,
|
||||
})
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
||||
|
||||
install_label = "install allowed" if install_allowed else "discovery only"
|
||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||
console.print(f" URL: {url}")
|
||||
console.print(f" Priority: {priority}")
|
||||
console.print(f"\nConfig saved to {config_path.relative_to(project_root)}")
|
||||
|
||||
|
||||
@preset_catalog_app.command("remove")
|
||||
def preset_catalog_remove(
|
||||
name: str = typer.Argument(help="Catalog name to remove"),
|
||||
):
|
||||
"""Remove a catalog from .specify/preset-catalogs.yml."""
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_path = specify_dir / "preset-catalogs.yml"
|
||||
if not config_path.exists():
|
||||
console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
except Exception:
|
||||
console.print("[red]Error:[/red] Failed to read preset catalog config.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalogs = config.get("catalogs", [])
|
||||
if not isinstance(catalogs, list):
|
||||
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||
raise typer.Exit(1)
|
||||
original_count = len(catalogs)
|
||||
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
||||
|
||||
if len(catalogs) == original_count:
|
||||
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
||||
|
||||
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||
if not catalogs:
|
||||
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
||||
|
||||
|
||||
# ===== Extension Commands =====
|
||||
|
||||
|
||||
|
||||
408
src/specify_cli/agents.py
Normal file
408
src/specify_cli/agents.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Agent Command Registrar for Spec Kit
|
||||
|
||||
Shared infrastructure for registering commands with AI agents.
|
||||
Used by both the extension system and the preset system to write
|
||||
command files into agent-specific directories in the correct format.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class CommandRegistrar:
|
||||
"""Handles registration of commands with AI agents.
|
||||
|
||||
Supports writing command files in Markdown or TOML format to the
|
||||
appropriate agent directory, with correct argument placeholders
|
||||
and companion files (e.g. Copilot .prompt.md).
|
||||
"""
|
||||
|
||||
# Agent configurations with directory, format, and argument placeholder
|
||||
AGENT_CONFIGS = {
|
||||
"claude": {
|
||||
"dir": ".claude/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"gemini": {
|
||||
"dir": ".gemini/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"copilot": {
|
||||
"dir": ".github/agents",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".agent.md"
|
||||
},
|
||||
"cursor": {
|
||||
"dir": ".cursor/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"qwen": {
|
||||
"dir": ".qwen/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"opencode": {
|
||||
"dir": ".opencode/command",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"windsurf": {
|
||||
"dir": ".windsurf/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kilocode": {
|
||||
"dir": ".kilocode/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"auggie": {
|
||||
"dir": ".augment/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"roo": {
|
||||
"dir": ".roo/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"codebuddy": {
|
||||
"dir": ".codebuddy/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"qodercli": {
|
||||
"dir": ".qoder/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kiro-cli": {
|
||||
"dir": ".kiro/prompts",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"amp": {
|
||||
"dir": ".agents/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"shai": {
|
||||
"dir": ".shai/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"tabnine": {
|
||||
"dir": ".tabnine/agent/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"bob": {
|
||||
"dir": ".bob/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||
"""Parse YAML frontmatter from Markdown content.
|
||||
|
||||
Args:
|
||||
content: Markdown content with YAML frontmatter
|
||||
|
||||
Returns:
|
||||
Tuple of (frontmatter_dict, body_content)
|
||||
"""
|
||||
if not content.startswith("---"):
|
||||
return {}, content
|
||||
|
||||
# Find second ---
|
||||
end_marker = content.find("---", 3)
|
||||
if end_marker == -1:
|
||||
return {}, content
|
||||
|
||||
frontmatter_str = content[3:end_marker].strip()
|
||||
body = content[end_marker + 3:].strip()
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_str) or {}
|
||||
except yaml.YAMLError:
|
||||
frontmatter = {}
|
||||
|
||||
return frontmatter, body
|
||||
|
||||
@staticmethod
|
||||
def render_frontmatter(fm: dict) -> str:
|
||||
"""Render frontmatter dictionary as YAML.
|
||||
|
||||
Args:
|
||||
fm: Frontmatter dictionary
|
||||
|
||||
Returns:
|
||||
YAML-formatted frontmatter with delimiters
|
||||
"""
|
||||
if not fm:
|
||||
return ""
|
||||
|
||||
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||
"""Adjust script paths from extension-relative to repo-relative.
|
||||
|
||||
Args:
|
||||
frontmatter: Frontmatter dictionary
|
||||
|
||||
Returns:
|
||||
Modified frontmatter with adjusted paths
|
||||
"""
|
||||
if "scripts" in frontmatter:
|
||||
for key in frontmatter["scripts"]:
|
||||
script_path = frontmatter["scripts"][key]
|
||||
if script_path.startswith("../../scripts/"):
|
||||
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
||||
return frontmatter
|
||||
|
||||
def render_markdown_command(
|
||||
self,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
source_id: str,
|
||||
context_note: str = None
|
||||
) -> str:
|
||||
"""Render command in Markdown format.
|
||||
|
||||
Args:
|
||||
frontmatter: Command frontmatter
|
||||
body: Command body content
|
||||
source_id: Source identifier (extension or preset ID)
|
||||
context_note: Custom context comment (default: <!-- Source: {source_id} -->)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown command file content
|
||||
"""
|
||||
if context_note is None:
|
||||
context_note = f"\n<!-- Source: {source_id} -->\n"
|
||||
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
|
||||
def render_toml_command(
|
||||
self,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
source_id: str
|
||||
) -> str:
|
||||
"""Render command in TOML format.
|
||||
|
||||
Args:
|
||||
frontmatter: Command frontmatter
|
||||
body: Command body content
|
||||
source_id: Source identifier (extension or preset ID)
|
||||
|
||||
Returns:
|
||||
Formatted TOML command file content
|
||||
"""
|
||||
toml_lines = []
|
||||
|
||||
if "description" in frontmatter:
|
||||
desc = frontmatter["description"].replace('"', '\\"')
|
||||
toml_lines.append(f'description = "{desc}"')
|
||||
toml_lines.append("")
|
||||
|
||||
toml_lines.append(f"# Source: {source_id}")
|
||||
toml_lines.append("")
|
||||
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(body)
|
||||
toml_lines.append('"""')
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||
"""Convert argument placeholder format.
|
||||
|
||||
Args:
|
||||
content: Command content
|
||||
from_placeholder: Source placeholder (e.g., "$ARGUMENTS")
|
||||
to_placeholder: Target placeholder (e.g., "{{args}}")
|
||||
|
||||
Returns:
|
||||
Content with converted placeholders
|
||||
"""
|
||||
return content.replace(from_placeholder, to_placeholder)
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
agent_name: str,
|
||||
commands: List[Dict[str, Any]],
|
||||
source_id: str,
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: str = None
|
||||
) -> List[str]:
|
||||
"""Register commands for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_name: Agent name (claude, gemini, copilot, etc.)
|
||||
commands: List of command info dicts with 'name', 'file', and optional 'aliases'
|
||||
source_id: Identifier of the source (extension or preset ID)
|
||||
source_dir: Directory containing command source files
|
||||
project_root: Path to project root
|
||||
context_note: Custom context comment for markdown output
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
|
||||
Raises:
|
||||
ValueError: If agent is not supported
|
||||
"""
|
||||
if agent_name not in self.AGENT_CONFIGS:
|
||||
raise ValueError(f"Unsupported agent: {agent_name}")
|
||||
|
||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
registered = []
|
||||
|
||||
for cmd_info in commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
cmd_file = cmd_info["file"]
|
||||
|
||||
source_file = source_dir / cmd_file
|
||||
if not source_file.exists():
|
||||
continue
|
||||
|
||||
content = source_file.read_text()
|
||||
frontmatter, body = self.parse_frontmatter(content)
|
||||
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
|
||||
if agent_config["format"] == "markdown":
|
||||
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||
elif agent_config["format"] == "toml":
|
||||
output = self.render_toml_command(frontmatter, body, source_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
dest_file.write_text(output)
|
||||
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, cmd_name)
|
||||
|
||||
registered.append(cmd_name)
|
||||
|
||||
for alias in cmd_info.get("aliases", []):
|
||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
||||
alias_file.write_text(output)
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, alias)
|
||||
registered.append(alias)
|
||||
|
||||
return registered
|
||||
|
||||
@staticmethod
|
||||
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
|
||||
"""Generate a companion .prompt.md file for a Copilot agent command.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root
|
||||
cmd_name: Command name (e.g. 'speckit.my-ext.example')
|
||||
"""
|
||||
prompts_dir = project_root / ".github" / "prompts"
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
|
||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n")
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
self,
|
||||
commands: List[Dict[str, Any]],
|
||||
source_id: str,
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: str = None
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all detected agents in the project.
|
||||
|
||||
Args:
|
||||
commands: List of command info dicts
|
||||
source_id: Identifier of the source (extension or preset ID)
|
||||
source_dir: Directory containing command source files
|
||||
project_root: Path to project root
|
||||
context_note: Custom context comment for markdown output
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
agent_dir = project_root / agent_config["dir"].split("/")[0]
|
||||
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name, commands, source_id, source_dir, project_root,
|
||||
context_note=context_note
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
def unregister_commands(
|
||||
self,
|
||||
registered_commands: Dict[str, List[str]],
|
||||
project_root: Path
|
||||
) -> None:
|
||||
"""Remove previously registered command files from agent directories.
|
||||
|
||||
Args:
|
||||
registered_commands: Dict mapping agent names to command name lists
|
||||
project_root: Path to project root
|
||||
"""
|
||||
for agent_name, cmd_names in registered_commands.items():
|
||||
if agent_name not in self.AGENT_CONFIGS:
|
||||
continue
|
||||
|
||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
|
||||
if agent_name == "copilot":
|
||||
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||
if prompt_file.exists():
|
||||
prompt_file.unlink()
|
||||
@@ -455,23 +455,7 @@ class ExtensionManager:
|
||||
# Unregister commands from all AI agents
|
||||
if registered_commands:
|
||||
registrar = CommandRegistrar()
|
||||
for agent_name, cmd_names in registered_commands.items():
|
||||
if agent_name not in registrar.AGENT_CONFIGS:
|
||||
continue
|
||||
|
||||
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = self.project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
|
||||
# Also remove companion .prompt.md for Copilot
|
||||
if agent_name == "copilot":
|
||||
prompt_file = self.project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||
if prompt_file.exists():
|
||||
prompt_file.unlink()
|
||||
registrar._registrar.unregister_commands(registered_commands, self.project_root)
|
||||
|
||||
if keep_config:
|
||||
# Preserve config files, only remove non-config files
|
||||
@@ -595,243 +579,44 @@ def version_satisfies(current: str, required: str) -> bool:
|
||||
|
||||
|
||||
class CommandRegistrar:
|
||||
"""Handles registration of extension commands with AI agents."""
|
||||
"""Handles registration of extension commands with AI agents.
|
||||
|
||||
# Agent configurations with directory, format, and argument placeholder
|
||||
AGENT_CONFIGS = {
|
||||
"claude": {
|
||||
"dir": ".claude/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"gemini": {
|
||||
"dir": ".gemini/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"copilot": {
|
||||
"dir": ".github/agents",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".agent.md"
|
||||
},
|
||||
"cursor": {
|
||||
"dir": ".cursor/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"qwen": {
|
||||
"dir": ".qwen/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"opencode": {
|
||||
"dir": ".opencode/command",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"windsurf": {
|
||||
"dir": ".windsurf/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kilocode": {
|
||||
"dir": ".kilocode/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"auggie": {
|
||||
"dir": ".augment/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"roo": {
|
||||
"dir": ".roo/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"codebuddy": {
|
||||
"dir": ".codebuddy/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"qodercli": {
|
||||
"dir": ".qoder/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kiro-cli": {
|
||||
"dir": ".kiro/prompts",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"amp": {
|
||||
"dir": ".agents/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"shai": {
|
||||
"dir": ".shai/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"tabnine": {
|
||||
"dir": ".tabnine/agent/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"bob": {
|
||||
"dir": ".bob/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
}
|
||||
}
|
||||
This is a backward-compatible wrapper around the shared CommandRegistrar
|
||||
in agents.py. Extension-specific methods accept ExtensionManifest objects
|
||||
and delegate to the generic API.
|
||||
"""
|
||||
|
||||
# Re-export AGENT_CONFIGS at class level for direct attribute access
|
||||
from .agents import CommandRegistrar as _AgentRegistrar
|
||||
AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS
|
||||
|
||||
def __init__(self):
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
self._registrar = _Registrar()
|
||||
|
||||
# Delegate static/utility methods
|
||||
@staticmethod
|
||||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||
"""Parse YAML frontmatter from Markdown content.
|
||||
|
||||
Args:
|
||||
content: Markdown content with YAML frontmatter
|
||||
|
||||
Returns:
|
||||
Tuple of (frontmatter_dict, body_content)
|
||||
"""
|
||||
if not content.startswith("---"):
|
||||
return {}, content
|
||||
|
||||
# Find second ---
|
||||
end_marker = content.find("---", 3)
|
||||
if end_marker == -1:
|
||||
return {}, content
|
||||
|
||||
frontmatter_str = content[3:end_marker].strip()
|
||||
body = content[end_marker + 3:].strip()
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_str) or {}
|
||||
except yaml.YAMLError:
|
||||
frontmatter = {}
|
||||
|
||||
return frontmatter, body
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
return _Registrar.parse_frontmatter(content)
|
||||
|
||||
@staticmethod
|
||||
def render_frontmatter(fm: dict) -> str:
|
||||
"""Render frontmatter dictionary as YAML.
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
return _Registrar.render_frontmatter(fm)
|
||||
|
||||
Args:
|
||||
fm: Frontmatter dictionary
|
||||
@staticmethod
|
||||
def _write_copilot_prompt(project_root, cmd_name: str) -> None:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
_Registrar.write_copilot_prompt(project_root, cmd_name)
|
||||
|
||||
Returns:
|
||||
YAML-formatted frontmatter with delimiters
|
||||
"""
|
||||
if not fm:
|
||||
return ""
|
||||
|
||||
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||
"""Adjust script paths from extension-relative to repo-relative.
|
||||
|
||||
Args:
|
||||
frontmatter: Frontmatter dictionary
|
||||
|
||||
Returns:
|
||||
Modified frontmatter with adjusted paths
|
||||
"""
|
||||
if "scripts" in frontmatter:
|
||||
for key in frontmatter["scripts"]:
|
||||
script_path = frontmatter["scripts"][key]
|
||||
if script_path.startswith("../../scripts/"):
|
||||
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
||||
return frontmatter
|
||||
|
||||
def _render_markdown_command(
|
||||
self,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
ext_id: str
|
||||
) -> str:
|
||||
"""Render command in Markdown format.
|
||||
|
||||
Args:
|
||||
frontmatter: Command frontmatter
|
||||
body: Command body content
|
||||
ext_id: Extension ID
|
||||
|
||||
Returns:
|
||||
Formatted Markdown command file content
|
||||
"""
|
||||
def _render_markdown_command(self, frontmatter, body, ext_id):
|
||||
# Preserve extension-specific comment format for backward compatibility
|
||||
context_note = f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n"
|
||||
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
|
||||
def _render_toml_command(
|
||||
self,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
ext_id: str
|
||||
) -> str:
|
||||
"""Render command in TOML format.
|
||||
|
||||
Args:
|
||||
frontmatter: Command frontmatter
|
||||
body: Command body content
|
||||
ext_id: Extension ID
|
||||
|
||||
Returns:
|
||||
Formatted TOML command file content
|
||||
"""
|
||||
# TOML format for Gemini/Qwen
|
||||
toml_lines = []
|
||||
|
||||
# Add description if present
|
||||
if "description" in frontmatter:
|
||||
# Escape quotes in description
|
||||
desc = frontmatter["description"].replace('"', '\\"')
|
||||
toml_lines.append(f'description = "{desc}"')
|
||||
toml_lines.append("")
|
||||
|
||||
# Add extension context as comments
|
||||
toml_lines.append(f"# Extension: {ext_id}")
|
||||
toml_lines.append(f"# Config: .specify/extensions/{ext_id}/")
|
||||
toml_lines.append("")
|
||||
|
||||
# Add prompt content
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(body)
|
||||
toml_lines.append('"""')
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||
"""Convert argument placeholder format.
|
||||
|
||||
Args:
|
||||
content: Command content
|
||||
from_placeholder: Source placeholder (e.g., "$ARGUMENTS")
|
||||
to_placeholder: Target placeholder (e.g., "{{args}}")
|
||||
|
||||
Returns:
|
||||
Content with converted placeholders
|
||||
"""
|
||||
return content.replace(from_placeholder, to_placeholder)
|
||||
def _render_toml_command(self, frontmatter, body, ext_id):
|
||||
return self._registrar.render_toml_command(frontmatter, body, ext_id)
|
||||
|
||||
def register_commands_for_agent(
|
||||
self,
|
||||
@@ -840,94 +625,14 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> List[str]:
|
||||
"""Register extension commands for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_name: Agent name (claude, gemini, copilot, etc.)
|
||||
manifest: Extension manifest
|
||||
extension_dir: Path to extension directory
|
||||
project_root: Path to project root
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
|
||||
Raises:
|
||||
ExtensionError: If agent is not supported
|
||||
"""
|
||||
"""Register extension commands for a specific agent."""
|
||||
if agent_name not in self.AGENT_CONFIGS:
|
||||
raise ExtensionError(f"Unsupported agent: {agent_name}")
|
||||
|
||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
registered = []
|
||||
|
||||
for cmd_info in manifest.commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
cmd_file = cmd_info["file"]
|
||||
|
||||
# Read source command file
|
||||
source_file = extension_dir / cmd_file
|
||||
if not source_file.exists():
|
||||
continue
|
||||
|
||||
content = source_file.read_text()
|
||||
frontmatter, body = self.parse_frontmatter(content)
|
||||
|
||||
# Adjust script paths
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
|
||||
# Convert argument placeholders
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
|
||||
# Render in agent-specific format
|
||||
if agent_config["format"] == "markdown":
|
||||
output = self._render_markdown_command(frontmatter, body, manifest.id)
|
||||
elif agent_config["format"] == "toml":
|
||||
output = self._render_toml_command(frontmatter, body, manifest.id)
|
||||
else:
|
||||
raise ExtensionError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
# Write command file
|
||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
dest_file.write_text(output)
|
||||
|
||||
# Generate companion .prompt.md for Copilot agents
|
||||
if agent_name == "copilot":
|
||||
self._write_copilot_prompt(project_root, cmd_name)
|
||||
|
||||
registered.append(cmd_name)
|
||||
|
||||
# Register aliases
|
||||
for alias in cmd_info.get("aliases", []):
|
||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
||||
alias_file.write_text(output)
|
||||
# Generate companion .prompt.md for alias too
|
||||
if agent_name == "copilot":
|
||||
self._write_copilot_prompt(project_root, alias)
|
||||
registered.append(alias)
|
||||
|
||||
return registered
|
||||
|
||||
@staticmethod
|
||||
def _write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
|
||||
"""Generate a companion .prompt.md file for a Copilot agent command.
|
||||
|
||||
Copilot requires a .prompt.md file in .github/prompts/ that references
|
||||
the corresponding .agent.md file in .github/agents/ via an ``agent:``
|
||||
frontmatter field.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root
|
||||
cmd_name: Command name (used as the file stem, e.g. 'speckit.my-ext.example')
|
||||
"""
|
||||
prompts_dir = project_root / ".github" / "prompts"
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
|
||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n")
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
return self._registrar.register_commands(
|
||||
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
|
||||
context_note=context_note
|
||||
)
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
self,
|
||||
@@ -935,35 +640,12 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register extension commands for all detected agents.
|
||||
|
||||
Args:
|
||||
manifest: Extension manifest
|
||||
extension_dir: Path to extension directory
|
||||
project_root: Path to project root
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Detect which agents are present in the project
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
agent_dir = project_root / agent_config["dir"].split("/")[0]
|
||||
|
||||
# Register if agent directory exists
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands_for_agent(
|
||||
agent_name, manifest, extension_dir, project_root
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
except ExtensionError:
|
||||
# Skip agent on error
|
||||
continue
|
||||
|
||||
return results
|
||||
"""Register extension commands for all detected agents."""
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
return self._registrar.register_commands_for_all_agents(
|
||||
manifest.commands, manifest.id, extension_dir, project_root,
|
||||
context_note=context_note
|
||||
)
|
||||
|
||||
def register_commands_for_claude(
|
||||
self,
|
||||
@@ -971,16 +653,7 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> List[str]:
|
||||
"""Register extension commands for Claude Code agent.
|
||||
|
||||
Args:
|
||||
manifest: Extension manifest
|
||||
extension_dir: Path to extension directory
|
||||
project_root: Path to project root
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
"""
|
||||
"""Register extension commands for Claude Code agent."""
|
||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
||||
|
||||
|
||||
|
||||
1269
src/specify_cli/presets.py
Normal file
1269
src/specify_cli/presets.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,938 +0,0 @@
|
||||
"""
|
||||
Template Pack Manager for Spec Kit
|
||||
|
||||
Handles installation, removal, and management of Spec Kit template packs.
|
||||
Template packs are self-contained, versioned collections of templates
|
||||
(artifact, command, and script templates) that can be installed to
|
||||
customize the Spec-Driven Development workflow.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
|
||||
class TemplateError(Exception):
|
||||
"""Base exception for template-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateValidationError(TemplateError):
|
||||
"""Raised when template pack manifest validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateCompatibilityError(TemplateError):
|
||||
"""Raised when template pack is incompatible with current environment."""
|
||||
pass
|
||||
|
||||
|
||||
VALID_TEMPLATE_TYPES = {"artifact", "command", "script"}
|
||||
|
||||
|
||||
class TemplatePackManifest:
|
||||
"""Represents and validates a template pack manifest (template-pack.yml)."""
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
REQUIRED_FIELDS = ["schema_version", "template_pack", "requires", "provides"]
|
||||
|
||||
def __init__(self, manifest_path: Path):
|
||||
"""Load and validate template pack manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to template-pack.yml file
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
"""
|
||||
self.path = manifest_path
|
||||
self.data = self._load_yaml(manifest_path)
|
||||
self._validate()
|
||||
|
||||
def _load_yaml(self, path: Path) -> dict:
|
||||
"""Load YAML file safely."""
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise TemplateValidationError(f"Invalid YAML in {path}: {e}")
|
||||
except FileNotFoundError:
|
||||
raise TemplateValidationError(f"Manifest not found: {path}")
|
||||
|
||||
def _validate(self):
|
||||
"""Validate manifest structure and required fields."""
|
||||
# Check required top-level fields
|
||||
for field in self.REQUIRED_FIELDS:
|
||||
if field not in self.data:
|
||||
raise TemplateValidationError(f"Missing required field: {field}")
|
||||
|
||||
# Validate schema version
|
||||
if self.data["schema_version"] != self.SCHEMA_VERSION:
|
||||
raise TemplateValidationError(
|
||||
f"Unsupported schema version: {self.data['schema_version']} "
|
||||
f"(expected {self.SCHEMA_VERSION})"
|
||||
)
|
||||
|
||||
# Validate template_pack metadata
|
||||
pack = self.data["template_pack"]
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
if field not in pack:
|
||||
raise TemplateValidationError(f"Missing template_pack.{field}")
|
||||
|
||||
# Validate pack ID format
|
||||
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template pack ID '{pack['id']}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
)
|
||||
|
||||
# Validate semantic version
|
||||
try:
|
||||
pkg_version.Version(pack["version"])
|
||||
except pkg_version.InvalidVersion:
|
||||
raise TemplateValidationError(f"Invalid version: {pack['version']}")
|
||||
|
||||
# Validate requires section
|
||||
requires = self.data["requires"]
|
||||
if "speckit_version" not in requires:
|
||||
raise TemplateValidationError("Missing requires.speckit_version")
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
if "templates" not in provides or not provides["templates"]:
|
||||
raise TemplateValidationError(
|
||||
"Template pack must provide at least one template"
|
||||
)
|
||||
|
||||
# Validate templates
|
||||
for tmpl in provides["templates"]:
|
||||
if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl:
|
||||
raise TemplateValidationError(
|
||||
"Template missing 'type', 'name', or 'file'"
|
||||
)
|
||||
|
||||
if tmpl["type"] not in VALID_TEMPLATE_TYPES:
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template type '{tmpl['type']}': "
|
||||
f"must be one of {sorted(VALID_TEMPLATE_TYPES)}"
|
||||
)
|
||||
|
||||
# Validate template name format
|
||||
if not re.match(r'^[a-z0-9-]+$', tmpl["name"]):
|
||||
raise TemplateValidationError(
|
||||
f"Invalid template name '{tmpl['name']}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Get template pack ID."""
|
||||
return self.data["template_pack"]["id"]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get template pack name."""
|
||||
return self.data["template_pack"]["name"]
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Get template pack version."""
|
||||
return self.data["template_pack"]["version"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Get template pack description."""
|
||||
return self.data["template_pack"]["description"]
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
"""Get template pack author."""
|
||||
return self.data["template_pack"].get("author", "")
|
||||
|
||||
@property
|
||||
def requires_speckit_version(self) -> str:
|
||||
"""Get required spec-kit version range."""
|
||||
return self.data["requires"]["speckit_version"]
|
||||
|
||||
@property
|
||||
def templates(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of provided templates."""
|
||||
return self.data["provides"]["templates"]
|
||||
|
||||
@property
|
||||
def tags(self) -> List[str]:
|
||||
"""Get template pack tags."""
|
||||
return self.data.get("tags", [])
|
||||
|
||||
def get_hash(self) -> str:
|
||||
"""Calculate SHA256 hash of manifest file."""
|
||||
with open(self.path, 'rb') as f:
|
||||
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
|
||||
|
||||
|
||||
class TemplatePackRegistry:
|
||||
"""Manages the registry of installed template packs."""
|
||||
|
||||
REGISTRY_FILE = ".registry"
|
||||
SCHEMA_VERSION = "1.0"
|
||||
|
||||
def __init__(self, packs_dir: Path):
|
||||
"""Initialize registry.
|
||||
|
||||
Args:
|
||||
packs_dir: Path to .specify/templates/packs/ directory
|
||||
"""
|
||||
self.packs_dir = packs_dir
|
||||
self.registry_path = packs_dir / self.REGISTRY_FILE
|
||||
self.data = self._load()
|
||||
|
||||
def _load(self) -> dict:
|
||||
"""Load registry from disk."""
|
||||
if not self.registry_path.exists():
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"template_packs": {}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"template_packs": {}
|
||||
}
|
||||
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.packs_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, 'w') as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, pack_id: str, metadata: dict):
|
||||
"""Add template pack to registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
metadata: Pack metadata (version, source, etc.)
|
||||
"""
|
||||
self.data["template_packs"][pack_id] = {
|
||||
**metadata,
|
||||
"installed_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
self._save()
|
||||
|
||||
def remove(self, pack_id: str):
|
||||
"""Remove template pack from registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
"""
|
||||
if pack_id in self.data["template_packs"]:
|
||||
del self.data["template_packs"][pack_id]
|
||||
self._save()
|
||||
|
||||
def get(self, pack_id: str) -> Optional[dict]:
|
||||
"""Get template pack metadata from registry.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
Pack metadata or None if not found
|
||||
"""
|
||||
return self.data["template_packs"].get(pack_id)
|
||||
|
||||
def list(self) -> Dict[str, dict]:
|
||||
"""Get all installed template packs.
|
||||
|
||||
Returns:
|
||||
Dictionary of pack_id -> metadata
|
||||
"""
|
||||
return self.data["template_packs"]
|
||||
|
||||
def is_installed(self, pack_id: str) -> bool:
|
||||
"""Check if template pack is installed.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
True if pack is installed
|
||||
"""
|
||||
return pack_id in self.data["template_packs"]
|
||||
|
||||
|
||||
class TemplatePackManager:
|
||||
"""Manages template pack lifecycle: installation, removal, updates."""
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template pack manager.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root directory
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.packs_dir = self.templates_dir / "packs"
|
||||
self.registry = TemplatePackRegistry(self.packs_dir)
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
manifest: TemplatePackManifest,
|
||||
speckit_version: str
|
||||
) -> bool:
|
||||
"""Check if template pack is compatible with current spec-kit version.
|
||||
|
||||
Args:
|
||||
manifest: Template pack manifest
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
True if compatible
|
||||
|
||||
Raises:
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
current = pkg_version.Version(speckit_version)
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(required)
|
||||
if current not in specifier:
|
||||
raise TemplateCompatibilityError(
|
||||
f"Template pack requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: uv tool install specify-cli --force"
|
||||
)
|
||||
except InvalidSpecifier:
|
||||
raise TemplateCompatibilityError(
|
||||
f"Invalid version specifier: {required}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def install_from_directory(
|
||||
self,
|
||||
source_dir: Path,
|
||||
speckit_version: str,
|
||||
) -> TemplatePackManifest:
|
||||
"""Install template pack from a local directory.
|
||||
|
||||
Args:
|
||||
source_dir: Path to template pack directory
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
Installed template pack manifest
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
manifest_path = source_dir / "template-pack.yml"
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
|
||||
self.check_compatibility(manifest, speckit_version)
|
||||
|
||||
if self.registry.is_installed(manifest.id):
|
||||
raise TemplateError(
|
||||
f"Template pack '{manifest.id}' is already installed. "
|
||||
f"Use 'specify template remove {manifest.id}' first."
|
||||
)
|
||||
|
||||
dest_dir = self.packs_dir / manifest.id
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
|
||||
self.registry.add(manifest.id, {
|
||||
"version": manifest.version,
|
||||
"source": "local",
|
||||
"manifest_hash": manifest.get_hash(),
|
||||
"enabled": True,
|
||||
})
|
||||
|
||||
return manifest
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str
|
||||
) -> TemplatePackManifest:
|
||||
"""Install template pack from ZIP file.
|
||||
|
||||
Args:
|
||||
zip_path: Path to template pack ZIP file
|
||||
speckit_version: Current spec-kit version
|
||||
|
||||
Returns:
|
||||
Installed template pack manifest
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If manifest is invalid
|
||||
TemplateCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
temp_path_resolved = temp_path.resolve()
|
||||
for member in zf.namelist():
|
||||
member_path = (temp_path / member).resolve()
|
||||
try:
|
||||
member_path.relative_to(temp_path_resolved)
|
||||
except ValueError:
|
||||
raise TemplateValidationError(
|
||||
f"Unsafe path in ZIP archive: {member} "
|
||||
"(potential path traversal)"
|
||||
)
|
||||
zf.extractall(temp_path)
|
||||
|
||||
pack_dir = temp_path
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
if not manifest_path.exists():
|
||||
subdirs = [d for d in temp_path.iterdir() if d.is_dir()]
|
||||
if len(subdirs) == 1:
|
||||
pack_dir = subdirs[0]
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise TemplateValidationError(
|
||||
"No template-pack.yml found in ZIP file"
|
||||
)
|
||||
|
||||
return self.install_from_directory(pack_dir, speckit_version)
|
||||
|
||||
def remove(self, pack_id: str) -> bool:
|
||||
"""Remove an installed template pack.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
True if pack was removed
|
||||
"""
|
||||
if not self.registry.is_installed(pack_id):
|
||||
return False
|
||||
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
if pack_dir.exists():
|
||||
shutil.rmtree(pack_dir)
|
||||
|
||||
self.registry.remove(pack_id)
|
||||
return True
|
||||
|
||||
def list_installed(self) -> List[Dict[str, Any]]:
|
||||
"""List all installed template packs with metadata.
|
||||
|
||||
Returns:
|
||||
List of template pack metadata dictionaries
|
||||
"""
|
||||
result = []
|
||||
|
||||
for pack_id, metadata in self.registry.list().items():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
try:
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
result.append({
|
||||
"id": pack_id,
|
||||
"name": manifest.name,
|
||||
"version": metadata["version"],
|
||||
"description": manifest.description,
|
||||
"enabled": metadata.get("enabled", True),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"template_count": len(manifest.templates),
|
||||
"tags": manifest.tags,
|
||||
})
|
||||
except TemplateValidationError:
|
||||
result.append({
|
||||
"id": pack_id,
|
||||
"name": pack_id,
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": "⚠️ Corrupted template pack",
|
||||
"enabled": False,
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"template_count": 0,
|
||||
"tags": [],
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_pack(self, pack_id: str) -> Optional[TemplatePackManifest]:
|
||||
"""Get manifest for an installed template pack.
|
||||
|
||||
Args:
|
||||
pack_id: Template pack ID
|
||||
|
||||
Returns:
|
||||
Template pack manifest or None if not installed
|
||||
"""
|
||||
if not self.registry.is_installed(pack_id):
|
||||
return None
|
||||
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
manifest_path = pack_dir / "template-pack.yml"
|
||||
|
||||
try:
|
||||
return TemplatePackManifest(manifest_path)
|
||||
except TemplateValidationError:
|
||||
return None
|
||||
|
||||
|
||||
class TemplateCatalog:
|
||||
"""Manages template pack catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.json"
|
||||
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/templates/catalog.community.json"
|
||||
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template catalog manager.
|
||||
|
||||
Args:
|
||||
project_root: Root directory of the spec-kit project
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.cache_dir = self.templates_dir / "packs" / ".cache"
|
||||
self.cache_file = self.cache_dir / "catalog.json"
|
||||
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
||||
|
||||
def _validate_catalog_url(self, url: str) -> None:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Raises:
|
||||
TemplateValidationError: If URL is invalid or uses non-HTTPS scheme
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise TemplateValidationError(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
raise TemplateValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
|
||||
def get_catalog_url(self) -> str:
|
||||
"""Get the primary catalog URL.
|
||||
|
||||
Returns:
|
||||
URL of the primary catalog
|
||||
"""
|
||||
env_value = os.environ.get("SPECKIT_TEMPLATE_CATALOG_URL")
|
||||
if env_value:
|
||||
catalog_url = env_value.strip()
|
||||
self._validate_catalog_url(catalog_url)
|
||||
return catalog_url
|
||||
return self.DEFAULT_CATALOG_URL
|
||||
|
||||
def is_cache_valid(self) -> bool:
|
||||
"""Check if cached catalog is still valid.
|
||||
|
||||
Returns:
|
||||
True if cache exists and is within cache duration
|
||||
"""
|
||||
if not self.cache_file.exists() or not self.cache_metadata_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age_seconds = (
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""Fetch template pack catalog from URL or cache.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, bypass cache and fetch from network
|
||||
|
||||
Returns:
|
||||
Catalog data dictionary
|
||||
|
||||
Raises:
|
||||
TemplateError: If catalog cannot be fetched
|
||||
"""
|
||||
if not force_refresh and self.is_cache_valid():
|
||||
try:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
with urllib.request.urlopen(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "template_packs" not in catalog_data
|
||||
):
|
||||
raise TemplateError("Invalid template catalog format")
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2)
|
||||
)
|
||||
|
||||
return catalog_data
|
||||
|
||||
except (ImportError, Exception) as e:
|
||||
if isinstance(e, TemplateError):
|
||||
raise
|
||||
raise TemplateError(
|
||||
f"Failed to fetch template catalog from {catalog_url}: {e}"
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search catalog for template packs.
|
||||
|
||||
Args:
|
||||
query: Search query (searches name, description, tags)
|
||||
tag: Filter by specific tag
|
||||
author: Filter by author name
|
||||
|
||||
Returns:
|
||||
List of matching template pack metadata
|
||||
"""
|
||||
try:
|
||||
catalog_data = self.fetch_catalog()
|
||||
except TemplateError:
|
||||
return []
|
||||
|
||||
results = []
|
||||
packs = catalog_data.get("template_packs", {})
|
||||
|
||||
for pack_id, pack_data in packs.items():
|
||||
if author and pack_data.get("author", "").lower() != author.lower():
|
||||
continue
|
||||
|
||||
if tag and tag.lower() not in [
|
||||
t.lower() for t in pack_data.get("tags", [])
|
||||
]:
|
||||
continue
|
||||
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
searchable_text = " ".join(
|
||||
[
|
||||
pack_data.get("name", ""),
|
||||
pack_data.get("description", ""),
|
||||
pack_id,
|
||||
]
|
||||
+ pack_data.get("tags", [])
|
||||
).lower()
|
||||
|
||||
if query_lower not in searchable_text:
|
||||
continue
|
||||
|
||||
results.append({**pack_data, "id": pack_id})
|
||||
|
||||
return results
|
||||
|
||||
def get_pack_info(
|
||||
self, pack_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed information about a specific template pack.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the template pack
|
||||
|
||||
Returns:
|
||||
Pack metadata or None if not found
|
||||
"""
|
||||
try:
|
||||
catalog_data = self.fetch_catalog()
|
||||
except TemplateError:
|
||||
return None
|
||||
|
||||
packs = catalog_data.get("template_packs", {})
|
||||
if pack_id in packs:
|
||||
return {**packs[pack_id], "id": pack_id}
|
||||
return None
|
||||
|
||||
def download_pack(
|
||||
self, pack_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download template pack ZIP from catalog.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the template pack to download
|
||||
target_dir: Directory to save ZIP file (defaults to cache directory)
|
||||
|
||||
Returns:
|
||||
Path to downloaded ZIP file
|
||||
|
||||
Raises:
|
||||
TemplateError: If pack not found or download fails
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
pack_info = self.get_pack_info(pack_id)
|
||||
if not pack_info:
|
||||
raise TemplateError(
|
||||
f"Template pack '{pack_id}' not found in catalog"
|
||||
)
|
||||
|
||||
download_url = pack_info.get("download_url")
|
||||
if not download_url:
|
||||
raise TemplateError(
|
||||
f"Template pack '{pack_id}' has no download URL"
|
||||
)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise TemplateError(
|
||||
f"Template pack download URL must use HTTPS: {download_url}"
|
||||
)
|
||||
|
||||
if target_dir is None:
|
||||
target_dir = self.cache_dir / "downloads"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
version = pack_info.get("version", "unknown")
|
||||
zip_filename = f"{pack_id}-{version}.zip"
|
||||
zip_path = target_dir / zip_filename
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(download_url, timeout=60) as response:
|
||||
zip_data = response.read()
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise TemplateError(
|
||||
f"Failed to download template pack from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise TemplateError(f"Failed to save template pack ZIP: {e}")
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the catalog cache."""
|
||||
if self.cache_file.exists():
|
||||
self.cache_file.unlink()
|
||||
if self.cache_metadata_file.exists():
|
||||
self.cache_metadata_file.unlink()
|
||||
|
||||
|
||||
class TemplateResolver:
|
||||
"""Resolves template names to file paths using a priority stack.
|
||||
|
||||
Resolution order:
|
||||
1. .specify/templates/overrides/ - Project-local overrides
|
||||
2. .specify/templates/packs/<pack-id>/ - Installed template packs
|
||||
3. .specify/extensions/<ext-id>/templates/ - Extension-provided templates
|
||||
4. .specify/templates/ - Core templates (shipped with Spec Kit)
|
||||
"""
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize template resolver.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root directory
|
||||
"""
|
||||
self.project_root = project_root
|
||||
self.templates_dir = project_root / ".specify" / "templates"
|
||||
self.packs_dir = self.templates_dir / "packs"
|
||||
self.overrides_dir = self.templates_dir / "overrides"
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
template_name: str,
|
||||
template_type: str = "artifact",
|
||||
) -> Optional[Path]:
|
||||
"""Resolve a template name to its file path.
|
||||
|
||||
Walks the priority stack and returns the first match.
|
||||
|
||||
Args:
|
||||
template_name: Template name (e.g., "spec-template")
|
||||
template_type: Template type ("artifact", "command", or "script")
|
||||
|
||||
Returns:
|
||||
Path to the resolved template file, or None if not found
|
||||
"""
|
||||
# Determine subdirectory based on template type
|
||||
if template_type == "artifact":
|
||||
subdirs = ["templates", ""]
|
||||
elif template_type == "command":
|
||||
subdirs = ["commands"]
|
||||
elif template_type == "script":
|
||||
subdirs = ["scripts"]
|
||||
else:
|
||||
subdirs = [""]
|
||||
|
||||
# Priority 1: Project-local overrides
|
||||
for subdir in subdirs:
|
||||
if template_type == "script":
|
||||
override = self.overrides_dir / "scripts" / f"{template_name}.sh"
|
||||
elif subdir:
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
else:
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
if override.exists():
|
||||
return override
|
||||
|
||||
# Priority 2: Installed packs (by registry order)
|
||||
if self.packs_dir.exists():
|
||||
registry = TemplatePackRegistry(self.packs_dir)
|
||||
for pack_id in registry.list():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = (
|
||||
pack_dir / subdir / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = pack_dir / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
if self.extensions_dir.exists():
|
||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = (
|
||||
ext_dir / "templates" / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = (
|
||||
ext_dir / "templates" / f"{template_name}.md"
|
||||
)
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Priority 4: Core templates
|
||||
if template_type == "artifact":
|
||||
core = self.templates_dir / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return core
|
||||
elif template_type == "command":
|
||||
core = self.templates_dir / "commands" / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return core
|
||||
|
||||
return None
|
||||
|
||||
def resolve_with_source(
|
||||
self,
|
||||
template_name: str,
|
||||
template_type: str = "artifact",
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Resolve a template name and return source attribution.
|
||||
|
||||
Args:
|
||||
template_name: Template name (e.g., "spec-template")
|
||||
template_type: Template type ("artifact", "command", or "script")
|
||||
|
||||
Returns:
|
||||
Dictionary with 'path' and 'source' keys, or None if not found
|
||||
"""
|
||||
# Priority 1: Project-local overrides
|
||||
override = self.overrides_dir / f"{template_name}.md"
|
||||
if override.exists():
|
||||
return {"path": str(override), "source": "project override"}
|
||||
|
||||
# Priority 2: Installed packs
|
||||
if self.packs_dir.exists():
|
||||
registry = TemplatePackRegistry(self.packs_dir)
|
||||
for pack_id in registry.list():
|
||||
pack_dir = self.packs_dir / pack_id
|
||||
# Check templates/ subdirectory first, then root
|
||||
for subdir in ["templates", "commands", "scripts", ""]:
|
||||
if subdir:
|
||||
candidate = (
|
||||
pack_dir / subdir / f"{template_name}.md"
|
||||
)
|
||||
else:
|
||||
candidate = pack_dir / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
meta = registry.get(pack_id)
|
||||
version = meta.get("version", "?") if meta else "?"
|
||||
return {
|
||||
"path": str(candidate),
|
||||
"source": f"{pack_id} v{version}",
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
if self.extensions_dir.exists():
|
||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
candidate = ext_dir / "templates" / f"{template_name}.md"
|
||||
if candidate.exists():
|
||||
return {
|
||||
"path": str(candidate),
|
||||
"source": f"extension:{ext_dir.name}",
|
||||
}
|
||||
|
||||
# Priority 4: Core templates
|
||||
core = self.templates_dir / f"{template_name}.md"
|
||||
if core.exists():
|
||||
return {"path": str(core), "source": "core"}
|
||||
|
||||
# Also check commands subdirectory for core
|
||||
core_cmd = self.templates_dir / "commands" / f"{template_name}.md"
|
||||
if core_cmd.exists():
|
||||
return {"path": str(core_cmd), "source": "core"}
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user