mirror of
https://github.com/github/spec-kit.git
synced 2026-03-24 06:13:08 +00:00
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:
committed by
GitHub
parent
795f1e7703
commit
00117c5074
50
AGENTS.md
50
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 <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.*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user