From 8b20d0b3365004eee6a54dd3e11d23f918e1a588 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:46:50 +0000 Subject: [PATCH 01/11] Initial plan From 3212309e7caee1fed5124f5fcb873e813f0f1291 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:01:16 +0000 Subject: [PATCH 02/11] Add agent pack infrastructure with embedded packs, manifest validation, resolution, and CLI commands - Create src/specify_cli/agent_pack.py with AgentBootstrap base class, AgentManifest schema/validation, pack resolution (user > project > catalog > embedded) - Generate all 25 official agent packs under src/specify_cli/core_pack/agents/ with speckit-agent.yml manifests and bootstrap.py modules - Add 'specify agent' CLI subcommands: list, info, validate, export, switch, search, add, remove - Update pyproject.toml to bundle agent packs in the wheel - Add comprehensive tests (39 tests): manifest validation, bootstrap API, resolution order, discovery, consistency with AGENT_CONFIG Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ef8b4682-7f1a-4b04-a112-df0878236b6b --- pyproject.toml | 2 + src/specify_cli/__init__.py | 451 +++++++++++++++ src/specify_cli/agent_pack.py | 478 ++++++++++++++++ src/specify_cli/core_pack/agents/__init__.py | 0 .../core_pack/agents/agy/__init__.py | 0 .../core_pack/agents/agy/bootstrap.py | 25 + .../core_pack/agents/agy/speckit-agent.yml | 23 + .../core_pack/agents/amp/__init__.py | 0 .../core_pack/agents/amp/bootstrap.py | 25 + .../core_pack/agents/amp/speckit-agent.yml | 25 + .../core_pack/agents/auggie/__init__.py | 0 .../core_pack/agents/auggie/bootstrap.py | 25 + .../core_pack/agents/auggie/speckit-agent.yml | 25 + .../core_pack/agents/bob/__init__.py | 0 .../core_pack/agents/bob/bootstrap.py | 25 + .../core_pack/agents/bob/speckit-agent.yml | 23 + .../core_pack/agents/claude/__init__.py | 0 .../core_pack/agents/claude/bootstrap.py | 25 + .../core_pack/agents/claude/speckit-agent.yml | 25 + .../core_pack/agents/codebuddy/__init__.py | 0 .../core_pack/agents/codebuddy/bootstrap.py | 25 + .../agents/codebuddy/speckit-agent.yml | 25 + .../core_pack/agents/codex/__init__.py | 0 .../core_pack/agents/codex/bootstrap.py | 25 + .../core_pack/agents/codex/speckit-agent.yml | 25 + .../core_pack/agents/copilot/__init__.py | 0 .../core_pack/agents/copilot/bootstrap.py | 25 + .../agents/copilot/speckit-agent.yml | 23 + .../core_pack/agents/cursor-agent/__init__.py | 0 .../agents/cursor-agent/bootstrap.py | 25 + .../agents/cursor-agent/speckit-agent.yml | 23 + .../core_pack/agents/gemini/__init__.py | 0 .../core_pack/agents/gemini/bootstrap.py | 25 + .../core_pack/agents/gemini/speckit-agent.yml | 25 + .../core_pack/agents/iflow/__init__.py | 0 .../core_pack/agents/iflow/bootstrap.py | 25 + .../core_pack/agents/iflow/speckit-agent.yml | 25 + .../core_pack/agents/junie/__init__.py | 0 .../core_pack/agents/junie/bootstrap.py | 25 + .../core_pack/agents/junie/speckit-agent.yml | 25 + .../core_pack/agents/kilocode/__init__.py | 0 .../core_pack/agents/kilocode/bootstrap.py | 25 + .../agents/kilocode/speckit-agent.yml | 23 + .../core_pack/agents/kimi/__init__.py | 0 .../core_pack/agents/kimi/bootstrap.py | 25 + .../core_pack/agents/kimi/speckit-agent.yml | 25 + .../core_pack/agents/kiro-cli/__init__.py | 0 .../core_pack/agents/kiro-cli/bootstrap.py | 25 + .../agents/kiro-cli/speckit-agent.yml | 25 + .../core_pack/agents/opencode/__init__.py | 0 .../core_pack/agents/opencode/bootstrap.py | 25 + .../agents/opencode/speckit-agent.yml | 25 + .../core_pack/agents/pi/__init__.py | 0 .../core_pack/agents/pi/bootstrap.py | 25 + .../core_pack/agents/pi/speckit-agent.yml | 25 + .../core_pack/agents/qodercli/__init__.py | 0 .../core_pack/agents/qodercli/bootstrap.py | 25 + .../agents/qodercli/speckit-agent.yml | 25 + .../core_pack/agents/qwen/__init__.py | 0 .../core_pack/agents/qwen/bootstrap.py | 25 + .../core_pack/agents/qwen/speckit-agent.yml | 25 + .../core_pack/agents/roo/__init__.py | 0 .../core_pack/agents/roo/bootstrap.py | 25 + .../core_pack/agents/roo/speckit-agent.yml | 23 + .../core_pack/agents/shai/__init__.py | 0 .../core_pack/agents/shai/bootstrap.py | 25 + .../core_pack/agents/shai/speckit-agent.yml | 25 + .../core_pack/agents/tabnine/__init__.py | 0 .../core_pack/agents/tabnine/bootstrap.py | 25 + .../agents/tabnine/speckit-agent.yml | 25 + .../core_pack/agents/trae/__init__.py | 0 .../core_pack/agents/trae/bootstrap.py | 25 + .../core_pack/agents/trae/speckit-agent.yml | 23 + .../core_pack/agents/vibe/__init__.py | 0 .../core_pack/agents/vibe/bootstrap.py | 25 + .../core_pack/agents/vibe/speckit-agent.yml | 25 + .../core_pack/agents/windsurf/__init__.py | 0 .../core_pack/agents/windsurf/bootstrap.py | 25 + .../agents/windsurf/speckit-agent.yml | 23 + tests/test_agent_pack.py | 520 ++++++++++++++++++ 80 files changed, 2685 insertions(+) create mode 100644 src/specify_cli/agent_pack.py create mode 100644 src/specify_cli/core_pack/agents/__init__.py create mode 100644 src/specify_cli/core_pack/agents/agy/__init__.py create mode 100644 src/specify_cli/core_pack/agents/agy/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/agy/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/amp/__init__.py create mode 100644 src/specify_cli/core_pack/agents/amp/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/amp/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/auggie/__init__.py create mode 100644 src/specify_cli/core_pack/agents/auggie/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/auggie/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/bob/__init__.py create mode 100644 src/specify_cli/core_pack/agents/bob/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/bob/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/claude/__init__.py create mode 100644 src/specify_cli/core_pack/agents/claude/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/claude/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/codebuddy/__init__.py create mode 100644 src/specify_cli/core_pack/agents/codebuddy/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/codebuddy/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/codex/__init__.py create mode 100644 src/specify_cli/core_pack/agents/codex/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/codex/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/copilot/__init__.py create mode 100644 src/specify_cli/core_pack/agents/copilot/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/copilot/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/cursor-agent/__init__.py create mode 100644 src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/cursor-agent/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/gemini/__init__.py create mode 100644 src/specify_cli/core_pack/agents/gemini/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/gemini/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/iflow/__init__.py create mode 100644 src/specify_cli/core_pack/agents/iflow/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/iflow/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/junie/__init__.py create mode 100644 src/specify_cli/core_pack/agents/junie/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/junie/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/kilocode/__init__.py create mode 100644 src/specify_cli/core_pack/agents/kilocode/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/kilocode/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/kimi/__init__.py create mode 100644 src/specify_cli/core_pack/agents/kimi/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/kimi/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/kiro-cli/__init__.py create mode 100644 src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/kiro-cli/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/opencode/__init__.py create mode 100644 src/specify_cli/core_pack/agents/opencode/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/opencode/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/pi/__init__.py create mode 100644 src/specify_cli/core_pack/agents/pi/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/pi/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/qodercli/__init__.py create mode 100644 src/specify_cli/core_pack/agents/qodercli/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/qodercli/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/qwen/__init__.py create mode 100644 src/specify_cli/core_pack/agents/qwen/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/qwen/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/roo/__init__.py create mode 100644 src/specify_cli/core_pack/agents/roo/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/roo/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/shai/__init__.py create mode 100644 src/specify_cli/core_pack/agents/shai/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/shai/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/tabnine/__init__.py create mode 100644 src/specify_cli/core_pack/agents/tabnine/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/tabnine/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/trae/__init__.py create mode 100644 src/specify_cli/core_pack/agents/trae/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/trae/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/vibe/__init__.py create mode 100644 src/specify_cli/core_pack/agents/vibe/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/vibe/speckit-agent.yml create mode 100644 src/specify_cli/core_pack/agents/windsurf/__init__.py create mode 100644 src/specify_cli/core_pack/agents/windsurf/bootstrap.py create mode 100644 src/specify_cli/core_pack/agents/windsurf/speckit-agent.yml create mode 100644 tests/test_agent_pack.py diff --git a/pyproject.toml b/pyproject.toml index f3ca76dd9d..a00a28b1f2 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 d2bf63eeb9..8623ed8041 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2366,6 +2366,457 @@ def version(): console.print() +# ===== Agent Commands ===== + +agent_app = typer.Typer( + name="agent", + help="Manage agent packs for AI assistants", + add_completion=False, +) +app.add_typer(agent_app, name="agent") + + +@agent_app.command("list") +def agent_list( + installed: bool = typer.Option(False, "--installed", help="Only show agents with local presence in the current project"), +): + """List available agent packs.""" + from .agent_pack import list_all_agents, list_embedded_agents + + show_banner() + + project_path = Path.cwd() + agents = list_all_agents(project_path=project_path if installed else None) + if not agents and not installed: + agents_from_embedded = list_embedded_agents() + if not agents_from_embedded: + console.print("[yellow]No agent packs found.[/yellow]") + console.print("[dim]Agent packs are embedded in the specify-cli wheel.[/dim]") + raise typer.Exit(0) + + table = Table(title="Available Agent Packs", show_lines=False) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="dim") + table.add_column("Source", style="green") + table.add_column("CLI Required", style="yellow", justify="center") + + for resolved in agents: + m = resolved.manifest + cli_marker = "✓" if m.requires_cli else "—" + source_display = resolved.source + if resolved.overrides: + source_display += f" (overrides {resolved.overrides})" + table.add_row(m.id, m.name, m.version, source_display, cli_marker) + + console.print(table) + console.print(f"\n[dim]{len(agents)} agent(s) available[/dim]") + + +@agent_app.command("info") +def agent_info( + agent_id: str = typer.Argument(..., help="Agent pack ID (e.g. 'claude', 'gemini')"), +): + """Show detailed information about an agent pack.""" + from .agent_pack import resolve_agent_pack, PackResolutionError + + show_banner() + + try: + resolved = resolve_agent_pack(agent_id, project_path=Path.cwd()) + except PackResolutionError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + m = resolved.manifest + + info_table = Table(show_header=False, box=None, padding=(0, 2)) + info_table.add_column("Key", style="cyan", justify="right") + info_table.add_column("Value", style="white") + + info_table.add_row("Agent", f"{m.name} ({m.id})") + info_table.add_row("Version", m.version) + info_table.add_row("Description", m.description or "—") + info_table.add_row("Author", m.author or "—") + info_table.add_row("License", m.license or "—") + info_table.add_row("", "") + + source_display = resolved.source + if resolved.source == "catalog": + source_display = f"catalog — {resolved.path}" + elif resolved.source == "embedded": + source_display = f"embedded (bundled in specify-cli wheel)" + + info_table.add_row("Source", source_display) + if resolved.overrides: + info_table.add_row("Overrides", resolved.overrides) + info_table.add_row("Pack Path", str(resolved.path)) + info_table.add_row("", "") + + info_table.add_row("Requires CLI", "Yes" if m.requires_cli else "No") + if m.install_url: + info_table.add_row("Install URL", m.install_url) + if m.cli_tool: + info_table.add_row("CLI Tool", m.cli_tool) + info_table.add_row("", "") + + info_table.add_row("Commands Dir", m.commands_dir or "—") + info_table.add_row("Format", m.command_format) + info_table.add_row("Arg Placeholder", m.arg_placeholder) + info_table.add_row("File Extension", m.file_extension) + info_table.add_row("", "") + + info_table.add_row("Tags", ", ".join(m.tags) if m.tags else "—") + info_table.add_row("Speckit Version", m.speckit_version) + + panel = Panel( + info_table, + title=f"[bold cyan]Agent: {m.name}[/bold cyan]", + border_style="cyan", + padding=(1, 2), + ) + console.print(panel) + + +@agent_app.command("validate") +def agent_validate( + pack_path: str = typer.Argument(..., help="Path to the agent pack directory to validate"), +): + """Validate an agent pack's structure and manifest.""" + from .agent_pack import validate_pack, ManifestValidationError, AgentManifest, MANIFEST_FILENAME + + show_banner() + + path = Path(pack_path).resolve() + if not path.is_dir(): + console.print(f"[red]Error:[/red] Not a directory: {path}") + raise typer.Exit(1) + + try: + warnings = validate_pack(path) + except ManifestValidationError as exc: + console.print(f"[red]Validation failed:[/red] {exc}") + raise typer.Exit(1) + + manifest = AgentManifest.from_yaml(path / MANIFEST_FILENAME) + console.print(f"[green]✓[/green] Pack '{manifest.id}' ({manifest.name}) is valid") + + if warnings: + console.print(f"\n[yellow]Warnings ({len(warnings)}):[/yellow]") + for w in warnings: + console.print(f" [yellow]⚠[/yellow] {w}") + else: + console.print("[green]No warnings.[/green]") + + +@agent_app.command("export") +def agent_export( + agent_id: str = typer.Argument(..., help="Agent pack ID to export"), + to: str = typer.Option(..., "--to", help="Destination directory for the exported pack"), +): + """Export the active agent pack to a directory for editing.""" + from .agent_pack import export_pack, PackResolutionError + + show_banner() + + dest = Path(to).resolve() + try: + result = export_pack(agent_id, dest, project_path=Path.cwd()) + except PackResolutionError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Exported '{agent_id}' pack to {result}") + console.print(f"[dim]Edit files in {result} and use as a project-level override.[/dim]") + + +@agent_app.command("switch") +def agent_switch( + agent_id: str = typer.Argument(..., help="Agent pack ID to switch to"), +): + """Switch the active AI agent in the current project. + + Tears down the current agent and sets up the new one. + Preserves specs, plans, tasks, constitution, memory, templates, and scripts. + """ + from .agent_pack import ( + resolve_agent_pack, + load_bootstrap, + PackResolutionError, + AgentPackError, + ) + + show_banner() + + project_path = Path.cwd() + init_options_file = project_path / ".specify" / "init-options.json" + + if not init_options_file.exists(): + console.print("[red]Error:[/red] Not a Specify project (missing .specify/init-options.json)") + console.print("[yellow]Hint:[/yellow] Run 'specify init --here' first.") + raise typer.Exit(1) + + # Load current project options + try: + options = json.loads(init_options_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + console.print(f"[red]Error reading init options:[/red] {exc}") + raise typer.Exit(1) + + current_agent = options.get("ai") + script_type = options.get("script", "sh") + + # Resolve the new agent pack + try: + resolved = resolve_agent_pack(agent_id, project_path=project_path) + except PackResolutionError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[bold]Switching agent: {current_agent or '(none)'} → {agent_id}[/bold]") + + # Teardown current agent (best effort — may have been set up with old system) + if current_agent: + try: + current_resolved = resolve_agent_pack(current_agent, project_path=project_path) + current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest) + console.print(f" [dim]Tearing down {current_agent}...[/dim]") + current_bootstrap.teardown(project_path) + console.print(f" [green]✓[/green] {current_agent} removed") + except AgentPackError: + # If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG + agent_config = AGENT_CONFIG.get(current_agent, {}) + agent_folder = agent_config.get("folder") + if agent_folder: + agent_dir = project_path / agent_folder.rstrip("/") + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) + console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)") + + # Setup new agent + try: + new_bootstrap = load_bootstrap(resolved.path, resolved.manifest) + console.print(f" [dim]Setting up {agent_id}...[/dim]") + new_bootstrap.setup(project_path, script_type, options) + console.print(f" [green]✓[/green] {agent_id} installed") + except AgentPackError as exc: + console.print(f"[red]Error setting up {agent_id}:[/red] {exc}") + raise typer.Exit(1) + + # Update init options + options["ai"] = agent_id + init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8") + console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]") + + # Re-register extension commands for the new agent + _reregister_extension_commands(project_path, agent_id) + + +def _reregister_extension_commands(project_path: Path, agent_id: str) -> None: + """Re-register all installed extension commands for a new agent after switching.""" + registry_file = project_path / ".specify" / "extensions" / ".registry" + if not registry_file.is_file(): + return + + try: + registry_data = json.loads(registry_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + + extensions = registry_data.get("extensions", {}) + if not extensions: + return + + try: + from .agents import CommandRegistrar + registrar = CommandRegistrar() + except ImportError: + return + + reregistered = 0 + for ext_id, ext_data in extensions.items(): + commands = ext_data.get("registered_commands", {}) + if not commands: + continue + + ext_dir = project_path / ".specify" / "extensions" / ext_id + if not ext_dir.is_dir(): + continue + + # Get the command list from the manifest + manifest_file = ext_dir / "extension.yml" + if not manifest_file.is_file(): + continue + + try: + from .extensions import ExtensionManifest + manifest = ExtensionManifest(manifest_file) + if manifest.commands: + registered = registrar.register_commands( + agent_id, manifest.commands, ext_id, ext_dir / "commands", project_path + ) + if registered: + reregistered += len(registered) + except Exception: + continue + + if reregistered: + console.print(f" [green]✓[/green] Re-registered {reregistered} extension command(s)") + + +@agent_app.command("search") +def agent_search( + query: str = typer.Argument(None, help="Search query (matches agent ID, name, or tags)"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), +): + """Search for agent packs across embedded and catalog sources.""" + from .agent_pack import list_all_agents + + show_banner() + + all_agents = list_all_agents(project_path=Path.cwd()) + + if query: + query_lower = query.lower() + all_agents = [ + a for a in all_agents + if query_lower in a.manifest.id.lower() + or query_lower in a.manifest.name.lower() + or query_lower in a.manifest.description.lower() + or any(query_lower in t.lower() for t in a.manifest.tags) + ] + + if tag: + tag_lower = tag.lower() + all_agents = [ + a for a in all_agents + if any(tag_lower == t.lower() for t in a.manifest.tags) + ] + + if not all_agents: + console.print("[yellow]No agents found matching your search.[/yellow]") + raise typer.Exit(0) + + table = Table(title="Search Results", show_lines=False) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Description", style="dim") + table.add_column("Tags", style="green") + table.add_column("Source", style="yellow") + + for resolved in all_agents: + m = resolved.manifest + table.add_row( + m.id, m.name, + (m.description[:50] + "...") if len(m.description) > 53 else m.description, + ", ".join(m.tags), + resolved.source, + ) + + console.print(table) + console.print(f"\n[dim]{len(all_agents)} result(s)[/dim]") + + +@agent_app.command("add") +def agent_add( + agent_id: str = typer.Argument(..., help="Agent pack ID to install"), + from_path: str = typer.Option(None, "--from", help="Install from a local path instead of a catalog"), +): + """Install an agent pack from a catalog or local path.""" + from .agent_pack import ( + _catalog_agents_dir, + AgentManifest, + ManifestValidationError, + MANIFEST_FILENAME, + ) + + show_banner() + + if from_path: + source = Path(from_path).resolve() + if not source.is_dir(): + console.print(f"[red]Error:[/red] Not a directory: {source}") + raise typer.Exit(1) + + manifest_file = source / MANIFEST_FILENAME + if not manifest_file.is_file(): + console.print(f"[red]Error:[/red] No {MANIFEST_FILENAME} found in {source}") + raise typer.Exit(1) + + try: + manifest = AgentManifest.from_yaml(manifest_file) + except ManifestValidationError as exc: + console.print(f"[red]Validation failed:[/red] {exc}") + raise typer.Exit(1) + + dest = _catalog_agents_dir() / manifest.id + dest.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, dest, dirs_exist_ok=True) + console.print(f"[green]✓[/green] Installed '{manifest.id}' ({manifest.name}) from {source}") + else: + # Catalog fetch — placeholder for future catalog integration + console.print(f"[yellow]Catalog fetch not yet implemented.[/yellow]") + console.print(f"[dim]Use --from 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 0000000000..f380213625 --- /dev/null +++ b/src/specify_cli/agent_pack.py @@ -0,0 +1,478 @@ +""" +Agent Pack Manager for Spec Kit + +Implements self-bootstrapping agent packs with declarative manifests +(speckit-agent.yml) and Python bootstrap modules (bootstrap.py). + +Agent packs resolve by priority: + 1. User-level (~/.specify/agents//) + 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 importlib.util +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml +from platformdirs import user_data_path + + +# --------------------------------------------------------------------------- +# Manifest schema +# --------------------------------------------------------------------------- + +MANIFEST_FILENAME = "speckit-agent.yml" +BOOTSTRAP_FILENAME = "bootstrap.py" + +MANIFEST_SCHEMA_VERSION = "1.0" + +# Required top-level keys +_REQUIRED_TOP_KEYS = {"schema_version", "agent"} + +# Required keys within the ``agent`` block +_REQUIRED_AGENT_KEYS = {"id", "name", "version"} + + +class AgentPackError(Exception): + """Base exception for agent-pack operations.""" + + +class ManifestValidationError(AgentPackError): + """Raised when a speckit-agent.yml file is invalid.""" + + +class PackResolutionError(AgentPackError): + """Raised when no pack can be found for the requested agent id.""" + + +# --------------------------------------------------------------------------- +# Manifest +# --------------------------------------------------------------------------- + +@dataclass +class AgentManifest: + """Parsed and validated representation of a speckit-agent.yml file.""" + + # identity + id: str + name: str + version: str + description: str = "" + author: str = "" + license: str = "" + + # runtime + requires_cli: bool = False + install_url: Optional[str] = None + cli_tool: Optional[str] = None + + # compatibility + speckit_version: str = ">=0.1.0" + + # discovery + tags: List[str] = field(default_factory=list) + + # command registration metadata (used by CommandRegistrar / extensions) + commands_dir: str = "" + command_format: str = "markdown" + arg_placeholder: str = "$ARGUMENTS" + file_extension: str = ".md" + + # raw data for anything else + raw: Dict[str, Any] = field(default_factory=dict, repr=False) + + # filesystem path to the pack directory that produced this manifest + pack_path: Optional[Path] = field(default=None, repr=False) + + @classmethod + def from_yaml(cls, path: Path) -> "AgentManifest": + """Load and validate a manifest from *path*. + + Raises ``ManifestValidationError`` on structural problems. + """ + try: + text = path.read_text(encoding="utf-8") + data = yaml.safe_load(text) or {} + except yaml.YAMLError as exc: + raise ManifestValidationError(f"Invalid YAML in {path}: {exc}") + except FileNotFoundError: + raise ManifestValidationError(f"Manifest not found: {path}") + + return cls.from_dict(data, pack_path=path.parent) + + @classmethod + def from_dict(cls, data: dict, *, pack_path: Optional[Path] = None) -> "AgentManifest": + """Build a manifest from a raw dictionary.""" + if not isinstance(data, dict): + raise ManifestValidationError("Manifest must be a YAML mapping") + + missing_top = _REQUIRED_TOP_KEYS - set(data) + if missing_top: + raise ManifestValidationError( + f"Missing required top-level key(s): {', '.join(sorted(missing_top))}" + ) + + if data.get("schema_version") != MANIFEST_SCHEMA_VERSION: + raise ManifestValidationError( + f"Unsupported schema_version: {data.get('schema_version')!r} " + f"(expected {MANIFEST_SCHEMA_VERSION!r})" + ) + + agent_block = data.get("agent") + if not isinstance(agent_block, dict): + raise ManifestValidationError("'agent' must be a mapping") + + missing_agent = _REQUIRED_AGENT_KEYS - set(agent_block) + if missing_agent: + raise ManifestValidationError( + f"Missing required agent key(s): {', '.join(sorted(missing_agent))}" + ) + + runtime = data.get("runtime") or {} + requires = data.get("requires") or {} + tags = data.get("tags") or [] + cmd_reg = data.get("command_registration") or {} + + return cls( + id=str(agent_block["id"]), + name=str(agent_block["name"]), + version=str(agent_block["version"]), + description=str(agent_block.get("description", "")), + author=str(agent_block.get("author", "")), + license=str(agent_block.get("license", "")), + requires_cli=bool(runtime.get("requires_cli", False)), + install_url=runtime.get("install_url"), + cli_tool=runtime.get("cli_tool"), + speckit_version=str(requires.get("speckit_version", ">=0.1.0")), + tags=[str(t) for t in tags] if isinstance(tags, list) else [], + commands_dir=str(cmd_reg.get("commands_dir", "")), + command_format=str(cmd_reg.get("format", "markdown")), + arg_placeholder=str(cmd_reg.get("arg_placeholder", "$ARGUMENTS")), + file_extension=str(cmd_reg.get("file_extension", ".md")), + raw=data, + pack_path=pack_path, + ) + + +# --------------------------------------------------------------------------- +# Bootstrap base class +# --------------------------------------------------------------------------- + +class AgentBootstrap: + """Base class that every agent pack's ``bootstrap.py`` must subclass. + + Subclasses override :meth:`setup` and :meth:`teardown` to define + agent-specific lifecycle operations. + """ + + def __init__(self, manifest: AgentManifest): + self.manifest = manifest + self.pack_path = manifest.pack_path + + # -- lifecycle ----------------------------------------------------------- + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install agent files into *project_path*. + + This is invoked by ``specify init --ai `` and + ``specify agent switch ``. + + Args: + project_path: Target project directory. + script_type: ``"sh"`` or ``"ps"``. + options: Arbitrary key/value options forwarded from the CLI. + """ + raise NotImplementedError + + def teardown(self, project_path: Path) -> None: + """Remove agent-specific files from *project_path*. + + Invoked by ``specify agent switch`` (for the *old* agent) and + ``specify agent remove`` when the user explicitly uninstalls. + Must preserve shared infrastructure (specs, plans, tasks, etc.). + + Args: + project_path: Project directory to clean up. + """ + raise NotImplementedError + + # -- helpers available to subclasses ------------------------------------ + + def agent_dir(self, project_path: Path) -> Path: + """Return the agent's top-level directory inside the project.""" + return project_path / self.manifest.commands_dir.split("/")[0] + + +# --------------------------------------------------------------------------- +# Pack resolution +# --------------------------------------------------------------------------- + +def _embedded_agents_dir() -> Path: + """Return the path to the embedded agent packs inside the wheel.""" + return Path(__file__).parent / "core_pack" / "agents" + + +def _user_agents_dir() -> Path: + """Return the user-level agent overrides directory.""" + return user_data_path("specify", "github") / "agents" + + +def _project_agents_dir(project_path: Path) -> Path: + """Return the project-level agent overrides directory.""" + return project_path / ".specify" / "agents" + + +def _catalog_agents_dir() -> Path: + """Return the catalog-installed agent cache directory.""" + return user_data_path("specify", "github") / "agent-cache" + + +@dataclass +class ResolvedPack: + """Result of resolving an agent pack through the priority stack.""" + manifest: AgentManifest + source: str # "user", "project", "catalog", "embedded" + path: Path + overrides: Optional[str] = None # version of the pack being overridden + + +def resolve_agent_pack( + agent_id: str, + project_path: Optional[Path] = None, +) -> ResolvedPack: + """Resolve an agent pack through the priority stack. + + Priority (highest first): + 1. User-level ``~/.specify/agents//`` + 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 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 0000000000..4f0dd5a705 --- /dev/null +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Antigravity agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Agy(AgentBootstrap): + """Bootstrap for Antigravity.""" + + AGENT_DIR = ".agent" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Antigravity agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Antigravity agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..754afaa1ff --- /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 0000000000..e69de29bb2 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 0000000000..51b676bfa9 --- /dev/null +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Amp agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Amp(AgentBootstrap): + """Bootstrap for Amp.""" + + AGENT_DIR = ".agents" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Amp agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Amp agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..eaca7fa384 --- /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 0000000000..e69de29bb2 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 0000000000..7ff391b9e5 --- /dev/null +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Auggie CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Auggie(AgentBootstrap): + """Bootstrap for Auggie CLI.""" + + AGENT_DIR = ".augment" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Auggie CLI agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Auggie CLI agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..d44bae6516 --- /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 0000000000..e69de29bb2 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 0000000000..ab4052a84f --- /dev/null +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for IBM Bob agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Bob(AgentBootstrap): + """Bootstrap for IBM Bob.""" + + AGENT_DIR = ".bob" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install IBM Bob agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove IBM Bob agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..5716f0ce18 --- /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 0000000000..e69de29bb2 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 0000000000..a2a515ee83 --- /dev/null +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Claude Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Claude(AgentBootstrap): + """Bootstrap for Claude Code.""" + + AGENT_DIR = ".claude" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Claude Code agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Claude Code agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..b8073b9520 --- /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 0000000000..e69de29bb2 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 0000000000..a6f061bae0 --- /dev/null +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for CodeBuddy agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Codebuddy(AgentBootstrap): + """Bootstrap for CodeBuddy.""" + + AGENT_DIR = ".codebuddy" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install CodeBuddy agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove CodeBuddy agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..d12fe608b8 --- /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 0000000000..e69de29bb2 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 0000000000..8f9a60a925 --- /dev/null +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Codex CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Codex(AgentBootstrap): + """Bootstrap for Codex CLI.""" + + AGENT_DIR = ".agents" + COMMANDS_SUBDIR = "skills" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Codex CLI agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Codex CLI agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..0bff60cfb9 --- /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 0000000000..e69de29bb2 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 0000000000..44a23e1f14 --- /dev/null +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for GitHub Copilot agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Copilot(AgentBootstrap): + """Bootstrap for GitHub Copilot.""" + + AGENT_DIR = ".github" + COMMANDS_SUBDIR = "agents" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install GitHub Copilot agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove GitHub Copilot agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..a5430ea701 --- /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 0000000000..e69de29bb2 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 0000000000..0af4d914f8 --- /dev/null +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Cursor agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class CursorAgent(AgentBootstrap): + """Bootstrap for Cursor.""" + + AGENT_DIR = ".cursor" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Cursor agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Cursor agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..871658c237 --- /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 0000000000..e69de29bb2 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 0000000000..8e18e5a728 --- /dev/null +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Gemini CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Gemini(AgentBootstrap): + """Bootstrap for Gemini CLI.""" + + AGENT_DIR = ".gemini" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Gemini CLI agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Gemini CLI agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..23864abfd3 --- /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 0000000000..e69de29bb2 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 0000000000..d421924dbd --- /dev/null +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for iFlow CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Iflow(AgentBootstrap): + """Bootstrap for iFlow CLI.""" + + AGENT_DIR = ".iflow" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install iFlow CLI agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove iFlow CLI agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..d148bc2316 --- /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 0000000000..e69de29bb2 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 0000000000..6748ec7d5f --- /dev/null +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Junie agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Junie(AgentBootstrap): + """Bootstrap for Junie.""" + + AGENT_DIR = ".junie" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Junie agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Junie agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..65ea20cadc --- /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 0000000000..e69de29bb2 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 0000000000..f88f00f476 --- /dev/null +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Kilo Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Kilocode(AgentBootstrap): + """Bootstrap for Kilo Code.""" + + AGENT_DIR = ".kilocode" + COMMANDS_SUBDIR = "workflows" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Kilo Code agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Kilo Code agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..1b4519f42c --- /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 0000000000..e69de29bb2 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 0000000000..50b8ca29cf --- /dev/null +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Kimi Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Kimi(AgentBootstrap): + """Bootstrap for Kimi Code.""" + + AGENT_DIR = ".kimi" + COMMANDS_SUBDIR = "skills" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Kimi Code agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Kimi Code agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..b439289d26 --- /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 0000000000..e69de29bb2 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 0000000000..1f2e1c2158 --- /dev/null +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Kiro CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class KiroCli(AgentBootstrap): + """Bootstrap for Kiro CLI.""" + + AGENT_DIR = ".kiro" + COMMANDS_SUBDIR = "prompts" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Kiro CLI agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Kiro CLI agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..80b23f3a1c --- /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 0000000000..e69de29bb2 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 0000000000..b1cc30de95 --- /dev/null +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for opencode agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Opencode(AgentBootstrap): + """Bootstrap for opencode.""" + + AGENT_DIR = ".opencode" + COMMANDS_SUBDIR = "command" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install opencode agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove opencode agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..9720592db8 --- /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 0000000000..e69de29bb2 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 0000000000..51b3cc7b79 --- /dev/null +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Pi Coding Agent agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Pi(AgentBootstrap): + """Bootstrap for Pi Coding Agent.""" + + AGENT_DIR = ".pi" + COMMANDS_SUBDIR = "prompts" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Pi Coding Agent agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Pi Coding Agent agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..31d94f7b03 --- /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 0000000000..e69de29bb2 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 0000000000..cbfb5c8284 --- /dev/null +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Qoder CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Qodercli(AgentBootstrap): + """Bootstrap for Qoder CLI.""" + + AGENT_DIR = ".qoder" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Qoder CLI agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Qoder CLI agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..3889369676 --- /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 0000000000..e69de29bb2 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 0000000000..186fe2ad6e --- /dev/null +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Qwen Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Qwen(AgentBootstrap): + """Bootstrap for Qwen Code.""" + + AGENT_DIR = ".qwen" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Qwen Code agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Qwen Code agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..fdf7261dcc --- /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 0000000000..e69de29bb2 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 0000000000..f15093147c --- /dev/null +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Roo Code agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Roo(AgentBootstrap): + """Bootstrap for Roo Code.""" + + AGENT_DIR = ".roo" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Roo Code agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Roo Code agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..44d80286f6 --- /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 0000000000..e69de29bb2 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 0000000000..968618d1d4 --- /dev/null +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for SHAI agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Shai(AgentBootstrap): + """Bootstrap for SHAI.""" + + AGENT_DIR = ".shai" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install SHAI agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove SHAI agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..e1cf6676ab --- /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 0000000000..e69de29bb2 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 0000000000..f04411f379 --- /dev/null +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Tabnine CLI agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Tabnine(AgentBootstrap): + """Bootstrap for Tabnine CLI.""" + + AGENT_DIR = ".tabnine/agent" + COMMANDS_SUBDIR = "commands" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Tabnine CLI agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Tabnine CLI agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..cb1dc5d060 --- /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 0000000000..e69de29bb2 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 0000000000..264be5b6a9 --- /dev/null +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Trae agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Trae(AgentBootstrap): + """Bootstrap for Trae.""" + + AGENT_DIR = ".trae" + COMMANDS_SUBDIR = "rules" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Trae agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Trae agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..d551d8609b --- /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 0000000000..e69de29bb2 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 0000000000..955dece052 --- /dev/null +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Mistral Vibe agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Vibe(AgentBootstrap): + """Bootstrap for Mistral Vibe.""" + + AGENT_DIR = ".vibe" + COMMANDS_SUBDIR = "prompts" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Mistral Vibe agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Mistral Vibe agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..ae82f0f540 --- /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 0000000000..e69de29bb2 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 0000000000..1331861858 --- /dev/null +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -0,0 +1,25 @@ +"""Bootstrap module for Windsurf agent pack.""" + +from pathlib import Path +from typing import Any, Dict + +from specify_cli.agent_pack import AgentBootstrap + + +class Windsurf(AgentBootstrap): + """Bootstrap for Windsurf.""" + + AGENT_DIR = ".windsurf" + COMMANDS_SUBDIR = "workflows" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + """Install Windsurf agent files into the project.""" + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + """Remove Windsurf agent files from the project.""" + import shutil + agent_dir = project_path / self.AGENT_DIR + if agent_dir.is_dir(): + shutil.rmtree(agent_dir) 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 0000000000..9618a51c53 --- /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 0000000000..77b5a74d79 --- /dev/null +++ b/tests/test_agent_pack.py @@ -0,0 +1,520 @@ +"""Tests for the agent pack infrastructure. + +Covers manifest validation, bootstrap API contract, pack resolution order, +CLI commands, and consistency with AGENT_CONFIG / CommandRegistrar.AGENT_CONFIGS. +""" + +import json +import shutil +import textwrap +from pathlib import Path + +import pytest +import yaml + +from specify_cli.agent_pack import ( + BOOTSTRAP_FILENAME, + MANIFEST_FILENAME, + MANIFEST_SCHEMA_VERSION, + AgentBootstrap, + AgentManifest, + AgentPackError, + ManifestValidationError, + PackResolutionError, + ResolvedPack, + export_pack, + list_all_agents, + list_embedded_agents, + load_bootstrap, + resolve_agent_pack, + validate_pack, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_manifest(path: Path, data: dict) -> Path: + """Write a speckit-agent.yml to *path* and return the file path.""" + path.mkdir(parents=True, exist_ok=True) + manifest_file = path / MANIFEST_FILENAME + manifest_file.write_text(yaml.dump(data, default_flow_style=False), encoding="utf-8") + return manifest_file + + +def _minimal_manifest_dict(agent_id: str = "test-agent", **overrides) -> dict: + """Return a minimal valid manifest dict, with optional overrides.""" + data = { + "schema_version": MANIFEST_SCHEMA_VERSION, + "agent": { + "id": agent_id, + "name": "Test Agent", + "version": "0.1.0", + "description": "A test agent", + }, + "runtime": {"requires_cli": False}, + "requires": {"speckit_version": ">=0.1.0"}, + "tags": ["test"], + "command_registration": { + "commands_dir": f".{agent_id}/commands", + "format": "markdown", + "arg_placeholder": "$ARGUMENTS", + "file_extension": ".md", + }, + } + data.update(overrides) + return data + + +def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: str = ".test-agent") -> Path: + """Write a minimal bootstrap.py to *pack_dir*.""" + pack_dir.mkdir(parents=True, exist_ok=True) + bootstrap_file = pack_dir / BOOTSTRAP_FILENAME + bootstrap_file.write_text(textwrap.dedent(f"""\ + from pathlib import Path + from typing import Any, Dict + from specify_cli.agent_pack import AgentBootstrap + + class {class_name}(AgentBootstrap): + AGENT_DIR = "{agent_dir}" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + (project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True) + + def teardown(self, project_path: Path) -> None: + import shutil + d = project_path / self.AGENT_DIR + if d.is_dir(): + shutil.rmtree(d) + """), encoding="utf-8") + return bootstrap_file + + +# =================================================================== +# Manifest validation +# =================================================================== + +class TestManifestValidation: + """Validate speckit-agent.yml parsing and error handling.""" + + def test_valid_manifest(self, tmp_path): + data = _minimal_manifest_dict() + _write_manifest(tmp_path, data) + m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + assert m.id == "test-agent" + assert m.name == "Test Agent" + assert m.version == "0.1.0" + assert m.command_format == "markdown" + + def test_missing_schema_version(self, tmp_path): + data = _minimal_manifest_dict() + del data["schema_version"] + _write_manifest(tmp_path, data) + with pytest.raises(ManifestValidationError, match="Missing required top-level"): + AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + + def test_wrong_schema_version(self, tmp_path): + data = _minimal_manifest_dict() + data["schema_version"] = "99.0" + _write_manifest(tmp_path, data) + with pytest.raises(ManifestValidationError, match="Unsupported schema_version"): + AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + + def test_missing_agent_block(self, tmp_path): + data = {"schema_version": MANIFEST_SCHEMA_VERSION} + _write_manifest(tmp_path, data) + with pytest.raises(ManifestValidationError, match="Missing required top-level"): + AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + + def test_missing_agent_id(self, tmp_path): + data = _minimal_manifest_dict() + del data["agent"]["id"] + _write_manifest(tmp_path, data) + with pytest.raises(ManifestValidationError, match="Missing required agent key"): + AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + + def test_missing_agent_name(self, tmp_path): + data = _minimal_manifest_dict() + del data["agent"]["name"] + _write_manifest(tmp_path, data) + with pytest.raises(ManifestValidationError, match="Missing required agent key"): + AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + + def test_missing_agent_version(self, tmp_path): + data = _minimal_manifest_dict() + del data["agent"]["version"] + _write_manifest(tmp_path, data) + with pytest.raises(ManifestValidationError, match="Missing required agent key"): + AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + + def test_agent_block_not_dict(self, tmp_path): + data = {"schema_version": MANIFEST_SCHEMA_VERSION, "agent": "not-a-dict"} + _write_manifest(tmp_path, data) + with pytest.raises(ManifestValidationError, match="must be a mapping"): + AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + + def test_missing_file(self, tmp_path): + with pytest.raises(ManifestValidationError, match="Manifest not found"): + AgentManifest.from_yaml(tmp_path / "nonexistent" / MANIFEST_FILENAME) + + def test_invalid_yaml(self, tmp_path): + tmp_path.mkdir(parents=True, exist_ok=True) + bad = tmp_path / MANIFEST_FILENAME + bad.write_text("{{{{bad yaml", encoding="utf-8") + with pytest.raises(ManifestValidationError, match="Invalid YAML"): + AgentManifest.from_yaml(bad) + + def test_runtime_fields(self, tmp_path): + data = _minimal_manifest_dict() + data["runtime"] = { + "requires_cli": True, + "install_url": "https://example.com", + "cli_tool": "myagent", + } + _write_manifest(tmp_path, data) + m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + assert m.requires_cli is True + assert m.install_url == "https://example.com" + assert m.cli_tool == "myagent" + + def test_command_registration_fields(self, tmp_path): + data = _minimal_manifest_dict() + data["command_registration"] = { + "commands_dir": ".test/commands", + "format": "toml", + "arg_placeholder": "{{args}}", + "file_extension": ".toml", + } + _write_manifest(tmp_path, data) + m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + assert m.commands_dir == ".test/commands" + assert m.command_format == "toml" + assert m.arg_placeholder == "{{args}}" + assert m.file_extension == ".toml" + + def test_tags_field(self, tmp_path): + data = _minimal_manifest_dict() + data["tags"] = ["cli", "test", "agent"] + _write_manifest(tmp_path, data) + m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + assert m.tags == ["cli", "test", "agent"] + + def test_optional_fields_default(self, tmp_path): + """Manifest with only required fields uses sensible defaults.""" + data = { + "schema_version": MANIFEST_SCHEMA_VERSION, + "agent": {"id": "bare", "name": "Bare Agent", "version": "0.0.1"}, + } + _write_manifest(tmp_path, data) + m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + assert m.requires_cli is False + assert m.install_url is None + assert m.command_format == "markdown" + assert m.arg_placeholder == "$ARGUMENTS" + assert m.tags == [] + + def test_from_dict(self): + data = _minimal_manifest_dict("dict-agent") + m = AgentManifest.from_dict(data) + assert m.id == "dict-agent" + assert m.pack_path is None + + def test_from_dict_not_dict(self): + with pytest.raises(ManifestValidationError, match="must be a YAML mapping"): + AgentManifest.from_dict("not-a-dict") + + +# =================================================================== +# Bootstrap API contract +# =================================================================== + +class TestBootstrapContract: + """Verify AgentBootstrap interface and load_bootstrap().""" + + def test_base_class_setup_raises(self, tmp_path): + m = AgentManifest.from_dict(_minimal_manifest_dict()) + b = AgentBootstrap(m) + with pytest.raises(NotImplementedError): + b.setup(tmp_path, "sh", {}) + + def test_base_class_teardown_raises(self, tmp_path): + m = AgentManifest.from_dict(_minimal_manifest_dict()) + b = AgentBootstrap(m) + with pytest.raises(NotImplementedError): + b.teardown(tmp_path) + + def test_load_bootstrap(self, tmp_path): + data = _minimal_manifest_dict() + _write_manifest(tmp_path, data) + _write_bootstrap(tmp_path) + m = AgentManifest.from_yaml(tmp_path / MANIFEST_FILENAME) + b = load_bootstrap(tmp_path, m) + assert isinstance(b, AgentBootstrap) + + def test_load_bootstrap_missing_file(self, tmp_path): + m = AgentManifest.from_dict(_minimal_manifest_dict()) + with pytest.raises(AgentPackError, match="Bootstrap module not found"): + load_bootstrap(tmp_path, m) + + def test_bootstrap_setup_and_teardown(self, tmp_path): + """Verify a loaded bootstrap can set up and tear down.""" + pack_dir = tmp_path / "pack" + data = _minimal_manifest_dict() + _write_manifest(pack_dir, data) + _write_bootstrap(pack_dir) + + m = AgentManifest.from_yaml(pack_dir / MANIFEST_FILENAME) + b = load_bootstrap(pack_dir, m) + + project = tmp_path / "project" + project.mkdir() + + b.setup(project, "sh", {}) + assert (project / ".test-agent" / "commands").is_dir() + + b.teardown(project) + assert not (project / ".test-agent").exists() + + def test_load_bootstrap_no_subclass(self, tmp_path): + """A bootstrap module without an AgentBootstrap subclass fails.""" + pack_dir = tmp_path / "pack" + pack_dir.mkdir(parents=True) + data = _minimal_manifest_dict() + _write_manifest(pack_dir, data) + (pack_dir / BOOTSTRAP_FILENAME).write_text("x = 1\n", encoding="utf-8") + m = AgentManifest.from_yaml(pack_dir / MANIFEST_FILENAME) + with pytest.raises(AgentPackError, match="No AgentBootstrap subclass"): + load_bootstrap(pack_dir, m) + + +# =================================================================== +# Pack resolution +# =================================================================== + +class TestResolutionOrder: + """Verify the 4-level priority resolution stack.""" + + def test_embedded_resolution(self): + """Embedded agents are resolvable (at least claude should exist).""" + resolved = resolve_agent_pack("claude") + assert resolved.source == "embedded" + assert resolved.manifest.id == "claude" + + def test_missing_agent_raises(self): + with pytest.raises(PackResolutionError, match="not found"): + resolve_agent_pack("nonexistent-agent-xyz") + + def test_project_level_overrides_embedded(self, tmp_path): + """A project-level pack shadows the embedded pack.""" + proj_agents = tmp_path / ".specify" / "agents" / "claude" + data = _minimal_manifest_dict("claude") + data["agent"]["version"] = "99.0.0" + _write_manifest(proj_agents, data) + _write_bootstrap(proj_agents, class_name="ClaudeOverride", agent_dir=".claude") + + resolved = resolve_agent_pack("claude", project_path=tmp_path) + assert resolved.source == "project" + assert resolved.manifest.version == "99.0.0" + + def test_user_level_overrides_everything(self, tmp_path, monkeypatch): + """A user-level pack has highest priority.""" + from specify_cli import agent_pack + + user_dir = tmp_path / "user_agents" + monkeypatch.setattr(agent_pack, "_user_agents_dir", lambda: user_dir) + + user_pack = user_dir / "claude" + data = _minimal_manifest_dict("claude") + data["agent"]["version"] = "999.0.0" + _write_manifest(user_pack, data) + + # Also create a project-level override + proj_agents = tmp_path / "project" / ".specify" / "agents" / "claude" + data2 = _minimal_manifest_dict("claude") + data2["agent"]["version"] = "50.0.0" + _write_manifest(proj_agents, data2) + + resolved = resolve_agent_pack("claude", project_path=tmp_path / "project") + assert resolved.source == "user" + assert resolved.manifest.version == "999.0.0" + + def test_catalog_overrides_embedded(self, tmp_path, monkeypatch): + """A catalog-cached pack overrides embedded.""" + from specify_cli import agent_pack + + cache_dir = tmp_path / "agent-cache" + monkeypatch.setattr(agent_pack, "_catalog_agents_dir", lambda: cache_dir) + + catalog_pack = cache_dir / "claude" + data = _minimal_manifest_dict("claude") + data["agent"]["version"] = "2.0.0" + _write_manifest(catalog_pack, data) + + resolved = resolve_agent_pack("claude") + assert resolved.source == "catalog" + assert resolved.manifest.version == "2.0.0" + + +# =================================================================== +# List and discovery +# =================================================================== + +class TestDiscovery: + """Verify list_embedded_agents() and list_all_agents().""" + + def test_list_embedded_agents_nonempty(self): + agents = list_embedded_agents() + assert len(agents) >= 25 + ids = {a.id for a in agents} + assert "claude" in ids + assert "gemini" in ids + assert "copilot" in ids + + def test_list_all_agents(self): + all_agents = list_all_agents() + assert len(all_agents) >= 25 + # Should be sorted by id + ids = [a.manifest.id for a in all_agents] + assert ids == sorted(ids) + + +# =================================================================== +# Validate pack +# =================================================================== + +class TestValidatePack: + + def test_valid_pack(self, tmp_path): + pack_dir = tmp_path / "pack" + data = _minimal_manifest_dict() + _write_manifest(pack_dir, data) + _write_bootstrap(pack_dir) + warnings = validate_pack(pack_dir) + assert warnings == [] # All fields present, no warnings + + def test_missing_manifest(self, tmp_path): + pack_dir = tmp_path / "pack" + pack_dir.mkdir(parents=True) + with pytest.raises(ManifestValidationError, match="Missing"): + validate_pack(pack_dir) + + def test_missing_bootstrap_warning(self, tmp_path): + pack_dir = tmp_path / "pack" + data = _minimal_manifest_dict() + _write_manifest(pack_dir, data) + warnings = validate_pack(pack_dir) + assert any("bootstrap.py" in w for w in warnings) + + def test_missing_description_warning(self, tmp_path): + pack_dir = tmp_path / "pack" + data = _minimal_manifest_dict() + data["agent"]["description"] = "" + _write_manifest(pack_dir, data) + _write_bootstrap(pack_dir) + warnings = validate_pack(pack_dir) + assert any("description" in w for w in warnings) + + def test_missing_tags_warning(self, tmp_path): + pack_dir = tmp_path / "pack" + data = _minimal_manifest_dict() + data["tags"] = [] + _write_manifest(pack_dir, data) + _write_bootstrap(pack_dir) + warnings = validate_pack(pack_dir) + assert any("tags" in w.lower() for w in warnings) + + +# =================================================================== +# Export pack +# =================================================================== + +class TestExportPack: + + def test_export_embedded(self, tmp_path): + dest = tmp_path / "export" + result = export_pack("claude", dest) + assert (result / MANIFEST_FILENAME).is_file() + assert (result / BOOTSTRAP_FILENAME).is_file() + + +# =================================================================== +# Embedded packs consistency with AGENT_CONFIG +# =================================================================== + +class TestEmbeddedPacksConsistency: + """Ensure embedded agent packs match the runtime AGENT_CONFIG.""" + + def test_all_agent_config_agents_have_embedded_packs(self): + """Every agent in AGENT_CONFIG (except 'generic') has an embedded pack.""" + from specify_cli import AGENT_CONFIG + + embedded = {m.id for m in list_embedded_agents()} + + for agent_key in AGENT_CONFIG: + if agent_key == "generic": + continue + assert agent_key in embedded, ( + f"Agent '{agent_key}' is in AGENT_CONFIG but has no embedded pack" + ) + + def test_embedded_packs_match_agent_config_metadata(self): + """Embedded pack manifests are consistent with AGENT_CONFIG fields.""" + from specify_cli import AGENT_CONFIG + + for manifest in list_embedded_agents(): + config = AGENT_CONFIG.get(manifest.id) + if config is None: + continue # Extra embedded packs are fine + + assert manifest.name == config["name"], ( + f"{manifest.id}: name mismatch: pack={manifest.name!r} config={config['name']!r}" + ) + assert manifest.requires_cli == config["requires_cli"], ( + f"{manifest.id}: requires_cli mismatch" + ) + + if config.get("install_url"): + assert manifest.install_url == config["install_url"], ( + f"{manifest.id}: install_url mismatch" + ) + + def test_embedded_packs_match_command_registrar(self): + """Embedded pack command_registration matches CommandRegistrar.AGENT_CONFIGS.""" + from specify_cli.agents import CommandRegistrar + + for manifest in list_embedded_agents(): + registrar_config = CommandRegistrar.AGENT_CONFIGS.get(manifest.id) + if registrar_config is None: + # Some agents in AGENT_CONFIG may not be in the registrar + # (e.g., agy, vibe — recently added) + continue + + assert manifest.commands_dir == registrar_config["dir"], ( + f"{manifest.id}: commands_dir mismatch: " + f"pack={manifest.commands_dir!r} registrar={registrar_config['dir']!r}" + ) + assert manifest.command_format == registrar_config["format"], ( + f"{manifest.id}: format mismatch" + ) + assert manifest.arg_placeholder == registrar_config["args"], ( + f"{manifest.id}: arg_placeholder mismatch" + ) + assert manifest.file_extension == registrar_config["extension"], ( + f"{manifest.id}: file_extension mismatch" + ) + + def test_each_embedded_pack_validates(self): + """Every embedded pack passes validate_pack().""" + from specify_cli.agent_pack import _embedded_agents_dir + + agents_dir = _embedded_agents_dir() + for child in sorted(agents_dir.iterdir()): + if not child.is_dir(): + continue + manifest_file = child / MANIFEST_FILENAME + if not manifest_file.is_file(): + continue + # Should not raise + warnings = validate_pack(child) + # Warnings are acceptable; hard errors are not From ec5471af61c3f9fe94cdb6122849ac43ca55ef5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:03:34 +0000 Subject: [PATCH 03/11] Fix code review issues: safe teardown for shared dirs, less brittle test assertions - Copilot: only remove .github/agents/ (preserves workflows, templates) - Tabnine: only remove .tabnine/agent/ (preserves other config) - Amp/Codex: only remove respective subdirs (commands/skills) to avoid deleting each other's files in shared .agents/ dir - Tests: use flexible assertions instead of hardcoded >= 25 counts Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ef8b4682-7f1a-4b04-a112-df0878236b6b --- .../core_pack/agents/amp/bootstrap.py | 16 ++++++++++++---- .../core_pack/agents/codex/bootstrap.py | 16 ++++++++++++---- .../core_pack/agents/copilot/bootstrap.py | 16 ++++++++++++---- .../core_pack/agents/tabnine/bootstrap.py | 16 ++++++++++++---- tests/test_agent_pack.py | 14 +++++++++----- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py index 51b676bfa9..e5e52021bf 100644 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -18,8 +18,16 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - commands_dir.mkdir(parents=True, exist_ok=True) def teardown(self, project_path: Path) -> None: - """Remove Amp agent files from the project.""" + """Remove Amp agent files from the project. + + Only removes the commands/ subdirectory — preserves other .agents/ + content (e.g. Codex skills/) which shares the same parent directory. + """ import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + if commands_dir.is_dir(): + shutil.rmtree(commands_dir) + # Remove .agents/ only if now empty + agents_dir = project_path / self.AGENT_DIR + if agents_dir.is_dir() and not any(agents_dir.iterdir()): + agents_dir.rmdir() diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py index 8f9a60a925..82afbc64ad 100644 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -18,8 +18,16 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - commands_dir.mkdir(parents=True, exist_ok=True) def teardown(self, project_path: Path) -> None: - """Remove Codex CLI agent files from the project.""" + """Remove Codex CLI agent files from the project. + + Only removes the skills/ subdirectory — preserves other .agents/ + content (e.g. Amp commands/) which shares the same parent directory. + """ import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + skills_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + if skills_dir.is_dir(): + shutil.rmtree(skills_dir) + # Remove .agents/ only if now empty + agents_dir = project_path / self.AGENT_DIR + if agents_dir.is_dir() and not any(agents_dir.iterdir()): + agents_dir.rmdir() diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py index 44a23e1f14..052473d5dd 100644 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -18,8 +18,16 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - commands_dir.mkdir(parents=True, exist_ok=True) def teardown(self, project_path: Path) -> None: - """Remove GitHub Copilot agent files from the project.""" + """Remove GitHub Copilot agent files from the project. + + Only removes the agents/ subdirectory — preserves other .github + content (workflows, issue templates, etc.). + """ import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + agents_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + if agents_dir.is_dir(): + shutil.rmtree(agents_dir) + # Also clean up companion .github/prompts/ if empty + prompts_dir = project_path / self.AGENT_DIR / "prompts" + if prompts_dir.is_dir() and not any(prompts_dir.iterdir()): + prompts_dir.rmdir() diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py index f04411f379..810a75c34b 100644 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -18,8 +18,16 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - commands_dir.mkdir(parents=True, exist_ok=True) def teardown(self, project_path: Path) -> None: - """Remove Tabnine CLI agent files from the project.""" + """Remove Tabnine CLI agent files from the project. + + Removes the agent/ subdirectory under .tabnine/ to preserve + any other Tabnine configuration. + """ import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + agent_subdir = project_path / self.AGENT_DIR + if agent_subdir.is_dir(): + shutil.rmtree(agent_subdir) + # Remove .tabnine/ only if now empty + tabnine_dir = project_path / ".tabnine" + if tabnine_dir.is_dir() and not any(tabnine_dir.iterdir()): + tabnine_dir.rmdir() diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index 77b5a74d79..ae42f052a1 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -365,18 +365,22 @@ class TestDiscovery: def test_list_embedded_agents_nonempty(self): agents = list_embedded_agents() - assert len(agents) >= 25 + assert len(agents) > 0 ids = {a.id for a in agents} - assert "claude" in ids - assert "gemini" in ids - assert "copilot" in ids + # 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) >= 25 + 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 # =================================================================== From b5a5e3fc356c9bd81337f82662a5ad037167c2c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:15:48 +0000 Subject: [PATCH 04/11] Add installed-file tracking with SHA-256 hashes for safe agent teardown Setup records installed files and their SHA-256 hashes in .specify/agent-manifest-.json. Teardown uses the manifest to remove only individual files (never directories). If any tracked file was modified since installation, teardown requires --force. - Add record_installed_files(), check_modified_files(), remove_tracked_files() and AgentFileModifiedError to agent_pack.py - Update all 25 bootstrap modules to use file-tracked setup/teardown - Add --force flag to 'specify agent switch' - Add 11 new tests for file tracking (record, check, remove, force, directory preservation, deleted-file handling, manifest structure) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/779eabf6-21d5-428b-9f01-dd363df4c84a --- src/specify_cli/__init__.py | 8 +- src/specify_cli/agent_pack.py | 154 +++++++++++++- .../core_pack/agents/agy/bootstrap.py | 19 +- .../core_pack/agents/amp/bootstrap.py | 21 +- .../core_pack/agents/auggie/bootstrap.py | 19 +- .../core_pack/agents/bob/bootstrap.py | 19 +- .../core_pack/agents/claude/bootstrap.py | 19 +- .../core_pack/agents/codebuddy/bootstrap.py | 19 +- .../core_pack/agents/codex/bootstrap.py | 21 +- .../core_pack/agents/copilot/bootstrap.py | 21 +- .../agents/cursor-agent/bootstrap.py | 19 +- .../core_pack/agents/gemini/bootstrap.py | 19 +- .../core_pack/agents/iflow/bootstrap.py | 19 +- .../core_pack/agents/junie/bootstrap.py | 19 +- .../core_pack/agents/kilocode/bootstrap.py | 19 +- .../core_pack/agents/kimi/bootstrap.py | 19 +- .../core_pack/agents/kiro-cli/bootstrap.py | 19 +- .../core_pack/agents/opencode/bootstrap.py | 19 +- .../core_pack/agents/pi/bootstrap.py | 19 +- .../core_pack/agents/qodercli/bootstrap.py | 19 +- .../core_pack/agents/qwen/bootstrap.py | 19 +- .../core_pack/agents/roo/bootstrap.py | 19 +- .../core_pack/agents/shai/bootstrap.py | 19 +- .../core_pack/agents/tabnine/bootstrap.py | 21 +- .../core_pack/agents/trae/bootstrap.py | 19 +- .../core_pack/agents/vibe/bootstrap.py | 19 +- .../core_pack/agents/windsurf/bootstrap.py | 19 +- tests/test_agent_pack.py | 201 +++++++++++++++++- 28 files changed, 639 insertions(+), 207 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8623ed8041..b2e905bcef 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2533,6 +2533,7 @@ def agent_export( @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. @@ -2544,6 +2545,7 @@ def agent_switch( load_bootstrap, PackResolutionError, AgentPackError, + AgentFileModifiedError, ) show_banner() @@ -2581,8 +2583,12 @@ def agent_switch( current_resolved = resolve_agent_pack(current_agent, project_path=project_path) current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest) console.print(f" [dim]Tearing down {current_agent}...[/dim]") - current_bootstrap.teardown(project_path) + current_bootstrap.teardown(project_path, force=force) console.print(f" [green]✓[/green] {current_agent} removed") + except AgentFileModifiedError as exc: + console.print(f"[red]Error:[/red] {exc}") + console.print("[yellow]Hint:[/yellow] Use --force to remove modified files.") + raise typer.Exit(1) except AgentPackError: # If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG agent_config = AGENT_CONFIG.get(current_agent, {}) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index f380213625..e5fe05ca38 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -14,7 +14,9 @@ `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 @@ -52,6 +54,10 @@ 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 # --------------------------------------------------------------------------- @@ -191,15 +197,22 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """ raise NotImplementedError - def teardown(self, project_path: Path) -> None: + def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove agent-specific files from *project_path*. Invoked by ``specify agent switch`` (for the *old* agent) and ``specify agent remove`` when the user explicitly uninstalls. Must preserve shared infrastructure (specs, plans, tasks, etc.). + Only individual files recorded in the install manifest are removed + — directories are never deleted. If any tracked file has been + modified since installation and *force* is ``False``, raises + :class:`AgentFileModifiedError`. + Args: project_path: Project directory to clean up. + force: When ``True``, remove files even if they were modified + after installation. """ raise NotImplementedError @@ -210,6 +223,145 @@ def agent_dir(self, project_path: Path) -> Path: return project_path / self.manifest.commands_dir.split("/")[0] +# --------------------------------------------------------------------------- +# 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 record_installed_files( + project_path: Path, + agent_id: str, + files: List[Path], +) -> Path: + """Record the installed files and their SHA-256 hashes. + + Writes ``.specify/agent-manifest-.json`` containing a + mapping of project-relative paths to their SHA-256 digests. + + Args: + project_path: Project root directory. + agent_id: Agent identifier. + files: Absolute or project-relative paths of the files that + were created during ``setup()``. + + Returns: + Path to the written manifest file. + """ + 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) + + 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, "files": entries}, indent=2), + encoding="utf-8", + ) + return manifest_file + + +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 [] + + modified: List[str] = [] + for rel_path, original_hash in data.get("files", {}).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, +) -> List[str]: + """Remove the individual files recorded in the install manifest. + + Raises :class:`AgentFileModifiedError` if any tracked file was + modified and *force* is ``False``. + + Directories are **never** deleted — only individual files. + + Args: + project_path: Project root directory. + agent_id: Agent identifier. + force: When ``True``, delete even modified files. + + Returns: + List of project-relative paths that were removed. + """ + 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: Dict[str, str] = data.get("files", {}) + 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 + manifest_file.unlink(missing_ok=True) + return removed + + # --------------------------------------------------------------------------- # Pack resolution # --------------------------------------------------------------------------- diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py index 4f0dd5a705..33bd5ba560 100644 --- a/src/specify_cli/core_pack/agents/agy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Agy(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Antigravity agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Antigravity agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py index e5e52021bf..236ec1c82b 100644 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Amp(AgentBootstrap): @@ -16,18 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: + def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Amp agent files from the project. - Only removes the commands/ subdirectory — preserves other .agents/ - content (e.g. Codex skills/) which shares the same parent directory. + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. """ - import shutil - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - if commands_dir.is_dir(): - shutil.rmtree(commands_dir) - # Remove .agents/ only if now empty - agents_dir = project_path / self.AGENT_DIR - if agents_dir.is_dir() and not any(agents_dir.iterdir()): - agents_dir.rmdir() + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py index 7ff391b9e5..d05b3a3b58 100644 --- a/src/specify_cli/core_pack/agents/auggie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Auggie(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Auggie CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Auggie CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py index ab4052a84f..876882b011 100644 --- a/src/specify_cli/core_pack/agents/bob/bootstrap.py +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Bob(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove IBM Bob agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove IBM Bob agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py index a2a515ee83..d4c255f23d 100644 --- a/src/specify_cli/core_pack/agents/claude/bootstrap.py +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Claude(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Claude Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Claude Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py index a6f061bae0..760741c1a4 100644 --- a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Codebuddy(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove CodeBuddy agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove CodeBuddy agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py index 82afbc64ad..ac7d2917af 100644 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Codex(AgentBootstrap): @@ -16,18 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: + def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Codex CLI agent files from the project. - Only removes the skills/ subdirectory — preserves other .agents/ - content (e.g. Amp commands/) which shares the same parent directory. + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. """ - import shutil - skills_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - if skills_dir.is_dir(): - shutil.rmtree(skills_dir) - # Remove .agents/ only if now empty - agents_dir = project_path / self.AGENT_DIR - if agents_dir.is_dir() and not any(agents_dir.iterdir()): - agents_dir.rmdir() + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py index 052473d5dd..0eaa0dc442 100644 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Copilot(AgentBootstrap): @@ -16,18 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: + def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove GitHub Copilot agent files from the project. - Only removes the agents/ subdirectory — preserves other .github - content (workflows, issue templates, etc.). + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. """ - import shutil - agents_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - if agents_dir.is_dir(): - shutil.rmtree(agents_dir) - # Also clean up companion .github/prompts/ if empty - prompts_dir = project_path / self.AGENT_DIR / "prompts" - if prompts_dir.is_dir() and not any(prompts_dir.iterdir()): - prompts_dir.rmdir() + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py index 0af4d914f8..b2573acdfd 100644 --- a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class CursorAgent(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Cursor agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Cursor agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py index 8e18e5a728..5f20e31a76 100644 --- a/src/specify_cli/core_pack/agents/gemini/bootstrap.py +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Gemini(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Gemini CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Gemini CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py index d421924dbd..506cb79b91 100644 --- a/src/specify_cli/core_pack/agents/iflow/bootstrap.py +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Iflow(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove iFlow CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove iFlow CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py index 6748ec7d5f..5b3b14175e 100644 --- a/src/specify_cli/core_pack/agents/junie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Junie(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Junie agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Junie agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py index f88f00f476..6b15f5023e 100644 --- a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Kilocode(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Kilo Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Kilo Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py index 50b8ca29cf..6dbd501934 100644 --- a/src/specify_cli/core_pack/agents/kimi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Kimi(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Kimi Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Kimi Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py index 1f2e1c2158..b13a366995 100644 --- a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class KiroCli(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Kiro CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Kiro CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py index b1cc30de95..4a94a3ee6c 100644 --- a/src/specify_cli/core_pack/agents/opencode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Opencode(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove opencode agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove opencode agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py index 51b3cc7b79..103f094cb9 100644 --- a/src/specify_cli/core_pack/agents/pi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Pi(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Pi Coding Agent agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Pi Coding Agent agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py index cbfb5c8284..af170c99c8 100644 --- a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Qodercli(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Qoder CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Qoder CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py index 186fe2ad6e..018ec1ae66 100644 --- a/src/specify_cli/core_pack/agents/qwen/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Qwen(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Qwen Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Qwen Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py index f15093147c..c9cbcb37fe 100644 --- a/src/specify_cli/core_pack/agents/roo/bootstrap.py +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Roo(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Roo Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Roo Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py index 968618d1d4..49a45e824a 100644 --- a/src/specify_cli/core_pack/agents/shai/bootstrap.py +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Shai(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove SHAI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove SHAI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py index 810a75c34b..29780dfa58 100644 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Tabnine(AgentBootstrap): @@ -16,18 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: + def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Tabnine CLI agent files from the project. - Removes the agent/ subdirectory under .tabnine/ to preserve - any other Tabnine configuration. + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. """ - import shutil - agent_subdir = project_path / self.AGENT_DIR - if agent_subdir.is_dir(): - shutil.rmtree(agent_subdir) - # Remove .tabnine/ only if now empty - tabnine_dir = project_path / ".tabnine" - if tabnine_dir.is_dir() and not any(tabnine_dir.iterdir()): - tabnine_dir.rmdir() + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py index 264be5b6a9..43c58b60aa 100644 --- a/src/specify_cli/core_pack/agents/trae/bootstrap.py +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Trae(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Trae agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Trae agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py index 955dece052..cb0ca8b5d9 100644 --- a/src/specify_cli/core_pack/agents/vibe/bootstrap.py +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Vibe(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Mistral Vibe agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Mistral Vibe agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py index 1331861858..1f8e47220f 100644 --- a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap +from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class Windsurf(AgentBootstrap): @@ -16,10 +16,15 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) + # Record installed files for tracked teardown + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - """Remove Windsurf agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Windsurf agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index ae42f052a1..7df69a5c70 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -17,15 +17,21 @@ MANIFEST_FILENAME, MANIFEST_SCHEMA_VERSION, AgentBootstrap, + AgentFileModifiedError, AgentManifest, AgentPackError, ManifestValidationError, PackResolutionError, ResolvedPack, + _manifest_path, + _sha256, + check_modified_files, export_pack, list_all_agents, list_embedded_agents, load_bootstrap, + record_installed_files, + remove_tracked_files, resolve_agent_pack, validate_pack, ) @@ -74,19 +80,19 @@ def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: s bootstrap_file.write_text(textwrap.dedent(f"""\ from pathlib import Path from typing import Any, Dict - from specify_cli.agent_pack import AgentBootstrap + from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files class {class_name}(AgentBootstrap): AGENT_DIR = "{agent_dir}" def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: - (project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True) + commands_dir = project_path / self.AGENT_DIR / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) - def teardown(self, project_path: Path) -> None: - import shutil - d = project_path / self.AGENT_DIR - if d.is_dir(): - shutil.rmtree(d) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + remove_tracked_files(project_path, self.manifest.id, force=force) """), encoding="utf-8") return bootstrap_file @@ -242,7 +248,7 @@ 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) + b.teardown(tmp_path, force=False) def test_load_bootstrap(self, tmp_path): data = _minimal_manifest_dict() @@ -258,7 +264,7 @@ def test_load_bootstrap_missing_file(self, tmp_path): load_bootstrap(tmp_path, m) def test_bootstrap_setup_and_teardown(self, tmp_path): - """Verify a loaded bootstrap can set up and tear down.""" + """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) @@ -273,8 +279,14 @@ def test_bootstrap_setup_and_teardown(self, tmp_path): b.setup(project, "sh", {}) assert (project / ".test-agent" / "commands").is_dir() + # The install manifest should exist in .specify/ + assert _manifest_path(project, "test-agent").is_file() + b.teardown(project) - assert not (project / ".test-agent").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.""" @@ -522,3 +534,172 @@ def test_each_embedded_pack_validates(self): # 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", [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", [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", [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", [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", [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", [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", [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", [f]) + data = json.loads(manifest_file.read_text(encoding="utf-8")) + + assert data["agent_id"] == "ag" + assert isinstance(data["files"], dict) + assert ".ag/x.md" in data["files"] + assert len(data["files"][".ag/x.md"]) == 64 From a63c248c80981db5d5c47a1dc1b40df44057ce1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:20:22 +0000 Subject: [PATCH 05/11] =?UTF-8?q?Move=20file=20recording=20to=20finalize?= =?UTF-8?q?=5Fsetup()=20=E2=80=94=20called=20after=20init=20pipeline=20wri?= =?UTF-8?q?tes=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code review: setup() now only creates directories, while finalize_setup() (on base class) scans the agent's commands_dir for all files and records them. This ensures files are tracked after the full init pipeline has written them, not before. - Add AgentBootstrap.finalize_setup() that scans commands_dir - Remove premature record_installed_files() from all 25 setup() methods - agent_switch calls finalize_setup() after setup() completes - Update test helper to match new pattern Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/779eabf6-21d5-428b-9f01-dd363df4c84a --- src/specify_cli/__init__.py | 2 ++ src/specify_cli/agent_pack.py | 16 ++++++++++++++++ .../core_pack/agents/agy/bootstrap.py | 5 +---- .../core_pack/agents/amp/bootstrap.py | 5 +---- .../core_pack/agents/auggie/bootstrap.py | 5 +---- .../core_pack/agents/bob/bootstrap.py | 5 +---- .../core_pack/agents/claude/bootstrap.py | 5 +---- .../core_pack/agents/codebuddy/bootstrap.py | 5 +---- .../core_pack/agents/codex/bootstrap.py | 5 +---- .../core_pack/agents/copilot/bootstrap.py | 5 +---- .../core_pack/agents/cursor-agent/bootstrap.py | 5 +---- .../core_pack/agents/gemini/bootstrap.py | 5 +---- .../core_pack/agents/iflow/bootstrap.py | 5 +---- .../core_pack/agents/junie/bootstrap.py | 5 +---- .../core_pack/agents/kilocode/bootstrap.py | 5 +---- .../core_pack/agents/kimi/bootstrap.py | 5 +---- .../core_pack/agents/kiro-cli/bootstrap.py | 5 +---- .../core_pack/agents/opencode/bootstrap.py | 5 +---- src/specify_cli/core_pack/agents/pi/bootstrap.py | 5 +---- .../core_pack/agents/qodercli/bootstrap.py | 5 +---- .../core_pack/agents/qwen/bootstrap.py | 5 +---- .../core_pack/agents/roo/bootstrap.py | 5 +---- .../core_pack/agents/shai/bootstrap.py | 5 +---- .../core_pack/agents/tabnine/bootstrap.py | 5 +---- .../core_pack/agents/trae/bootstrap.py | 5 +---- .../core_pack/agents/vibe/bootstrap.py | 5 +---- .../core_pack/agents/windsurf/bootstrap.py | 5 +---- tests/test_agent_pack.py | 16 ++++++++++------ 28 files changed, 53 insertions(+), 106 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b2e905bcef..049aca956a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2604,6 +2604,8 @@ def agent_switch( new_bootstrap = load_bootstrap(resolved.path, resolved.manifest) console.print(f" [dim]Setting up {agent_id}...[/dim]") new_bootstrap.setup(project_path, script_type, options) + # Record all installed files for tracked teardown + new_bootstrap.finalize_setup(project_path) console.print(f" [green]✓[/green] {agent_id} installed") except AgentPackError as exc: console.print(f"[red]Error setting up {agent_id}:[/red] {exc}") diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index e5fe05ca38..dac6a56985 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -222,6 +222,22 @@ 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 finalize_setup(self, project_path: Path) -> None: + """Record all files in the agent directory for tracked teardown. + + This must be called **after** the full init pipeline has finished + writing files (commands, context files, etc.) into the agent + directory. It scans ``self.manifest.commands_dir`` and records + every file with its SHA-256 hash so that :meth:`teardown` can + detect user modifications. + """ + if not self.manifest.commands_dir: + return + commands_dir = project_path / self.manifest.commands_dir + if commands_dir.is_dir(): + installed = [p for p in commands_dir.rglob("*") if p.is_file()] + record_installed_files(project_path, self.manifest.id, installed) + # --------------------------------------------------------------------------- # Installed-file tracking diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py index 33bd5ba560..21e5be321a 100644 --- a/src/specify_cli/core_pack/agents/agy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Agy(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Antigravity agent files from the project. diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py index 236ec1c82b..3eebd24cc7 100644 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Amp(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Amp agent files from the project. diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py index d05b3a3b58..c7c15a4fec 100644 --- a/src/specify_cli/core_pack/agents/auggie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Auggie(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Auggie CLI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py index 876882b011..bac8f9c284 100644 --- a/src/specify_cli/core_pack/agents/bob/bootstrap.py +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Bob(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove IBM Bob agent files from the project. diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py index d4c255f23d..9a3fb0c7bf 100644 --- a/src/specify_cli/core_pack/agents/claude/bootstrap.py +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Claude(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Claude Code agent files from the project. diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py index 760741c1a4..fbcc6439c2 100644 --- a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Codebuddy(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove CodeBuddy agent files from the project. diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py index ac7d2917af..7ecbef17ba 100644 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Codex(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Codex CLI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py index 0eaa0dc442..63a2866161 100644 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Copilot(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove GitHub Copilot agent files from the project. diff --git a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py index b2573acdfd..e01062dfe2 100644 --- a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class CursorAgent(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Cursor agent files from the project. diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py index 5f20e31a76..eab6ad7ea7 100644 --- a/src/specify_cli/core_pack/agents/gemini/bootstrap.py +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Gemini(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Gemini CLI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py index 506cb79b91..eea1e1bd12 100644 --- a/src/specify_cli/core_pack/agents/iflow/bootstrap.py +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Iflow(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove iFlow CLI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py index 5b3b14175e..e8650a3b1f 100644 --- a/src/specify_cli/core_pack/agents/junie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Junie(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Junie agent files from the project. diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py index 6b15f5023e..44a8a00793 100644 --- a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Kilocode(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Kilo Code agent files from the project. diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py index 6dbd501934..0f7136a556 100644 --- a/src/specify_cli/core_pack/agents/kimi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Kimi(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Kimi Code agent files from the project. diff --git a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py index b13a366995..d51b4b6cf7 100644 --- a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class KiroCli(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Kiro CLI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py index 4a94a3ee6c..fbd76f5347 100644 --- a/src/specify_cli/core_pack/agents/opencode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Opencode(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove opencode agent files from the project. diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py index 103f094cb9..591a0d8676 100644 --- a/src/specify_cli/core_pack/agents/pi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Pi(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Pi Coding Agent agent files from the project. diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py index af170c99c8..40e892f090 100644 --- a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Qodercli(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Qoder CLI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py index 018ec1ae66..8e2d5902cb 100644 --- a/src/specify_cli/core_pack/agents/qwen/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Qwen(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Qwen Code agent files from the project. diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py index c9cbcb37fe..fd8b66f26f 100644 --- a/src/specify_cli/core_pack/agents/roo/bootstrap.py +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Roo(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Roo Code agent files from the project. diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py index 49a45e824a..ed3c45b271 100644 --- a/src/specify_cli/core_pack/agents/shai/bootstrap.py +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Shai(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove SHAI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py index 29780dfa58..0e79eff3b5 100644 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Tabnine(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Tabnine CLI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py index 43c58b60aa..3846b4dc2f 100644 --- a/src/specify_cli/core_pack/agents/trae/bootstrap.py +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Trae(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Trae agent files from the project. diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py index cb0ca8b5d9..1ae353b475 100644 --- a/src/specify_cli/core_pack/agents/vibe/bootstrap.py +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Vibe(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Mistral Vibe agent files from the project. diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py index 1f8e47220f..fccbae3abc 100644 --- a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict -from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files +from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class Windsurf(AgentBootstrap): @@ -16,9 +16,6 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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) - # Record installed files for tracked teardown - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) def teardown(self, project_path: Path, *, force: bool = False) -> None: """Remove Windsurf agent files from the project. diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index 7df69a5c70..bc3f4bb459 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -80,16 +80,13 @@ def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: s bootstrap_file.write_text(textwrap.dedent(f"""\ from pathlib import Path from typing import Any, Dict - from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files + 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]) -> None: - commands_dir = project_path / self.AGENT_DIR / "commands" - commands_dir.mkdir(parents=True, exist_ok=True) - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) + (project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True) def teardown(self, project_path: Path, *, force: bool = False) -> None: remove_tracked_files(project_path, self.manifest.id, force=force) @@ -279,10 +276,17 @@ def test_bootstrap_setup_and_teardown(self, tmp_path): b.setup(project, "sh", {}) assert (project / ".test-agent" / "commands").is_dir() - # The install manifest should exist in .specify/ + # Simulate the init pipeline writing a file + cmd_file = project / ".test-agent" / "commands" / "hello.md" + cmd_file.write_text("hello", encoding="utf-8") + + # finalize_setup records files for tracking + b.finalize_setup(project) assert _manifest_path(project, "test-agent").is_file() b.teardown(project) + # The tracked file should be removed + assert not cmd_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) From e190116d13851869e85de987c24f50a64b639cf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:34:59 +0000 Subject: [PATCH 06/11] refactor: setup reports files, CLI checks modifications before teardown, categorised manifest - setup() returns List[Path] of installed files so CLI can record them - finalize_setup() accepts agent_files + extension_files for combined tracking - Install manifest categorises files: agent_files and extension_files - get_tracked_files() returns (agent_files, extension_files) split - remove_tracked_files() accepts explicit files dict for CLI-driven teardown - agent_switch checks for modifications BEFORE teardown and prompts user - _reregister_extension_commands() returns List[Path] of created files - teardown() accepts files parameter to receive explicit file lists - All 25 bootstraps updated with new signatures - 5 new tests: categorised manifest, get_tracked_files, explicit file teardown, extension file modification detection Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/32e470fc-6bf5-453c-bf6c-79a8521efa56 --- src/specify_cli/__init__.py | 85 +++++-- src/specify_cli/agent_pack.py | 235 ++++++++++++++---- .../core_pack/agents/agy/bootstrap.py | 15 +- .../core_pack/agents/amp/bootstrap.py | 15 +- .../core_pack/agents/auggie/bootstrap.py | 15 +- .../core_pack/agents/bob/bootstrap.py | 15 +- .../core_pack/agents/claude/bootstrap.py | 15 +- .../core_pack/agents/codebuddy/bootstrap.py | 15 +- .../core_pack/agents/codex/bootstrap.py | 15 +- .../core_pack/agents/copilot/bootstrap.py | 15 +- .../agents/cursor-agent/bootstrap.py | 15 +- .../core_pack/agents/gemini/bootstrap.py | 15 +- .../core_pack/agents/iflow/bootstrap.py | 15 +- .../core_pack/agents/junie/bootstrap.py | 15 +- .../core_pack/agents/kilocode/bootstrap.py | 15 +- .../core_pack/agents/kimi/bootstrap.py | 15 +- .../core_pack/agents/kiro-cli/bootstrap.py | 15 +- .../core_pack/agents/opencode/bootstrap.py | 15 +- .../core_pack/agents/pi/bootstrap.py | 15 +- .../core_pack/agents/qodercli/bootstrap.py | 15 +- .../core_pack/agents/qwen/bootstrap.py | 15 +- .../core_pack/agents/roo/bootstrap.py | 15 +- .../core_pack/agents/shai/bootstrap.py | 15 +- .../core_pack/agents/tabnine/bootstrap.py | 15 +- .../core_pack/agents/trae/bootstrap.py | 15 +- .../core_pack/agents/vibe/bootstrap.py | 15 +- .../core_pack/agents/windsurf/bootstrap.py | 15 +- tests/test_agent_pack.py | 139 +++++++++-- 28 files changed, 592 insertions(+), 242 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 049aca956a..845b3d2a01 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 @@ -2543,9 +2543,10 @@ def agent_switch( from .agent_pack import ( resolve_agent_pack, load_bootstrap, + check_modified_files, + get_tracked_files, PackResolutionError, AgentPackError, - AgentFileModifiedError, ) show_banner() @@ -2582,13 +2583,28 @@ def agent_switch( 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=force) + 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 AgentFileModifiedError as exc: - console.print(f"[red]Error:[/red] {exc}") - console.print("[yellow]Hint:[/yellow] Use --force to remove modified files.") - raise typer.Exit(1) except AgentPackError: # If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG agent_config = AGENT_CONFIG.get(current_agent, {}) @@ -2603,9 +2619,7 @@ def agent_switch( try: new_bootstrap = load_bootstrap(resolved.path, resolved.manifest) console.print(f" [dim]Setting up {agent_id}...[/dim]") - new_bootstrap.setup(project_path, script_type, options) - # Record all installed files for tracked teardown - new_bootstrap.finalize_setup(project_path) + 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}") @@ -2614,32 +2628,54 @@ def agent_switch( # Update init options options["ai"] = agent_id init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8") - console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]") # Re-register extension commands for the new agent - _reregister_extension_commands(project_path, agent_id) + 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. -def _reregister_extension_commands(project_path: Path, agent_id: str) -> None: - """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 + return created_files try: registry_data = json.loads(registry_file.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): - return + return created_files extensions = registry_data.get("extensions", {}) if not extensions: - return + return created_files try: from .agents import CommandRegistrar registrar = CommandRegistrar() except ImportError: - return + 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(): @@ -2668,8 +2704,19 @@ def _reregister_extension_commands(project_path: Path, agent_id: str) -> None: 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)") + console.print(f" [green]✓[/green] Re-registered {reregistered} extension command(s)" + f" ({len(created_files)} file(s))") + + return created_files @agent_app.command("search") diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index dac6a56985..08229c9326 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -184,35 +184,58 @@ def __init__(self, manifest: AgentManifest): # -- lifecycle ----------------------------------------------------------- - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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) -> None: + 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 recorded in the install manifest are removed - — directories are never deleted. If any tracked file has been - modified since installation and *force* is ``False``, raises - :class:`AgentFileModifiedError`. + 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 @@ -222,21 +245,44 @@ 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 finalize_setup(self, project_path: Path) -> None: - """Record all files in the agent directory for tracked teardown. + 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, etc.) into the agent - directory. It scans ``self.manifest.commands_dir`` and records - every file with its SHA-256 hash so that :meth:`teardown` can - detect user modifications. + 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 ``commands_dir`` for anything additional, and writes the + install manifest. + + Args: + agent_files: Files reported by :meth:`setup`. + extension_files: Files created by extension registration. """ - if not self.manifest.commands_dir: - return - commands_dir = project_path / self.manifest.commands_dir - if commands_dir.is_dir(): - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) + all_agent = list(agent_files or []) + all_extension = list(extension_files or []) + + # Also scan the commands directory for files created by the + # init pipeline that setup() did not report directly. + if self.manifest.commands_dir: + commands_dir = project_path / self.manifest.commands_dir + if commands_dir.is_dir(): + agent_set = {p.resolve() for p in all_agent} + for p in commands_dir.rglob("*"): + if p.is_file() and p.resolve() not in agent_set: + all_agent.append(p) + + record_installed_files( + project_path, + self.manifest.id, + agent_files=all_agent, + extension_files=all_extension, + ) # --------------------------------------------------------------------------- @@ -257,41 +303,107 @@ def _sha256(path: Path) -> str: 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, - files: List[Path], + 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 a - mapping of project-relative paths to their SHA-256 digests. + 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. - files: Absolute or project-relative paths of the files that - were created during ``setup()``. + 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. """ - 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) + 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, "files": entries}, indent=2), + 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, @@ -310,8 +422,10 @@ def check_modified_files( except (json.JSONDecodeError, OSError): return [] + entries = _all_tracked_entries(data) + modified: List[str] = [] - for rel_path, original_hash in data.get("files", {}).items(): + 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: @@ -327,11 +441,18 @@ def remove_tracked_files( agent_id: str, *, force: bool = False, + files: Optional[Dict[str, str]] = None, ) -> List[str]: - """Remove the individual files recorded in the install manifest. + """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``. + 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. @@ -339,32 +460,37 @@ def remove_tracked_files( 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 not manifest_file.is_file(): - return [] - - try: - data = json.loads(manifest_file.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return [] - - entries: Dict[str, str] = data.get("files", {}) - 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." - ) + 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: @@ -374,7 +500,8 @@ def remove_tracked_files( removed.append(rel_path) # Clean up the install manifest itself - manifest_file.unlink(missing_ok=True) + if manifest_file.is_file(): + manifest_file.unlink(missing_ok=True) return removed diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py index 21e5be321a..0434c2c469 100644 --- a/src/specify_cli/core_pack/agents/agy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Antigravity agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Agy(AgentBootstrap): AGENT_DIR = ".agent" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py index 3eebd24cc7..ab305ede90 100644 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Amp agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Amp(AgentBootstrap): AGENT_DIR = ".agents" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py index c7c15a4fec..8abd5618ca 100644 --- a/src/specify_cli/core_pack/agents/auggie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Auggie CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Auggie(AgentBootstrap): AGENT_DIR = ".augment" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py index bac8f9c284..4f8e2cdb1a 100644 --- a/src/specify_cli/core_pack/agents/bob/bootstrap.py +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for IBM Bob agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Bob(AgentBootstrap): AGENT_DIR = ".bob" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py index 9a3fb0c7bf..917556c388 100644 --- a/src/specify_cli/core_pack/agents/claude/bootstrap.py +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Claude Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Claude(AgentBootstrap): AGENT_DIR = ".claude" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py index fbcc6439c2..f4921d549c 100644 --- a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for CodeBuddy agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Codebuddy(AgentBootstrap): AGENT_DIR = ".codebuddy" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py index 7ecbef17ba..4accd01be7 100644 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Codex CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Codex(AgentBootstrap): AGENT_DIR = ".agents" COMMANDS_SUBDIR = "skills" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py index 63a2866161..eb2c3cdea5 100644 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for GitHub Copilot agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Copilot(AgentBootstrap): AGENT_DIR = ".github" COMMANDS_SUBDIR = "agents" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py index e01062dfe2..4a3d43de88 100644 --- a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Cursor agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class CursorAgent(AgentBootstrap): AGENT_DIR = ".cursor" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py index eab6ad7ea7..48d0922a4a 100644 --- a/src/specify_cli/core_pack/agents/gemini/bootstrap.py +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Gemini CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Gemini(AgentBootstrap): AGENT_DIR = ".gemini" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py index eea1e1bd12..80770d0d63 100644 --- a/src/specify_cli/core_pack/agents/iflow/bootstrap.py +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for iFlow CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Iflow(AgentBootstrap): AGENT_DIR = ".iflow" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py index e8650a3b1f..63f9929528 100644 --- a/src/specify_cli/core_pack/agents/junie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Junie agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Junie(AgentBootstrap): AGENT_DIR = ".junie" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py index 44a8a00793..2f6aaa521e 100644 --- a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Kilo Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Kilocode(AgentBootstrap): AGENT_DIR = ".kilocode" COMMANDS_SUBDIR = "workflows" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py index 0f7136a556..2e3c400c77 100644 --- a/src/specify_cli/core_pack/agents/kimi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Kimi Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Kimi(AgentBootstrap): AGENT_DIR = ".kimi" COMMANDS_SUBDIR = "skills" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py index d51b4b6cf7..d5f8f298c2 100644 --- a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Kiro CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class KiroCli(AgentBootstrap): AGENT_DIR = ".kiro" COMMANDS_SUBDIR = "prompts" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py index fbd76f5347..223a0545bf 100644 --- a/src/specify_cli/core_pack/agents/opencode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for opencode agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Opencode(AgentBootstrap): AGENT_DIR = ".opencode" COMMANDS_SUBDIR = "command" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py index 591a0d8676..0d760669ab 100644 --- a/src/specify_cli/core_pack/agents/pi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Pi Coding Agent agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Pi(AgentBootstrap): AGENT_DIR = ".pi" COMMANDS_SUBDIR = "prompts" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py index 40e892f090..728abd0950 100644 --- a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Qoder CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Qodercli(AgentBootstrap): AGENT_DIR = ".qoder" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py index 8e2d5902cb..baf4cf3e8a 100644 --- a/src/specify_cli/core_pack/agents/qwen/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Qwen Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Qwen(AgentBootstrap): AGENT_DIR = ".qwen" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py index fd8b66f26f..cc01848002 100644 --- a/src/specify_cli/core_pack/agents/roo/bootstrap.py +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Roo Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Roo(AgentBootstrap): AGENT_DIR = ".roo" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py index ed3c45b271..2b679f5163 100644 --- a/src/specify_cli/core_pack/agents/shai/bootstrap.py +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for SHAI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Shai(AgentBootstrap): AGENT_DIR = ".shai" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py index 0e79eff3b5..53024bd868 100644 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Tabnine CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Tabnine(AgentBootstrap): AGENT_DIR = ".tabnine/agent" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py index 3846b4dc2f..77b7c5d679 100644 --- a/src/specify_cli/core_pack/agents/trae/bootstrap.py +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Trae agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Trae(AgentBootstrap): AGENT_DIR = ".trae" COMMANDS_SUBDIR = "rules" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py index 1ae353b475..1b29fe4315 100644 --- a/src/specify_cli/core_pack/agents/vibe/bootstrap.py +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Mistral Vibe agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Vibe(AgentBootstrap): AGENT_DIR = ".vibe" COMMANDS_SUBDIR = "prompts" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py index fccbae3abc..192ca32dc4 100644 --- a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Windsurf agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Windsurf(AgentBootstrap): AGENT_DIR = ".windsurf" COMMANDS_SUBDIR = "workflows" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + 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 [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + 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. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + 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``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index bc3f4bb459..b93cc65d2b 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -27,6 +27,7 @@ _sha256, check_modified_files, export_pack, + get_tracked_files, list_all_agents, list_embedded_agents, load_bootstrap, @@ -79,17 +80,18 @@ def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: s bootstrap_file = pack_dir / BOOTSTRAP_FILENAME bootstrap_file.write_text(textwrap.dedent(f"""\ from pathlib import Path - from typing import Any, Dict + 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]) -> None: + 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) -> None: - remove_tracked_files(project_path, self.manifest.id, force=force) + 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 @@ -273,20 +275,31 @@ def test_bootstrap_setup_and_teardown(self, tmp_path): project = tmp_path / "project" project.mkdir() - b.setup(project, "sh", {}) + 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") - # finalize_setup records files for tracking - b.finalize_setup(project) + # 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 file should be removed + # 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) @@ -557,7 +570,7 @@ def test_record_and_check_unmodified(self, tmp_path): f.parent.mkdir(parents=True) f.write_text("hello world", encoding="utf-8") - record_installed_files(project, "myagent", [f]) + record_installed_files(project, "myagent", agent_files=[f]) # No modifications yet assert check_modified_files(project, "myagent") == [] @@ -571,7 +584,7 @@ def test_check_detects_modification(self, tmp_path): f.parent.mkdir(parents=True) f.write_text("original", encoding="utf-8") - record_installed_files(project, "myagent", [f]) + record_installed_files(project, "myagent", agent_files=[f]) # Now modify the file f.write_text("modified content", encoding="utf-8") @@ -595,7 +608,7 @@ def test_remove_tracked_unmodified(self, tmp_path): f1.write_text("aaa", encoding="utf-8") f2.write_text("bbb", encoding="utf-8") - record_installed_files(project, "ag", [f1, f2]) + record_installed_files(project, "ag", agent_files=[f1, f2]) removed = remove_tracked_files(project, "ag") assert len(removed) == 2 @@ -615,7 +628,7 @@ def test_remove_tracked_modified_without_force_raises(self, tmp_path): f.parent.mkdir(parents=True) f.write_text("original", encoding="utf-8") - record_installed_files(project, "ag", [f]) + record_installed_files(project, "ag", agent_files=[f]) f.write_text("user-edited", encoding="utf-8") with pytest.raises(AgentFileModifiedError, match="modified"): @@ -633,7 +646,7 @@ def test_remove_tracked_modified_with_force(self, tmp_path): f.parent.mkdir(parents=True) f.write_text("original", encoding="utf-8") - record_installed_files(project, "ag", [f]) + record_installed_files(project, "ag", agent_files=[f]) f.write_text("user-edited", encoding="utf-8") removed = remove_tracked_files(project, "ag", force=True) @@ -655,7 +668,7 @@ def test_remove_preserves_directories(self, tmp_path): f = d / "deep.md" f.write_text("deep", encoding="utf-8") - record_installed_files(project, "myagent", [f]) + record_installed_files(project, "myagent", agent_files=[f]) remove_tracked_files(project, "myagent") assert not f.exists() @@ -672,7 +685,7 @@ def test_deleted_file_skipped_gracefully(self, tmp_path): f.parent.mkdir(parents=True) f.write_text("data", encoding="utf-8") - record_installed_files(project, "ag", [f]) + record_installed_files(project, "ag", agent_files=[f]) # User deletes the file before teardown f.unlink() @@ -700,10 +713,98 @@ def test_manifest_json_structure(self, tmp_path): f.parent.mkdir(parents=True) f.write_text("content", encoding="utf-8") - manifest_file = record_installed_files(project, "ag", [f]) + 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["files"], dict) - assert ".ag/x.md" in data["files"] - assert len(data["files"][".ag/x.md"]) == 64 + 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] From c3efd1fb71c15a57978655f8597dbef716903493 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:37:27 +0000 Subject: [PATCH 07/11] style: fix f-string formatting in _reregister_extension_commands Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/32e470fc-6bf5-453c-bf6c-79a8521efa56 --- src/specify_cli/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 845b3d2a01..dd1fb1f584 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2713,8 +2713,9 @@ def _reregister_extension_commands(project_path: Path, agent_id: str) -> List[Pa created_files.append(p) if reregistered: - console.print(f" [green]✓[/green] Re-registered {reregistered} extension command(s)" - f" ({len(created_files)} file(s))") + console.print( + f" [green]✓[/green] Re-registered {reregistered} extension command(s) ({len(created_files)} file(s))" + ) return created_files From c2227a7ffd736fe9d4753ba4ae3f2ea665ab048c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:53:03 +0000 Subject: [PATCH 08/11] feat: add --agent flag to init for pack-based flow with file tracking - `specify init --agent claude` resolves through the pack system and records all installed files in .specify/agent-manifest-.json via finalize_setup() after the init pipeline finishes - --agent and --ai are mutually exclusive; --agent additionally enables tracked teardown/switch - init-options.json gains "agent_pack" key when --agent is used - 4 new parity tests verify: pack resolution matches AGENT_CONFIG, commands_dir parity, finalize_setup records pipeline-created files, pack metadata matches CommandRegistrar configuration Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/930d8c4d-ce42-41fb-a40f-561fb1468e81 --- src/specify_cli/__init__.py | 37 ++++++++++++- tests/test_agent_pack.py | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index dd1fb1f584..a23512997a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -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 (pack-based flow — resolves through the agent pack system and records installed files for tracked teardown). 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 @@ -2090,6 +2117,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 +2161,13 @@ 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. This runs AFTER the full init pipeline has + # finished creating files (scaffolding, skills, presets, + # extensions) so finalize_setup captures everything. + if use_agent_pack and agent_bootstrap is not None: + agent_bootstrap.finalize_setup(project_path) + tracker.complete("final", "project ready") except (typer.Exit, SystemExit): raise diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index b93cc65d2b..c622bb2b25 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -808,3 +808,109 @@ def test_check_detects_extension_file_modification(self, tmp_path): modified = check_modified_files(project, "ag") assert len(modified) == 1 assert ".ag/ext.md" in modified[0] + + +# =================================================================== +# --agent flag on init (pack-based flow parity) +# =================================================================== + +class TestInitAgentFlag: + """Verify the --agent flag on ``specify init`` resolves through the + pack system and that pack metadata is consistent with AGENT_CONFIG.""" + + def test_agent_resolves_same_agent_as_ai(self): + """--agent resolves the same agent as --ai for all + agents in AGENT_CONFIG (except 'generic').""" + from specify_cli import AGENT_CONFIG + + for agent_id in AGENT_CONFIG: + if agent_id == "generic": + continue + try: + resolved = resolve_agent_pack(agent_id) + except PackResolutionError: + pytest.fail(f"--agent {agent_id} would fail: no pack found") + + assert resolved.manifest.id == agent_id + + def test_pack_commands_dir_matches_agent_config(self): + """The pack's commands_dir matches the directory that the old + flow (AGENT_CONFIG) would use, ensuring both flows write files + to the same location.""" + from specify_cli import AGENT_CONFIG + + for agent_id, config in AGENT_CONFIG.items(): + if agent_id == "generic": + continue + try: + resolved = resolve_agent_pack(agent_id) + except PackResolutionError: + continue + + # AGENT_CONFIG stores folder + commands_subdir + folder = config.get("folder", "").rstrip("/") + subdir = config.get("commands_subdir", "commands") + expected_dir = f"{folder}/{subdir}" if folder else "" + # Normalise path separators + expected_dir = expected_dir.lstrip("/") + + assert resolved.manifest.commands_dir == expected_dir, ( + f"{agent_id}: commands_dir mismatch: " + f"pack={resolved.manifest.commands_dir!r} " + f"config_derived={expected_dir!r}" + ) + + def test_finalize_setup_records_files_after_init(self, tmp_path): + """Simulates the --agent init flow: setup → create files → + finalize_setup, then verifies the install manifest is present.""" + # Pick any embedded agent (claude) + resolved = resolve_agent_pack("claude") + bootstrap = load_bootstrap(resolved.path, resolved.manifest) + + project = tmp_path / "project" + project.mkdir() + (project / ".specify").mkdir() + + # setup() creates the directory structure + setup_files = bootstrap.setup(project, "sh", {}) + assert isinstance(setup_files, list) + + # Simulate the init pipeline creating command files + commands_dir = project / resolved.manifest.commands_dir + commands_dir.mkdir(parents=True, exist_ok=True) + cmd_file = commands_dir / "speckit-plan.md" + cmd_file.write_text("plan command", encoding="utf-8") + + # finalize_setup records everything + bootstrap.finalize_setup(project) + + manifest_file = _manifest_path(project, "claude") + assert manifest_file.is_file() + + data = json.loads(manifest_file.read_text(encoding="utf-8")) + all_tracked = { + **data.get("agent_files", {}), + **data.get("extension_files", {}), + } + assert any("speckit-plan.md" in p for p in all_tracked), ( + "finalize_setup should record files created by the init pipeline" + ) + + def test_pack_metadata_enables_same_extension_registration(self): + """Pack command_registration metadata matches CommandRegistrar + configuration, ensuring that extension registration via the pack + system writes to the same directories and with the same format as + the old AGENT_CONFIG-based flow.""" + 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: + continue + + # These four fields are what CommandRegistrar uses to render + # extension commands — they must match exactly. + assert manifest.commands_dir == registrar_config["dir"] + assert manifest.command_format == registrar_config["format"] + assert manifest.arg_placeholder == registrar_config["args"] + assert manifest.file_extension == registrar_config["extension"] From d6016ab9db6a921380f9af6215fc208976000561 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:54:56 +0000 Subject: [PATCH 09/11] style: simplify --agent help text, normalize comment spelling Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/930d8c4d-ce42-41fb-a40f-561fb1468e81 --- src/specify_cli/__init__.py | 2 +- tests/test_agent_pack.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index a23512997a..a5f82a1260 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1715,7 +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 (pack-based flow — resolves through the agent pack system and records installed files for tracked teardown). Accepts the same agent IDs as --ai."), + 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"), diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index c622bb2b25..173048b711 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -851,7 +851,7 @@ def test_pack_commands_dir_matches_agent_config(self): folder = config.get("folder", "").rstrip("/") subdir = config.get("commands_subdir", "commands") expected_dir = f"{folder}/{subdir}" if folder else "" - # Normalise path separators + # Normalize path separators expected_dir = expected_dir.lstrip("/") assert resolved.manifest.commands_dir == expected_dir, ( From 9b580a536b8493010f537688643f2b69f1055ee7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:29:33 +0000 Subject: [PATCH 10/11] feat: setup() owns scaffolding and returns actual installed files - AgentBootstrap._scaffold_project() calls scaffold_from_core_pack, snapshots before/after, returns all new files - finalize_setup() filters agent_files to only track files under the agent's own directory tree (shared .specify/ files not tracked) - All 25 bootstrap setup() methods call _scaffold_project() and return the actual file list instead of [] - --agent init flow routes through setup() for scaffolding instead of calling scaffold_from_core_pack directly - 100 new tests (TestSetupReturnsFiles): verify every agent's setup() returns non-empty, existing, absolute paths including agent-dir files - Parity tests use CliRunner to invoke the real init command - finalize_setup bug fix: skills-migrated agents (agy) now have their skills directory scanned correctly - 1262 tests pass (452 in test_agent_pack.py alone) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/054690bb-c048-41e0-b553-377d5cb36b78 --- src/specify_cli/__init__.py | 37 +- src/specify_cli/agent_pack.py | 95 +++- .../core_pack/agents/agy/bootstrap.py | 2 +- .../core_pack/agents/amp/bootstrap.py | 2 +- .../core_pack/agents/auggie/bootstrap.py | 2 +- .../core_pack/agents/bob/bootstrap.py | 2 +- .../core_pack/agents/claude/bootstrap.py | 2 +- .../core_pack/agents/codebuddy/bootstrap.py | 2 +- .../core_pack/agents/codex/bootstrap.py | 2 +- .../core_pack/agents/copilot/bootstrap.py | 2 +- .../agents/cursor-agent/bootstrap.py | 2 +- .../core_pack/agents/gemini/bootstrap.py | 2 +- .../core_pack/agents/iflow/bootstrap.py | 2 +- .../core_pack/agents/junie/bootstrap.py | 2 +- .../core_pack/agents/kilocode/bootstrap.py | 2 +- .../core_pack/agents/kimi/bootstrap.py | 2 +- .../core_pack/agents/kiro-cli/bootstrap.py | 2 +- .../core_pack/agents/opencode/bootstrap.py | 2 +- .../core_pack/agents/pi/bootstrap.py | 2 +- .../core_pack/agents/qodercli/bootstrap.py | 2 +- .../core_pack/agents/qwen/bootstrap.py | 2 +- .../core_pack/agents/roo/bootstrap.py | 2 +- .../core_pack/agents/shai/bootstrap.py | 2 +- .../core_pack/agents/tabnine/bootstrap.py | 2 +- .../core_pack/agents/trae/bootstrap.py | 2 +- .../core_pack/agents/vibe/bootstrap.py | 2 +- .../core_pack/agents/windsurf/bootstrap.py | 2 +- tests/test_agent_pack.py | 531 +++++++++++++++--- 28 files changed, 592 insertions(+), 121 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index a5f82a1260..c801cbd752 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1984,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"), @@ -2019,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: @@ -2162,11 +2184,14 @@ def init( tracker.skip("cleanup", "not needed (no download)") # When --agent is used, record all installed agent files for - # tracked teardown. This runs AFTER the full init pipeline has - # finished creating files (scaffolding, skills, presets, - # extensions) so finalize_setup captures everything. + # 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_bootstrap.finalize_setup( + project_path, agent_files=agent_setup_files) tracker.complete("final", "project ready") except (typer.Exit, SystemExit): diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 08229c9326..4c92cadebd 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -245,6 +245,57 @@ 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, @@ -257,25 +308,51 @@ def finalize_setup( 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 ``commands_dir`` for anything additional, and writes 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_agent = list(agent_files or []) all_extension = list(extension_files or []) - # Also scan the commands directory for files created by the - # init pipeline that setup() did not report directly. + # Filter agent_files: only keep files under the agent's directory + # tree. setup() may return shared project files (e.g. .specify/) + # which must not be tracked per-agent. + 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 # shared file — not tracked per-agent + + # Scan the agent's directory tree for files created by the init + # pipeline that setup() did not report directly. We scan the + # entire agent directory (the parent of commands_dir) because + # skills-migrated agents replace the commands directory with a + # sibling skills directory during init. if self.manifest.commands_dir: commands_dir = project_path / self.manifest.commands_dir - if commands_dir.is_dir(): - agent_set = {p.resolve() for p in all_agent} - for p in commands_dir.rglob("*"): - if p.is_file() and p.resolve() not in agent_set: - all_agent.append(p) + # Scan the agent root (e.g. .claude/) so we catch both + # commands and skills directories. + agent_root = commands_dir.parent + agent_set = {p.resolve() for p in all_agent} + for scan_dir in (commands_dir, agent_root): + if scan_dir.is_dir(): + for p in scan_dir.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, diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py index 0434c2c469..b7b6ae9d71 100644 --- a/src/specify_cli/core_pack/agents/agy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py index ab305ede90..da70993286 100644 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py index 8abd5618ca..27f89a30f3 100644 --- a/src/specify_cli/core_pack/agents/auggie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py index 4f8e2cdb1a..afdd3e058c 100644 --- a/src/specify_cli/core_pack/agents/bob/bootstrap.py +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py index 917556c388..e1b3fade9d 100644 --- a/src/specify_cli/core_pack/agents/claude/bootstrap.py +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py index f4921d549c..c054b5a966 100644 --- a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py index 4accd01be7..05e9b500e4 100644 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py index eb2c3cdea5..cb5a2d4cba 100644 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py index 4a3d43de88..a30fb4e82c 100644 --- a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py index 48d0922a4a..92421aba88 100644 --- a/src/specify_cli/core_pack/agents/gemini/bootstrap.py +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py index 80770d0d63..520a3cba5b 100644 --- a/src/specify_cli/core_pack/agents/iflow/bootstrap.py +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py index 63f9929528..f830bdfd76 100644 --- a/src/specify_cli/core_pack/agents/junie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py index 2f6aaa521e..e41ee47773 100644 --- a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py index 2e3c400c77..e4e6c71f98 100644 --- a/src/specify_cli/core_pack/agents/kimi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py index d5f8f298c2..756dcee586 100644 --- a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py index 223a0545bf..a23b006f79 100644 --- a/src/specify_cli/core_pack/agents/opencode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py index 0d760669ab..f63c8b08c7 100644 --- a/src/specify_cli/core_pack/agents/pi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py index 728abd0950..721205cd08 100644 --- a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py index baf4cf3e8a..7688b8fe08 100644 --- a/src/specify_cli/core_pack/agents/qwen/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py index cc01848002..e4416a95c6 100644 --- a/src/specify_cli/core_pack/agents/roo/bootstrap.py +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py index 2b679f5163..87880c8245 100644 --- a/src/specify_cli/core_pack/agents/shai/bootstrap.py +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py index 53024bd868..fe6cc3c783 100644 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py index 77b7c5d679..6c774fdd04 100644 --- a/src/specify_cli/core_pack/agents/trae/bootstrap.py +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py index 1b29fe4315..439974bb54 100644 --- a/src/specify_cli/core_pack/agents/vibe/bootstrap.py +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py index 192ca32dc4..08b4fc80be 100644 --- a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -16,7 +16,7 @@ def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) - """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 [] # directories only — actual files are created by the init pipeline + 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. diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index 173048b711..c44b77fec4 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -811,106 +811,475 @@ def test_check_detects_extension_file_modification(self, tmp_path): # =================================================================== -# --agent flag on init (pack-based flow parity) +# setup() returns actual files (not empty list) # =================================================================== -class TestInitAgentFlag: - """Verify the --agent flag on ``specify init`` resolves through the - pack system and that pack metadata is consistent with AGENT_CONFIG.""" +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) - def test_agent_resolves_same_agent_as_ai(self): - """--agent resolves the same agent as --ai for all - agents in AGENT_CONFIG (except 'generic').""" - from specify_cli import AGENT_CONFIG + project = tmp_path / f"setup_{agent}" + project.mkdir() - for agent_id in AGENT_CONFIG: - if agent_id == "generic": - continue - try: - resolved = resolve_agent_pack(agent_id) - except PackResolutionError: - pytest.fail(f"--agent {agent_id} would fail: no pack found") + 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) - assert resolved.manifest.id == agent_id + 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)}'") - def test_pack_commands_dir_matches_agent_config(self): - """The pack's commands_dir matches the directory that the old - flow (AGENT_CONFIG) would use, ensuring both flows write files - to the same location.""" + +# =================================================================== +# --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 - for agent_id, config in AGENT_CONFIG.items(): - if agent_id == "generic": - continue + 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: - resolved = resolve_agent_pack(agent_id) - except PackResolutionError: - continue + 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() + } - # AGENT_CONFIG stores folder + commands_subdir - folder = config.get("folder", "").rstrip("/") - subdir = config.get("commands_subdir", "commands") - expected_dir = f"{folder}/{subdir}" if folder else "" - # Normalize path separators - expected_dir = expected_dir.lstrip("/") - - assert resolved.manifest.commands_dir == expected_dir, ( - f"{agent_id}: commands_dir mismatch: " - f"pack={resolved.manifest.commands_dir!r} " - f"config_derived={expected_dir!r}" - ) + # 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", {}), + } - def test_finalize_setup_records_files_after_init(self, tmp_path): - """Simulates the --agent init flow: setup → create files → - finalize_setup, then verifies the install manifest is present.""" - # Pick any embedded agent (claude) - resolved = resolve_agent_pack("claude") - bootstrap = load_bootstrap(resolved.path, resolved.manifest) + missing = on_disk - tracked + assert not missing, ( + f"Agent '{agent}': files not tracked by manifest:\n " + + "\n ".join(sorted(missing))) - project = tmp_path / "project" - project.mkdir() - (project / ".specify").mkdir() + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_teardown_removes_all_tracked_files(self, agent, tmp_path): + """Full lifecycle: setup → scaffold → finalize → teardown. - # setup() creates the directory structure - setup_files = bootstrap.setup(project, "sh", {}) - assert isinstance(setup_files, list) + 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 - # Simulate the init pipeline creating command files - commands_dir = project / resolved.manifest.commands_dir - commands_dir.mkdir(parents=True, exist_ok=True) - cmd_file = commands_dir / "speckit-plan.md" - cmd_file.write_text("plan command", encoding="utf-8") + project = tmp_path / f"lifecycle_{agent}" + project.mkdir() - # finalize_setup records everything + # 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) - manifest_file = _manifest_path(project, "claude") - assert manifest_file.is_file() + # 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" - data = json.loads(manifest_file.read_text(encoding="utf-8")) - all_tracked = { - **data.get("agent_files", {}), - **data.get("extension_files", {}), - } - assert any("speckit-plan.md" in p for p in all_tracked), ( - "finalize_setup should record files created by the init pipeline" - ) + # 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 ------------------------------------ - def test_pack_metadata_enables_same_extension_registration(self): - """Pack command_registration metadata matches CommandRegistrar - configuration, ensuring that extension registration via the pack - system writes to the same directories and with the same format as - the old AGENT_CONFIG-based flow.""" + @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 - for manifest in list_embedded_agents(): - registrar_config = CommandRegistrar.AGENT_CONFIGS.get(manifest.id) - if registrar_config is None: - continue + 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}") - # These four fields are what CommandRegistrar uses to render - # extension commands — they must match exactly. - assert manifest.commands_dir == registrar_config["dir"] - assert manifest.command_format == registrar_config["format"] - assert manifest.arg_placeholder == registrar_config["args"] - assert manifest.file_extension == registrar_config["extension"] + 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"] From 978addc390996342a6f3f6114f11cfc36cf63b3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:32:36 +0000 Subject: [PATCH 11/11] refactor: simplify finalize_setup scan to agent_root only, improve comments Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/054690bb-c048-41e0-b553-377d5cb36b78 --- src/specify_cli/agent_pack.py | 39 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 4c92cadebd..efef7fb24a 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -324,8 +324,10 @@ def finalize_setup( all_extension = list(extension_files or []) # Filter agent_files: only keep files under the agent's directory - # tree. setup() may return shared project files (e.g. .specify/) - # which must not be tracked per-agent. + # 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] = [] @@ -334,25 +336,22 @@ def finalize_setup( p.resolve().relative_to(agent_root_resolved) all_agent.append(p) except ValueError: - pass # shared file — not tracked per-agent - - # Scan the agent's directory tree for files created by the init - # pipeline that setup() did not report directly. We scan the - # entire agent directory (the parent of commands_dir) because - # skills-migrated agents replace the commands directory with a - # sibling skills directory during init. + 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: - commands_dir = project_path / self.manifest.commands_dir - # Scan the agent root (e.g. .claude/) so we catch both - # commands and skills directories. - agent_root = commands_dir.parent - agent_set = {p.resolve() for p in all_agent} - for scan_dir in (commands_dir, agent_root): - if scan_dir.is_dir(): - for p in scan_dir.rglob("*"): - if p.is_file() and p.resolve() not in agent_set: - all_agent.append(p) - agent_set.add(p.resolve()) + 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,