Add agent pack infrastructure with embedded packs, manifest validation, resolution, and CLI commands

- Create src/specify_cli/agent_pack.py with AgentBootstrap base class,
  AgentManifest schema/validation, pack resolution (user > project > catalog > embedded)
- Generate all 25 official agent packs under src/specify_cli/core_pack/agents/
  with speckit-agent.yml manifests and bootstrap.py modules
- Add 'specify agent' CLI subcommands: list, info, validate, export,
  switch, search, add, remove
- Update pyproject.toml to bundle agent packs in the wheel
- Add comprehensive tests (39 tests): manifest validation, bootstrap API,
  resolution order, discovery, consistency with AGENT_CONFIG

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ef8b4682-7f1a-4b04-a112-df0878236b6b
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 21:01:16 +00:00
committed by GitHub
parent 8b20d0b336
commit 3212309e7c
80 changed files with 2685 additions and 0 deletions

View File

@@ -43,6 +43,8 @@ packages = ["src/specify_cli"]
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh"
".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1"
# Official agent packs (embedded in wheel for zero-config offline operation)
"src/specify_cli/core_pack/agents" = "specify_cli/core_pack/agents"
[project.optional-dependencies]
test = [

View File

@@ -2366,6 +2366,457 @@ def version():
console.print()
# ===== Agent Commands =====
agent_app = typer.Typer(
name="agent",
help="Manage agent packs for AI assistants",
add_completion=False,
)
app.add_typer(agent_app, name="agent")
@agent_app.command("list")
def agent_list(
installed: bool = typer.Option(False, "--installed", help="Only show agents with local presence in the current project"),
):
"""List available agent packs."""
from .agent_pack import list_all_agents, list_embedded_agents
show_banner()
project_path = Path.cwd()
agents = list_all_agents(project_path=project_path if installed else None)
if not agents and not installed:
agents_from_embedded = list_embedded_agents()
if not agents_from_embedded:
console.print("[yellow]No agent packs found.[/yellow]")
console.print("[dim]Agent packs are embedded in the specify-cli wheel.[/dim]")
raise typer.Exit(0)
table = Table(title="Available Agent Packs", show_lines=False)
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Name", style="white")
table.add_column("Version", style="dim")
table.add_column("Source", style="green")
table.add_column("CLI Required", style="yellow", justify="center")
for resolved in agents:
m = resolved.manifest
cli_marker = "" if m.requires_cli else ""
source_display = resolved.source
if resolved.overrides:
source_display += f" (overrides {resolved.overrides})"
table.add_row(m.id, m.name, m.version, source_display, cli_marker)
console.print(table)
console.print(f"\n[dim]{len(agents)} agent(s) available[/dim]")
@agent_app.command("info")
def agent_info(
agent_id: str = typer.Argument(..., help="Agent pack ID (e.g. 'claude', 'gemini')"),
):
"""Show detailed information about an agent pack."""
from .agent_pack import resolve_agent_pack, PackResolutionError
show_banner()
try:
resolved = resolve_agent_pack(agent_id, project_path=Path.cwd())
except PackResolutionError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
m = resolved.manifest
info_table = Table(show_header=False, box=None, padding=(0, 2))
info_table.add_column("Key", style="cyan", justify="right")
info_table.add_column("Value", style="white")
info_table.add_row("Agent", f"{m.name} ({m.id})")
info_table.add_row("Version", m.version)
info_table.add_row("Description", m.description or "")
info_table.add_row("Author", m.author or "")
info_table.add_row("License", m.license or "")
info_table.add_row("", "")
source_display = resolved.source
if resolved.source == "catalog":
source_display = f"catalog — {resolved.path}"
elif resolved.source == "embedded":
source_display = f"embedded (bundled in specify-cli wheel)"
info_table.add_row("Source", source_display)
if resolved.overrides:
info_table.add_row("Overrides", resolved.overrides)
info_table.add_row("Pack Path", str(resolved.path))
info_table.add_row("", "")
info_table.add_row("Requires CLI", "Yes" if m.requires_cli else "No")
if m.install_url:
info_table.add_row("Install URL", m.install_url)
if m.cli_tool:
info_table.add_row("CLI Tool", m.cli_tool)
info_table.add_row("", "")
info_table.add_row("Commands Dir", m.commands_dir or "")
info_table.add_row("Format", m.command_format)
info_table.add_row("Arg Placeholder", m.arg_placeholder)
info_table.add_row("File Extension", m.file_extension)
info_table.add_row("", "")
info_table.add_row("Tags", ", ".join(m.tags) if m.tags else "")
info_table.add_row("Speckit Version", m.speckit_version)
panel = Panel(
info_table,
title=f"[bold cyan]Agent: {m.name}[/bold cyan]",
border_style="cyan",
padding=(1, 2),
)
console.print(panel)
@agent_app.command("validate")
def agent_validate(
pack_path: str = typer.Argument(..., help="Path to the agent pack directory to validate"),
):
"""Validate an agent pack's structure and manifest."""
from .agent_pack import validate_pack, ManifestValidationError, AgentManifest, MANIFEST_FILENAME
show_banner()
path = Path(pack_path).resolve()
if not path.is_dir():
console.print(f"[red]Error:[/red] Not a directory: {path}")
raise typer.Exit(1)
try:
warnings = validate_pack(path)
except ManifestValidationError as exc:
console.print(f"[red]Validation failed:[/red] {exc}")
raise typer.Exit(1)
manifest = AgentManifest.from_yaml(path / MANIFEST_FILENAME)
console.print(f"[green]✓[/green] Pack '{manifest.id}' ({manifest.name}) is valid")
if warnings:
console.print(f"\n[yellow]Warnings ({len(warnings)}):[/yellow]")
for w in warnings:
console.print(f" [yellow]⚠[/yellow] {w}")
else:
console.print("[green]No warnings.[/green]")
@agent_app.command("export")
def agent_export(
agent_id: str = typer.Argument(..., help="Agent pack ID to export"),
to: str = typer.Option(..., "--to", help="Destination directory for the exported pack"),
):
"""Export the active agent pack to a directory for editing."""
from .agent_pack import export_pack, PackResolutionError
show_banner()
dest = Path(to).resolve()
try:
result = export_pack(agent_id, dest, project_path=Path.cwd())
except PackResolutionError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print(f"[green]✓[/green] Exported '{agent_id}' pack to {result}")
console.print(f"[dim]Edit files in {result} and use as a project-level override.[/dim]")
@agent_app.command("switch")
def agent_switch(
agent_id: str = typer.Argument(..., help="Agent pack ID to switch to"),
):
"""Switch the active AI agent in the current project.
Tears down the current agent and sets up the new one.
Preserves specs, plans, tasks, constitution, memory, templates, and scripts.
"""
from .agent_pack import (
resolve_agent_pack,
load_bootstrap,
PackResolutionError,
AgentPackError,
)
show_banner()
project_path = Path.cwd()
init_options_file = project_path / ".specify" / "init-options.json"
if not init_options_file.exists():
console.print("[red]Error:[/red] Not a Specify project (missing .specify/init-options.json)")
console.print("[yellow]Hint:[/yellow] Run 'specify init --here' first.")
raise typer.Exit(1)
# Load current project options
try:
options = json.loads(init_options_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
console.print(f"[red]Error reading init options:[/red] {exc}")
raise typer.Exit(1)
current_agent = options.get("ai")
script_type = options.get("script", "sh")
# Resolve the new agent pack
try:
resolved = resolve_agent_pack(agent_id, project_path=project_path)
except PackResolutionError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print(f"[bold]Switching agent: {current_agent or '(none)'}{agent_id}[/bold]")
# Teardown current agent (best effort — may have been set up with old system)
if current_agent:
try:
current_resolved = resolve_agent_pack(current_agent, project_path=project_path)
current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest)
console.print(f" [dim]Tearing down {current_agent}...[/dim]")
current_bootstrap.teardown(project_path)
console.print(f" [green]✓[/green] {current_agent} removed")
except AgentPackError:
# If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG
agent_config = AGENT_CONFIG.get(current_agent, {})
agent_folder = agent_config.get("folder")
if agent_folder:
agent_dir = project_path / agent_folder.rstrip("/")
if agent_dir.is_dir():
shutil.rmtree(agent_dir)
console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)")
# Setup new agent
try:
new_bootstrap = load_bootstrap(resolved.path, resolved.manifest)
console.print(f" [dim]Setting up {agent_id}...[/dim]")
new_bootstrap.setup(project_path, script_type, options)
console.print(f" [green]✓[/green] {agent_id} installed")
except AgentPackError as exc:
console.print(f"[red]Error setting up {agent_id}:[/red] {exc}")
raise typer.Exit(1)
# Update init options
options["ai"] = agent_id
init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8")
console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]")
# Re-register extension commands for the new agent
_reregister_extension_commands(project_path, agent_id)
def _reregister_extension_commands(project_path: Path, agent_id: str) -> None:
"""Re-register all installed extension commands for a new agent after switching."""
registry_file = project_path / ".specify" / "extensions" / ".registry"
if not registry_file.is_file():
return
try:
registry_data = json.loads(registry_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
extensions = registry_data.get("extensions", {})
if not extensions:
return
try:
from .agents import CommandRegistrar
registrar = CommandRegistrar()
except ImportError:
return
reregistered = 0
for ext_id, ext_data in extensions.items():
commands = ext_data.get("registered_commands", {})
if not commands:
continue
ext_dir = project_path / ".specify" / "extensions" / ext_id
if not ext_dir.is_dir():
continue
# Get the command list from the manifest
manifest_file = ext_dir / "extension.yml"
if not manifest_file.is_file():
continue
try:
from .extensions import ExtensionManifest
manifest = ExtensionManifest(manifest_file)
if manifest.commands:
registered = registrar.register_commands(
agent_id, manifest.commands, ext_id, ext_dir / "commands", project_path
)
if registered:
reregistered += len(registered)
except Exception:
continue
if reregistered:
console.print(f" [green]✓[/green] Re-registered {reregistered} extension command(s)")
@agent_app.command("search")
def agent_search(
query: str = typer.Argument(None, help="Search query (matches agent ID, name, or tags)"),
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
):
"""Search for agent packs across embedded and catalog sources."""
from .agent_pack import list_all_agents
show_banner()
all_agents = list_all_agents(project_path=Path.cwd())
if query:
query_lower = query.lower()
all_agents = [
a for a in all_agents
if query_lower in a.manifest.id.lower()
or query_lower in a.manifest.name.lower()
or query_lower in a.manifest.description.lower()
or any(query_lower in t.lower() for t in a.manifest.tags)
]
if tag:
tag_lower = tag.lower()
all_agents = [
a for a in all_agents
if any(tag_lower == t.lower() for t in a.manifest.tags)
]
if not all_agents:
console.print("[yellow]No agents found matching your search.[/yellow]")
raise typer.Exit(0)
table = Table(title="Search Results", show_lines=False)
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Name", style="white")
table.add_column("Description", style="dim")
table.add_column("Tags", style="green")
table.add_column("Source", style="yellow")
for resolved in all_agents:
m = resolved.manifest
table.add_row(
m.id, m.name,
(m.description[:50] + "...") if len(m.description) > 53 else m.description,
", ".join(m.tags),
resolved.source,
)
console.print(table)
console.print(f"\n[dim]{len(all_agents)} result(s)[/dim]")
@agent_app.command("add")
def agent_add(
agent_id: str = typer.Argument(..., help="Agent pack ID to install"),
from_path: str = typer.Option(None, "--from", help="Install from a local path instead of a catalog"),
):
"""Install an agent pack from a catalog or local path."""
from .agent_pack import (
_catalog_agents_dir,
AgentManifest,
ManifestValidationError,
MANIFEST_FILENAME,
)
show_banner()
if from_path:
source = Path(from_path).resolve()
if not source.is_dir():
console.print(f"[red]Error:[/red] Not a directory: {source}")
raise typer.Exit(1)
manifest_file = source / MANIFEST_FILENAME
if not manifest_file.is_file():
console.print(f"[red]Error:[/red] No {MANIFEST_FILENAME} found in {source}")
raise typer.Exit(1)
try:
manifest = AgentManifest.from_yaml(manifest_file)
except ManifestValidationError as exc:
console.print(f"[red]Validation failed:[/red] {exc}")
raise typer.Exit(1)
dest = _catalog_agents_dir() / manifest.id
dest.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, dest, dirs_exist_ok=True)
console.print(f"[green]✓[/green] Installed '{manifest.id}' ({manifest.name}) from {source}")
else:
# Catalog fetch — placeholder for future catalog integration
console.print(f"[yellow]Catalog fetch not yet implemented.[/yellow]")
console.print(f"[dim]Use --from <path> to install from a local directory.[/dim]")
raise typer.Exit(1)
@agent_app.command("remove")
def agent_remove(
agent_id: str = typer.Argument(..., help="Agent pack ID to remove"),
):
"""Remove a cached/override agent pack.
If the agent is an official embedded agent, removing the override
falls back to the embedded version.
"""
from .agent_pack import (
_catalog_agents_dir,
_user_agents_dir,
_embedded_agents_dir,
MANIFEST_FILENAME,
)
show_banner()
removed = False
# Check user-level
user_pack = _user_agents_dir() / agent_id
if user_pack.is_dir():
shutil.rmtree(user_pack)
console.print(f"[green]✓[/green] Removed user-level override for '{agent_id}'")
removed = True
# Check project-level
project_pack = Path.cwd() / ".specify" / "agents" / agent_id
if project_pack.is_dir():
shutil.rmtree(project_pack)
console.print(f"[green]✓[/green] Removed project-level override for '{agent_id}'")
removed = True
# Check catalog cache
catalog_pack = _catalog_agents_dir() / agent_id
if catalog_pack.is_dir():
shutil.rmtree(catalog_pack)
console.print(f"[green]✓[/green] Removed catalog-cached version of '{agent_id}'")
removed = True
if not removed:
# Check if it's an embedded agent
embedded_pack = _embedded_agents_dir() / agent_id / MANIFEST_FILENAME
if embedded_pack.is_file():
console.print(f"[yellow]'{agent_id}' is an embedded official agent and cannot be removed.[/yellow]")
console.print("[dim]It has no overrides to remove.[/dim]")
else:
console.print(f"[red]Error:[/red] Agent '{agent_id}' not found.")
raise typer.Exit(1)
else:
# Check for embedded fallback
embedded_pack = _embedded_agents_dir() / agent_id / MANIFEST_FILENAME
if embedded_pack.is_file():
console.print(f"[dim]Embedded version of '{agent_id}' is now active.[/dim]")
# ===== Extension Commands =====
extension_app = typer.Typer(

View File

@@ -0,0 +1,478 @@
"""
Agent Pack Manager for Spec Kit
Implements self-bootstrapping agent packs with declarative manifests
(speckit-agent.yml) and Python bootstrap modules (bootstrap.py).
Agent packs resolve by priority:
1. User-level (~/.specify/agents/<id>/)
2. Project-level (.specify/agents/<id>/)
3. Catalog-installed (downloaded via `specify agent add`)
4. Embedded in wheel (official packs under core_pack/agents/)
The embedded packs ship inside the pip wheel so that
`pip install specify-cli && specify init --ai claude` works offline.
"""
import importlib.util
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from platformdirs import user_data_path
# ---------------------------------------------------------------------------
# Manifest schema
# ---------------------------------------------------------------------------
MANIFEST_FILENAME = "speckit-agent.yml"
BOOTSTRAP_FILENAME = "bootstrap.py"
MANIFEST_SCHEMA_VERSION = "1.0"
# Required top-level keys
_REQUIRED_TOP_KEYS = {"schema_version", "agent"}
# Required keys within the ``agent`` block
_REQUIRED_AGENT_KEYS = {"id", "name", "version"}
class AgentPackError(Exception):
"""Base exception for agent-pack operations."""
class ManifestValidationError(AgentPackError):
"""Raised when a speckit-agent.yml file is invalid."""
class PackResolutionError(AgentPackError):
"""Raised when no pack can be found for the requested agent id."""
# ---------------------------------------------------------------------------
# Manifest
# ---------------------------------------------------------------------------
@dataclass
class AgentManifest:
"""Parsed and validated representation of a speckit-agent.yml file."""
# identity
id: str
name: str
version: str
description: str = ""
author: str = ""
license: str = ""
# runtime
requires_cli: bool = False
install_url: Optional[str] = None
cli_tool: Optional[str] = None
# compatibility
speckit_version: str = ">=0.1.0"
# discovery
tags: List[str] = field(default_factory=list)
# command registration metadata (used by CommandRegistrar / extensions)
commands_dir: str = ""
command_format: str = "markdown"
arg_placeholder: str = "$ARGUMENTS"
file_extension: str = ".md"
# raw data for anything else
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
# filesystem path to the pack directory that produced this manifest
pack_path: Optional[Path] = field(default=None, repr=False)
@classmethod
def from_yaml(cls, path: Path) -> "AgentManifest":
"""Load and validate a manifest from *path*.
Raises ``ManifestValidationError`` on structural problems.
"""
try:
text = path.read_text(encoding="utf-8")
data = yaml.safe_load(text) or {}
except yaml.YAMLError as exc:
raise ManifestValidationError(f"Invalid YAML in {path}: {exc}")
except FileNotFoundError:
raise ManifestValidationError(f"Manifest not found: {path}")
return cls.from_dict(data, pack_path=path.parent)
@classmethod
def from_dict(cls, data: dict, *, pack_path: Optional[Path] = None) -> "AgentManifest":
"""Build a manifest from a raw dictionary."""
if not isinstance(data, dict):
raise ManifestValidationError("Manifest must be a YAML mapping")
missing_top = _REQUIRED_TOP_KEYS - set(data)
if missing_top:
raise ManifestValidationError(
f"Missing required top-level key(s): {', '.join(sorted(missing_top))}"
)
if data.get("schema_version") != MANIFEST_SCHEMA_VERSION:
raise ManifestValidationError(
f"Unsupported schema_version: {data.get('schema_version')!r} "
f"(expected {MANIFEST_SCHEMA_VERSION!r})"
)
agent_block = data.get("agent")
if not isinstance(agent_block, dict):
raise ManifestValidationError("'agent' must be a mapping")
missing_agent = _REQUIRED_AGENT_KEYS - set(agent_block)
if missing_agent:
raise ManifestValidationError(
f"Missing required agent key(s): {', '.join(sorted(missing_agent))}"
)
runtime = data.get("runtime") or {}
requires = data.get("requires") or {}
tags = data.get("tags") or []
cmd_reg = data.get("command_registration") or {}
return cls(
id=str(agent_block["id"]),
name=str(agent_block["name"]),
version=str(agent_block["version"]),
description=str(agent_block.get("description", "")),
author=str(agent_block.get("author", "")),
license=str(agent_block.get("license", "")),
requires_cli=bool(runtime.get("requires_cli", False)),
install_url=runtime.get("install_url"),
cli_tool=runtime.get("cli_tool"),
speckit_version=str(requires.get("speckit_version", ">=0.1.0")),
tags=[str(t) for t in tags] if isinstance(tags, list) else [],
commands_dir=str(cmd_reg.get("commands_dir", "")),
command_format=str(cmd_reg.get("format", "markdown")),
arg_placeholder=str(cmd_reg.get("arg_placeholder", "$ARGUMENTS")),
file_extension=str(cmd_reg.get("file_extension", ".md")),
raw=data,
pack_path=pack_path,
)
# ---------------------------------------------------------------------------
# Bootstrap base class
# ---------------------------------------------------------------------------
class AgentBootstrap:
"""Base class that every agent pack's ``bootstrap.py`` must subclass.
Subclasses override :meth:`setup` and :meth:`teardown` to define
agent-specific lifecycle operations.
"""
def __init__(self, manifest: AgentManifest):
self.manifest = manifest
self.pack_path = manifest.pack_path
# -- lifecycle -----------------------------------------------------------
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install agent files into *project_path*.
This is invoked by ``specify init --ai <agent>`` and
``specify agent switch <agent>``.
Args:
project_path: Target project directory.
script_type: ``"sh"`` or ``"ps"``.
options: Arbitrary key/value options forwarded from the CLI.
"""
raise NotImplementedError
def teardown(self, project_path: Path) -> None:
"""Remove agent-specific files from *project_path*.
Invoked by ``specify agent switch`` (for the *old* agent) and
``specify agent remove`` when the user explicitly uninstalls.
Must preserve shared infrastructure (specs, plans, tasks, etc.).
Args:
project_path: Project directory to clean up.
"""
raise NotImplementedError
# -- helpers available to subclasses ------------------------------------
def agent_dir(self, project_path: Path) -> Path:
"""Return the agent's top-level directory inside the project."""
return project_path / self.manifest.commands_dir.split("/")[0]
# ---------------------------------------------------------------------------
# Pack resolution
# ---------------------------------------------------------------------------
def _embedded_agents_dir() -> Path:
"""Return the path to the embedded agent packs inside the wheel."""
return Path(__file__).parent / "core_pack" / "agents"
def _user_agents_dir() -> Path:
"""Return the user-level agent overrides directory."""
return user_data_path("specify", "github") / "agents"
def _project_agents_dir(project_path: Path) -> Path:
"""Return the project-level agent overrides directory."""
return project_path / ".specify" / "agents"
def _catalog_agents_dir() -> Path:
"""Return the catalog-installed agent cache directory."""
return user_data_path("specify", "github") / "agent-cache"
@dataclass
class ResolvedPack:
"""Result of resolving an agent pack through the priority stack."""
manifest: AgentManifest
source: str # "user", "project", "catalog", "embedded"
path: Path
overrides: Optional[str] = None # version of the pack being overridden
def resolve_agent_pack(
agent_id: str,
project_path: Optional[Path] = None,
) -> ResolvedPack:
"""Resolve an agent pack through the priority stack.
Priority (highest first):
1. User-level ``~/.specify/agents/<id>/``
2. Project-level ``.specify/agents/<id>/``
3. Catalog-installed cache
4. Embedded in wheel
Raises ``PackResolutionError`` when no pack is found at any level.
"""
candidates: List[tuple[str, Path]] = []
# Priority 1 — user level
user_dir = _user_agents_dir() / agent_id
candidates.append(("user", user_dir))
# Priority 2 — project level
if project_path is not None:
proj_dir = _project_agents_dir(project_path) / agent_id
candidates.append(("project", proj_dir))
# Priority 3 — catalog cache
catalog_dir = _catalog_agents_dir() / agent_id
candidates.append(("catalog", catalog_dir))
# Priority 4 — embedded
embedded_dir = _embedded_agents_dir() / agent_id
candidates.append(("embedded", embedded_dir))
embedded_manifest: Optional[AgentManifest] = None
for source, pack_dir in candidates:
manifest_file = pack_dir / MANIFEST_FILENAME
if manifest_file.is_file():
manifest = AgentManifest.from_yaml(manifest_file)
if source == "embedded":
embedded_manifest = manifest
overrides = None
if source != "embedded" and embedded_manifest is None:
# Try loading embedded to record what it overrides
emb_file = _embedded_agents_dir() / agent_id / MANIFEST_FILENAME
if emb_file.is_file():
try:
emb = AgentManifest.from_yaml(emb_file)
overrides = f"embedded v{emb.version}"
except AgentPackError:
pass
return ResolvedPack(
manifest=manifest,
source=source,
path=pack_dir,
overrides=overrides,
)
raise PackResolutionError(
f"Agent '{agent_id}' not found locally or in any active catalog.\n"
f"Run 'specify agent search' to browse available agents, or\n"
f"'specify agent add {agent_id} --from <path>' for offline install."
)
# ---------------------------------------------------------------------------
# Pack discovery helpers
# ---------------------------------------------------------------------------
def list_embedded_agents() -> List[AgentManifest]:
"""Return manifests for all agent packs embedded in the wheel."""
agents_dir = _embedded_agents_dir()
if not agents_dir.is_dir():
return []
manifests: List[AgentManifest] = []
for child in sorted(agents_dir.iterdir()):
manifest_file = child / MANIFEST_FILENAME
if child.is_dir() and manifest_file.is_file():
try:
manifests.append(AgentManifest.from_yaml(manifest_file))
except AgentPackError:
continue
return manifests
def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]:
"""List all available agents, resolved through the priority stack.
Each agent id appears at most once, at its highest-priority source.
"""
seen: dict[str, ResolvedPack] = {}
# Start from lowest priority (embedded) so higher priorities overwrite
for manifest in list_embedded_agents():
seen[manifest.id] = ResolvedPack(
manifest=manifest,
source="embedded",
path=manifest.pack_path or _embedded_agents_dir() / manifest.id,
)
# Catalog cache
catalog_dir = _catalog_agents_dir()
if catalog_dir.is_dir():
for child in sorted(catalog_dir.iterdir()):
mf = child / MANIFEST_FILENAME
if child.is_dir() and mf.is_file():
try:
m = AgentManifest.from_yaml(mf)
overrides = f"embedded v{seen[m.id].manifest.version}" if m.id in seen else None
seen[m.id] = ResolvedPack(manifest=m, source="catalog", path=child, overrides=overrides)
except AgentPackError:
continue
# Project-level
if project_path is not None:
proj_dir = _project_agents_dir(project_path)
if proj_dir.is_dir():
for child in sorted(proj_dir.iterdir()):
mf = child / MANIFEST_FILENAME
if child.is_dir() and mf.is_file():
try:
m = AgentManifest.from_yaml(mf)
overrides = f"embedded v{seen[m.id].manifest.version}" if m.id in seen else None
seen[m.id] = ResolvedPack(manifest=m, source="project", path=child, overrides=overrides)
except AgentPackError:
continue
# User-level
user_dir = _user_agents_dir()
if user_dir.is_dir():
for child in sorted(user_dir.iterdir()):
mf = child / MANIFEST_FILENAME
if child.is_dir() and mf.is_file():
try:
m = AgentManifest.from_yaml(mf)
overrides = f"embedded v{seen[m.id].manifest.version}" if m.id in seen else None
seen[m.id] = ResolvedPack(manifest=m, source="user", path=child, overrides=overrides)
except AgentPackError:
continue
return sorted(seen.values(), key=lambda r: r.manifest.id)
def load_bootstrap(pack_path: Path, manifest: AgentManifest) -> AgentBootstrap:
"""Import ``bootstrap.py`` from *pack_path* and return the bootstrap instance.
The bootstrap module must define exactly one public subclass of
``AgentBootstrap``. That class is instantiated with *manifest* and
returned.
"""
bootstrap_file = pack_path / BOOTSTRAP_FILENAME
if not bootstrap_file.is_file():
raise AgentPackError(
f"Bootstrap module not found: {bootstrap_file}"
)
spec = importlib.util.spec_from_file_location(
f"speckit_agent_{manifest.id}_bootstrap", bootstrap_file
)
if spec is None or spec.loader is None:
raise AgentPackError(f"Cannot load bootstrap module: {bootstrap_file}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Find the AgentBootstrap subclass
candidates = [
obj
for name, obj in vars(module).items()
if (
isinstance(obj, type)
and issubclass(obj, AgentBootstrap)
and obj is not AgentBootstrap
and not name.startswith("_")
)
]
if not candidates:
raise AgentPackError(
f"No AgentBootstrap subclass found in {bootstrap_file}"
)
if len(candidates) > 1:
raise AgentPackError(
f"Multiple AgentBootstrap subclasses in {bootstrap_file}: "
f"{[c.__name__ for c in candidates]}"
)
return candidates[0](manifest)
def validate_pack(pack_path: Path) -> List[str]:
"""Validate a pack directory structure and return a list of warnings.
Returns an empty list when the pack is fully valid.
Raises ``ManifestValidationError`` on hard errors.
"""
warnings: List[str] = []
manifest_file = pack_path / MANIFEST_FILENAME
if not manifest_file.is_file():
raise ManifestValidationError(
f"Missing {MANIFEST_FILENAME} in {pack_path}"
)
manifest = AgentManifest.from_yaml(manifest_file)
bootstrap_file = pack_path / BOOTSTRAP_FILENAME
if not bootstrap_file.is_file():
warnings.append(f"Missing {BOOTSTRAP_FILENAME} (pack cannot be bootstrapped)")
if not manifest.commands_dir:
warnings.append("command_registration.commands_dir not set in manifest")
if not manifest.description:
warnings.append("agent.description is empty")
if not manifest.tags:
warnings.append("No tags specified (reduces discoverability)")
return warnings
def export_pack(agent_id: str, dest: Path, project_path: Optional[Path] = None) -> Path:
"""Export the active pack for *agent_id* to *dest*.
Returns the path to the exported pack directory.
"""
resolved = resolve_agent_pack(agent_id, project_path=project_path)
dest.mkdir(parents=True, exist_ok=True)
shutil.copytree(resolved.path, dest, dirs_exist_ok=True)
return dest

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Antigravity agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Agy(AgentBootstrap):
"""Bootstrap for Antigravity."""
AGENT_DIR = ".agent"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Antigravity agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Antigravity agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,23 @@
schema_version: "1.0"
agent:
id: "agy"
name: "Antigravity"
version: "1.0.0"
description: "Antigravity IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'antigravity']
command_registration:
commands_dir: ".agent/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Amp agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Amp(AgentBootstrap):
"""Bootstrap for Amp."""
AGENT_DIR = ".agents"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Amp agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Amp agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "amp"
name: "Amp"
version: "1.0.0"
description: "Amp CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://ampcode.com/manual#install"
cli_tool: "amp"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'amp']
command_registration:
commands_dir: ".agents/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Auggie CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Auggie(AgentBootstrap):
"""Bootstrap for Auggie CLI."""
AGENT_DIR = ".augment"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Auggie CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Auggie CLI agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "auggie"
name: "Auggie CLI"
version: "1.0.0"
description: "Auggie CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli"
cli_tool: "auggie"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'augment', 'auggie']
command_registration:
commands_dir: ".augment/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for IBM Bob agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Bob(AgentBootstrap):
"""Bootstrap for IBM Bob."""
AGENT_DIR = ".bob"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install IBM Bob agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove IBM Bob agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,23 @@
schema_version: "1.0"
agent:
id: "bob"
name: "IBM Bob"
version: "1.0.0"
description: "IBM Bob IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'ibm', 'bob']
command_registration:
commands_dir: ".bob/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Claude Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Claude(AgentBootstrap):
"""Bootstrap for Claude Code."""
AGENT_DIR = ".claude"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Claude Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Claude Code agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "claude"
name: "Claude Code"
version: "1.0.0"
description: "Anthropic's Claude Code CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://docs.anthropic.com/en/docs/claude-code/setup"
cli_tool: "claude"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'anthropic', 'claude']
command_registration:
commands_dir: ".claude/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for CodeBuddy agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Codebuddy(AgentBootstrap):
"""Bootstrap for CodeBuddy."""
AGENT_DIR = ".codebuddy"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install CodeBuddy agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove CodeBuddy agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "codebuddy"
name: "CodeBuddy"
version: "1.0.0"
description: "CodeBuddy CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://www.codebuddy.ai/cli"
cli_tool: "codebuddy"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'codebuddy']
command_registration:
commands_dir: ".codebuddy/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Codex CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Codex(AgentBootstrap):
"""Bootstrap for Codex CLI."""
AGENT_DIR = ".agents"
COMMANDS_SUBDIR = "skills"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Codex CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Codex CLI agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "codex"
name: "Codex CLI"
version: "1.0.0"
description: "OpenAI Codex CLI with project skills support"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/openai/codex"
cli_tool: "codex"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'openai', 'codex', 'skills']
command_registration:
commands_dir: ".agents/skills"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: "/SKILL.md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for GitHub Copilot agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Copilot(AgentBootstrap):
"""Bootstrap for GitHub Copilot."""
AGENT_DIR = ".github"
COMMANDS_SUBDIR = "agents"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install GitHub Copilot agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove GitHub Copilot agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,23 @@
schema_version: "1.0"
agent:
id: "copilot"
name: "GitHub Copilot"
version: "1.0.0"
description: "GitHub Copilot for AI-assisted development in VS Code"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'github', 'copilot']
command_registration:
commands_dir: ".github/agents"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".agent.md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Cursor agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class CursorAgent(AgentBootstrap):
"""Bootstrap for Cursor."""
AGENT_DIR = ".cursor"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Cursor agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Cursor agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,23 @@
schema_version: "1.0"
agent:
id: "cursor-agent"
name: "Cursor"
version: "1.0.0"
description: "Cursor IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'cursor']
command_registration:
commands_dir: ".cursor/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Gemini CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Gemini(AgentBootstrap):
"""Bootstrap for Gemini CLI."""
AGENT_DIR = ".gemini"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Gemini CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Gemini CLI agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "gemini"
name: "Gemini CLI"
version: "1.0.0"
description: "Google's Gemini CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/google-gemini/gemini-cli"
cli_tool: "gemini"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'google', 'gemini']
command_registration:
commands_dir: ".gemini/commands"
format: "toml"
arg_placeholder: "{{args}}"
file_extension: ".toml"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for iFlow CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Iflow(AgentBootstrap):
"""Bootstrap for iFlow CLI."""
AGENT_DIR = ".iflow"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install iFlow CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove iFlow CLI agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "iflow"
name: "iFlow CLI"
version: "1.0.0"
description: "iFlow CLI by iflow-ai for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://docs.iflow.cn/en/cli/quickstart"
cli_tool: "iflow"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'iflow']
command_registration:
commands_dir: ".iflow/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Junie agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Junie(AgentBootstrap):
"""Bootstrap for Junie."""
AGENT_DIR = ".junie"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Junie agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Junie agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "junie"
name: "Junie"
version: "1.0.0"
description: "Junie by JetBrains for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://junie.jetbrains.com/"
cli_tool: "junie"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'jetbrains', 'junie']
command_registration:
commands_dir: ".junie/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Kilo Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Kilocode(AgentBootstrap):
"""Bootstrap for Kilo Code."""
AGENT_DIR = ".kilocode"
COMMANDS_SUBDIR = "workflows"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Kilo Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Kilo Code agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,23 @@
schema_version: "1.0"
agent:
id: "kilocode"
name: "Kilo Code"
version: "1.0.0"
description: "Kilo Code IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'kilocode']
command_registration:
commands_dir: ".kilocode/workflows"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Kimi Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Kimi(AgentBootstrap):
"""Bootstrap for Kimi Code."""
AGENT_DIR = ".kimi"
COMMANDS_SUBDIR = "skills"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Kimi Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Kimi Code agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "kimi"
name: "Kimi Code"
version: "1.0.0"
description: "Kimi Code CLI by Moonshot AI with skills support"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://code.kimi.com/"
cli_tool: "kimi"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'moonshot', 'kimi', 'skills']
command_registration:
commands_dir: ".kimi/skills"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: "/SKILL.md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Kiro CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class KiroCli(AgentBootstrap):
"""Bootstrap for Kiro CLI."""
AGENT_DIR = ".kiro"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Kiro CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Kiro CLI agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "kiro-cli"
name: "Kiro CLI"
version: "1.0.0"
description: "Kiro CLI by Amazon for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://kiro.dev/docs/cli/"
cli_tool: "kiro-cli"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'amazon', 'kiro']
command_registration:
commands_dir: ".kiro/prompts"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for opencode agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Opencode(AgentBootstrap):
"""Bootstrap for opencode."""
AGENT_DIR = ".opencode"
COMMANDS_SUBDIR = "command"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install opencode agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove opencode agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "opencode"
name: "opencode"
version: "1.0.0"
description: "opencode CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://opencode.ai"
cli_tool: "opencode"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'opencode']
command_registration:
commands_dir: ".opencode/command"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Pi Coding Agent agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Pi(AgentBootstrap):
"""Bootstrap for Pi Coding Agent."""
AGENT_DIR = ".pi"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Pi Coding Agent agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Pi Coding Agent agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "pi"
name: "Pi Coding Agent"
version: "1.0.0"
description: "Pi terminal coding agent for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://www.npmjs.com/package/@mariozechner/pi-coding-agent"
cli_tool: "pi"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'pi']
command_registration:
commands_dir: ".pi/prompts"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Qoder CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Qodercli(AgentBootstrap):
"""Bootstrap for Qoder CLI."""
AGENT_DIR = ".qoder"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Qoder CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Qoder CLI agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "qodercli"
name: "Qoder CLI"
version: "1.0.0"
description: "Qoder CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://qoder.com/cli"
cli_tool: "qodercli"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'qoder']
command_registration:
commands_dir: ".qoder/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Qwen Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Qwen(AgentBootstrap):
"""Bootstrap for Qwen Code."""
AGENT_DIR = ".qwen"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Qwen Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Qwen Code agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "qwen"
name: "Qwen Code"
version: "1.0.0"
description: "Alibaba's Qwen Code CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/QwenLM/qwen-code"
cli_tool: "qwen"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'alibaba', 'qwen']
command_registration:
commands_dir: ".qwen/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Roo Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Roo(AgentBootstrap):
"""Bootstrap for Roo Code."""
AGENT_DIR = ".roo"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Roo Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Roo Code agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,23 @@
schema_version: "1.0"
agent:
id: "roo"
name: "Roo Code"
version: "1.0.0"
description: "Roo Code IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'roo']
command_registration:
commands_dir: ".roo/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for SHAI agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Shai(AgentBootstrap):
"""Bootstrap for SHAI."""
AGENT_DIR = ".shai"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install SHAI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove SHAI agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "shai"
name: "SHAI"
version: "1.0.0"
description: "SHAI CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/ovh/shai"
cli_tool: "shai"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'ovh', 'shai']
command_registration:
commands_dir: ".shai/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Tabnine CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Tabnine(AgentBootstrap):
"""Bootstrap for Tabnine CLI."""
AGENT_DIR = ".tabnine/agent"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Tabnine CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Tabnine CLI agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "tabnine"
name: "Tabnine CLI"
version: "1.0.0"
description: "Tabnine CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://docs.tabnine.com/main/getting-started/tabnine-cli"
cli_tool: "tabnine"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'tabnine']
command_registration:
commands_dir: ".tabnine/agent/commands"
format: "toml"
arg_placeholder: "{{args}}"
file_extension: ".toml"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Trae agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Trae(AgentBootstrap):
"""Bootstrap for Trae."""
AGENT_DIR = ".trae"
COMMANDS_SUBDIR = "rules"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Trae agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Trae agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,23 @@
schema_version: "1.0"
agent:
id: "trae"
name: "Trae"
version: "1.0.0"
description: "Trae IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'trae']
command_registration:
commands_dir: ".trae/rules"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Mistral Vibe agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Vibe(AgentBootstrap):
"""Bootstrap for Mistral Vibe."""
AGENT_DIR = ".vibe"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Mistral Vibe agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Mistral Vibe agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,25 @@
schema_version: "1.0"
agent:
id: "vibe"
name: "Mistral Vibe"
version: "1.0.0"
description: "Mistral Vibe CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/mistralai/mistral-vibe"
cli_tool: "vibe"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'mistral', 'vibe']
command_registration:
commands_dir: ".vibe/prompts"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -0,0 +1,25 @@
"""Bootstrap module for Windsurf agent pack."""
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class Windsurf(AgentBootstrap):
"""Bootstrap for Windsurf."""
AGENT_DIR = ".windsurf"
COMMANDS_SUBDIR = "workflows"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
"""Install Windsurf agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
"""Remove Windsurf agent files from the project."""
import shutil
agent_dir = project_path / self.AGENT_DIR
if agent_dir.is_dir():
shutil.rmtree(agent_dir)

View File

@@ -0,0 +1,23 @@
schema_version: "1.0"
agent:
id: "windsurf"
name: "Windsurf"
version: "1.0.0"
description: "Windsurf IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'windsurf']
command_registration:
commands_dir: ".windsurf/workflows"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

520
tests/test_agent_pack.py Normal file
View File

@@ -0,0 +1,520 @@
"""Tests for the agent pack infrastructure.
Covers manifest validation, bootstrap API contract, pack resolution order,
CLI commands, and consistency with AGENT_CONFIG / CommandRegistrar.AGENT_CONFIGS.
"""
import json
import shutil
import textwrap
from pathlib import Path
import pytest
import yaml
from specify_cli.agent_pack import (
BOOTSTRAP_FILENAME,
MANIFEST_FILENAME,
MANIFEST_SCHEMA_VERSION,
AgentBootstrap,
AgentManifest,
AgentPackError,
ManifestValidationError,
PackResolutionError,
ResolvedPack,
export_pack,
list_all_agents,
list_embedded_agents,
load_bootstrap,
resolve_agent_pack,
validate_pack,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_manifest(path: Path, data: dict) -> Path:
"""Write a speckit-agent.yml to *path* and return the file path."""
path.mkdir(parents=True, exist_ok=True)
manifest_file = path / MANIFEST_FILENAME
manifest_file.write_text(yaml.dump(data, default_flow_style=False), encoding="utf-8")
return manifest_file
def _minimal_manifest_dict(agent_id: str = "test-agent", **overrides) -> dict:
"""Return a minimal valid manifest dict, with optional overrides."""
data = {
"schema_version": MANIFEST_SCHEMA_VERSION,
"agent": {
"id": agent_id,
"name": "Test Agent",
"version": "0.1.0",
"description": "A test agent",
},
"runtime": {"requires_cli": False},
"requires": {"speckit_version": ">=0.1.0"},
"tags": ["test"],
"command_registration": {
"commands_dir": f".{agent_id}/commands",
"format": "markdown",
"arg_placeholder": "$ARGUMENTS",
"file_extension": ".md",
},
}
data.update(overrides)
return data
def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: str = ".test-agent") -> Path:
"""Write a minimal bootstrap.py to *pack_dir*."""
pack_dir.mkdir(parents=True, exist_ok=True)
bootstrap_file = pack_dir / BOOTSTRAP_FILENAME
bootstrap_file.write_text(textwrap.dedent(f"""\
from pathlib import Path
from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap
class {class_name}(AgentBootstrap):
AGENT_DIR = "{agent_dir}"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
(project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True)
def teardown(self, project_path: Path) -> None:
import shutil
d = project_path / self.AGENT_DIR
if d.is_dir():
shutil.rmtree(d)
"""), encoding="utf-8")
return bootstrap_file
# ===================================================================
# Manifest validation
# ===================================================================
class TestManifestValidation:
"""Validate speckit-agent.yml parsing and error handling."""
def test_valid_manifest(self, tmp_path):
data = _minimal_manifest_dict()
_write_manifest(tmp_path, data)
m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
assert m.id == "test-agent"
assert m.name == "Test Agent"
assert m.version == "0.1.0"
assert m.command_format == "markdown"
def test_missing_schema_version(self, tmp_path):
data = _minimal_manifest_dict()
del data["schema_version"]
_write_manifest(tmp_path, data)
with pytest.raises(ManifestValidationError, match="Missing required top-level"):
AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
def test_wrong_schema_version(self, tmp_path):
data = _minimal_manifest_dict()
data["schema_version"] = "99.0"
_write_manifest(tmp_path, data)
with pytest.raises(ManifestValidationError, match="Unsupported schema_version"):
AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
def test_missing_agent_block(self, tmp_path):
data = {"schema_version": MANIFEST_SCHEMA_VERSION}
_write_manifest(tmp_path, data)
with pytest.raises(ManifestValidationError, match="Missing required top-level"):
AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
def test_missing_agent_id(self, tmp_path):
data = _minimal_manifest_dict()
del data["agent"]["id"]
_write_manifest(tmp_path, data)
with pytest.raises(ManifestValidationError, match="Missing required agent key"):
AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
def test_missing_agent_name(self, tmp_path):
data = _minimal_manifest_dict()
del data["agent"]["name"]
_write_manifest(tmp_path, data)
with pytest.raises(ManifestValidationError, match="Missing required agent key"):
AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
def test_missing_agent_version(self, tmp_path):
data = _minimal_manifest_dict()
del data["agent"]["version"]
_write_manifest(tmp_path, data)
with pytest.raises(ManifestValidationError, match="Missing required agent key"):
AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
def test_agent_block_not_dict(self, tmp_path):
data = {"schema_version": MANIFEST_SCHEMA_VERSION, "agent": "not-a-dict"}
_write_manifest(tmp_path, data)
with pytest.raises(ManifestValidationError, match="must be a mapping"):
AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
def test_missing_file(self, tmp_path):
with pytest.raises(ManifestValidationError, match="Manifest not found"):
AgentManifest.from_yaml(tmp_path / "nonexistent" / MANIFEST_FILENAME)
def test_invalid_yaml(self, tmp_path):
tmp_path.mkdir(parents=True, exist_ok=True)
bad = tmp_path / MANIFEST_FILENAME
bad.write_text("{{{{bad yaml", encoding="utf-8")
with pytest.raises(ManifestValidationError, match="Invalid YAML"):
AgentManifest.from_yaml(bad)
def test_runtime_fields(self, tmp_path):
data = _minimal_manifest_dict()
data["runtime"] = {
"requires_cli": True,
"install_url": "https://example.com",
"cli_tool": "myagent",
}
_write_manifest(tmp_path, data)
m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
assert m.requires_cli is True
assert m.install_url == "https://example.com"
assert m.cli_tool == "myagent"
def test_command_registration_fields(self, tmp_path):
data = _minimal_manifest_dict()
data["command_registration"] = {
"commands_dir": ".test/commands",
"format": "toml",
"arg_placeholder": "{{args}}",
"file_extension": ".toml",
}
_write_manifest(tmp_path, data)
m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
assert m.commands_dir == ".test/commands"
assert m.command_format == "toml"
assert m.arg_placeholder == "{{args}}"
assert m.file_extension == ".toml"
def test_tags_field(self, tmp_path):
data = _minimal_manifest_dict()
data["tags"] = ["cli", "test", "agent"]
_write_manifest(tmp_path, data)
m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
assert m.tags == ["cli", "test", "agent"]
def test_optional_fields_default(self, tmp_path):
"""Manifest with only required fields uses sensible defaults."""
data = {
"schema_version": MANIFEST_SCHEMA_VERSION,
"agent": {"id": "bare", "name": "Bare Agent", "version": "0.0.1"},
}
_write_manifest(tmp_path, data)
m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
assert m.requires_cli is False
assert m.install_url is None
assert m.command_format == "markdown"
assert m.arg_placeholder == "$ARGUMENTS"
assert m.tags == []
def test_from_dict(self):
data = _minimal_manifest_dict("dict-agent")
m = AgentManifest.from_dict(data)
assert m.id == "dict-agent"
assert m.pack_path is None
def test_from_dict_not_dict(self):
with pytest.raises(ManifestValidationError, match="must be a YAML mapping"):
AgentManifest.from_dict("not-a-dict")
# ===================================================================
# Bootstrap API contract
# ===================================================================
class TestBootstrapContract:
"""Verify AgentBootstrap interface and load_bootstrap()."""
def test_base_class_setup_raises(self, tmp_path):
m = AgentManifest.from_dict(_minimal_manifest_dict())
b = AgentBootstrap(m)
with pytest.raises(NotImplementedError):
b.setup(tmp_path, "sh", {})
def test_base_class_teardown_raises(self, tmp_path):
m = AgentManifest.from_dict(_minimal_manifest_dict())
b = AgentBootstrap(m)
with pytest.raises(NotImplementedError):
b.teardown(tmp_path)
def test_load_bootstrap(self, tmp_path):
data = _minimal_manifest_dict()
_write_manifest(tmp_path, data)
_write_bootstrap(tmp_path)
m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME)
b = load_bootstrap(tmp_path, m)
assert isinstance(b, AgentBootstrap)
def test_load_bootstrap_missing_file(self, tmp_path):
m = AgentManifest.from_dict(_minimal_manifest_dict())
with pytest.raises(AgentPackError, match="Bootstrap module not found"):
load_bootstrap(tmp_path, m)
def test_bootstrap_setup_and_teardown(self, tmp_path):
"""Verify a loaded bootstrap can set up and tear down."""
pack_dir = tmp_path / "pack"
data = _minimal_manifest_dict()
_write_manifest(pack_dir, data)
_write_bootstrap(pack_dir)
m = AgentManifest.from_yaml(pack_dir / MANIFEST_FILENAME)
b = load_bootstrap(pack_dir, m)
project = tmp_path / "project"
project.mkdir()
b.setup(project, "sh", {})
assert (project / ".test-agent" / "commands").is_dir()
b.teardown(project)
assert not (project / ".test-agent").exists()
def test_load_bootstrap_no_subclass(self, tmp_path):
"""A bootstrap module without an AgentBootstrap subclass fails."""
pack_dir = tmp_path / "pack"
pack_dir.mkdir(parents=True)
data = _minimal_manifest_dict()
_write_manifest(pack_dir, data)
(pack_dir / BOOTSTRAP_FILENAME).write_text("x = 1\n", encoding="utf-8")
m = AgentManifest.from_yaml(pack_dir / MANIFEST_FILENAME)
with pytest.raises(AgentPackError, match="No AgentBootstrap subclass"):
load_bootstrap(pack_dir, m)
# ===================================================================
# Pack resolution
# ===================================================================
class TestResolutionOrder:
"""Verify the 4-level priority resolution stack."""
def test_embedded_resolution(self):
"""Embedded agents are resolvable (at least claude should exist)."""
resolved = resolve_agent_pack("claude")
assert resolved.source == "embedded"
assert resolved.manifest.id == "claude"
def test_missing_agent_raises(self):
with pytest.raises(PackResolutionError, match="not found"):
resolve_agent_pack("nonexistent-agent-xyz")
def test_project_level_overrides_embedded(self, tmp_path):
"""A project-level pack shadows the embedded pack."""
proj_agents = tmp_path / ".specify" / "agents" / "claude"
data = _minimal_manifest_dict("claude")
data["agent"]["version"] = "99.0.0"
_write_manifest(proj_agents, data)
_write_bootstrap(proj_agents, class_name="ClaudeOverride", agent_dir=".claude")
resolved = resolve_agent_pack("claude", project_path=tmp_path)
assert resolved.source == "project"
assert resolved.manifest.version == "99.0.0"
def test_user_level_overrides_everything(self, tmp_path, monkeypatch):
"""A user-level pack has highest priority."""
from specify_cli import agent_pack
user_dir = tmp_path / "user_agents"
monkeypatch.setattr(agent_pack, "_user_agents_dir", lambda: user_dir)
user_pack = user_dir / "claude"
data = _minimal_manifest_dict("claude")
data["agent"]["version"] = "999.0.0"
_write_manifest(user_pack, data)
# Also create a project-level override
proj_agents = tmp_path / "project" / ".specify" / "agents" / "claude"
data2 = _minimal_manifest_dict("claude")
data2["agent"]["version"] = "50.0.0"
_write_manifest(proj_agents, data2)
resolved = resolve_agent_pack("claude", project_path=tmp_path / "project")
assert resolved.source == "user"
assert resolved.manifest.version == "999.0.0"
def test_catalog_overrides_embedded(self, tmp_path, monkeypatch):
"""A catalog-cached pack overrides embedded."""
from specify_cli import agent_pack
cache_dir = tmp_path / "agent-cache"
monkeypatch.setattr(agent_pack, "_catalog_agents_dir", lambda: cache_dir)
catalog_pack = cache_dir / "claude"
data = _minimal_manifest_dict("claude")
data["agent"]["version"] = "2.0.0"
_write_manifest(catalog_pack, data)
resolved = resolve_agent_pack("claude")
assert resolved.source == "catalog"
assert resolved.manifest.version == "2.0.0"
# ===================================================================
# List and discovery
# ===================================================================
class TestDiscovery:
"""Verify list_embedded_agents() and list_all_agents()."""
def test_list_embedded_agents_nonempty(self):
agents = list_embedded_agents()
assert len(agents) >= 25
ids = {a.id for a in agents}
assert "claude" in ids
assert "gemini" in ids
assert "copilot" in ids
def test_list_all_agents(self):
all_agents = list_all_agents()
assert len(all_agents) >= 25
# Should be sorted by id
ids = [a.manifest.id for a in all_agents]
assert ids == sorted(ids)
# ===================================================================
# Validate pack
# ===================================================================
class TestValidatePack:
def test_valid_pack(self, tmp_path):
pack_dir = tmp_path / "pack"
data = _minimal_manifest_dict()
_write_manifest(pack_dir, data)
_write_bootstrap(pack_dir)
warnings = validate_pack(pack_dir)
assert warnings == [] # All fields present, no warnings
def test_missing_manifest(self, tmp_path):
pack_dir = tmp_path / "pack"
pack_dir.mkdir(parents=True)
with pytest.raises(ManifestValidationError, match="Missing"):
validate_pack(pack_dir)
def test_missing_bootstrap_warning(self, tmp_path):
pack_dir = tmp_path / "pack"
data = _minimal_manifest_dict()
_write_manifest(pack_dir, data)
warnings = validate_pack(pack_dir)
assert any("bootstrap.py" in w for w in warnings)
def test_missing_description_warning(self, tmp_path):
pack_dir = tmp_path / "pack"
data = _minimal_manifest_dict()
data["agent"]["description"] = ""
_write_manifest(pack_dir, data)
_write_bootstrap(pack_dir)
warnings = validate_pack(pack_dir)
assert any("description" in w for w in warnings)
def test_missing_tags_warning(self, tmp_path):
pack_dir = tmp_path / "pack"
data = _minimal_manifest_dict()
data["tags"] = []
_write_manifest(pack_dir, data)
_write_bootstrap(pack_dir)
warnings = validate_pack(pack_dir)
assert any("tags" in w.lower() for w in warnings)
# ===================================================================
# Export pack
# ===================================================================
class TestExportPack:
def test_export_embedded(self, tmp_path):
dest = tmp_path / "export"
result = export_pack("claude", dest)
assert (result / MANIFEST_FILENAME).is_file()
assert (result / BOOTSTRAP_FILENAME).is_file()
# ===================================================================
# Embedded packs consistency with AGENT_CONFIG
# ===================================================================
class TestEmbeddedPacksConsistency:
"""Ensure embedded agent packs match the runtime AGENT_CONFIG."""
def test_all_agent_config_agents_have_embedded_packs(self):
"""Every agent in AGENT_CONFIG (except 'generic') has an embedded pack."""
from specify_cli import AGENT_CONFIG
embedded = {m.id for m in list_embedded_agents()}
for agent_key in AGENT_CONFIG:
if agent_key == "generic":
continue
assert agent_key in embedded, (
f"Agent '{agent_key}' is in AGENT_CONFIG but has no embedded pack"
)
def test_embedded_packs_match_agent_config_metadata(self):
"""Embedded pack manifests are consistent with AGENT_CONFIG fields."""
from specify_cli import AGENT_CONFIG
for manifest in list_embedded_agents():
config = AGENT_CONFIG.get(manifest.id)
if config is None:
continue # Extra embedded packs are fine
assert manifest.name == config["name"], (
f"{manifest.id}: name mismatch: pack={manifest.name!r} config={config['name']!r}"
)
assert manifest.requires_cli == config["requires_cli"], (
f"{manifest.id}: requires_cli mismatch"
)
if config.get("install_url"):
assert manifest.install_url == config["install_url"], (
f"{manifest.id}: install_url mismatch"
)
def test_embedded_packs_match_command_registrar(self):
"""Embedded pack command_registration matches CommandRegistrar.AGENT_CONFIGS."""
from specify_cli.agents import CommandRegistrar
for manifest in list_embedded_agents():
registrar_config = CommandRegistrar.AGENT_CONFIGS.get(manifest.id)
if registrar_config is None:
# Some agents in AGENT_CONFIG may not be in the registrar
# (e.g., agy, vibe — recently added)
continue
assert manifest.commands_dir == registrar_config["dir"], (
f"{manifest.id}: commands_dir mismatch: "
f"pack={manifest.commands_dir!r} registrar={registrar_config['dir']!r}"
)
assert manifest.command_format == registrar_config["format"], (
f"{manifest.id}: format mismatch"
)
assert manifest.arg_placeholder == registrar_config["args"], (
f"{manifest.id}: arg_placeholder mismatch"
)
assert manifest.file_extension == registrar_config["extension"], (
f"{manifest.id}: file_extension mismatch"
)
def test_each_embedded_pack_validates(self):
"""Every embedded pack passes validate_pack()."""
from specify_cli.agent_pack import _embedded_agents_dir
agents_dir = _embedded_agents_dir()
for child in sorted(agents_dir.iterdir()):
if not child.is_dir():
continue
manifest_file = child / MANIFEST_FILENAME
if not manifest_file.is_file():
continue
# Should not raise
warnings = validate_pack(child)
# Warnings are acceptable; hard errors are not