feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)

* Initial plan

* feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* test(templates): add comprehensive unit tests for template pack system

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* 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

* feat(presets): propagate command overrides to skills via init-options

- Add save_init_options() / load_init_options() helpers that persist
  CLI flags from 'specify init' to .specify/init-options.json
- PresetManager._register_skills() overwrites SKILL.md files when
  --ai-skills was used during init and corresponding skill dirs exist
- PresetManager._unregister_skills() restores core template content
  on preset removal
- registered_skills stored in preset registry metadata
- 8 new tests covering skill override, skip conditions, and restore

* fix: address PR check failures (ruff F541, CodeQL URL substring)

- Remove extraneous f-prefix from two f-strings without placeholders
- Replace substring URL check in test with startswith/endswith assertions
  to satisfy CodeQL incomplete URL substring sanitization rule

* fix: address Copilot PR review comments

- Move save_init_options() before preset install so skills propagation
  works during 'specify init --preset --ai-skills'
- Clean up downloaded ZIP after successful preset install during init
- Validate --from URL scheme (require HTTPS, HTTP only for localhost)
- Expose unregister_commands() on extensions.py CommandRegistrar wrapper
  instead of reaching into private _registrar field
- Use _get_merged_packs() for search() and get_pack_info() so all
  active catalogs are searched, not just the highest-priority one
- Fix fetch_catalog() cache to verify cached URL matches current URL
- Fix PresetResolver: script resolution uses .sh extension, consistent
  file extensions throughout resolve(), and resolve_with_source()
  delegates to resolve() to honor template_type parameter
- Fix bash common.sh: fall through to directory scan when python3
  returns empty preset list
- Fix PowerShell Resolve-Template: filter out dot-folders and sort
  extensions deterministically

* fix: narrow empty except blocks and add explanatory comments

* fix: address Copilot PR review comments (round 2)

- Fix init --preset error masking: distinguish "not found" from real errors
- Fix bash resolve_template: skip hidden dirs in extensions (match Python/PS)
- Fix temp dir leaks in tests: use temp_dir fixture instead of mkdtemp
- Fix self-test catalog entry: add note that it's local-only (no download_url)
- Fix Windows path issue in resolve_with_source: use Path.relative_to()
- Fix skill restore path: use project's .specify/templates/commands/ not source tree
- Add encoding="utf-8" to all file read/write in agents.py
- Update test to set up core command templates for skill restoration

* fix: remove self-test from catalog.json (local-only preset)

* fix: address Copilot PR review comments (round 3)

- Fix PS Resolve-Template fallback to skip dot-prefixed dirs (.cache)
- Rename _catalog to _catalog_name for consistency with extension system
- Enforce install_allowed policy in CLI preset add and download_pack()
- Fix shell injection: pass registry path via env var instead of string interpolation

* fix: correct PresetError docstring from template to preset

* Removed CHANGELOG requirement

* Applying review recommendations

* Applying review recommendations

* Applying review recommendations

* Applying review recommendations

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
This commit is contained in:
Copilot
2026-03-13 15:09:14 -05:00
committed by GitHub
parent c883952b43
commit 69ee7a836e
32 changed files with 5446 additions and 403 deletions

View File

