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/24] 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/24] 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/24] 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/24] 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/24] =?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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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, From 55bcbd3977ff5add089da64e6b601891cc156590 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:36:36 +0000 Subject: [PATCH 12/24] fix: resolve all ruff check failures (F541 f-string placeholders, F401 unused imports, F841 unused variable) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/e19bd25e-f084-4f38-85b6-8105cbb50494 --- src/specify_cli/__init__.py | 6 +++--- src/specify_cli/agent_pack.py | 2 +- tests/test_agent_pack.py | 7 ++----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c801cbd752..f535401c2f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2505,7 +2505,7 @@ def agent_info( if resolved.source == "catalog": source_display = f"catalog — {resolved.path}" elif resolved.source == "embedded": - source_display = f"embedded (bundled in specify-cli wheel)" + source_display = "embedded (bundled in specify-cli wheel)" info_table.add_row("Source", source_display) if resolved.overrides: @@ -2871,8 +2871,8 @@ def agent_add( 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]") + console.print("[yellow]Catalog fetch not yet implemented.[/yellow]") + console.print("[dim]Use --from to install from a local directory.[/dim]") raise typer.Exit(1) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index efef7fb24a..61873ecdeb 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -563,7 +563,7 @@ def remove_tracked_files( modified = check_modified_files(project_path, agent_id) if modified: raise AgentFileModifiedError( - f"The following agent files have been modified since installation:\n" + "The following agent files have been modified since installation:\n" + "\n".join(f" {p}" for p in modified) + "\nUse --force to remove them anyway." ) diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index c44b77fec4..e3d490ddf6 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -5,7 +5,6 @@ """ import json -import shutil import textwrap from pathlib import Path @@ -22,7 +21,6 @@ AgentPackError, ManifestValidationError, PackResolutionError, - ResolvedPack, _manifest_path, _sha256, check_modified_files, @@ -548,9 +546,8 @@ def test_each_embedded_pack_validates(self): 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 + # Should not raise — warnings are acceptable; hard errors are not + validate_pack(child) # =================================================================== From 795f1e7703e3a7fc429d113c8e015cb1453673ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:01:21 +0000 Subject: [PATCH 13/24] fix: add explanatory comments to all empty except clauses (code quality) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/bb495c08-5d15-410f-9ba9-89d3fc413904 --- src/specify_cli/agent_pack.py | 4 ++-- tests/test_agent_pack.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 61873ecdeb..360cf5b101 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -336,7 +336,7 @@ def finalize_setup( p.resolve().relative_to(agent_root_resolved) all_agent.append(p) except ValueError: - pass + pass # Path is outside the agent root — skip it # Scan the agent's directory tree for files created by later # init pipeline steps (skills, presets, extensions) that @@ -665,7 +665,7 @@ def resolve_agent_pack( emb = AgentManifest.from_yaml(emb_file) overrides = f"embedded v{emb.version}" except AgentPackError: - pass + pass # Embedded manifest unreadable — skip override info return ResolvedPack( manifest=manifest, diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index e3d490ddf6..1a8676d572 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -967,7 +967,7 @@ def _mock_download( tracker.start(key) tracker.complete(key, "mocked") except Exception: - pass + pass # Tracker key may not exist — safe to ignore in mock flag = "--agent" if use_agent_flag else "--ai" args = [ @@ -983,7 +983,7 @@ def _mock_download( if agent in AGENT_SKILLS_MIGRATIONS: args.append("--ai-skills") except (ImportError, AttributeError): - pass + pass # AGENT_SKILLS_MIGRATIONS may not exist — proceed without --ai-skills with _patch( "specify_cli.download_and_extract_template", _mock_download, @@ -1151,7 +1151,7 @@ def test_setup_creates_commands_dir(self, agent, agent_projects): f"(skills migration removes commands)") return except (ImportError, AttributeError): - pass + pass # AGENT_SKILLS_MIGRATIONS unavailable — fall through to failure pytest.fail( f"Agent '{agent}': commands_dir " From 00117c50746cadbda271c6d20c671cbf02a148a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:32:46 +0000 Subject: [PATCH 14/24] =?UTF-8?q?feat:=20address=20all=2010=20code=20quali?= =?UTF-8?q?ty=20issues=20=E2=80=94=20ID=20validation,=20rollback,=20Defaul?= =?UTF-8?q?tBootstrap,=20logging,=20CLI=20fixes,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/40d5aec5-d8e9-4e3f-ae60-6cf67ff491f3 --- AGENTS.md | 50 ++++++++ src/specify_cli/__init__.py | 99 +++++++++++++--- src/specify_cli/agent_pack.py | 111 ++++++++++++++++-- src/specify_cli/core_pack/agents/__init__.py | 0 .../core_pack/agents/agy/__init__.py | 0 .../core_pack/agents/agy/bootstrap.py | 30 ----- .../core_pack/agents/amp/__init__.py | 0 .../core_pack/agents/amp/bootstrap.py | 30 ----- .../core_pack/agents/auggie/__init__.py | 0 .../core_pack/agents/auggie/bootstrap.py | 30 ----- .../core_pack/agents/bob/__init__.py | 0 .../core_pack/agents/bob/bootstrap.py | 30 ----- .../core_pack/agents/claude/__init__.py | 0 .../core_pack/agents/claude/bootstrap.py | 30 ----- .../core_pack/agents/codebuddy/__init__.py | 0 .../core_pack/agents/codebuddy/bootstrap.py | 30 ----- .../core_pack/agents/codex/__init__.py | 0 .../core_pack/agents/codex/bootstrap.py | 30 ----- .../core_pack/agents/copilot/__init__.py | 0 .../core_pack/agents/copilot/bootstrap.py | 30 ----- .../core_pack/agents/cursor-agent/__init__.py | 0 .../agents/cursor-agent/bootstrap.py | 30 ----- .../core_pack/agents/gemini/__init__.py | 0 .../core_pack/agents/gemini/bootstrap.py | 30 ----- .../core_pack/agents/iflow/__init__.py | 0 .../core_pack/agents/iflow/bootstrap.py | 30 ----- .../core_pack/agents/junie/__init__.py | 0 .../core_pack/agents/junie/bootstrap.py | 30 ----- .../core_pack/agents/kilocode/__init__.py | 0 .../core_pack/agents/kilocode/bootstrap.py | 30 ----- .../core_pack/agents/kimi/__init__.py | 0 .../core_pack/agents/kimi/bootstrap.py | 30 ----- .../core_pack/agents/kiro-cli/__init__.py | 0 .../core_pack/agents/kiro-cli/bootstrap.py | 30 ----- .../core_pack/agents/opencode/__init__.py | 0 .../core_pack/agents/opencode/bootstrap.py | 30 ----- .../core_pack/agents/pi/__init__.py | 0 .../core_pack/agents/pi/bootstrap.py | 30 ----- .../core_pack/agents/qodercli/__init__.py | 0 .../core_pack/agents/qodercli/bootstrap.py | 30 ----- .../core_pack/agents/qwen/__init__.py | 0 .../core_pack/agents/qwen/bootstrap.py | 30 ----- .../core_pack/agents/roo/__init__.py | 0 .../core_pack/agents/roo/bootstrap.py | 30 ----- .../core_pack/agents/shai/__init__.py | 0 .../core_pack/agents/shai/bootstrap.py | 30 ----- .../core_pack/agents/tabnine/__init__.py | 0 .../core_pack/agents/tabnine/bootstrap.py | 30 ----- .../core_pack/agents/trae/__init__.py | 0 .../core_pack/agents/trae/bootstrap.py | 30 ----- .../core_pack/agents/vibe/__init__.py | 0 .../core_pack/agents/vibe/bootstrap.py | 30 ----- .../core_pack/agents/windsurf/__init__.py | 0 .../core_pack/agents/windsurf/bootstrap.py | 30 ----- tests/test_agent_pack.py | 79 ++++++++++++- 55 files changed, 312 insertions(+), 777 deletions(-) delete mode 100644 src/specify_cli/core_pack/agents/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/agy/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/agy/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/amp/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/amp/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/auggie/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/auggie/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/bob/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/bob/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/claude/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/claude/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/codebuddy/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/codebuddy/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/codex/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/codex/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/copilot/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/copilot/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/cursor-agent/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/gemini/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/gemini/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/iflow/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/iflow/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/junie/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/junie/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/kilocode/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/kilocode/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/kimi/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/kimi/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/kiro-cli/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/opencode/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/opencode/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/pi/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/pi/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/qodercli/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/qodercli/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/qwen/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/qwen/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/roo/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/roo/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/shai/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/shai/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/tabnine/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/tabnine/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/trae/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/trae/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/vibe/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/vibe/bootstrap.py delete mode 100644 src/specify_cli/core_pack/agents/windsurf/__init__.py delete mode 100644 src/specify_cli/core_pack/agents/windsurf/bootstrap.py diff --git a/AGENTS.md b/AGENTS.md index a15e0bc4b7..791477d487 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -427,4 +427,54 @@ When adding new agents: --- +## Agent Pack System (new) + +The agent pack system is a declarative, self-contained replacement for the legacy `AGENT_CONFIG` + case/switch architecture. Each agent is defined by a `speckit-agent.yml` manifest and an optional `bootstrap.py` module. When `bootstrap.py` is absent, the built-in `DefaultBootstrap` class derives its directory layout from the manifest's `commands_dir` field. + +### `--agent` flag on `specify init` + +`specify init --agent ` uses the pack-based init flow instead of the legacy `--ai` flow. Both accept the same agent IDs, but `--agent` additionally enables installed-file tracking so that `specify agent switch` can cleanly tear down agent files later. + +```bash +specify init my-project --agent claude # Pack-based flow (with file tracking) +specify init --here --agent gemini --ai-skills # With skills +``` + +`--agent` and `--ai` are mutually exclusive. When `--agent` is used, `init-options.json` gains `"agent_pack": true`. + +### `specify agent` subcommands + +| Command | Description | +| ------------------------------- | ----------- | +| `specify agent list` | List all available agent packs | +| `specify agent list --installed`| List only agents installed in the current project | +| `specify agent info ` | Show detailed information about an agent pack | +| `specify agent switch ` | Switch the active agent (tears down old, sets up new) | +| `specify agent search [query]` | Search agents by name, ID, description, or tags | +| `specify agent validate ` | Validate an agent pack directory | +| `specify agent export ` | Export an agent pack for editing | +| `specify agent add ` | Install an agent pack from a local path | +| `specify agent remove ` | Remove a cached/override agent pack | + +### Pack resolution order + +Agent packs resolve by priority (highest first): +1. **User-level** (`~/.specify/agents//`) — applies to all projects +2. **Project-level** (`.specify/agents//`) — project-specific override +3. **Catalog cache** (downloaded via `specify agent add`) +4. **Embedded** (bundled in the specify-cli wheel) + +### Trust boundary + +Agent packs can include a `bootstrap.py` module that is dynamically imported and executed. Pack authors can run arbitrary code through this mechanism. Only install packs from trusted sources. The 4-level resolution stack means that placing a pack in any of the resolution directories causes its code to run when the agent is loaded. + +### Installed-file tracking + +When using `--agent`, all installed files are recorded in `.specify/agent-manifest-.json` with SHA-256 hashes. During `specify agent switch`, the CLI: +1. Checks for user-modified files before teardown +2. Prompts for confirmation if files were changed +3. Feeds tracked file lists into teardown for precise, file-level removal (directories are never deleted) + +--- + *This documentation should be updated whenever new agents are added to maintain accuracy and completeness.* diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f535401c2f..5a65e6d8a2 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2438,15 +2438,24 @@ def version(): @agent_app.command("list") def agent_list( - installed: bool = typer.Option(False, "--installed", help="Only show agents with local presence in the current project"), + installed: bool = typer.Option(False, "--installed", help="Only show agents that have files present in the current project"), ): """List available agent packs.""" - from .agent_pack import list_all_agents, list_embedded_agents + from .agent_pack import list_all_agents, list_embedded_agents, _manifest_path show_banner() project_path = Path.cwd() - agents = list_all_agents(project_path=project_path if installed else None) + agents = list_all_agents(project_path=project_path) + + if installed: + # Filter to only agents that have an install manifest in the + # current project, i.e. agents whose files are actually present. + agents = [ + a for a in agents + if _manifest_path(project_path, a.manifest.id).is_file() + ] + if not agents and not installed: agents_from_embedded = list_embedded_agents() if not agents_from_embedded: @@ -2454,7 +2463,13 @@ def agent_list( 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) + if not agents and installed: + console.print("[yellow]No agents are installed in the current project.[/yellow]") + console.print("[dim]Use 'specify init --agent ' or 'specify agent switch ' to install one.[/dim]") + raise typer.Exit(0) + + title = "Installed Agents" if installed else "Available Agent Packs" + table = Table(title=title, show_lines=False) table.add_column("ID", style="cyan", no_wrap=True) table.add_column("Name", style="white") table.add_column("Version", style="dim") @@ -2470,7 +2485,7 @@ def agent_list( 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]") + console.print(f"\n[dim]{len(agents)} agent(s) {'installed' if installed else 'available'}[/dim]") @agent_app.command("info") @@ -2638,6 +2653,11 @@ def agent_switch( console.print(f"[bold]Switching agent: {current_agent or '(none)'} → {agent_id}[/bold]") + # Snapshot tracked files before teardown so we can attempt rollback + # if the new agent's setup fails after teardown. + old_tracked_agent: dict[str, str] = {} + old_tracked_ext: dict[str, str] = {} + # Teardown current agent (best effort — may have been set up with old system) if current_agent: try: @@ -2655,8 +2675,8 @@ def agent_switch( 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} + old_tracked_agent, old_tracked_ext = get_tracked_files(project_path, current_agent) + all_files = {**old_tracked_agent, **old_tracked_ext} console.print(f" [dim]Tearing down {current_agent}...[/dim]") current_bootstrap.teardown( @@ -2675,18 +2695,52 @@ def agent_switch( shutil.rmtree(agent_dir) console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)") - # Setup new agent + # Setup new agent — with rollback on failure try: new_bootstrap = load_bootstrap(resolved.path, resolved.manifest) console.print(f" [dim]Setting up {agent_id}...[/dim]") agent_files = new_bootstrap.setup(project_path, script_type, options) console.print(f" [green]✓[/green] {agent_id} installed") - except AgentPackError as exc: + except (AgentPackError, Exception) as exc: console.print(f"[red]Error setting up {agent_id}:[/red] {exc}") + + # Attempt to restore the old agent so the project is not left + # in a broken state after teardown succeeded but setup failed. + if current_agent: + console.print(f"[yellow]Attempting to restore previous agent ({current_agent})...[/yellow]") + try: + rollback_resolved = resolve_agent_pack(current_agent, project_path=project_path) + rollback_bs = load_bootstrap(rollback_resolved.path, rollback_resolved.manifest) + rollback_files = rollback_bs.setup(project_path, script_type, options) + rollback_bs.finalize_setup( + project_path, + agent_files=rollback_files, + extension_files=list( + (project_path / p).resolve() + for p in old_tracked_ext + if (project_path / p).is_file() + ), + ) + console.print(f" [green]✓[/green] {current_agent} restored") + except Exception: + # Rollback also failed — mark error state in init-options + console.print( + f"[red]Rollback failed.[/red] " + f"The project may be in a broken state — " + f"run 'specify init --here --agent {current_agent}' to repair." + ) + options["agent_switch_error"] = ( + f"Switch to '{agent_id}' failed after teardown of " + f"'{current_agent}'. Restore manually." + ) + init_options_file.write_text( + json.dumps(options, indent=2), encoding="utf-8" + ) raise typer.Exit(1) # Update init options options["ai"] = agent_id + options.pop("agent_switch_error", None) # clear any previous error init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8") # Re-register extension commands for the new agent @@ -2761,7 +2815,12 @@ def _reregister_extension_commands(project_path: Path, agent_id: str) -> List[Pa ) if registered: reregistered += len(registered) - except Exception: + except Exception as exc: + import logging as _logging + _logging.getLogger(__name__).debug( + "Failed to re-register extension '%s' for agent '%s': %s", + ext_id, agent_id, exc, + ) continue # Collect files created by extension registration @@ -2879,6 +2938,7 @@ def agent_add( @agent_app.command("remove") def agent_remove( agent_id: str = typer.Argument(..., help="Agent pack ID to remove"), + force: bool = typer.Option(False, "--force", help="Skip confirmation prompts"), ): """Remove a cached/override agent pack. @@ -2896,12 +2956,23 @@ def agent_remove( removed = False - # Check user-level + # Check user-level — prompt because this affects all projects globally 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 + if not force: + console.print( + f"[yellow]User-level override for '{agent_id}' affects all projects globally.[/yellow]" + ) + if not typer.confirm("Remove this user-level override?"): + console.print("[dim]Skipped user-level override removal.[/dim]") + else: + shutil.rmtree(user_pack) + console.print(f"[green]✓[/green] Removed user-level override for '{agent_id}'") + removed = True + else: + 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 diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 360cf5b101..97c881ba95 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -17,6 +17,8 @@ import hashlib import importlib.util import json +import logging +import re import shutil from dataclasses import dataclass, field from pathlib import Path @@ -25,6 +27,29 @@ import yaml from platformdirs import user_data_path +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Agent ID validation +# --------------------------------------------------------------------------- + +#: Regex that every agent ID must match: lowercase alphanumeric + hyphens. +_AGENT_ID_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$") + + +def _validate_agent_id(agent_id: str) -> None: + """Raise ``PackResolutionError`` when *agent_id* is unsafe or malformed. + + Rejects IDs containing ``/``, ``..``, or characters outside ``[a-z0-9-]`` + to prevent path-traversal attacks through the resolution stack. + """ + if not agent_id or not _AGENT_ID_RE.match(agent_id): + raise PackResolutionError( + f"Invalid agent ID {agent_id!r} — " + "IDs must match [a-z0-9-] (lowercase alphanumeric and hyphens, " + "no leading/trailing hyphens)." + ) + # --------------------------------------------------------------------------- # Manifest schema @@ -242,7 +267,16 @@ def teardown( # -- helpers available to subclasses ------------------------------------ def agent_dir(self, project_path: Path) -> Path: - """Return the agent's top-level directory inside the project.""" + """Return the agent's top-level directory inside the project. + + Raises ``AgentPackError`` when the manifest's ``commands_dir`` is + empty, since the agent directory cannot be determined. + """ + if not self.manifest.commands_dir: + raise AgentPackError( + f"Agent '{self.manifest.id}' has an empty commands_dir — " + "cannot determine agent directory." + ) return project_path / self.manifest.commands_dir.split("/")[0] def collect_installed_files(self, project_path: Path) -> List[Path]: @@ -361,6 +395,53 @@ def finalize_setup( ) +class DefaultBootstrap(AgentBootstrap): + """Generic bootstrap that derives its directory layout from the manifest. + + This replaces the need for per-agent ``bootstrap.py`` files when the + agent follows the standard setup/teardown pattern — create the + commands directory, run the shared scaffold, and delegate teardown to + ``remove_tracked_files``. + + The ``AGENT_DIR`` and ``COMMANDS_SUBDIR`` class attributes are + computed from the manifest's ``commands_dir`` field (e.g. + ``".claude/commands"`` → ``AGENT_DIR=".claude"``, + ``COMMANDS_SUBDIR="commands"``). + """ + + def __init__(self, manifest: AgentManifest): + super().__init__(manifest) + parts = manifest.commands_dir.split("/") if manifest.commands_dir else [] + self.AGENT_DIR = parts[0] if parts else "" + self.COMMANDS_SUBDIR = parts[1] if len(parts) > 1 else "" + + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: + """Install agent files into the project using the standard scaffold.""" + if self.AGENT_DIR and self.COMMANDS_SUBDIR: + commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR + commands_dir.mkdir(parents=True, exist_ok=True) + return self._scaffold_project(project_path, script_type) + + def teardown( + self, + project_path: Path, + *, + force: bool = False, + files: Optional[Dict[str, str]] = None, + ) -> List[str]: + """Remove agent files from the project. + + Only removes individual tracked files — directories are never + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. + """ + return remove_tracked_files( + project_path, self.manifest.id, force=force, files=files + ) + + # --------------------------------------------------------------------------- # Installed-file tracking # --------------------------------------------------------------------------- @@ -626,8 +707,11 @@ def resolve_agent_pack( 3. Catalog-installed cache 4. Embedded in wheel - Raises ``PackResolutionError`` when no pack is found at any level. + Raises ``PackResolutionError`` when *agent_id* is invalid or when + no pack is found at any level. """ + _validate_agent_id(agent_id) + candidates: List[tuple[str, Path]] = [] # Priority 1 — user level @@ -763,15 +847,23 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: 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. + When a ``bootstrap.py`` exists, the module must define exactly one + public subclass of ``AgentBootstrap``. When it is absent the + :class:`DefaultBootstrap` is used instead — it derives its directory + layout from the manifest's ``commands_dir`` field. + + .. warning:: + **Trust boundary:** ``bootstrap.py`` modules are dynamically + imported and can execute arbitrary code. The 4-level resolution + stack (user → project → catalog → embedded) means that *any* + pack author whose pack is placed in one of these directories can + run code with the privileges of the current process. Only + install packs from trusted sources. """ bootstrap_file = pack_path / BOOTSTRAP_FILENAME if not bootstrap_file.is_file(): - raise AgentPackError( - f"Bootstrap module not found: {bootstrap_file}" - ) + # No bootstrap module — use the generic DefaultBootstrap + return DefaultBootstrap(manifest) spec = importlib.util.spec_from_file_location( f"speckit_agent_{manifest.id}_bootstrap", bootstrap_file @@ -790,6 +882,7 @@ def load_bootstrap(pack_path: Path, manifest: AgentManifest) -> AgentBootstrap: isinstance(obj, type) and issubclass(obj, AgentBootstrap) and obj is not AgentBootstrap + and obj is not DefaultBootstrap and not name.startswith("_") ) ] @@ -824,7 +917,7 @@ def validate_pack(pack_path: Path) -> List[str]: bootstrap_file = pack_path / BOOTSTRAP_FILENAME if not bootstrap_file.is_file(): - warnings.append(f"Missing {BOOTSTRAP_FILENAME} (pack cannot be bootstrapped)") + warnings.append(f"Missing {BOOTSTRAP_FILENAME} (DefaultBootstrap will be used)") if not manifest.commands_dir: warnings.append("command_registration.commands_dir not set in manifest") diff --git a/src/specify_cli/core_pack/agents/__init__.py b/src/specify_cli/core_pack/agents/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/agy/__init__.py b/src/specify_cli/core_pack/agents/agy/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py deleted file mode 100644 index b7b6ae9d71..0000000000 --- a/src/specify_cli/core_pack/agents/agy/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Antigravity agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Agy(AgentBootstrap): - """Bootstrap for Antigravity.""" - - AGENT_DIR = ".agent" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Antigravity agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Antigravity agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/amp/__init__.py b/src/specify_cli/core_pack/agents/amp/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py deleted file mode 100644 index da70993286..0000000000 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Amp agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Amp(AgentBootstrap): - """Bootstrap for Amp.""" - - AGENT_DIR = ".agents" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Amp agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Amp agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/auggie/__init__.py b/src/specify_cli/core_pack/agents/auggie/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py deleted file mode 100644 index 27f89a30f3..0000000000 --- a/src/specify_cli/core_pack/agents/auggie/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Auggie CLI agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Auggie(AgentBootstrap): - """Bootstrap for Auggie CLI.""" - - AGENT_DIR = ".augment" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Auggie CLI agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Auggie CLI agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/bob/__init__.py b/src/specify_cli/core_pack/agents/bob/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py deleted file mode 100644 index afdd3e058c..0000000000 --- a/src/specify_cli/core_pack/agents/bob/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for IBM Bob agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Bob(AgentBootstrap): - """Bootstrap for IBM Bob.""" - - AGENT_DIR = ".bob" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install IBM Bob agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove IBM Bob agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/claude/__init__.py b/src/specify_cli/core_pack/agents/claude/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py deleted file mode 100644 index e1b3fade9d..0000000000 --- a/src/specify_cli/core_pack/agents/claude/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Claude Code agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Claude(AgentBootstrap): - """Bootstrap for Claude Code.""" - - AGENT_DIR = ".claude" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Claude Code agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Claude Code agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/codebuddy/__init__.py b/src/specify_cli/core_pack/agents/codebuddy/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py deleted file mode 100644 index c054b5a966..0000000000 --- a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for CodeBuddy agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Codebuddy(AgentBootstrap): - """Bootstrap for CodeBuddy.""" - - AGENT_DIR = ".codebuddy" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install CodeBuddy agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove CodeBuddy agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/codex/__init__.py b/src/specify_cli/core_pack/agents/codex/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py deleted file mode 100644 index 05e9b500e4..0000000000 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Codex CLI agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Codex(AgentBootstrap): - """Bootstrap for Codex CLI.""" - - AGENT_DIR = ".agents" - COMMANDS_SUBDIR = "skills" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Codex CLI agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Codex CLI agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/copilot/__init__.py b/src/specify_cli/core_pack/agents/copilot/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py deleted file mode 100644 index cb5a2d4cba..0000000000 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for GitHub Copilot agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Copilot(AgentBootstrap): - """Bootstrap for GitHub Copilot.""" - - AGENT_DIR = ".github" - COMMANDS_SUBDIR = "agents" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install GitHub Copilot agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove GitHub Copilot agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/cursor-agent/__init__.py b/src/specify_cli/core_pack/agents/cursor-agent/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py deleted file mode 100644 index a30fb4e82c..0000000000 --- a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Cursor agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class CursorAgent(AgentBootstrap): - """Bootstrap for Cursor.""" - - AGENT_DIR = ".cursor" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Cursor agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Cursor agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/gemini/__init__.py b/src/specify_cli/core_pack/agents/gemini/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py deleted file mode 100644 index 92421aba88..0000000000 --- a/src/specify_cli/core_pack/agents/gemini/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Gemini CLI agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Gemini(AgentBootstrap): - """Bootstrap for Gemini CLI.""" - - AGENT_DIR = ".gemini" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Gemini CLI agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Gemini CLI agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/iflow/__init__.py b/src/specify_cli/core_pack/agents/iflow/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py deleted file mode 100644 index 520a3cba5b..0000000000 --- a/src/specify_cli/core_pack/agents/iflow/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for iFlow CLI agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Iflow(AgentBootstrap): - """Bootstrap for iFlow CLI.""" - - AGENT_DIR = ".iflow" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install iFlow CLI agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove iFlow CLI agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/junie/__init__.py b/src/specify_cli/core_pack/agents/junie/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py deleted file mode 100644 index f830bdfd76..0000000000 --- a/src/specify_cli/core_pack/agents/junie/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Junie agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Junie(AgentBootstrap): - """Bootstrap for Junie.""" - - AGENT_DIR = ".junie" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Junie agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Junie agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kilocode/__init__.py b/src/specify_cli/core_pack/agents/kilocode/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py deleted file mode 100644 index e41ee47773..0000000000 --- a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Kilo Code agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Kilocode(AgentBootstrap): - """Bootstrap for Kilo Code.""" - - AGENT_DIR = ".kilocode" - COMMANDS_SUBDIR = "workflows" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Kilo Code agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Kilo Code agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kimi/__init__.py b/src/specify_cli/core_pack/agents/kimi/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py deleted file mode 100644 index e4e6c71f98..0000000000 --- a/src/specify_cli/core_pack/agents/kimi/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Kimi Code agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Kimi(AgentBootstrap): - """Bootstrap for Kimi Code.""" - - AGENT_DIR = ".kimi" - COMMANDS_SUBDIR = "skills" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Kimi Code agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Kimi Code agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kiro-cli/__init__.py b/src/specify_cli/core_pack/agents/kiro-cli/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py deleted file mode 100644 index 756dcee586..0000000000 --- a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Kiro CLI agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class KiroCli(AgentBootstrap): - """Bootstrap for Kiro CLI.""" - - AGENT_DIR = ".kiro" - COMMANDS_SUBDIR = "prompts" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Kiro CLI agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Kiro CLI agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/opencode/__init__.py b/src/specify_cli/core_pack/agents/opencode/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py deleted file mode 100644 index a23b006f79..0000000000 --- a/src/specify_cli/core_pack/agents/opencode/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for opencode agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Opencode(AgentBootstrap): - """Bootstrap for opencode.""" - - AGENT_DIR = ".opencode" - COMMANDS_SUBDIR = "command" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install opencode agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove opencode agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/pi/__init__.py b/src/specify_cli/core_pack/agents/pi/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py deleted file mode 100644 index f63c8b08c7..0000000000 --- a/src/specify_cli/core_pack/agents/pi/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Pi Coding Agent agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Pi(AgentBootstrap): - """Bootstrap for Pi Coding Agent.""" - - AGENT_DIR = ".pi" - COMMANDS_SUBDIR = "prompts" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Pi Coding Agent agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Pi Coding Agent agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/qodercli/__init__.py b/src/specify_cli/core_pack/agents/qodercli/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py deleted file mode 100644 index 721205cd08..0000000000 --- a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Qoder CLI agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Qodercli(AgentBootstrap): - """Bootstrap for Qoder CLI.""" - - AGENT_DIR = ".qoder" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Qoder CLI agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Qoder CLI agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/qwen/__init__.py b/src/specify_cli/core_pack/agents/qwen/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py deleted file mode 100644 index 7688b8fe08..0000000000 --- a/src/specify_cli/core_pack/agents/qwen/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Qwen Code agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Qwen(AgentBootstrap): - """Bootstrap for Qwen Code.""" - - AGENT_DIR = ".qwen" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Qwen Code agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Qwen Code agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/roo/__init__.py b/src/specify_cli/core_pack/agents/roo/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py deleted file mode 100644 index e4416a95c6..0000000000 --- a/src/specify_cli/core_pack/agents/roo/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Roo Code agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Roo(AgentBootstrap): - """Bootstrap for Roo Code.""" - - AGENT_DIR = ".roo" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Roo Code agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Roo Code agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/shai/__init__.py b/src/specify_cli/core_pack/agents/shai/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py deleted file mode 100644 index 87880c8245..0000000000 --- a/src/specify_cli/core_pack/agents/shai/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for SHAI agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Shai(AgentBootstrap): - """Bootstrap for SHAI.""" - - AGENT_DIR = ".shai" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install SHAI agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove SHAI agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/tabnine/__init__.py b/src/specify_cli/core_pack/agents/tabnine/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py deleted file mode 100644 index fe6cc3c783..0000000000 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Tabnine CLI agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Tabnine(AgentBootstrap): - """Bootstrap for Tabnine CLI.""" - - AGENT_DIR = ".tabnine/agent" - COMMANDS_SUBDIR = "commands" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Tabnine CLI agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Tabnine CLI agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/trae/__init__.py b/src/specify_cli/core_pack/agents/trae/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py deleted file mode 100644 index 6c774fdd04..0000000000 --- a/src/specify_cli/core_pack/agents/trae/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Trae agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Trae(AgentBootstrap): - """Bootstrap for Trae.""" - - AGENT_DIR = ".trae" - COMMANDS_SUBDIR = "rules" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Trae agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Trae agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/vibe/__init__.py b/src/specify_cli/core_pack/agents/vibe/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py deleted file mode 100644 index 439974bb54..0000000000 --- a/src/specify_cli/core_pack/agents/vibe/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Mistral Vibe agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Vibe(AgentBootstrap): - """Bootstrap for Mistral Vibe.""" - - AGENT_DIR = ".vibe" - COMMANDS_SUBDIR = "prompts" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Mistral Vibe agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Mistral Vibe agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/windsurf/__init__.py b/src/specify_cli/core_pack/agents/windsurf/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py deleted file mode 100644 index 08b4fc80be..0000000000 --- a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bootstrap module for Windsurf agent pack.""" - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files - - -class Windsurf(AgentBootstrap): - """Bootstrap for Windsurf.""" - - AGENT_DIR = ".windsurf" - COMMANDS_SUBDIR = "workflows" - - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: - """Install Windsurf agent files into the project.""" - commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR - commands_dir.mkdir(parents=True, exist_ok=True) - return self._scaffold_project(project_path, script_type) - - def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: - """Remove Windsurf agent files from the project. - - Only removes individual tracked files — directories are never - deleted. When *files* is provided, exactly those files are - removed. Otherwise the install manifest is consulted and - ``AgentFileModifiedError`` is raised if any tracked file was - modified and *force* is ``False``. - """ - return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index 1a8676d572..e3b66057f5 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -255,10 +255,12 @@ def test_load_bootstrap(self, tmp_path): b = load_bootstrap(tmp_path, m) assert isinstance(b, AgentBootstrap) - def test_load_bootstrap_missing_file(self, tmp_path): + def test_load_bootstrap_missing_file_uses_default(self, tmp_path): + """When bootstrap.py is absent, DefaultBootstrap is returned.""" + from specify_cli.agent_pack import DefaultBootstrap m = AgentManifest.from_dict(_minimal_manifest_dict()) - with pytest.raises(AgentPackError, match="Bootstrap module not found"): - load_bootstrap(tmp_path, m) + b = load_bootstrap(tmp_path, m) + assert isinstance(b, DefaultBootstrap) def test_bootstrap_setup_and_teardown(self, tmp_path): """Verify a loaded bootstrap can set up and tear down via file tracking.""" @@ -315,6 +317,37 @@ def test_load_bootstrap_no_subclass(self, tmp_path): load_bootstrap(pack_dir, m) +class TestDefaultBootstrap: + """Verify the DefaultBootstrap class works for all embedded packs.""" + + def test_default_bootstrap_derives_dirs_from_manifest(self): + from specify_cli.agent_pack import DefaultBootstrap + data = _minimal_manifest_dict() + m = AgentManifest.from_dict(data) + b = DefaultBootstrap(m) + assert b.AGENT_DIR == ".test-agent" + assert b.COMMANDS_SUBDIR == "commands" + + def test_default_bootstrap_empty_commands_dir(self): + from specify_cli.agent_pack import DefaultBootstrap + data = _minimal_manifest_dict() + data["command_registration"]["commands_dir"] = "" + m = AgentManifest.from_dict(data) + b = DefaultBootstrap(m) + assert b.AGENT_DIR == "" + assert b.COMMANDS_SUBDIR == "" + + def test_agent_dir_raises_on_empty_commands_dir(self, tmp_path): + """agent_dir() raises AgentPackError when commands_dir is empty.""" + from specify_cli.agent_pack import DefaultBootstrap + data = _minimal_manifest_dict() + data["command_registration"]["commands_dir"] = "" + m = AgentManifest.from_dict(data) + b = DefaultBootstrap(m) + with pytest.raises(AgentPackError, match="empty commands_dir"): + b.agent_dir(tmp_path) + + # =================================================================== # Pack resolution # =================================================================== @@ -383,6 +416,43 @@ def test_catalog_overrides_embedded(self, tmp_path, monkeypatch): assert resolved.manifest.version == "2.0.0" +class TestAgentIdValidation: + """Verify that resolve_agent_pack rejects malicious/invalid IDs.""" + + @pytest.mark.parametrize("bad_id", [ + "../etc/passwd", + "foo/bar", + "agent..evil", + "UPPERCASE", + "has space", + "has_underscore", + "", + "-leading-hyphen", + "trailing-hyphen-", + "agent@evil", + ]) + def test_invalid_ids_rejected(self, bad_id): + with pytest.raises(PackResolutionError, match="Invalid agent ID"): + resolve_agent_pack(bad_id) + + @pytest.mark.parametrize("good_id", [ + "claude", + "cursor-agent", + "kiro-cli", + "a", + "a1", + "my-agent-2", + ]) + def test_valid_ids_accepted(self, good_id): + """Valid IDs pass validation (they may not resolve, but don't fail validation).""" + try: + resolve_agent_pack(good_id) + except PackResolutionError as exc: + # May fail because agent doesn't exist, but NOT because of + # invalid ID. + assert "Invalid agent ID" not in str(exc) + + # =================================================================== # List and discovery # =================================================================== @@ -466,7 +536,8 @@ 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() + # bootstrap.py is optional — DefaultBootstrap handles + # agents without one. # =================================================================== From ab8c58ff23bf36551e1df8332a2d969a72b2da59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:35:15 +0000 Subject: [PATCH 15/24] fix: improve test match specificity and rollback error message per code review Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/40d5aec5-d8e9-4e3f-ae60-6cf67ff491f3 --- src/specify_cli/__init__.py | 3 ++- tests/test_agent_pack.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5a65e6d8a2..86252fc066 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2731,7 +2731,8 @@ def agent_switch( ) options["agent_switch_error"] = ( f"Switch to '{agent_id}' failed after teardown of " - f"'{current_agent}'. Restore manually." + f"'{current_agent}'. Run 'specify init --here --agent " + f"{current_agent}' to restore." ) init_options_file.write_text( json.dumps(options, indent=2), encoding="utf-8" diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index e3b66057f5..ff258a0002 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -344,7 +344,7 @@ def test_agent_dir_raises_on_empty_commands_dir(self, tmp_path): data["command_registration"]["commands_dir"] = "" m = AgentManifest.from_dict(data) b = DefaultBootstrap(m) - with pytest.raises(AgentPackError, match="empty commands_dir"): + with pytest.raises(AgentPackError, match=f"Agent '{m.id}' has an empty commands_dir"): b.agent_dir(tmp_path) From b94e5412341191c49bfc8a8e38b2a29c714483b9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:28:15 -0500 Subject: [PATCH 16/24] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20legacy=20teardown,=20generic=20agent,=20~/.specify=20paths,?= =?UTF-8?q?=203-segment=20commands=5Fdir,=20full=20file=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Legacy --ai teardown: detect empty tracked files and fall back to AGENT_CONFIG-based directory removal during agent switch - --agent generic: falls through to legacy flow (no embedded pack) - User/catalog dirs: use ~/.specify/ instead of platformdirs for consistency with extensions/presets - DefaultBootstrap: join all path segments after first for COMMANDS_SUBDIR (fixes 3+-segment commands_dir like .tabnine/agent/commands) - agent_add --from: validate manifest.id matches provided agent_id - finalize_setup: track all files from setup(), not just agent-root files - setup() docstring: reference --agent not --ai - AGENTS.md: document generic agent fallback behavior --- AGENTS.md | 2 +- src/specify_cli/__init__.py | 43 ++++++++++++++++++++++++++-------- src/specify_cli/agent_pack.py | 44 +++++++++++------------------------ 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 791477d487..6fc9303f1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -440,7 +440,7 @@ specify init my-project --agent claude # Pack-based flow (with file tra specify init --here --agent gemini --ai-skills # With skills ``` -`--agent` and `--ai` are mutually exclusive. When `--agent` is used, `init-options.json` gains `"agent_pack": true`. +`--agent` and `--ai` are mutually exclusive. When `--agent` is used, `init-options.json` gains `"agent_pack": true`. The `generic` agent (which requires `--ai-commands-dir`) falls through to the legacy flow since it has no embedded pack. ### `specify agent` subcommands diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 86252fc066..3918963c85 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 (enables file tracking for clean teardown when switching agents). 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. Use --ai generic for custom agent directories."), 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"), @@ -1784,7 +1784,10 @@ def init( 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 + # "generic" uses --ai-commands-dir and has no embedded pack, + # so it falls through to the legacy flow. + if agent != "generic": + use_agent_pack = True # Detect when option values are likely misinterpreted flags (parameter ordering issue) if ai_assistant and ai_assistant.startswith("--"): @@ -2678,15 +2681,28 @@ def agent_switch( old_tracked_agent, old_tracked_ext = get_tracked_files(project_path, current_agent) all_files = {**old_tracked_agent, **old_tracked_ext} - console.print(f" [dim]Tearing down {current_agent}...[/dim]") - current_bootstrap.teardown( - project_path, - force=True, # already confirmed above - files=all_files if all_files else None, - ) - console.print(f" [green]✓[/green] {current_agent} removed") + if all_files: + console.print(f" [dim]Tearing down {current_agent}...[/dim]") + current_bootstrap.teardown( + project_path, + force=True, # already confirmed above + files=all_files, + ) + console.print(f" [green]✓[/green] {current_agent} removed") + else: + # No install manifest (legacy --ai project) — fall back + # to removing the agent directory 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)") + else: + console.print(f" [yellow]Warning:[/yellow] No tracked files or AGENT_CONFIG entry for '{current_agent}' — skipping teardown") except AgentPackError: - # If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG + # If pack-based resolution/load fails, try legacy cleanup via AGENT_CONFIG agent_config = AGENT_CONFIG.get(current_agent, {}) agent_folder = agent_config.get("folder") if agent_folder: @@ -2925,6 +2941,13 @@ def agent_add( console.print(f"[red]Validation failed:[/red] {exc}") raise typer.Exit(1) + if manifest.id != agent_id: + console.print( + f"[red]Error:[/red] Manifest ID '{manifest.id}' does not match " + f"the specified agent ID '{agent_id}'." + ) + 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) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 97c881ba95..144a1a6e29 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -25,7 +25,6 @@ from typing import Any, Dict, List, Optional import yaml -from platformdirs import user_data_path logger = logging.getLogger(__name__) @@ -212,8 +211,10 @@ def __init__(self, manifest: AgentManifest): 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 ``. + This is invoked by ``specify init --agent `` and + ``specify agent switch ``. The legacy ``--ai`` flag + uses the old non-pack bootstrap flow and does not call this + method. Implementations **must** return every file they create so that the CLI can record both agent-installed files and extension-installed @@ -340,37 +341,20 @@ def finalize_setup( This must be called **after** the full init pipeline has finished writing files (commands, context files, extensions) into the - project. It combines the files reported by :meth:`setup` with - any extra files (e.g. from extension registration), scans the - agent's directory tree for anything additional, and writes the - install manifest. + project. It records every file reported by :meth:`setup` plus + any extra files (e.g. from extension registration) and scans + the agent's directory tree for anything additional. - ``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. + All files returned by ``setup()`` are tracked — including shared + project infrastructure — so that teardown/switch can precisely + remove everything the agent installed. Args: agent_files: Files reported by :meth:`setup`. extension_files: Files created by extension registration. """ all_extension = list(extension_files or []) - - # Filter agent_files: only keep files under the agent's directory - # tree. setup() returns *all* scaffolded files (including shared - # project infrastructure in .specify/) but only agent-owned files - # should be tracked per-agent — shared files are not removed - # during teardown/switch. - agent_root = self.agent_dir(project_path) - agent_root_resolved = agent_root.resolve() - all_agent: List[Path] = [] - for p in (agent_files or []): - try: - p.resolve().relative_to(agent_root_resolved) - all_agent.append(p) - except ValueError: - pass # Path is outside the agent root — skip it + all_agent: List[Path] = list(agent_files or []) # Scan the agent's directory tree for files created by later # init pipeline steps (skills, presets, extensions) that @@ -413,7 +397,7 @@ def __init__(self, manifest: AgentManifest): super().__init__(manifest) parts = manifest.commands_dir.split("/") if manifest.commands_dir else [] self.AGENT_DIR = parts[0] if parts else "" - self.COMMANDS_SUBDIR = parts[1] if len(parts) > 1 else "" + self.COMMANDS_SUBDIR = "/".join(parts[1:]) if len(parts) > 1 else "" def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install agent files into the project using the standard scaffold.""" @@ -673,7 +657,7 @@ def _embedded_agents_dir() -> Path: def _user_agents_dir() -> Path: """Return the user-level agent overrides directory.""" - return user_data_path("specify", "github") / "agents" + return Path.home() / ".specify" / "agents" def _project_agents_dir(project_path: Path) -> Path: @@ -683,7 +667,7 @@ def _project_agents_dir(project_path: Path) -> Path: def _catalog_agents_dir() -> Path: """Return the catalog-installed agent cache directory.""" - return user_data_path("specify", "github") / "agent-cache" + return Path.home() / ".specify" / "agent-cache" @dataclass From 790448294e37c1e58d3daa20907ddcaf55e6dca6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:58:58 -0500 Subject: [PATCH 17/24] =?UTF-8?q?fix:=20address=20PR=20review=20round=202?= =?UTF-8?q?=20=E2=80=94=20legacy=20rmtree=20confirmation,=20agent=5Fpack?= =?UTF-8?q?=20flag,=20registrar=20alias,=20manifest=20ID=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Legacy rmtree: prompt user before deleting agent directory in legacy fallback path (both no-manifest and AgentPackError cases), respects --force - Set options['agent_pack'] = True during agent_switch so projects originally created with --ai reflect pack-based management after switch - Add cursor-agent alias in CommandRegistrar.AGENT_CONFIGS so extension re-registration works when switching to/from cursor-agent - Validate manifest.id matches agent_id in resolve_agent_pack() to prevent malicious override packs from injecting different IDs --- src/specify_cli/__init__.py | 13 +++++++++++++ src/specify_cli/agent_pack.py | 8 ++++++++ src/specify_cli/agents.py | 6 ++++++ 3 files changed, 27 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 3918963c85..5282b19801 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2697,6 +2697,12 @@ def agent_switch( if agent_folder: agent_dir = project_path / agent_folder.rstrip("/") if agent_dir.is_dir(): + if not force: + console.print(f"[yellow]No install manifest found for '{current_agent}' (legacy project).[/yellow]") + console.print(f" Directory to remove: {agent_dir}") + if not typer.confirm("Remove this directory?"): + console.print("[dim]Aborted. Use --force to skip this check.[/dim]") + raise typer.Exit(0) shutil.rmtree(agent_dir) console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)") else: @@ -2708,6 +2714,12 @@ def agent_switch( if agent_folder: agent_dir = project_path / agent_folder.rstrip("/") if agent_dir.is_dir(): + if not force: + console.print(f"[yellow]No agent pack found for '{current_agent}' (legacy project).[/yellow]") + console.print(f" Directory to remove: {agent_dir}") + if not typer.confirm("Remove this directory?"): + console.print("[dim]Aborted. Use --force to skip this check.[/dim]") + raise typer.Exit(0) shutil.rmtree(agent_dir) console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)") @@ -2757,6 +2769,7 @@ def agent_switch( # Update init options options["ai"] = agent_id + options["agent_pack"] = True options.pop("agent_switch_error", None) # clear any previous error init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8") diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 144a1a6e29..78f6de88db 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -721,6 +721,14 @@ def resolve_agent_pack( manifest_file = pack_dir / MANIFEST_FILENAME if manifest_file.is_file(): manifest = AgentManifest.from_yaml(manifest_file) + # Verify the manifest's declared ID matches the requested + # agent_id to prevent a malicious override from injecting + # a different ID (used for file paths and module names). + if manifest.id != agent_id: + raise PackResolutionError( + f"Agent pack manifest ID '{manifest.id}' does not match " + f"requested ID '{agent_id}' (in {pack_dir})." + ) if source == "embedded": embedded_manifest = manifest diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4f1ab728fb..4165e3f3d4 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -47,6 +47,12 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".md" }, + "cursor-agent": { + "dir": ".cursor/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, "qwen": { "dir": ".qwen/commands", "format": "markdown", From 34fa61e1cceca4ddde0537aeb73b21b558a433d0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:00:42 -0500 Subject: [PATCH 18/24] docs: clarify that tracking all setup files is intentional (safe due to hash check) --- src/specify_cli/agent_pack.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 78f6de88db..5e24c2ee2e 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -347,13 +347,20 @@ def finalize_setup( All files returned by ``setup()`` are tracked — including shared project infrastructure — so that teardown/switch can precisely - remove everything the agent installed. + remove everything the agent installed. This is intentional: + ``remove_tracked_files()`` only deletes files whose SHA-256 + hash still matches the original, so user-modified files are + always preserved (unless ``--force`` is used). Args: agent_files: Files reported by :meth:`setup`. extension_files: Files created by extension registration. """ all_extension = list(extension_files or []) + # Track ALL files returned by setup(), not just those under the + # agent's directory tree. This is safe because teardown only + # removes files that are unmodified (hash check) and prompts + # for confirmation on modified files. all_agent: List[Path] = list(agent_files or []) # Scan the agent's directory tree for files created by later From 48392ea865a224b9b02d2b344da6e4f78365d7cd Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:24:53 -0500 Subject: [PATCH 19/24] fix: hash-check before deletion, track all files, fix overrides bug, update help text - remove_tracked_files: always compare SHA-256 hash before deleting, even when called with explicit files dict; skip modified files unless --force is set (was unconditionally deleting all tracked files) - finalize_setup: track ALL files from setup() (no agent-root filter); safe because removal now checks hashes - list_all_agents: track embedded versions in separate dict so overrides always reference the correct embedded version, not a catalog/project pack that overwrote the seen dict - --ai-skills help text: updated to say 'requires --ai or --agent' --- src/specify_cli/__init__.py | 2 +- src/specify_cli/agent_pack.py | 32 +++++++++++++++++++------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5282b19801..ea66463342 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1725,7 +1725,7 @@ def init( skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), - ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai or --agent)"), offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 5e24c2ee2e..2530ef4549 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -346,21 +346,17 @@ def finalize_setup( the agent's directory tree for anything additional. All files returned by ``setup()`` are tracked — including shared - project infrastructure — so that teardown/switch can precisely - remove everything the agent installed. This is intentional: - ``remove_tracked_files()`` only deletes files whose SHA-256 - hash still matches the original, so user-modified files are - always preserved (unless ``--force`` is used). + project infrastructure — so that teardown/switch can detect + modifications. ``remove_tracked_files()`` compares SHA-256 + hashes before deleting and will only remove files whose hash + still matches, preserving any user-modified files (unless + ``--force`` is used). Args: agent_files: Files reported by :meth:`setup`. extension_files: Files created by extension registration. """ all_extension = list(extension_files or []) - # Track ALL files returned by setup(), not just those under the - # agent's directory tree. This is safe because teardown only - # removes files that are unmodified (hash check) and prompts - # for confirmation on modified files. all_agent: List[Path] = list(agent_files or []) # Scan the agent's directory tree for files created by later @@ -641,9 +637,13 @@ def remove_tracked_files( ) removed: List[str] = [] - for rel_path in entries: + for rel_path, original_hash in entries.items(): abs_path = project_path / rel_path if abs_path.is_file(): + if original_hash and _sha256(abs_path) != original_hash: + # File was modified since installation — skip unless forced + if not force: + continue abs_path.unlink() removed.append(rel_path) @@ -792,6 +792,11 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: """ seen: dict[str, ResolvedPack] = {} + # Track embedded versions separately so overrides can accurately + # reference what they replace, even after catalog/project/user + # packs have overwritten the seen dict entry. + embedded_versions: dict[str, str] = {} + # Start from lowest priority (embedded) so higher priorities overwrite for manifest in list_embedded_agents(): seen[manifest.id] = ResolvedPack( @@ -799,6 +804,7 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: source="embedded", path=manifest.pack_path or _embedded_agents_dir() / manifest.id, ) + embedded_versions[manifest.id] = manifest.version # Catalog cache catalog_dir = _catalog_agents_dir() @@ -808,7 +814,7 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: 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 + overrides = f"embedded v{embedded_versions[m.id]}" if m.id in embedded_versions else None seen[m.id] = ResolvedPack(manifest=m, source="catalog", path=child, overrides=overrides) except AgentPackError: continue @@ -822,7 +828,7 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: 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 + overrides = f"embedded v{embedded_versions[m.id]}" if m.id in embedded_versions else None seen[m.id] = ResolvedPack(manifest=m, source="project", path=child, overrides=overrides) except AgentPackError: continue @@ -835,7 +841,7 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: 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 + overrides = f"embedded v{embedded_versions[m.id]}" if m.id in embedded_versions else None seen[m.id] = ResolvedPack(manifest=m, source="user", path=child, overrides=overrides) except AgentPackError: continue From 720ac509d20772e3eb1b3379051b375db9eb9d59 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:56:50 -0500 Subject: [PATCH 20/24] fix: path traversal guard, rollback extension re-registration, lifecycle docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove_tracked_files: validate resolved path stays within project_path before unlinking; reject entries with '../' that escape the project root - Rollback: call _reregister_extension_commands() during rollback (same as success path) so extension files are properly restored - AgentBootstrap: comprehensive lifecycle flow docstring documenting the setup → finalize_setup → get_tracked_files → check_modified → teardown chain and explaining why tracking all files is safe (hash check) --- src/specify_cli/__init__.py | 8 +++----- src/specify_cli/agent_pack.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ea66463342..e9e6e10525 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2740,14 +2740,12 @@ def agent_switch( rollback_resolved = resolve_agent_pack(current_agent, project_path=project_path) rollback_bs = load_bootstrap(rollback_resolved.path, rollback_resolved.manifest) rollback_files = rollback_bs.setup(project_path, script_type, options) + # Re-register extension commands (same as the success path) + rollback_ext_files = _reregister_extension_commands(project_path, current_agent) rollback_bs.finalize_setup( project_path, agent_files=rollback_files, - extension_files=list( - (project_path / p).resolve() - for p in old_tracked_ext - if (project_path / p).is_file() - ), + extension_files=rollback_ext_files, ) console.print(f" [green]✓[/green] {current_agent} restored") except Exception: diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 2530ef4549..aed4d840a2 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -200,6 +200,24 @@ class AgentBootstrap: Subclasses override :meth:`setup` and :meth:`teardown` to define agent-specific lifecycle operations. + + **Lifecycle flow (setup → tracking → teardown):** + + 1. ``setup()`` installs files and returns **every** file it created + (agent commands, shared scripts, templates, etc.). + 2. The CLI calls ``finalize_setup(agent_files, extension_files)`` + which SHA-256 hashes each file and writes the manifest at + ``.specify/agent-manifest-.json``. + 3. During switch/remove, the CLI reads the manifest via + ``get_tracked_files()`` and calls ``check_modified_files()`` + to detect changes. Modified files are listed and the user + is prompted for confirmation. + 4. ``teardown()`` delegates to ``remove_tracked_files()`` which + **compares hashes before deleting** — only files whose + SHA-256 still matches the original are removed. Modified + files are preserved unless ``--force`` is used. This makes + it safe to track all files (including shared project + infrastructure) without risk of deleting user work. """ def __init__(self, manifest: AgentManifest): @@ -637,8 +655,16 @@ def remove_tracked_files( ) removed: List[str] = [] + project_root = project_path.resolve() for rel_path, original_hash in entries.items(): abs_path = project_path / rel_path + # Guard against path traversal: reject entries that resolve + # outside the project directory (e.g. via "../" in a + # tampered manifest). + try: + abs_path.resolve().relative_to(project_root) + except ValueError: + continue if abs_path.is_file(): if original_hash and _sha256(abs_path) != original_hash: # File was modified since installation — skip unless forced From 433502b72d2b21955846c913c02f8de0d67d0f51 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:17:19 -0500 Subject: [PATCH 21/24] fix: force flag passthrough, cross-platform hashes, manifest retention, docstring accuracy - agent_switch: pass force=force (user's actual flag) instead of force=True so hash-check protection is preserved for unconfirmed files - _hash_file_list: use as_posix() for POSIX-stable manifest keys; guard relative_to with try/except to skip files outside project root - remove_tracked_files: updated docstring to accurately describe hash comparison behavior (values ARE used, not ignored); manifest is only deleted when all tracked files were removed (preserves tracking of skipped modified files) --- src/specify_cli/__init__.py | 2 +- src/specify_cli/agent_pack.py | 42 ++++++++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e9e6e10525..ea432804b3 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2685,7 +2685,7 @@ def agent_switch( console.print(f" [dim]Tearing down {current_agent}...[/dim]") current_bootstrap.teardown( project_path, - force=True, # already confirmed above + force=force, files=all_files, ) console.print(f" [green]✓[/green] {current_agent} removed") diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index aed4d840a2..4786c6fe9b 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -469,13 +469,23 @@ 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.""" + """Build a {relative_path: sha256} dict from a list of file paths. + + Uses POSIX-style separators for stable cross-platform manifests. + Silently skips files that are not under *project_path*. + """ entries: Dict[str, str] = {} + project_root = project_path.resolve() 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) + if not abs_path.is_file(): + continue + try: + rel = abs_path.resolve().relative_to(project_root) + except ValueError: + # File is outside the project root — skip it + continue + entries[rel.as_posix()] = _sha256(abs_path) return entries @@ -607,9 +617,11 @@ def remove_tracked_files( ) -> List[str]: """Remove individual tracked files. - If *files* is provided, exactly those files are removed (the values - are ignored but accepted for forward compatibility). Otherwise the - install manifest for *agent_id* is read. + If *files* is provided, those files are candidates for removal. + Each file's current SHA-256 is compared against the recorded hash; + files whose hash no longer matches (i.e. user-modified) are skipped + unless *force* is ``True``. When *files* is ``None``, the install + manifest for *agent_id* is read instead. Raises :class:`AgentFileModifiedError` if any tracked file was modified and *force* is ``False`` (only when reading from the @@ -618,12 +630,16 @@ def remove_tracked_files( Directories are **never** deleted — only individual files. + The install manifest is only deleted when every tracked file was + successfully removed. If some files were skipped (modified), the + manifest is preserved so they remain tracked. + Args: project_path: Project root directory. agent_id: Agent identifier. force: When ``True``, delete even modified files. - files: Explicit mapping of project-relative path → hash. When - supplied, the install manifest is not consulted. + files: Mapping of project-relative path → SHA-256 hash. + When supplied, the install manifest is not consulted. Returns: List of project-relative paths that were removed. @@ -673,9 +689,13 @@ def remove_tracked_files( abs_path.unlink() removed.append(rel_path) - # Clean up the install manifest itself + # Clean up the install manifest only when all tracked files were + # removed. If some were skipped (modified), keep the manifest so + # those files remain tracked for future teardown attempts. if manifest_file.is_file(): - manifest_file.unlink(missing_ok=True) + remaining = len(entries) - len(removed) + if remaining == 0: + manifest_file.unlink(missing_ok=True) return removed From ca9c73da0f82af2b22fb1ea6c3140f9a858708a5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:18:57 -0500 Subject: [PATCH 22/24] docs: explain extension file overlap in finalize_setup scan comment --- src/specify_cli/agent_pack.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 4786c6fe9b..7899fba6e3 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -383,6 +383,12 @@ def finalize_setup( # (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). + # + # Note: extension command files live under agent_root too, so + # they may appear in both agent_files and extension_files. + # This is harmless — during teardown the two dicts are merged + # by key ({**agent, **ext}), so duplicates are deduplicated + # and the hash is identical either way. if self.manifest.commands_dir: agent_root = self.agent_dir(project_path) if agent_root.is_dir(): From 38ae7595685ae9a216f531a538066af0aadd7dfe Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:44:25 -0500 Subject: [PATCH 23/24] fix: stale manifest cleanup, resolve with project_path, AGENTS.md add docs - remove_tracked_files: count only still-existing files as remaining; user-deleted files no longer prevent manifest cleanup - init --agent: pass project_path to resolve_agent_pack so project-level overrides (.specify/agents/) are honored during --here init - AGENTS.md: update agent add to show --from requirement and note catalog fetch is not yet implemented --- AGENTS.md | 4 +++- src/specify_cli/__init__.py | 2 +- src/specify_cli/agent_pack.py | 15 ++++++++++----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6fc9303f1d..664418dea2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -453,9 +453,11 @@ specify init --here --agent gemini --ai-skills # With skills | `specify agent search [query]` | Search agents by name, ID, description, or tags | | `specify agent validate ` | Validate an agent pack directory | | `specify agent export ` | Export an agent pack for editing | -| `specify agent add ` | Install an agent pack from a local path | +| `specify agent add --from ` | Install an agent pack from a local directory | | `specify agent remove ` | Remove a cached/override agent pack | +> **Note:** `specify agent add ` without `--from ` is reserved for future catalog-based installation, which is not yet implemented. + ### Pack resolution order Agent packs resolve by priority (highest first): diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ea432804b3..f4378e7435 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1877,7 +1877,7 @@ def init( if use_agent_pack: from .agent_pack import resolve_agent_pack, load_bootstrap, PackResolutionError, AgentPackError try: - resolved = resolve_agent_pack(selected_ai) + resolved = resolve_agent_pack(selected_ai, project_path=project_path) 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: diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 7899fba6e3..d77404bf2e 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -695,12 +695,17 @@ def remove_tracked_files( abs_path.unlink() removed.append(rel_path) - # Clean up the install manifest only when all tracked files were - # removed. If some were skipped (modified), keep the manifest so - # those files remain tracked for future teardown attempts. + # Clean up the install manifest only when no tracked files remain + # on disk. Files already deleted by the user count as gone, not + # as "remaining" — only files that still exist and were skipped + # (e.g. modified without --force) prevent manifest cleanup. if manifest_file.is_file(): - remaining = len(entries) - len(removed) - if remaining == 0: + still_on_disk = sum( + 1 for rel_path in entries + if (project_path / rel_path).is_file() + and rel_path not in removed + ) + if still_on_disk == 0: manifest_file.unlink(missing_ok=True) return removed From b59b82813cf484f02cf8b9ad281f548600912f2d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:15:28 -0500 Subject: [PATCH 24/24] fix: only delete manifest when full tracked set was processed When remove_tracked_files is called with an explicit files dict (subset), skip manifest deletion to avoid losing tracking of entries not in the subset. Manifest cleanup only runs when the full set is read from the manifest itself (files=None). --- src/specify_cli/agent_pack.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index d77404bf2e..c84e8f3727 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -695,11 +695,12 @@ def remove_tracked_files( abs_path.unlink() removed.append(rel_path) - # Clean up the install manifest only when no tracked files remain - # on disk. Files already deleted by the user count as gone, not - # as "remaining" — only files that still exist and were skipped - # (e.g. modified without --force) prevent manifest cleanup. - if manifest_file.is_file(): + # Clean up the install manifest only when the full tracked set was + # processed (i.e. read from the manifest itself). When an explicit + # ``files`` dict is provided the caller may be passing a subset + # (e.g. only agent_files), so deleting the manifest could lose + # tracking of entries not in the subset. + if files is None and manifest_file.is_file(): still_on_disk = sum( 1 for rel_path in entries if (project_path / rel_path).is_file()