feat: address all 10 code quality issues — ID validation, rollback, DefaultBootstrap, logging, CLI fixes, docs

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
This commit is contained in:
copilot-swe-agent[bot]
2026-03-23 14:32:46 +00:00
committed by GitHub
parent 795f1e7703
commit 00117c5074
55 changed files with 312 additions and 777 deletions

View File

@@ -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 <id>` 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 <id>` | Show detailed information about an agent pack |
| `specify agent switch <id>` | 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 <path>` | Validate an agent pack directory |
| `specify agent export <id>` | Export an agent pack for editing |
| `specify agent add <id>` | Install an agent pack from a local path |
| `specify agent remove <id>` | Remove a cached/override agent pack |
### Pack resolution order
Agent packs resolve by priority (highest first):
1. **User-level** (`~/.specify/agents/<id>/`) — applies to all projects
2. **Project-level** (`.specify/agents/<id>/`) — 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-<id>.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.* *This documentation should be updated whenever new agents are added to maintain accuracy and completeness.*

View File

@@ -2438,15 +2438,24 @@ app.add_typer(agent_app, name="agent")
@agent_app.command("list") @agent_app.command("list")
def agent_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.""" """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() show_banner()
project_path = Path.cwd() 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: if not agents and not installed:
agents_from_embedded = list_embedded_agents() agents_from_embedded = list_embedded_agents()
if not agents_from_embedded: 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]") console.print("[dim]Agent packs are embedded in the specify-cli wheel.[/dim]")
raise typer.Exit(0) 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 <id>' or 'specify agent switch <id>' 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("ID", style="cyan", no_wrap=True)
table.add_column("Name", style="white") table.add_column("Name", style="white")
table.add_column("Version", style="dim") 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) table.add_row(m.id, m.name, m.version, source_display, cli_marker)
console.print(table) 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") @agent_app.command("info")
@@ -2638,6 +2653,11 @@ def agent_switch(
console.print(f"[bold]Switching agent: {current_agent or '(none)'}{agent_id}[/bold]") 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) # Teardown current agent (best effort — may have been set up with old system)
if current_agent: if current_agent:
try: try:
@@ -2655,8 +2675,8 @@ def agent_switch(
raise typer.Exit(0) raise typer.Exit(0)
# Retrieve tracked file lists and feed them into teardown # Retrieve tracked file lists and feed them into teardown
agent_files, extension_files = get_tracked_files(project_path, current_agent) old_tracked_agent, old_tracked_ext = get_tracked_files(project_path, current_agent)
all_files = {**agent_files, **extension_files} all_files = {**old_tracked_agent, **old_tracked_ext}
console.print(f" [dim]Tearing down {current_agent}...[/dim]") console.print(f" [dim]Tearing down {current_agent}...[/dim]")
current_bootstrap.teardown( current_bootstrap.teardown(
@@ -2675,18 +2695,52 @@ def agent_switch(
shutil.rmtree(agent_dir) shutil.rmtree(agent_dir)
console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)") console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)")
# Setup new agent # Setup new agent — with rollback on failure
try: try:
new_bootstrap = load_bootstrap(resolved.path, resolved.manifest) new_bootstrap = load_bootstrap(resolved.path, resolved.manifest)
console.print(f" [dim]Setting up {agent_id}...[/dim]") console.print(f" [dim]Setting up {agent_id}...[/dim]")
agent_files = new_bootstrap.setup(project_path, script_type, options) agent_files = new_bootstrap.setup(project_path, script_type, options)
console.print(f" [green]✓[/green] {agent_id} installed") 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}") 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) raise typer.Exit(1)
# Update init options # Update init options
options["ai"] = agent_id 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") init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8")
# Re-register extension commands for the new agent # 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: if registered:
reregistered += len(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 continue
# Collect files created by extension registration # Collect files created by extension registration
@@ -2879,6 +2938,7 @@ def agent_add(
@agent_app.command("remove") @agent_app.command("remove")
def agent_remove( def agent_remove(
agent_id: str = typer.Argument(..., help="Agent pack ID to 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. """Remove a cached/override agent pack.
@@ -2896,12 +2956,23 @@ def agent_remove(
removed = False removed = False
# Check user-level # Check user-level — prompt because this affects all projects globally
user_pack = _user_agents_dir() / agent_id user_pack = _user_agents_dir() / agent_id
if user_pack.is_dir(): if user_pack.is_dir():
shutil.rmtree(user_pack) if not force:
console.print(f"[green]✓[/green] Removed user-level override for '{agent_id}'") console.print(
removed = True 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 # Check project-level
project_pack = Path.cwd() / ".specify" / "agents" / agent_id project_pack = Path.cwd() / ".specify" / "agents" / agent_id

View File

@@ -17,6 +17,8 @@ The embedded packs ship inside the pip wheel so that
import hashlib import hashlib
import importlib.util import importlib.util
import json import json
import logging
import re
import shutil import shutil
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -25,6 +27,29 @@ from typing import Any, Dict, List, Optional
import yaml import yaml
from platformdirs import user_data_path 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 # Manifest schema
@@ -242,7 +267,16 @@ class AgentBootstrap:
# -- helpers available to subclasses ------------------------------------ # -- helpers available to subclasses ------------------------------------
def agent_dir(self, project_path: Path) -> Path: 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] return project_path / self.manifest.commands_dir.split("/")[0]
def collect_installed_files(self, project_path: Path) -> List[Path]: 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 # Installed-file tracking
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -626,8 +707,11 @@ def resolve_agent_pack(
3. Catalog-installed cache 3. Catalog-installed cache
4. Embedded in wheel 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]] = [] candidates: List[tuple[str, Path]] = []
# Priority 1 — user level # 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: def load_bootstrap(pack_path: Path, manifest: AgentManifest) -> AgentBootstrap:
"""Import ``bootstrap.py`` from *pack_path* and return the bootstrap instance. """Import ``bootstrap.py`` from *pack_path* and return the bootstrap instance.
The bootstrap module must define exactly one public subclass of When a ``bootstrap.py`` exists, the module must define exactly one
``AgentBootstrap``. That class is instantiated with *manifest* and public subclass of ``AgentBootstrap``. When it is absent the
returned. :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 bootstrap_file = pack_path / BOOTSTRAP_FILENAME
if not bootstrap_file.is_file(): if not bootstrap_file.is_file():
raise AgentPackError( # No bootstrap module — use the generic DefaultBootstrap
f"Bootstrap module not found: {bootstrap_file}" return DefaultBootstrap(manifest)
)
spec = importlib.util.spec_from_file_location( spec = importlib.util.spec_from_file_location(
f"speckit_agent_{manifest.id}_bootstrap", bootstrap_file f"speckit_agent_{manifest.id}_bootstrap", bootstrap_file
@@ -790,6 +882,7 @@ def load_bootstrap(pack_path: Path, manifest: AgentManifest) -> AgentBootstrap:
isinstance(obj, type) isinstance(obj, type)
and issubclass(obj, AgentBootstrap) and issubclass(obj, AgentBootstrap)
and obj is not AgentBootstrap and obj is not AgentBootstrap
and obj is not DefaultBootstrap
and not name.startswith("_") and not name.startswith("_")
) )
] ]
@@ -824,7 +917,7 @@ def validate_pack(pack_path: Path) -> List[str]:
bootstrap_file = pack_path / BOOTSTRAP_FILENAME bootstrap_file = pack_path / BOOTSTRAP_FILENAME
if not bootstrap_file.is_file(): 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: if not manifest.commands_dir:
warnings.append("command_registration.commands_dir not set in manifest") warnings.append("command_registration.commands_dir not set in manifest")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -255,10 +255,12 @@ class TestBootstrapContract:
b = load_bootstrap(tmp_path, m) b = load_bootstrap(tmp_path, m)
assert isinstance(b, AgentBootstrap) 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()) m = AgentManifest.from_dict(_minimal_manifest_dict())
with pytest.raises(AgentPackError, match="Bootstrap module not found"): b = load_bootstrap(tmp_path, m)
load_bootstrap(tmp_path, m) assert isinstance(b, DefaultBootstrap)
def test_bootstrap_setup_and_teardown(self, tmp_path): def test_bootstrap_setup_and_teardown(self, tmp_path):
"""Verify a loaded bootstrap can set up and tear down via file tracking.""" """Verify a loaded bootstrap can set up and tear down via file tracking."""
@@ -315,6 +317,37 @@ class TestBootstrapContract:
load_bootstrap(pack_dir, m) 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 # Pack resolution
# =================================================================== # ===================================================================
@@ -383,6 +416,43 @@ class TestResolutionOrder:
assert resolved.manifest.version == "2.0.0" 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 # List and discovery
# =================================================================== # ===================================================================
@@ -466,7 +536,8 @@ class TestExportPack:
dest = tmp_path / "export" dest = tmp_path / "export"
result = export_pack("claude", dest) result = export_pack("claude", dest)
assert (result / MANIFEST_FILENAME).is_file() assert (result / MANIFEST_FILENAME).is_file()
assert (result / BOOTSTRAP_FILENAME).is_file() # bootstrap.py is optional — DefaultBootstrap handles
# agents without one.
# =================================================================== # ===================================================================