diff --git a/AGENTS.md b/AGENTS.md index a15e0bc4..791477d4 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 f535401c..5a65e6d8 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2438,15 +2438,24 @@ 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"), + 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 360cf5b1..97c881ba 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -17,6 +17,8 @@ The embedded packs ship inside the pip wheel so that 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 @@ from typing import Any, Dict, List, Optional 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 @@ class AgentBootstrap: # -- 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 @@ class AgentBootstrap: ) +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 e69de29b..00000000 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 e69de29b..00000000 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 b7b6ae9d..00000000 --- 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 e69de29b..00000000 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 da709932..00000000 --- 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 e69de29b..00000000 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 27f89a30..00000000 --- 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 e69de29b..00000000 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 afdd3e05..00000000 --- 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 e69de29b..00000000 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 e1b3fade..00000000 --- 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 e69de29b..00000000 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 c054b5a9..00000000 --- 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 e69de29b..00000000 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 05e9b500..00000000 --- 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 e69de29b..00000000 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 cb5a2d4c..00000000 --- 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 e69de29b..00000000 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 a30fb4e8..00000000 --- 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 e69de29b..00000000 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 92421aba..00000000 --- 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 e69de29b..00000000 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 520a3cba..00000000 --- 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 e69de29b..00000000 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 f830bdfd..00000000 --- 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 e69de29b..00000000 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 e41ee477..00000000 --- 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 e69de29b..00000000 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 e4e6c71f..00000000 --- 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 e69de29b..00000000 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 756dcee5..00000000 --- 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 e69de29b..00000000 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 a23b006f..00000000 --- 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 e69de29b..00000000 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 f63c8b08..00000000 --- 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 e69de29b..00000000 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 721205cd..00000000 --- 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 e69de29b..00000000 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 7688b8fe..00000000 --- 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 e69de29b..00000000 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 e4416a95..00000000 --- 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 e69de29b..00000000 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 87880c82..00000000 --- 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 e69de29b..00000000 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 fe6cc3c7..00000000 --- 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 e69de29b..00000000 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 6c774fdd..00000000 --- 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 e69de29b..00000000 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 439974bb..00000000 --- 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 e69de29b..00000000 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 08b4fc80..00000000 --- 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 1a8676d5..e3b66057 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -255,10 +255,12 @@ class TestBootstrapContract: 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 @@ class TestBootstrapContract: 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 @@ class TestResolutionOrder: 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 @@ class TestExportPack: 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. # ===================================================================