@@ -34,7 +34,7 @@ import shlex
import json
import yaml
from pathlib import Path
from typing import Optional, Tuple
from typing import Any, Optional, Tuple
import typer
import httpx
@@ -1067,6 +1067,36 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
else:
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
INIT_OPTIONS_FILE = ".specify/init-options.json"
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""Persist the CLI options used during ``specify init``.
Writes a small JSON file to ``.specify/init-options.json`` so that
later operations (e.g. preset install) can adapt their behaviour
without scanning the filesystem.
"""
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(json.dumps(options, indent=2, sort_keys=True))
def load_init_options(project_path: Path) -> dict[str, Any]:
"""Load the init options previously saved by ``specify init``.
Returns an empty dict if the file does not exist or cannot be parsed.
"""
path = project_path / INIT_OPTIONS_FILE
if not path.exists():
return {}
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
return {}
# Agent-specific skill directory overrides for agents whose skills directory
# doesn't follow the standard <agent_folder>/skills/ pattern
AGENT_SKILLS_DIR_OVERRIDES = {
@@ -1300,6 +1330,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)"),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
):
"""
Initialize a new Specify project from the latest template.
@@ -1328,6 +1359,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 --preset healthcare-compliance # With preset
"""
show_banner()
@@ -1589,6 +1621,50 @@ def init(
else:
tracker.skip("git", "--no-git flag")
# Persist the CLI options so later operations (e.g. preset add)
# can adapt their behaviour without re-scanning the filesystem.
# Must be saved BEFORE preset install so _get_skills_dir() works.
save_init_options(project_path, {
"ai": selected_ai,
"ai_skills": ai_skills,
"ai_commands_dir": ai_commands_dir,
"here": here,
"preset": preset,
"script": selected_script,
"speckit_version": get_speckit_version(),
})
# Install preset if specified
if preset:
try:
from .presets import PresetManager, PresetCatalog, PresetError
preset_manager = PresetManager(project_path)
speckit_ver = get_speckit_version()
# Try local directory first, then catalog
local_path = Path(preset).resolve()
if local_path.is_dir() and (local_path / "preset.yml").exists():
preset_manager.install_from_directory(local_path, speckit_ver)
else:
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
else:
try:
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
# Clean up downloaded ZIP to avoid cache accumulation
try:
zip_path.unlink(missing_ok=True)
except OSError:
# Best-effort cleanup; failure to delete is non-fatal
pass
except PresetError as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
except Exception as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
tracker.complete("final", "project ready")
except Exception as e:
tracker.error("final", str(e))
@@ -1826,6 +1902,20 @@ catalog_app = typer.Typer(
)
extension_app.add_typer(catalog_app, name="catalog")
preset_app = typer.Typer(
name="preset",
help="Manage spec-kit presets",
add_completion=False,
)
app.add_typer(preset_app, name="preset")
preset_catalog_app = typer.Typer(
name="catalog",
help="Manage preset catalogs",
add_completion=False,
)
preset_app.add_typer(preset_catalog_app, name="catalog")
def get_speckit_version() -> str:
"""Get current spec-kit version."""
@@ -1848,6 +1938,490 @@ def get_speckit_version() -> str:
return "unknown"
# ===== Preset Commands =====
@preset_app.command("list")
def preset_list():
"""List installed presets."""
from .presets import PresetManager
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
manager = PresetManager(project_root)
installed = manager.list_installed()
if not installed:
console.print("[yellow]No presets installed.[/yellow]")
console.print("\nInstall a preset with:")
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
return
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
for pack in installed:
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
pri = pack.get('priority', 10)
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']}{status} — priority {pri}")
console.print(f" {pack['description']}")
if pack.get("tags"):
tags_str = ", ".join(pack["tags"])
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
@preset_app.command("add")
def preset_add(
pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install a preset."""
from .presets import (
PresetManager,
PresetCatalog,
PresetError,
PresetValidationError,
PresetCompatibilityError,
)
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
manager = PresetManager(project_root)
speckit_version = get_speckit_version()
try:
if dev:
dev_path = Path(dev).resolve()
if not dev_path.exists():
console.print(f"[red]Error:[/red] Directory not found: {dev}")
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...")
manifest = manager.install_from_directory(dev_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
elif from_url:
# Validate URL scheme before downloading
from urllib.parse import urlparse as _urlparse
_parsed = _urlparse(from_url)
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
import urllib.request
import urllib.error
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
try:
with urllib.request.urlopen(from_url, timeout=60) as response:
zip_path.write_bytes(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {e}")
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
elif pack_id:
catalog = PresetCatalog(project_root)
pack_info = catalog.get_pack_info(pack_id)
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
raise typer.Exit(1)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
raise typer.Exit(1)
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
try:
zip_path = catalog.download_pack(pack_id)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
finally:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
else:
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)
except PresetCompatibilityError as e:
console.print(f"[red]Compatibility Error:[/red] {e}")
raise typer.Exit(1)
except PresetValidationError as e:
console.print(f"[red]Validation Error:[/red] {e}")
raise typer.Exit(1)
except PresetError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@preset_app.command("remove")
def preset_remove(
pack_id: str = typer.Argument(..., help="Preset ID to remove"),
):
"""Remove an installed preset."""
from .presets import PresetManager
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
manager = PresetManager(project_root)
if not manager.registry.is_installed(pack_id):
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
raise typer.Exit(1)
if manager.remove(pack_id):
console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully")
else:
console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'")
raise typer.Exit(1)
@preset_app.command("search")
def preset_search(
query: str = typer.Argument(None, help="Search query"),
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
author: str = typer.Option(None, "--author", help="Filter by author"),
):
"""Search for presets in the catalog."""
from .presets import PresetCatalog, PresetError
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
catalog = PresetCatalog(project_root)
try:
results = catalog.search(query=query, tag=tag, author=author)
except PresetError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
if not results:
console.print("[yellow]No presets found matching your criteria.[/yellow]")
return
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
for pack in results:
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
console.print(f" {pack.get('description', '')}")
if pack.get("tags"):
tags_str = ", ".join(pack["tags"])
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print()
@preset_app.command("resolve")
def preset_resolve(
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
):
"""Show which template will be resolved for a given name."""
from .presets import PresetResolver
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
resolver = PresetResolver(project_root)
result = resolver.resolve_with_source(template_name)
if result:
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
console.print(f" [dim](from: {result['source']})[/dim]")
else:
console.print(f" [yellow]{template_name}[/yellow]: not found")
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
@preset_app.command("info")
def preset_info(
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
):
"""Show detailed information about a preset."""
from .presets import PresetCatalog, PresetManager, PresetError
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Check if installed locally first
manager = PresetManager(project_root)
local_pack = manager.get_pack(pack_id)
if local_pack:
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
console.print(f" ID: {local_pack.id}")
console.print(f" Version: {local_pack.version}")
console.print(f" Description: {local_pack.description}")
if local_pack.author:
console.print(f" Author: {local_pack.author}")
if local_pack.tags:
console.print(f" Tags: {', '.join(local_pack.tags)}")
console.print(f" Templates: {len(local_pack.templates)}")
for tmpl in local_pack.templates:
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
repo = local_pack.data.get("preset", {}).get("repository")
if repo:
console.print(f" Repository: {repo}")
license_val = local_pack.data.get("preset", {}).get("license")
if license_val:
console.print(f" License: {license_val}")
console.print("\n [green]Status: installed[/green]")
console.print()
return
# Fall back to catalog
catalog = PresetCatalog(project_root)
try:
pack_info = catalog.get_pack_info(pack_id)
except PresetError:
pack_info = None
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)")
raise typer.Exit(1)
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n")
console.print(f" ID: {pack_info['id']}")
console.print(f" Version: {pack_info.get('version', '?')}")
console.print(f" Description: {pack_info.get('description', '')}")
if pack_info.get("author"):
console.print(f" Author: {pack_info['author']}")
if pack_info.get("tags"):
console.print(f" Tags: {', '.join(pack_info['tags'])}")
if pack_info.get("repository"):
console.print(f" Repository: {pack_info['repository']}")
if pack_info.get("license"):
console.print(f" License: {pack_info['license']}")
console.print("\n [yellow]Status: not installed[/yellow]")
console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]")
console.print()
# ===== Preset Catalog Commands =====
@preset_catalog_app.command("list")
def preset_catalog_list():
"""List all active preset catalogs."""
from .presets import PresetCatalog, PresetValidationError
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
catalog = PresetCatalog(project_root)
try:
active_catalogs = catalog.get_active_catalogs()
except PresetValidationError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
for entry in active_catalogs:
install_str = (
"[green]install allowed[/green]"
if entry.install_allowed
else "[yellow]discovery only[/yellow]"
)
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
if entry.description:
console.print(f" {entry.description}")
console.print(f" URL: {entry.url}")
console.print(f" Install: {install_str}")
console.print()
config_path = project_root / ".specify" / "preset-catalogs.yml"
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]")
else:
try:
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
except PresetValidationError:
proj_loaded = False
if proj_loaded:
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
else:
try:
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
except PresetValidationError:
user_loaded = False
if user_loaded:
console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
else:
console.print("[dim]Using built-in default catalog stack.[/dim]")
console.print(
"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
)
@preset_catalog_app.command("add")
def preset_catalog_add(
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
name: str = typer.Option(..., "--name", help="Catalog name"),
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
install_allowed: bool = typer.Option(
False, "--install-allowed/--no-install-allowed",
help="Allow presets from this catalog to be installed",
),
description: str = typer.Option("", "--description", help="Description of the catalog"),
):
"""Add a catalog to .specify/preset-catalogs.yml."""
from .presets import PresetCatalog, PresetValidationError
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate URL
tmp_catalog = PresetCatalog(project_root)
try:
tmp_catalog._validate_catalog_url(url)
except PresetValidationError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
config_path = specify_dir / "preset-catalogs.yml"
# Load existing config
if config_path.exists():
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception as e:
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
raise typer.Exit(1)
else:
config = {}
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
# Check for duplicate name
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
raise typer.Exit(1)
catalogs.append({
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
})
config["catalogs"] = catalogs
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
install_label = "install allowed" if install_allowed else "discovery only"
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
console.print(f" URL: {url}")
console.print(f" Priority: {priority}")
console.print(f"\nConfig saved to {config_path.relative_to(project_root)}")
@preset_catalog_app.command("remove")
def preset_catalog_remove(
name: str = typer.Argument(help="Catalog name to remove"),
):
"""Remove a catalog from .specify/preset-catalogs.yml."""
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
config_path = specify_dir / "preset-catalogs.yml"
if not config_path.exists():
console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.")
raise typer.Exit(1)
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception:
console.print("[red]Error:[/red] Failed to read preset catalog config.")
raise typer.Exit(1)
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
original_count = len(catalogs)
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
raise typer.Exit(1)
config["catalogs"] = catalogs
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
console.print(f"[green]✓[/green] Removed catalog '{name}'")
if not catalogs:
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
# ===== Extension Commands =====
def _resolve_installed_extension(
argument: str,
installed_extensions: list,

422
src/specify_cli/agents.py Normal file
View File

@@ -0,0 +1,422 @@
"""
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": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"opencode": {
"dir": ".opencode/command",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"codex": {
"dir": ".codex/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"windsurf": {
"dir": ".windsurf/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kilocode": {
"dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"auggie": {
"dir": ".augment/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"roo": {
"dir": ".roo/commands",
"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"
},
"kimi": {
"dir": ".kimi/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md"
}
}
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
Args:
content: Markdown content with YAML frontmatter
Returns:
Tuple of (frontmatter_dict, body_content)
"""
if not content.startswith("---"):
return {}, content
# Find second ---
end_marker = content.find("---", 3)
if end_marker == -1:
return {}, content
frontmatter_str = content[3:end_marker].strip()
body = content[end_marker + 3:].strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
except yaml.YAMLError:
frontmatter = {}
return frontmatter, body
@staticmethod
def render_frontmatter(fm: dict) -> str:
"""Render frontmatter dictionary as YAML.
Args:
fm: Frontmatter dictionary
Returns:
YAML-formatted frontmatter with delimiters
"""
if not fm:
return ""
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
"""Adjust script paths from extension-relative to repo-relative.
Args:
frontmatter: Frontmatter dictionary
Returns:
Modified frontmatter with adjusted paths
"""
if "scripts" in frontmatter:
for key in frontmatter["scripts"]:
script_path = frontmatter["scripts"][key]
if script_path.startswith("../../scripts/"):
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
return frontmatter
def render_markdown_command(
self,
frontmatter: dict,
body: str,
source_id: str,
context_note: str = None
) -> str:
"""Render command in Markdown format.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
context_note: Custom context comment (default: <!-- Source: {source_id} -->)
Returns:
Formatted Markdown command file content
"""
if context_note is None:
context_note = f"\n<!-- Source: {source_id} -->\n"
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
def render_toml_command(
self,
frontmatter: dict,
body: str,
source_id: str
) -> str:
"""Render command in TOML format.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
Returns:
Formatted TOML command file content
"""
toml_lines = []
if "description" in frontmatter:
desc = frontmatter["description"].replace('"', '\\"')
toml_lines.append(f'description = "{desc}"')
toml_lines.append("")
toml_lines.append(f"# Source: {source_id}")
toml_lines.append("")
toml_lines.append('prompt = """')
toml_lines.append(body)
toml_lines.append('"""')
return "\n".join(toml_lines)
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
"""Convert argument placeholder format.
Args:
content: Command content
from_placeholder: Source placeholder (e.g., "$ARGUMENTS")
to_placeholder: Target placeholder (e.g., "{{args}}")
Returns:
Content with converted placeholders
"""
return content.replace(from_placeholder, to_placeholder)
def register_commands(
self,
agent_name: str,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None
) -> List[str]:
"""Register commands for a specific agent.
Args:
agent_name: Agent name (claude, gemini, copilot, etc.)
commands: List of command info dicts with 'name', 'file', and optional 'aliases'
source_id: Identifier of the source (extension or preset ID)
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
List of registered command names
Raises:
ValueError: If agent is not supported
"""
if agent_name not in self.AGENT_CONFIGS:
raise ValueError(f"Unsupported agent: {agent_name}")
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
commands_dir.mkdir(parents=True, exist_ok=True)
registered = []
for cmd_info in commands:
cmd_name = cmd_info["name"]
cmd_file = cmd_info["file"]
source_file = source_dir / cmd_file
if not source_file.exists():
continue
content = source_file.read_text(encoding="utf-8")
frontmatter, body = self.parse_frontmatter(content)
frontmatter = self._adjust_script_paths(frontmatter)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
if agent_config["format"] == "markdown":
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
elif agent_config["format"] == "toml":
output = self.render_toml_command(frontmatter, body, source_id)
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text(output, encoding="utf-8")
if agent_name == "copilot":
self.write_copilot_prompt(project_root, cmd_name)
registered.append(cmd_name)
for alias in cmd_info.get("aliases", []):
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(output, encoding="utf-8")
if agent_name == "copilot":
self.write_copilot_prompt(project_root, alias)
registered.append(alias)
return registered
@staticmethod
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
"""Generate a companion .prompt.md file for a Copilot agent command.
Args:
project_root: Path to project root
cmd_name: Command name (e.g. 'speckit.my-ext.example')
"""
prompts_dir = project_root / ".github" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
def register_commands_for_all_agents(
self,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
Args:
commands: List of command info dicts
source_id: Identifier of the source (extension or preset ID)
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
for agent_name, agent_config in self.AGENT_CONFIGS.items():
agent_dir = project_root / agent_config["dir"].split("/")[0]
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id, source_dir, project_root,
context_note=context_note
)
if registered:
results[agent_name] = registered
except ValueError:
continue
return results
def unregister_commands(
self,
registered_commands: Dict[str, List[str]],
project_root: Path
) -> None:
"""Remove previously registered command files from agent directories.
Args:
registered_commands: Dict mapping agent names to command name lists
project_root: Path to project root
"""
for agent_name, cmd_names in registered_commands.items():
if agent_name not in self.AGENT_CONFIGS:
continue
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
for cmd_name in cmd_names:
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
if cmd_file.exists():
cmd_file.unlink()
if agent_name == "copilot":
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
if prompt_file.exists():
prompt_file.unlink()

View File

@@ -578,23 +578,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.unregister_commands(registered_commands, self.project_root)
if keep_config:
# Preserve config files, only remove non-config files
@@ -718,255 +702,47 @@ 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": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"opencode": {
"dir": ".opencode/command",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"codex": {
"dir": ".codex/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"windsurf": {
"dir": ".windsurf/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kilocode": {
"dir": ".kilocode/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"auggie": {
"dir": ".augment/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"roo": {
"dir": ".roo/commands",
"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"
},
"kimi": {
"dir": ".kimi/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.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):
# Preserve extension-specific context comments for backward compatibility
base = self._registrar.render_toml_command(frontmatter, body, ext_id)
context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n"
return base.rstrip("\n") + "\n" + context_lines
def register_commands_for_agent(
self,
@@ -975,96 +751,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.parent.mkdir(parents=True, exist_ok=True)
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.parent.mkdir(parents=True, exist_ok=True)
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,
@@ -1072,35 +766,20 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path
) -> Dict[str, List[str]]:
"""Register extension commands for all detected agents.
"""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
)
Args:
manifest: Extension manifest
extension_dir: Path to extension directory
project_root: Path to project root
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
# Detect which agents are present in the project
for agent_name, agent_config in self.AGENT_CONFIGS.items():
agent_dir = project_root / agent_config["dir"].split("/")[0]
# Register if agent directory exists
if agent_dir.exists():
try:
registered = self.register_commands_for_agent(
agent_name, manifest, extension_dir, project_root
)
if registered:
results[agent_name] = registered
except ExtensionError:
# Skip agent on error
continue
return results
def unregister_commands(
self,
registered_commands: Dict[str, List[str]],
project_root: Path
) -> None:
"""Remove previously registered command files from agent directories."""
self._registrar.unregister_commands(registered_commands, project_root)
def register_commands_for_claude(
self,
@@ -1108,16 +787,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)

1530
src/specify_cli/presets.py Normal file

File diff suppressed because it is too large Load Diff