diff --git a/pyproject.toml b/pyproject.toml index f3ca76dd9..a00a28b1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d2bf63eeb..c801cbd75 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -36,7 +36,7 @@ import stat import yaml from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any, List, Optional, Tuple import typer import httpx @@ -1715,6 +1715,7 @@ def _handle_agent_skills_migration(console: Console, agent_key: str) -> None: def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), + agent: str = typer.Option(None, "--agent", help="AI agent to use (enables file tracking for clean teardown when switching agents). Accepts the same agent IDs as --ai."), ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), @@ -1753,6 +1754,7 @@ def init( Examples: specify init my-project specify init my-project --ai claude + specify init my-project --agent claude # Pack-based flow (with file tracking) specify init my-project --ai copilot --no-git specify init --ignore-agent-tools my-project specify init . --ai claude # Initialize in current directory @@ -1765,6 +1767,7 @@ def init( specify init --here --force # Skip confirmation when current directory not empty specify init my-project --ai claude --ai-skills # Install agent skills specify init --here --ai gemini --ai-skills + specify init my-project --agent claude --ai-skills # Pack-based flow with skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent specify init my-project --offline # Use bundled assets (no network access) specify init my-project --ai claude --preset healthcare-compliance # With preset @@ -1772,6 +1775,17 @@ def init( show_banner() + # --agent and --ai are interchangeable for agent selection, but --agent + # additionally opts into the pack-based flow (file tracking via + # finalize_setup for tracked teardown/switch). + use_agent_pack = False + if agent: + if ai_assistant: + console.print("[red]Error:[/red] --agent and --ai cannot both be specified. Use one or the other.") + raise typer.Exit(1) + ai_assistant = agent + use_agent_pack = True + # Detect when option values are likely misinterpreted flags (parameter ordering issue) if ai_assistant and ai_assistant.startswith("--"): console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") @@ -1802,7 +1816,7 @@ def init( raise typer.Exit(1) if ai_skills and not ai_assistant: - console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") + console.print("[red]Error:[/red] --ai-skills requires --ai or --agent to be specified") console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") raise typer.Exit(1) @@ -1854,6 +1868,19 @@ def init( "copilot" ) + # When --agent is used, validate that the agent resolves through the pack + # system and prepare the bootstrap for post-init file tracking. + agent_bootstrap = None + if use_agent_pack: + from .agent_pack import resolve_agent_pack, load_bootstrap, PackResolutionError, AgentPackError + try: + resolved = resolve_agent_pack(selected_ai) + agent_bootstrap = load_bootstrap(resolved.path, resolved.manifest) + console.print(f"[dim]Pack-based flow: {resolved.manifest.name} ({resolved.source})[/dim]") + except (PackResolutionError, AgentPackError) as exc: + console.print(f"[red]Error resolving agent pack:[/red] {exc}") + raise typer.Exit(1) + # Agents that have moved from explicit commands/prompts to agent skills. if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: # If selected interactively (no --ai provided), automatically enable @@ -1957,7 +1984,10 @@ def init( "This will become the default in v0.6.0." ) - if use_github: + if use_agent_pack: + # Pack-based flow: setup() owns scaffolding, always uses bundled assets. + tracker.add("scaffold", "Apply bundled assets") + elif use_github: for key, label in [ ("fetch", "Fetch latest release"), ("download", "Download template"), @@ -1992,7 +2022,26 @@ def init( verify = not skip_tls local_ssl_context = ssl_context if verify else False - if use_github: + # -- scaffolding ------------------------------------------------ + # Pack-based flow (--agent): setup() owns scaffolding and + # returns every file it created. Legacy flow (--ai): scaffold + # directly or download from GitHub. + agent_setup_files: list[Path] = [] + + if use_agent_pack and agent_bootstrap is not None: + tracker.start("scaffold") + try: + agent_setup_files = agent_bootstrap.setup( + project_path, selected_script, {"here": here}) + tracker.complete( + "scaffold", + f"{selected_ai} ({len(agent_setup_files)} files)") + except Exception as exc: + tracker.error("scaffold", str(exc)) + if not here and project_path.exists(): + shutil.rmtree(project_path) + raise typer.Exit(1) + elif use_github: with httpx.Client(verify=local_ssl_context) as local_client: download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) else: @@ -2090,6 +2139,7 @@ def init( "ai": selected_ai, "ai_skills": ai_skills, "ai_commands_dir": ai_commands_dir, + "agent_pack": use_agent_pack, "branch_numbering": branch_numbering or "sequential", "here": here, "preset": preset, @@ -2133,6 +2183,16 @@ def init( if not use_github: tracker.skip("cleanup", "not needed (no download)") + # When --agent is used, record all installed agent files for + # tracked teardown. setup() already returned the files it + # created; pass them to finalize_setup so the manifest is + # accurate. finalize_setup also scans the agent directory + # to catch any additional files created by later pipeline + # steps (skills, extensions, presets). + if use_agent_pack and agent_bootstrap is not None: + agent_bootstrap.finalize_setup( + project_path, agent_files=agent_setup_files) + tracker.complete("final", "project ready") except (typer.Exit, SystemExit): raise @@ -2366,6 +2426,513 @@ 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"), + force: bool = typer.Option(False, "--force", help="Remove agent files even if they were modified since installation"), +): + """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, + check_modified_files, + get_tracked_files, + 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) + + # Check for modified files BEFORE teardown and prompt for confirmation + modified = check_modified_files(project_path, current_agent) + if modified and not force: + console.print("[yellow]The following files have been modified since installation:[/yellow]") + for f in modified: + console.print(f" {f}") + if not typer.confirm("Remove these modified files?"): + console.print("[dim]Aborted. Use --force to skip this check.[/dim]") + raise typer.Exit(0) + + # Retrieve tracked file lists and feed them into teardown + agent_files, extension_files = get_tracked_files(project_path, current_agent) + all_files = {**agent_files, **extension_files} + + console.print(f" [dim]Tearing down {current_agent}...[/dim]") + current_bootstrap.teardown( + project_path, + force=True, # already confirmed above + files=all_files if all_files else None, + ) + 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]") + agent_files = 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") + + # Re-register extension commands for the new agent + extension_files = _reregister_extension_commands(project_path, agent_id) + + # Record all installed files (agent + extensions) for tracked teardown + new_bootstrap.finalize_setup( + project_path, + agent_files=agent_files, + extension_files=extension_files, + ) + + console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]") + + +def _reregister_extension_commands(project_path: Path, agent_id: str) -> List[Path]: + """Re-register all installed extension commands for a new agent after switching. + + Returns: + List of absolute file paths created by extension registration. + """ + created_files: List[Path] = [] + registry_file = project_path / ".specify" / "extensions" / ".registry" + if not registry_file.is_file(): + return created_files + + try: + registry_data = json.loads(registry_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return created_files + + extensions = registry_data.get("extensions", {}) + if not extensions: + return created_files + + try: + from .agents import CommandRegistrar + registrar = CommandRegistrar() + except ImportError: + return created_files + + # Snapshot the commands directory before registration so we can + # detect which files were created by extension commands. + agent_config = registrar.AGENT_CONFIGS.get(agent_id) + if agent_config: + commands_dir = project_path / agent_config["dir"] + pre_existing = set(commands_dir.rglob("*")) if commands_dir.is_dir() else set() + else: + pre_existing = set() + + 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 + + # Collect files created by extension registration + if agent_config: + commands_dir = project_path / agent_config["dir"] + if commands_dir.is_dir(): + for p in commands_dir.rglob("*"): + if p.is_file() and p not in pre_existing: + created_files.append(p) + + if reregistered: + console.print( + f" [green]✓[/green] Re-registered {reregistered} extension command(s) ({len(created_files)} file(s))" + ) + + return created_files + + +@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 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( diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py new file mode 100644 index 000000000..efef7fb24 --- /dev/null +++ b/src/specify_cli/agent_pack.py @@ -0,0 +1,849 @@ +""" +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//) + 2. Project-level (.specify/agents//) + 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 hashlib +import importlib.util +import json +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.""" + + +class AgentFileModifiedError(AgentPackError): + """Raised when teardown finds user-modified files and ``--force`` is not set.""" + + +# --------------------------------------------------------------------------- +# 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]) -> List[Path]: + """Install agent files into *project_path*. + + This is invoked by ``specify init --ai `` and + ``specify agent switch ``. + + Implementations **must** return every file they create so that the + CLI can record both agent-installed files and extension-installed + files in a single install manifest. + + Args: + project_path: Target project directory. + script_type: ``"sh"`` or ``"ps"``. + options: Arbitrary key/value options forwarded from the CLI. + + Returns: + List of absolute paths of files created during setup. + """ + raise NotImplementedError + + def teardown( + self, + project_path: Path, + *, + force: bool = False, + files: Optional[Dict[str, str]] = None, + ) -> List[str]: + """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.). + + Only individual files are removed — directories are **never** + deleted. + + The caller (CLI) is expected to check for user-modified files + **before** invoking teardown and prompt for confirmation. If + *files* is provided, exactly those files are removed (values are + ignored but kept for forward compatibility). Otherwise the + install manifest is read. + + Args: + project_path: Project directory to clean up. + force: When ``True``, remove files even if they were modified + after installation. + files: Mapping of project-relative path → SHA-256 hash. + When supplied, only these files are removed and the + install manifest is not consulted. + + Returns: + List of project-relative paths that were actually deleted. + """ + 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] + + def collect_installed_files(self, project_path: Path) -> List[Path]: + """Return every file under the agent's directory tree. + + Subclasses should call this at the end of :meth:`setup` to build + the return list. Any files present in the agent directory at + that point — whether created by ``setup()`` itself, by the + scaffold pipeline, or by a preceding step — are reported. + """ + root = self.agent_dir(project_path) + if not root.is_dir(): + return [] + return sorted(p for p in root.rglob("*") if p.is_file()) + + def _scaffold_project( + self, + project_path: Path, + script_type: str, + is_current_dir: bool = False, + ) -> List[Path]: + """Run the shared scaffolding pipeline and return new files. + + Calls ``scaffold_from_core_pack`` for this agent and then + collects every file that was created. Subclasses should call + this from :meth:`setup` when they want to use the shared + scaffolding rather than creating files manually. + + Returns: + List of absolute paths of **all** files created by the + scaffold (agent-specific commands, shared scripts, + templates, etc.). + """ + # Lazy import to avoid circular dependency (agent_pack is + # imported by specify_cli.__init__). + from specify_cli import scaffold_from_core_pack + + # Snapshot existing files + before: set[Path] = set() + if project_path.exists(): + before = {p for p in project_path.rglob("*") if p.is_file()} + + ok = scaffold_from_core_pack( + project_path, self.manifest.id, script_type, is_current_dir, + ) + if not ok: + raise AgentPackError( + f"Scaffolding failed for agent '{self.manifest.id}'") + + # Collect every new file + after = {p for p in project_path.rglob("*") if p.is_file()} + return sorted(after - before) + + def finalize_setup( + self, + project_path: Path, + agent_files: Optional[List[Path]] = None, + extension_files: Optional[List[Path]] = None, + ) -> None: + """Record installed files for tracked teardown. + + This must be called **after** the full init pipeline has finished + writing files (commands, context files, extensions) into the + project. It combines the files reported by :meth:`setup` with + any extra files (e.g. from extension registration), scans the + agent's directory tree for anything additional, and writes the + install manifest. + + ``setup()`` may return *all* files created by the shared + scaffolding (including shared project files in ``.specify/``). + Only files under the agent's own directory tree are recorded as + ``agent_files`` — shared project infrastructure is not tracked + per-agent and will not be removed during teardown. + + Args: + agent_files: Files reported by :meth:`setup`. + extension_files: Files created by extension registration. + """ + all_extension = list(extension_files or []) + + # Filter agent_files: only keep files under the agent's directory + # tree. setup() returns *all* scaffolded files (including shared + # project infrastructure in .specify/) but only agent-owned files + # should be tracked per-agent — shared files are not removed + # during teardown/switch. + agent_root = self.agent_dir(project_path) + agent_root_resolved = agent_root.resolve() + all_agent: List[Path] = [] + for p in (agent_files or []): + try: + p.resolve().relative_to(agent_root_resolved) + all_agent.append(p) + except ValueError: + pass + + # Scan the agent's directory tree for files created by later + # init pipeline steps (skills, presets, extensions) that + # setup() did not report. We scan the agent root directory + # (e.g. .claude/) so we catch both commands and skills + # directories (skills-migrated agents replace the commands + # directory with a sibling skills directory during init). + if self.manifest.commands_dir: + agent_root = self.agent_dir(project_path) + if agent_root.is_dir(): + agent_set = {p.resolve() for p in all_agent} + for p in agent_root.rglob("*"): + if p.is_file() and p.resolve() not in agent_set: + all_agent.append(p) + agent_set.add(p.resolve()) + + record_installed_files( + project_path, + self.manifest.id, + agent_files=all_agent, + extension_files=all_extension, + ) + + +# --------------------------------------------------------------------------- +# Installed-file tracking +# --------------------------------------------------------------------------- + +def _manifest_path(project_path: Path, agent_id: str) -> Path: + """Return the path to the install manifest for *agent_id*.""" + return project_path / ".specify" / f"agent-manifest-{agent_id}.json" + + +def _sha256(path: Path) -> str: + """Return the hex SHA-256 of a file.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +def _hash_file_list( + project_path: Path, + files: List[Path], +) -> Dict[str, str]: + """Build a {relative_path: sha256} dict from a list of file paths.""" + entries: Dict[str, str] = {} + for file_path in files: + abs_path = project_path / file_path if not file_path.is_absolute() else file_path + if abs_path.is_file(): + rel = str(abs_path.relative_to(project_path)) + entries[rel] = _sha256(abs_path) + return entries + + +def record_installed_files( + project_path: Path, + agent_id: str, + agent_files: Optional[List[Path]] = None, + extension_files: Optional[List[Path]] = None, +) -> Path: + """Record the installed files and their SHA-256 hashes. + + Writes ``.specify/agent-manifest-.json`` containing + categorised mappings of project-relative paths to SHA-256 digests. + + Args: + project_path: Project root directory. + agent_id: Agent identifier. + agent_files: Files created by the agent's ``setup()`` and the + init pipeline (core commands / templates). + extension_files: Files created by extension registration. + + Returns: + Path to the written manifest file. + """ + agent_entries = _hash_file_list(project_path, agent_files or []) + extension_entries = _hash_file_list(project_path, extension_files or []) + + manifest_file = _manifest_path(project_path, agent_id) + manifest_file.parent.mkdir(parents=True, exist_ok=True) + manifest_file.write_text( + json.dumps( + { + "agent_id": agent_id, + "agent_files": agent_entries, + "extension_files": extension_entries, + }, + indent=2, + ), + encoding="utf-8", + ) + return manifest_file + + +def _all_tracked_entries(data: dict) -> Dict[str, str]: + """Return the combined file → hash mapping from a manifest dict. + + Supports both the new categorised layout (``agent_files`` + + ``extension_files``) and the legacy flat ``files`` key. + """ + combined: Dict[str, str] = {} + # Legacy flat format + if "files" in data and isinstance(data["files"], dict): + combined.update(data["files"]) + # New categorised format + if "agent_files" in data and isinstance(data["agent_files"], dict): + combined.update(data["agent_files"]) + if "extension_files" in data and isinstance(data["extension_files"], dict): + combined.update(data["extension_files"]) + return combined + + +def get_tracked_files( + project_path: Path, + agent_id: str, +) -> tuple[Dict[str, str], Dict[str, str]]: + """Return the tracked file hashes split by source. + + Returns: + A tuple ``(agent_files, extension_files)`` where each is a + ``{relative_path: sha256}`` dict. Returns two empty dicts + when no install manifest exists. + """ + manifest_file = _manifest_path(project_path, agent_id) + if not manifest_file.is_file(): + return {}, {} + + try: + data = json.loads(manifest_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {}, {} + + # Support legacy flat format + if "files" in data and "agent_files" not in data: + return dict(data["files"]), {} + + agent_entries = data.get("agent_files", {}) + ext_entries = data.get("extension_files", {}) + return dict(agent_entries), dict(ext_entries) + + +def check_modified_files( + project_path: Path, + agent_id: str, +) -> List[str]: + """Return project-relative paths of files modified since installation. + + Returns an empty list when no install manifest exists or when every + tracked file still has its original hash. + """ + manifest_file = _manifest_path(project_path, agent_id) + if not manifest_file.is_file(): + return [] + + try: + data = json.loads(manifest_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [] + + entries = _all_tracked_entries(data) + + modified: List[str] = [] + for rel_path, original_hash in entries.items(): + abs_path = project_path / rel_path + if abs_path.is_file(): + if _sha256(abs_path) != original_hash: + modified.append(rel_path) + # If the file was deleted by the user, treat it as not needing + # removal — skip rather than flag as modified. + + return modified + + +def remove_tracked_files( + project_path: Path, + agent_id: str, + *, + force: bool = False, + files: Optional[Dict[str, str]] = None, +) -> List[str]: + """Remove individual tracked files. + + If *files* is provided, exactly those files are removed (the values + are ignored but accepted for forward compatibility). Otherwise the + install manifest for *agent_id* is read. + + Raises :class:`AgentFileModifiedError` if any tracked file was + modified and *force* is ``False`` (only when reading from the + manifest — callers that pass *files* are expected to have already + prompted the user). + + Directories are **never** deleted — only individual files. + + Args: + project_path: Project root directory. + agent_id: Agent identifier. + force: When ``True``, delete even modified files. + files: Explicit mapping of project-relative path → hash. When + supplied, the install manifest is not consulted. + + Returns: + List of project-relative paths that were removed. + """ + manifest_file = _manifest_path(project_path, agent_id) + + if files is not None: + entries = files + else: + if not manifest_file.is_file(): + return [] + try: + data = json.loads(manifest_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [] + + entries = _all_tracked_entries(data) + if not entries: + manifest_file.unlink(missing_ok=True) + return [] + + if not force: + modified = check_modified_files(project_path, agent_id) + if modified: + raise AgentFileModifiedError( + f"The following agent files have been modified since installation:\n" + + "\n".join(f" {p}" for p in modified) + + "\nUse --force to remove them anyway." + ) + + removed: List[str] = [] + for rel_path in entries: + abs_path = project_path / rel_path + if abs_path.is_file(): + abs_path.unlink() + removed.append(rel_path) + + # Clean up the install manifest itself + if manifest_file.is_file(): + manifest_file.unlink(missing_ok=True) + return removed + + +# --------------------------------------------------------------------------- +# 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//`` + 2. Project-level ``.specify/agents//`` + 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 ' 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 diff --git a/src/specify_cli/core_pack/agents/__init__.py b/src/specify_cli/core_pack/agents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/agy/__init__.py b/src/specify_cli/core_pack/agents/agy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py new file mode 100644 index 000000000..b7b6ae9d7 --- /dev/null +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Antigravity agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Antigravity agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/agy/speckit-agent.yml b/src/specify_cli/core_pack/agents/agy/speckit-agent.yml new file mode 100644 index 000000000..754afaa1f --- /dev/null +++ b/src/specify_cli/core_pack/agents/agy/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/amp/__init__.py b/src/specify_cli/core_pack/agents/amp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py new file mode 100644 index 000000000..da7099328 --- /dev/null +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Amp agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Amp agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/amp/speckit-agent.yml b/src/specify_cli/core_pack/agents/amp/speckit-agent.yml new file mode 100644 index 000000000..eaca7fa38 --- /dev/null +++ b/src/specify_cli/core_pack/agents/amp/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/auggie/__init__.py b/src/specify_cli/core_pack/agents/auggie/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py new file mode 100644 index 000000000..27f89a30f --- /dev/null +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Auggie CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Auggie CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/auggie/speckit-agent.yml b/src/specify_cli/core_pack/agents/auggie/speckit-agent.yml new file mode 100644 index 000000000..d44bae651 --- /dev/null +++ b/src/specify_cli/core_pack/agents/auggie/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/bob/__init__.py b/src/specify_cli/core_pack/agents/bob/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py new file mode 100644 index 000000000..afdd3e058 --- /dev/null +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for IBM Bob agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove IBM Bob agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/bob/speckit-agent.yml b/src/specify_cli/core_pack/agents/bob/speckit-agent.yml new file mode 100644 index 000000000..5716f0ce1 --- /dev/null +++ b/src/specify_cli/core_pack/agents/bob/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/claude/__init__.py b/src/specify_cli/core_pack/agents/claude/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py new file mode 100644 index 000000000..e1b3fade9 --- /dev/null +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Claude Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Claude Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/claude/speckit-agent.yml b/src/specify_cli/core_pack/agents/claude/speckit-agent.yml new file mode 100644 index 000000000..b8073b952 --- /dev/null +++ b/src/specify_cli/core_pack/agents/claude/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/codebuddy/__init__.py b/src/specify_cli/core_pack/agents/codebuddy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py new file mode 100644 index 000000000..c054b5a96 --- /dev/null +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for CodeBuddy agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove CodeBuddy agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/codebuddy/speckit-agent.yml b/src/specify_cli/core_pack/agents/codebuddy/speckit-agent.yml new file mode 100644 index 000000000..d12fe608b --- /dev/null +++ b/src/specify_cli/core_pack/agents/codebuddy/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/codex/__init__.py b/src/specify_cli/core_pack/agents/codex/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py new file mode 100644 index 000000000..05e9b500e --- /dev/null +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Codex CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Codex CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/codex/speckit-agent.yml b/src/specify_cli/core_pack/agents/codex/speckit-agent.yml new file mode 100644 index 000000000..0bff60cfb --- /dev/null +++ b/src/specify_cli/core_pack/agents/codex/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/copilot/__init__.py b/src/specify_cli/core_pack/agents/copilot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py new file mode 100644 index 000000000..cb5a2d4cb --- /dev/null +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for GitHub Copilot agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove GitHub Copilot agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/copilot/speckit-agent.yml b/src/specify_cli/core_pack/agents/copilot/speckit-agent.yml new file mode 100644 index 000000000..a5430ea70 --- /dev/null +++ b/src/specify_cli/core_pack/agents/copilot/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/cursor-agent/__init__.py b/src/specify_cli/core_pack/agents/cursor-agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py new file mode 100644 index 000000000..a30fb4e82 --- /dev/null +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Cursor agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Cursor agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/cursor-agent/speckit-agent.yml b/src/specify_cli/core_pack/agents/cursor-agent/speckit-agent.yml new file mode 100644 index 000000000..871658c23 --- /dev/null +++ b/src/specify_cli/core_pack/agents/cursor-agent/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/gemini/__init__.py b/src/specify_cli/core_pack/agents/gemini/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py new file mode 100644 index 000000000..92421aba8 --- /dev/null +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Gemini CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Gemini CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/gemini/speckit-agent.yml b/src/specify_cli/core_pack/agents/gemini/speckit-agent.yml new file mode 100644 index 000000000..23864abfd --- /dev/null +++ b/src/specify_cli/core_pack/agents/gemini/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/iflow/__init__.py b/src/specify_cli/core_pack/agents/iflow/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py new file mode 100644 index 000000000..520a3cba5 --- /dev/null +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for iFlow CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove iFlow CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/iflow/speckit-agent.yml b/src/specify_cli/core_pack/agents/iflow/speckit-agent.yml new file mode 100644 index 000000000..d148bc231 --- /dev/null +++ b/src/specify_cli/core_pack/agents/iflow/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/junie/__init__.py b/src/specify_cli/core_pack/agents/junie/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py new file mode 100644 index 000000000..f830bdfd7 --- /dev/null +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Junie agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Junie agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/junie/speckit-agent.yml b/src/specify_cli/core_pack/agents/junie/speckit-agent.yml new file mode 100644 index 000000000..65ea20cad --- /dev/null +++ b/src/specify_cli/core_pack/agents/junie/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/kilocode/__init__.py b/src/specify_cli/core_pack/agents/kilocode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py new file mode 100644 index 000000000..e41ee4777 --- /dev/null +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Kilo Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Kilo Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kilocode/speckit-agent.yml b/src/specify_cli/core_pack/agents/kilocode/speckit-agent.yml new file mode 100644 index 000000000..1b4519f42 --- /dev/null +++ b/src/specify_cli/core_pack/agents/kilocode/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/kimi/__init__.py b/src/specify_cli/core_pack/agents/kimi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py new file mode 100644 index 000000000..e4e6c71f9 --- /dev/null +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Kimi Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Kimi Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kimi/speckit-agent.yml b/src/specify_cli/core_pack/agents/kimi/speckit-agent.yml new file mode 100644 index 000000000..b439289d2 --- /dev/null +++ b/src/specify_cli/core_pack/agents/kimi/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/kiro-cli/__init__.py b/src/specify_cli/core_pack/agents/kiro-cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py new file mode 100644 index 000000000..756dcee58 --- /dev/null +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Kiro CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Kiro CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kiro-cli/speckit-agent.yml b/src/specify_cli/core_pack/agents/kiro-cli/speckit-agent.yml new file mode 100644 index 000000000..80b23f3a1 --- /dev/null +++ b/src/specify_cli/core_pack/agents/kiro-cli/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/opencode/__init__.py b/src/specify_cli/core_pack/agents/opencode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py new file mode 100644 index 000000000..a23b006f7 --- /dev/null +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for opencode agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove opencode agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/opencode/speckit-agent.yml b/src/specify_cli/core_pack/agents/opencode/speckit-agent.yml new file mode 100644 index 000000000..9720592db --- /dev/null +++ b/src/specify_cli/core_pack/agents/opencode/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/pi/__init__.py b/src/specify_cli/core_pack/agents/pi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py new file mode 100644 index 000000000..f63c8b08c --- /dev/null +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Pi Coding Agent agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Pi Coding Agent agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/pi/speckit-agent.yml b/src/specify_cli/core_pack/agents/pi/speckit-agent.yml new file mode 100644 index 000000000..31d94f7b0 --- /dev/null +++ b/src/specify_cli/core_pack/agents/pi/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/qodercli/__init__.py b/src/specify_cli/core_pack/agents/qodercli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py new file mode 100644 index 000000000..721205cd0 --- /dev/null +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Qoder CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Qoder CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/qodercli/speckit-agent.yml b/src/specify_cli/core_pack/agents/qodercli/speckit-agent.yml new file mode 100644 index 000000000..388936967 --- /dev/null +++ b/src/specify_cli/core_pack/agents/qodercli/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/qwen/__init__.py b/src/specify_cli/core_pack/agents/qwen/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py new file mode 100644 index 000000000..7688b8fe0 --- /dev/null +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Qwen Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Qwen Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/qwen/speckit-agent.yml b/src/specify_cli/core_pack/agents/qwen/speckit-agent.yml new file mode 100644 index 000000000..fdf7261dc --- /dev/null +++ b/src/specify_cli/core_pack/agents/qwen/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/roo/__init__.py b/src/specify_cli/core_pack/agents/roo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py new file mode 100644 index 000000000..e4416a95c --- /dev/null +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Roo Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Roo Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/roo/speckit-agent.yml b/src/specify_cli/core_pack/agents/roo/speckit-agent.yml new file mode 100644 index 000000000..44d80286f --- /dev/null +++ b/src/specify_cli/core_pack/agents/roo/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/shai/__init__.py b/src/specify_cli/core_pack/agents/shai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py new file mode 100644 index 000000000..87880c824 --- /dev/null +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for SHAI agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove SHAI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/shai/speckit-agent.yml b/src/specify_cli/core_pack/agents/shai/speckit-agent.yml new file mode 100644 index 000000000..e1cf6676a --- /dev/null +++ b/src/specify_cli/core_pack/agents/shai/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/tabnine/__init__.py b/src/specify_cli/core_pack/agents/tabnine/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py new file mode 100644 index 000000000..fe6cc3c78 --- /dev/null +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Tabnine CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Tabnine CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/tabnine/speckit-agent.yml b/src/specify_cli/core_pack/agents/tabnine/speckit-agent.yml new file mode 100644 index 000000000..cb1dc5d06 --- /dev/null +++ b/src/specify_cli/core_pack/agents/tabnine/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/trae/__init__.py b/src/specify_cli/core_pack/agents/trae/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py new file mode 100644 index 000000000..6c774fdd0 --- /dev/null +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Trae agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Trae agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/trae/speckit-agent.yml b/src/specify_cli/core_pack/agents/trae/speckit-agent.yml new file mode 100644 index 000000000..d551d8609 --- /dev/null +++ b/src/specify_cli/core_pack/agents/trae/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/vibe/__init__.py b/src/specify_cli/core_pack/agents/vibe/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py new file mode 100644 index 000000000..439974bb5 --- /dev/null +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Mistral Vibe agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Mistral Vibe agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/vibe/speckit-agent.yml b/src/specify_cli/core_pack/agents/vibe/speckit-agent.yml new file mode 100644 index 000000000..ae82f0f54 --- /dev/null +++ b/src/specify_cli/core_pack/agents/vibe/speckit-agent.yml @@ -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" diff --git a/src/specify_cli/core_pack/agents/windsurf/__init__.py b/src/specify_cli/core_pack/agents/windsurf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py new file mode 100644 index 000000000..08b4fc80b --- /dev/null +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -0,0 +1,30 @@ +"""Bootstrap module for Windsurf agent pack.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + +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]) -> List[Path]: + """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) + return self._scaffold_project(project_path, script_type) + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + """Remove Windsurf agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/windsurf/speckit-agent.yml b/src/specify_cli/core_pack/agents/windsurf/speckit-agent.yml new file mode 100644 index 000000000..9618a51c5 --- /dev/null +++ b/src/specify_cli/core_pack/agents/windsurf/speckit-agent.yml @@ -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" diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py new file mode 100644 index 000000000..c44b77fec --- /dev/null +++ b/tests/test_agent_pack.py @@ -0,0 +1,1285 @@ +"""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, + AgentFileModifiedError, + AgentManifest, + AgentPackError, + ManifestValidationError, + PackResolutionError, + ResolvedPack, + _manifest_path, + _sha256, + check_modified_files, + export_pack, + get_tracked_files, + list_all_agents, + list_embedded_agents, + load_bootstrap, + record_installed_files, + remove_tracked_files, + 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, List, Optional + from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files + + class {class_name}(AgentBootstrap): + AGENT_DIR = "{agent_dir}" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: + (project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True) + return [] + + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) + """), 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, force=False) + + 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 via file tracking.""" + 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() + + agent_files = b.setup(project, "sh", {}) + assert isinstance(agent_files, list) + assert (project / ".test-agent" / "commands").is_dir() + + # Simulate the init pipeline writing a file + cmd_file = project / ".test-agent" / "commands" / "hello.md" + cmd_file.write_text("hello", encoding="utf-8") + + # Simulate extension registration writing a file + ext_file = project / ".test-agent" / "commands" / "ext-cmd.md" + ext_file.write_text("ext", encoding="utf-8") + + # finalize_setup records both agent and extension files + b.finalize_setup(project, agent_files=agent_files, extension_files=[ext_file]) + assert _manifest_path(project, "test-agent").is_file() + + # Verify the manifest separates agent and extension files + manifest_data = json.loads(_manifest_path(project, "test-agent").read_text()) + assert "agent_files" in manifest_data + assert "extension_files" in manifest_data + + b.teardown(project) + # The tracked files should be removed + assert not cmd_file.exists() + assert not ext_file.exists() + # Install manifest itself should be cleaned up + assert not _manifest_path(project, "test-agent").is_file() + # Directories are preserved (only files are removed) + assert (project / ".test-agent" / "commands").is_dir() + + 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) > 0 + ids = {a.id for a in agents} + # Verify core agents are present + for core_agent in ("claude", "gemini", "copilot"): + assert core_agent in ids + + def test_list_all_agents(self): + all_agents = list_all_agents() + assert len(all_agents) > 0 + # Should be sorted by id + ids = [a.manifest.id for a in all_agents] + assert ids == sorted(ids) + # Verify core agents are present + id_set = set(ids) + for core_agent in ("claude", "gemini", "copilot"): + assert core_agent in id_set + + +# =================================================================== +# 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 + + +# =================================================================== +# File tracking (record / check / remove) +# =================================================================== + +class TestFileTracking: + """Verify installed-file tracking with hashes.""" + + def test_record_and_check_unmodified(self, tmp_path): + """Files recorded at install time are reported as unmodified.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + # Create a file to track + f = project / ".myagent" / "commands" / "hello.md" + f.parent.mkdir(parents=True) + f.write_text("hello world", encoding="utf-8") + + record_installed_files(project, "myagent", agent_files=[f]) + + # No modifications yet + assert check_modified_files(project, "myagent") == [] + + def test_check_detects_modification(self, tmp_path): + """A modified file is reported by check_modified_files().""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + f = project / ".myagent" / "cmd.md" + f.parent.mkdir(parents=True) + f.write_text("original", encoding="utf-8") + + record_installed_files(project, "myagent", agent_files=[f]) + + # Now modify the file + f.write_text("modified content", encoding="utf-8") + + modified = check_modified_files(project, "myagent") + assert len(modified) == 1 + assert ".myagent/cmd.md" in modified[0] + + def test_check_no_manifest(self, tmp_path): + """check_modified_files returns [] when no manifest exists.""" + assert check_modified_files(tmp_path, "nonexistent") == [] + + def test_remove_tracked_unmodified(self, tmp_path): + """remove_tracked_files deletes unmodified files.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + f1 = project / ".ag" / "a.md" + f2 = project / ".ag" / "b.md" + f1.parent.mkdir(parents=True) + f1.write_text("aaa", encoding="utf-8") + f2.write_text("bbb", encoding="utf-8") + + record_installed_files(project, "ag", agent_files=[f1, f2]) + + removed = remove_tracked_files(project, "ag") + assert len(removed) == 2 + assert not f1.exists() + assert not f2.exists() + # Directories are preserved + assert f1.parent.is_dir() + # Install manifest is cleaned up + assert not _manifest_path(project, "ag").is_file() + + def test_remove_tracked_modified_without_force_raises(self, tmp_path): + """Removing modified files without --force raises AgentFileModifiedError.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + f = project / ".ag" / "c.md" + f.parent.mkdir(parents=True) + f.write_text("original", encoding="utf-8") + + record_installed_files(project, "ag", agent_files=[f]) + f.write_text("user-edited", encoding="utf-8") + + with pytest.raises(AgentFileModifiedError, match="modified"): + remove_tracked_files(project, "ag", force=False) + + # File should still exist + assert f.is_file() + + def test_remove_tracked_modified_with_force(self, tmp_path): + """Removing modified files with --force succeeds.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + f = project / ".ag" / "d.md" + f.parent.mkdir(parents=True) + f.write_text("original", encoding="utf-8") + + record_installed_files(project, "ag", agent_files=[f]) + f.write_text("user-edited", encoding="utf-8") + + removed = remove_tracked_files(project, "ag", force=True) + assert len(removed) == 1 + assert not f.is_file() + + def test_remove_no_manifest(self, tmp_path): + """remove_tracked_files returns [] when no manifest exists.""" + removed = remove_tracked_files(tmp_path, "nonexistent") + assert removed == [] + + def test_remove_preserves_directories(self, tmp_path): + """Directories are never deleted, even when all files are removed.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + d = project / ".myagent" / "commands" / "sub" + d.mkdir(parents=True) + f = d / "deep.md" + f.write_text("deep", encoding="utf-8") + + record_installed_files(project, "myagent", agent_files=[f]) + remove_tracked_files(project, "myagent") + + assert not f.exists() + # All parent directories remain + assert d.is_dir() + assert (project / ".myagent").is_dir() + + def test_deleted_file_skipped_gracefully(self, tmp_path): + """A file deleted by the user before teardown is silently skipped.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + f = project / ".ag" / "gone.md" + f.parent.mkdir(parents=True) + f.write_text("data", encoding="utf-8") + + record_installed_files(project, "ag", agent_files=[f]) + + # User deletes the file before teardown + f.unlink() + + # Should not raise, and should not report as modified + assert check_modified_files(project, "ag") == [] + removed = remove_tracked_files(project, "ag") + assert removed == [] + + def test_sha256_consistency(self, tmp_path): + """_sha256 produces consistent hashes.""" + f = tmp_path / "test.txt" + f.write_text("hello", encoding="utf-8") + h1 = _sha256(f) + h2 = _sha256(f) + assert h1 == h2 + assert len(h1) == 64 # SHA-256 hex length + + def test_manifest_json_structure(self, tmp_path): + """The install manifest has the expected JSON structure.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + f = project / ".ag" / "x.md" + f.parent.mkdir(parents=True) + f.write_text("content", encoding="utf-8") + + manifest_file = record_installed_files(project, "ag", agent_files=[f]) + data = json.loads(manifest_file.read_text(encoding="utf-8")) + + assert data["agent_id"] == "ag" + assert isinstance(data["agent_files"], dict) + assert ".ag/x.md" in data["agent_files"] + assert len(data["agent_files"][".ag/x.md"]) == 64 + + # -- New: categorised manifest & explicit file teardown -- + + def test_manifest_categorises_agent_and_extension_files(self, tmp_path): + """record_installed_files stores agent and extension files separately.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + agent_f = project / ".ag" / "core.md" + ext_f = project / ".ag" / "ext-cmd.md" + agent_f.parent.mkdir(parents=True) + agent_f.write_text("core", encoding="utf-8") + ext_f.write_text("ext", encoding="utf-8") + + manifest_file = record_installed_files( + project, "ag", agent_files=[agent_f], extension_files=[ext_f] + ) + data = json.loads(manifest_file.read_text(encoding="utf-8")) + + assert ".ag/core.md" in data["agent_files"] + assert ".ag/ext-cmd.md" in data["extension_files"] + assert ".ag/core.md" not in data.get("extension_files", {}) + assert ".ag/ext-cmd.md" not in data.get("agent_files", {}) + + def test_get_tracked_files_returns_both_categories(self, tmp_path): + """get_tracked_files splits agent and extension files.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + agent_f = project / ".ag" / "a.md" + ext_f = project / ".ag" / "e.md" + agent_f.parent.mkdir(parents=True) + agent_f.write_text("a", encoding="utf-8") + ext_f.write_text("e", encoding="utf-8") + + record_installed_files( + project, "ag", agent_files=[agent_f], extension_files=[ext_f] + ) + + agent_files, extension_files = get_tracked_files(project, "ag") + assert ".ag/a.md" in agent_files + assert ".ag/e.md" in extension_files + + def test_get_tracked_files_no_manifest(self, tmp_path): + """get_tracked_files returns ({}, {}) when no manifest exists.""" + agent_files, extension_files = get_tracked_files(tmp_path, "nope") + assert agent_files == {} + assert extension_files == {} + + def test_teardown_with_explicit_files(self, tmp_path): + """teardown accepts explicit files dict (CLI-driven teardown).""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + f1 = project / ".ag" / "a.md" + f2 = project / ".ag" / "b.md" + f1.parent.mkdir(parents=True) + f1.write_text("aaa", encoding="utf-8") + f2.write_text("bbb", encoding="utf-8") + + # Record the files + record_installed_files(project, "ag", agent_files=[f1, f2]) + + # Get the tracked entries + agent_entries, _ = get_tracked_files(project, "ag") + + # Pass explicit files to remove_tracked_files + removed = remove_tracked_files(project, "ag", files=agent_entries) + assert len(removed) == 2 + assert not f1.exists() + assert not f2.exists() + + def test_check_detects_extension_file_modification(self, tmp_path): + """Modified extension files are also detected by check_modified_files.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + ext_f = project / ".ag" / "ext.md" + ext_f.parent.mkdir(parents=True) + ext_f.write_text("original", encoding="utf-8") + + record_installed_files(project, "ag", extension_files=[ext_f]) + + ext_f.write_text("user-edited", encoding="utf-8") + + modified = check_modified_files(project, "ag") + assert len(modified) == 1 + assert ".ag/ext.md" in modified[0] + + +# =================================================================== +# setup() returns actual files (not empty list) +# =================================================================== + +class TestSetupReturnsFiles: + """Verify that every embedded agent's ``setup()`` calls the shared + scaffolding and returns the actual files it created — not an empty + list.""" + + @pytest.mark.parametrize("agent", [ + a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic" + ]) + def test_setup_returns_nonempty_file_list(self, agent, tmp_path): + """setup() must return at least one Path (the scaffolded command + files, scripts, templates, etc.).""" + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) + + project = tmp_path / f"setup_{agent}" + project.mkdir() + + files = bootstrap.setup(project, "sh", {}) + assert isinstance(files, list) + assert len(files) > 0, ( + f"Agent '{agent}': setup() returned an empty list — " + f"it must return the files it installed") + + @pytest.mark.parametrize("agent", [ + a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic" + ]) + def test_setup_returns_only_existing_paths(self, agent, tmp_path): + """Every path returned by setup() must exist on disk.""" + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) + + project = tmp_path / f"exists_{agent}" + project.mkdir() + + files = bootstrap.setup(project, "sh", {}) + for f in files: + assert f.is_file(), ( + f"Agent '{agent}': setup() returned '{f}' but it " + f"does not exist on disk") + + @pytest.mark.parametrize("agent", [ + a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic" + ]) + def test_setup_returns_absolute_paths(self, agent, tmp_path): + """setup() must return absolute paths.""" + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) + + project = tmp_path / f"abs_{agent}" + project.mkdir() + + files = bootstrap.setup(project, "sh", {}) + for f in files: + assert f.is_absolute(), ( + f"Agent '{agent}': setup() returned relative path '{f}'") + + @pytest.mark.parametrize("agent", [ + a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic" + ]) + def test_setup_returns_include_agent_dir_files(self, agent, tmp_path): + """setup() return list must include files under the agent's + directory tree (these are the files tracked for teardown).""" + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) + + project = tmp_path / f"agentdir_{agent}" + project.mkdir() + + files = bootstrap.setup(project, "sh", {}) + agent_root = bootstrap.agent_dir(project) + + agent_dir_files = [ + f for f in files + if f.resolve().is_relative_to(agent_root.resolve()) + ] + assert len(agent_dir_files) > 0, ( + f"Agent '{agent}': setup() returned no files under " + f"'{agent_root.relative_to(project)}'") + + +# =================================================================== +# --agent / --ai parity via CliRunner (end-to-end init command) +# =================================================================== + +def _collect_project_files( + root: Path, + *, + exclude_metadata: bool = False, +) -> dict[str, bytes]: + """Walk *root* and return ``{relative_posix_path: file_bytes}``. + + When *exclude_metadata* is True, files that are expected to differ + between ``--ai`` and ``--agent`` flows are excluded: + + - ``.specify/agent-manifest-*.json`` (tracking data, ``--agent`` only) + - ``.specify/init-options.json`` (contains ``agent_pack`` flag) + """ + result: dict[str, bytes] = {} + for p in root.rglob("*"): + if p.is_file(): + rel = p.relative_to(root).as_posix() + if exclude_metadata: + if rel.startswith(".specify/agent-manifest-"): + continue + if rel == ".specify/init-options.json": + continue + result[rel] = p.read_bytes() + return result + + +# All agents except "generic" (which requires --ai-commands-dir) +_ALL_AGENTS = [a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic"] + + +def _run_init_via_cli( + project_dir: Path, + agent: str, + *, + use_agent_flag: bool, +) -> tuple[int, str]: + """Invoke ``specify init --ai/--agent `` via CliRunner. + + Patches ``download_and_extract_template`` to use + ``scaffold_from_core_pack`` so the test works without network access + while exercising the real CLI code path — the same functions and + branching that the user runs. + + Returns ``(exit_code, captured_output)``. + """ + from unittest.mock import patch as _patch + + from typer.testing import CliRunner + + from specify_cli import app as specify_app + from specify_cli import scaffold_from_core_pack as _scaffold + + runner = CliRunner() + + def _mock_download( + project_path, ai_assistant, script_type, + is_current_dir=False, **kwargs, + ): + ok = _scaffold(project_path, ai_assistant, script_type, is_current_dir) + if not ok: + raise RuntimeError( + f"scaffold_from_core_pack failed for {ai_assistant}") + tracker = kwargs.get("tracker") + if tracker: + for key in [ + "fetch", "download", "extract", + "zip-list", "extracted-summary", + ]: + try: + tracker.start(key) + tracker.complete(key, "mocked") + except Exception: + pass + + flag = "--agent" if use_agent_flag else "--ai" + args = [ + "init", str(project_dir), + flag, agent, + "--no-git", "--ignore-agent-tools", + ] + + # Agents migrated to skills need --ai-skills to avoid the fail-fast + # migration error — same requirement as the real CLI. + try: + from specify_cli import AGENT_SKILLS_MIGRATIONS + if agent in AGENT_SKILLS_MIGRATIONS: + args.append("--ai-skills") + except (ImportError, AttributeError): + pass + + with _patch( + "specify_cli.download_and_extract_template", _mock_download, + ): + result = runner.invoke(specify_app, args) + + return result.exit_code, result.output or "" + + +class TestInitFlowParity: + """End-to-end parity: ``specify init --ai`` and ``specify init --agent`` + produce identical project files for every supported agent. + + Each test invokes the actual CLI via ``typer.testing.CliRunner`` with the + network download mocked so both flows exercise the same init pipeline + without requiring internet access. + + The ``--agent`` flow additionally calls ``finalize_setup()`` which writes + a tracking manifest in ``.specify/agent-manifest-.json``. Aside from + that manifest and the ``agent_pack`` key in ``init-options.json``, every + project file must be byte-for-byte identical between the two flows. + + All {n} non-generic agents are tested. + """.format(n=len(_ALL_AGENTS)) + + # -- per-class lazy caches (init is run once per agent per flow) -------- + + @pytest.fixture(scope="class") + def ai_projects(self, tmp_path_factory): + """Cache: run ``specify init --ai`` once per agent.""" + cache: dict[str, tuple[Path, int, str]] = {} + def _get(agent: str) -> Path: + if agent not in cache: + project = tmp_path_factory.mktemp("parity_ai") / agent + exit_code, output = _run_init_via_cli( + project, agent, use_agent_flag=False) + cache[agent] = (project, exit_code, output) + project, exit_code, output = cache[agent] + assert exit_code == 0, ( + f"specify init --ai {agent} failed (exit {exit_code}):\n" + f"{output}") + return project + return _get + + @pytest.fixture(scope="class") + def agent_projects(self, tmp_path_factory): + """Cache: run ``specify init --agent`` once per agent.""" + cache: dict[str, tuple[Path, int, str]] = {} + def _get(agent: str) -> Path: + if agent not in cache: + project = tmp_path_factory.mktemp("parity_agent") / agent + exit_code, output = _run_init_via_cli( + project, agent, use_agent_flag=True) + cache[agent] = (project, exit_code, output) + project, exit_code, output = cache[agent] + assert exit_code == 0, ( + f"specify init --agent {agent} failed (exit {exit_code}):\n" + f"{output}") + return project + return _get + + # -- parametrized parity tests over every agent ------------------------- + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_ai_init_succeeds(self, agent, ai_projects): + """``specify init --ai `` completes successfully.""" + assert ai_projects(agent).is_dir() + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_agent_init_succeeds(self, agent, agent_projects): + """``specify init --agent `` completes successfully.""" + assert agent_projects(agent).is_dir() + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_same_file_set(self, agent, ai_projects, agent_projects): + """--ai and --agent produce the same set of project files.""" + ai_files = _collect_project_files( + ai_projects(agent), exclude_metadata=True) + agent_files = _collect_project_files( + agent_projects(agent), exclude_metadata=True) + + only_ai = sorted(set(ai_files) - set(agent_files)) + only_agent = sorted(set(agent_files) - set(ai_files)) + + assert not only_ai, ( + f"Agent '{agent}': files only in --ai output:\n " + + "\n ".join(only_ai)) + assert not only_agent, ( + f"Agent '{agent}': files only in --agent output:\n " + + "\n ".join(only_agent)) + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_same_file_contents(self, agent, ai_projects, agent_projects): + """--ai and --agent produce byte-for-byte identical file contents.""" + ai_files = _collect_project_files( + ai_projects(agent), exclude_metadata=True) + agent_files = _collect_project_files( + agent_projects(agent), exclude_metadata=True) + + for name in ai_files: + if name not in agent_files: + continue # caught by test_same_file_set + assert ai_files[name] == agent_files[name], ( + f"Agent '{agent}': file '{name}' content differs " + f"between --ai and --agent flows") + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_same_directory_structure(self, agent, ai_projects, agent_projects): + """--ai and --agent produce the same directory tree.""" + def _dirs(root: Path) -> set[str]: + return { + p.relative_to(root).as_posix() + for p in root.rglob("*") if p.is_dir() + } + + ai_dirs = _dirs(ai_projects(agent)) + agent_dirs = _dirs(agent_projects(agent)) + + only_ai = sorted(ai_dirs - agent_dirs) + only_agent = sorted(agent_dirs - ai_dirs) + + assert not only_ai, ( + f"Agent '{agent}': dirs only in --ai:\n " + + "\n ".join(only_ai)) + assert not only_agent, ( + f"Agent '{agent}': dirs only in --agent:\n " + + "\n ".join(only_agent)) + + # -- pack lifecycle (setup / finalize / teardown) ----------------------- + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_agent_resolves_through_pack_system(self, agent): + """Every AGENT_CONFIG agent resolves a valid pack.""" + try: + resolved = resolve_agent_pack(agent) + except PackResolutionError: + pytest.fail(f"--agent {agent} would fail: no pack found") + assert resolved.manifest.id == agent + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_setup_creates_commands_dir(self, agent, agent_projects): + """The pack's setup() creates the commands directory that the + scaffold pipeline writes command files into. + + For agents that migrate to skills (``--ai-skills``), the commands + directory is replaced by a skills directory during init — verify + that the agent directory itself exists instead. + """ + project = agent_projects(agent) + resolved = resolve_agent_pack(agent) + cmd_dir = project / resolved.manifest.commands_dir + + if cmd_dir.is_dir(): + return # commands directory present — normal flow + + # For skills-migrated agents, the commands dir is removed and + # replaced by a skills dir. Verify the parent agent dir exists. + try: + from specify_cli import AGENT_SKILLS_MIGRATIONS + if agent in AGENT_SKILLS_MIGRATIONS: + agent_dir = cmd_dir.parent + assert agent_dir.is_dir(), ( + f"Agent '{agent}': agent dir " + f"'{agent_dir.relative_to(project)}' missing " + f"(skills migration removes commands)") + return + except (ImportError, AttributeError): + pass + + pytest.fail( + f"Agent '{agent}': commands_dir " + f"'{resolved.manifest.commands_dir}' not present after init") + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_commands_dir_matches_agent_config(self, agent): + """Pack commands_dir matches the directory derived from AGENT_CONFIG.""" + from specify_cli import AGENT_CONFIG + + config = AGENT_CONFIG[agent] + try: + resolved = resolve_agent_pack(agent) + except PackResolutionError: + pytest.fail(f"No pack for {agent}") + + folder = config.get("folder", "").rstrip("/") + subdir = config.get("commands_subdir", "commands") + expected = (f"{folder}/{subdir}" if folder else "").lstrip("/") + + assert resolved.manifest.commands_dir == expected, ( + f"{agent}: pack={resolved.manifest.commands_dir!r} " + f"vs config={expected!r}") + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_tracking_manifest_present(self, agent, agent_projects): + """--agent flow writes an install manifest for tracked teardown.""" + manifest = _manifest_path(agent_projects(agent), agent) + assert manifest.is_file(), ( + f"Agent '{agent}': missing tracking manifest") + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_tracking_manifest_records_all_commands(self, agent, agent_projects): + """The install manifest tracks every file in the commands (or skills) + directory that exists after init.""" + project = agent_projects(agent) + resolved = resolve_agent_pack(agent) + cmd_dir = project / resolved.manifest.commands_dir + + # For skills-migrated agents the commands_dir is removed. + # Check the parent agent dir for skills files instead. + if not cmd_dir.is_dir(): + try: + from specify_cli import AGENT_SKILLS_MIGRATIONS + if agent in AGENT_SKILLS_MIGRATIONS: + cmd_dir = cmd_dir.parent + if not cmd_dir.is_dir(): + pytest.skip( + f"{agent}: skills dir not present") + except (ImportError, AttributeError): + pytest.skip(f"{agent}: commands_dir missing") + + # Actual files on disk + on_disk = { + p.relative_to(project).as_posix() + for p in cmd_dir.rglob("*") if p.is_file() + } + + # Files recorded in the tracking manifest + manifest = _manifest_path(project, agent) + data = json.loads(manifest.read_text(encoding="utf-8")) + tracked = { + *data.get("agent_files", {}), + *data.get("extension_files", {}), + } + + missing = on_disk - tracked + assert not missing, ( + f"Agent '{agent}': files not tracked by manifest:\n " + + "\n ".join(sorted(missing))) + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_teardown_removes_all_tracked_files(self, agent, tmp_path): + """Full lifecycle: setup → scaffold → finalize → teardown. + + After teardown every tracked file must be deleted, but directories + are preserved. This proves the pack's teardown() is functional. + """ + from specify_cli import scaffold_from_core_pack + + project = tmp_path / f"lifecycle_{agent}" + project.mkdir() + + # 1. Scaffold (same as init pipeline) + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok, f"scaffold failed for {agent}" + + # 2. Resolve pack and finalize + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) + bootstrap.finalize_setup(project) + + # 3. Read tracked files + agent_files, ext_files = get_tracked_files(project, agent) + all_tracked = {**agent_files, **ext_files} + assert len(all_tracked) > 0, f"{agent}: no files tracked" + + # 4. Teardown + removed = remove_tracked_files( + project, agent, force=True, files=all_tracked) + assert len(removed) > 0, f"{agent}: teardown removed nothing" + + # 5. Verify all tracked files are gone + for rel_path in all_tracked: + assert not (project / rel_path).exists(), ( + f"{agent}: '{rel_path}' still present after teardown") + + # -- extension registration metadata ------------------------------------ + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_extension_registration_metadata_matches(self, agent): + """Pack command_registration matches CommandRegistrar config.""" + from specify_cli.agents import CommandRegistrar + + try: + resolved = resolve_agent_pack(agent) + except PackResolutionError: + pytest.skip(f"No pack for {agent}") + + reg = CommandRegistrar.AGENT_CONFIGS.get(agent) + if reg is None: + pytest.skip(f"No CommandRegistrar config for {agent}") + + m = resolved.manifest + assert m.commands_dir == reg["dir"] + assert m.command_format == reg["format"] + assert m.arg_placeholder == reg["args"] + assert m.file_extension == reg["extension"]