mirror of
https://github.com/github/spec-kit.git
synced 2026-03-23 22:03: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.*
|
||||
|
||||
@@ -2438,15 +2438,24 @@ app.add_typer(agent_app, name="agent")
|
||||
|
||||
@agent_app.command("list")
|
||||
def agent_list(
|
||||
installed: bool = typer.Option(False, "--installed", help="Only show agents with local presence in the current project"),
|
||||
installed: bool = typer.Option(False, "--installed", help="Only show agents that have files present in the current project"),
|
||||
):
|
||||
"""List available agent packs."""
|
||||
from .agent_pack import list_all_agents, list_embedded_agents
|
||||
from .agent_pack import list_all_agents, list_embedded_agents, _manifest_path
|
||||
|
||||
show_banner()
|
||||
|
||||
project_path = Path.cwd()
|
||||
agents = list_all_agents(project_path=project_path if installed else None)
|
||||
agents = list_all_agents(project_path=project_path)
|
||||
|
||||
if installed:
|
||||
# Filter to only agents that have an install manifest in the
|
||||
# current project, i.e. agents whose files are actually present.
|
||||
agents = [
|
||||
a for a in agents
|
||||
if _manifest_path(project_path, a.manifest.id).is_file()
|
||||
]
|
||||
|
||||
if not agents and not installed:
|
||||
agents_from_embedded = list_embedded_agents()
|
||||
if not agents_from_embedded:
|
||||
@@ -2454,7 +2463,13 @@ def agent_list(
|
||||
console.print("[dim]Agent packs are embedded in the specify-cli wheel.[/dim]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
table = Table(title="Available Agent Packs", show_lines=False)
|
||||
if not agents and installed:
|
||||
console.print("[yellow]No agents are installed in the current project.[/yellow]")
|
||||
console.print("[dim]Use 'specify init --agent <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("Name", style="white")
|
||||
table.add_column("Version", style="dim")
|
||||
@@ -2470,7 +2485,7 @@ def agent_list(
|
||||
table.add_row(m.id, m.name, m.version, source_display, cli_marker)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n[dim]{len(agents)} agent(s) available[/dim]")
|
||||
console.print(f"\n[dim]{len(agents)} agent(s) {'installed' if installed else 'available'}[/dim]")
|
||||
|
||||
|
||||
@agent_app.command("info")
|
||||
@@ -2638,6 +2653,11 @@ def agent_switch(
|
||||
|
||||
console.print(f"[bold]Switching agent: {current_agent or '(none)'} → {agent_id}[/bold]")
|
||||
|
||||
# Snapshot tracked files before teardown so we can attempt rollback
|
||||
# if the new agent's setup fails after teardown.
|
||||
old_tracked_agent: dict[str, str] = {}
|
||||
old_tracked_ext: dict[str, str] = {}
|
||||
|
||||
# Teardown current agent (best effort — may have been set up with old system)
|
||||
if current_agent:
|
||||
try:
|
||||
@@ -2655,8 +2675,8 @@ def agent_switch(
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Retrieve tracked file lists and feed them into teardown
|
||||
agent_files, extension_files = get_tracked_files(project_path, current_agent)
|
||||
all_files = {**agent_files, **extension_files}
|
||||
old_tracked_agent, old_tracked_ext = get_tracked_files(project_path, current_agent)
|
||||
all_files = {**old_tracked_agent, **old_tracked_ext}
|
||||
|
||||
console.print(f" [dim]Tearing down {current_agent}...[/dim]")
|
||||
current_bootstrap.teardown(
|
||||
@@ -2675,18 +2695,52 @@ def agent_switch(
|
||||
shutil.rmtree(agent_dir)
|
||||
console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)")
|
||||
|
||||
# Setup new agent
|
||||
# Setup new agent — with rollback on failure
|
||||
try:
|
||||
new_bootstrap = load_bootstrap(resolved.path, resolved.manifest)
|
||||
console.print(f" [dim]Setting up {agent_id}...[/dim]")
|
||||
agent_files = new_bootstrap.setup(project_path, script_type, options)
|
||||
console.print(f" [green]✓[/green] {agent_id} installed")
|
||||
except AgentPackError as exc:
|
||||
except (AgentPackError, Exception) as exc:
|
||||
console.print(f"[red]Error setting up {agent_id}:[/red] {exc}")
|
||||
|
||||
# Attempt to restore the old agent so the project is not left
|
||||
# in a broken state after teardown succeeded but setup failed.
|
||||
if current_agent:
|
||||
console.print(f"[yellow]Attempting to restore previous agent ({current_agent})...[/yellow]")
|
||||
try:
|
||||
rollback_resolved = resolve_agent_pack(current_agent, project_path=project_path)
|
||||
rollback_bs = load_bootstrap(rollback_resolved.path, rollback_resolved.manifest)
|
||||
rollback_files = rollback_bs.setup(project_path, script_type, options)
|
||||
rollback_bs.finalize_setup(
|
||||
project_path,
|
||||
agent_files=rollback_files,
|
||||
extension_files=list(
|
||||
(project_path / p).resolve()
|
||||
for p in old_tracked_ext
|
||||
if (project_path / p).is_file()
|
||||
),
|
||||
)
|
||||
console.print(f" [green]✓[/green] {current_agent} restored")
|
||||
except Exception:
|
||||
# Rollback also failed — mark error state in init-options
|
||||
console.print(
|
||||
f"[red]Rollback failed.[/red] "
|
||||
f"The project may be in a broken state — "
|
||||
f"run 'specify init --here --agent {current_agent}' to repair."
|
||||
)
|
||||
options["agent_switch_error"] = (
|
||||
f"Switch to '{agent_id}' failed after teardown of "
|
||||
f"'{current_agent}'. Restore manually."
|
||||
)
|
||||
init_options_file.write_text(
|
||||
json.dumps(options, indent=2), encoding="utf-8"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Update init options
|
||||
options["ai"] = agent_id
|
||||
options.pop("agent_switch_error", None) # clear any previous error
|
||||
init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8")
|
||||
|
||||
# Re-register extension commands for the new agent
|
||||
@@ -2761,7 +2815,12 @@ def _reregister_extension_commands(project_path: Path, agent_id: str) -> List[Pa
|
||||
)
|
||||
if registered:
|
||||
reregistered += len(registered)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).debug(
|
||||
"Failed to re-register extension '%s' for agent '%s': %s",
|
||||
ext_id, agent_id, exc,
|
||||
)
|
||||
continue
|
||||
|
||||
# Collect files created by extension registration
|
||||
@@ -2879,6 +2938,7 @@ def agent_add(
|
||||
@agent_app.command("remove")
|
||||
def agent_remove(
|
||||
agent_id: str = typer.Argument(..., help="Agent pack ID to remove"),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompts"),
|
||||
):
|
||||
"""Remove a cached/override agent pack.
|
||||
|
||||
@@ -2896,12 +2956,23 @@ def agent_remove(
|
||||
|
||||
removed = False
|
||||
|
||||
# Check user-level
|
||||
# Check user-level — prompt because this affects all projects globally
|
||||
user_pack = _user_agents_dir() / agent_id
|
||||
if user_pack.is_dir():
|
||||
shutil.rmtree(user_pack)
|
||||
console.print(f"[green]✓[/green] Removed user-level override for '{agent_id}'")
|
||||
removed = True
|
||||
if not force:
|
||||
console.print(
|
||||
f"[yellow]User-level override for '{agent_id}' affects all projects globally.[/yellow]"
|
||||
)
|
||||
if not typer.confirm("Remove this user-level override?"):
|
||||
console.print("[dim]Skipped user-level override removal.[/dim]")
|
||||
else:
|
||||
shutil.rmtree(user_pack)
|
||||
console.print(f"[green]✓[/green] Removed user-level override for '{agent_id}'")
|
||||
removed = True
|
||||
else:
|
||||
shutil.rmtree(user_pack)
|
||||
console.print(f"[green]✓[/green] Removed user-level override for '{agent_id}'")
|
||||
removed = True
|
||||
|
||||
# Check project-level
|
||||
project_pack = Path.cwd() / ".specify" / "agents" / agent_id
|
||||
|
||||
@@ -17,6 +17,8 @@ The embedded packs ship inside the pip wheel so that
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -25,6 +27,29 @@ from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
from platformdirs import user_data_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent ID validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Regex that every agent ID must match: lowercase alphanumeric + hyphens.
|
||||
_AGENT_ID_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
def _validate_agent_id(agent_id: str) -> None:
|
||||
"""Raise ``PackResolutionError`` when *agent_id* is unsafe or malformed.
|
||||
|
||||
Rejects IDs containing ``/``, ``..``, or characters outside ``[a-z0-9-]``
|
||||
to prevent path-traversal attacks through the resolution stack.
|
||||
"""
|
||||
if not agent_id or not _AGENT_ID_RE.match(agent_id):
|
||||
raise PackResolutionError(
|
||||
f"Invalid agent ID {agent_id!r} — "
|
||||
"IDs must match [a-z0-9-] (lowercase alphanumeric and hyphens, "
|
||||
"no leading/trailing hyphens)."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest schema
|
||||
@@ -242,7 +267,16 @@ class AgentBootstrap:
|
||||
# -- helpers available to subclasses ------------------------------------
|
||||
|
||||
def agent_dir(self, project_path: Path) -> Path:
|
||||
"""Return the agent's top-level directory inside the project."""
|
||||
"""Return the agent's top-level directory inside the project.
|
||||
|
||||
Raises ``AgentPackError`` when the manifest's ``commands_dir`` is
|
||||
empty, since the agent directory cannot be determined.
|
||||
"""
|
||||
if not self.manifest.commands_dir:
|
||||
raise AgentPackError(
|
||||
f"Agent '{self.manifest.id}' has an empty commands_dir — "
|
||||
"cannot determine agent directory."
|
||||
)
|
||||
return project_path / self.manifest.commands_dir.split("/")[0]
|
||||
|
||||
def collect_installed_files(self, project_path: Path) -> List[Path]:
|
||||
@@ -361,6 +395,53 @@ class AgentBootstrap:
|
||||
)
|
||||
|
||||
|
||||
class DefaultBootstrap(AgentBootstrap):
|
||||
"""Generic bootstrap that derives its directory layout from the manifest.
|
||||
|
||||
This replaces the need for per-agent ``bootstrap.py`` files when the
|
||||
agent follows the standard setup/teardown pattern — create the
|
||||
commands directory, run the shared scaffold, and delegate teardown to
|
||||
``remove_tracked_files``.
|
||||
|
||||
The ``AGENT_DIR`` and ``COMMANDS_SUBDIR`` class attributes are
|
||||
computed from the manifest's ``commands_dir`` field (e.g.
|
||||
``".claude/commands"`` → ``AGENT_DIR=".claude"``,
|
||||
``COMMANDS_SUBDIR="commands"``).
|
||||
"""
|
||||
|
||||
def __init__(self, manifest: AgentManifest):
|
||||
super().__init__(manifest)
|
||||
parts = manifest.commands_dir.split("/") if manifest.commands_dir else []
|
||||
self.AGENT_DIR = parts[0] if parts else ""
|
||||
self.COMMANDS_SUBDIR = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
|
||||
"""Install agent files into the project using the standard scaffold."""
|
||||
if self.AGENT_DIR and self.COMMANDS_SUBDIR:
|
||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||
return self._scaffold_project(project_path, script_type)
|
||||
|
||||
def teardown(
|
||||
self,
|
||||
project_path: Path,
|
||||
*,
|
||||
force: bool = False,
|
||||
files: Optional[Dict[str, str]] = None,
|
||||
) -> List[str]:
|
||||
"""Remove agent files from the project.
|
||||
|
||||
Only removes individual tracked files — directories are never
|
||||
deleted. When *files* is provided, exactly those files are
|
||||
removed. Otherwise the install manifest is consulted and
|
||||
``AgentFileModifiedError`` is raised if any tracked file was
|
||||
modified and *force* is ``False``.
|
||||
"""
|
||||
return remove_tracked_files(
|
||||
project_path, self.manifest.id, force=force, files=files
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installed-file tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -626,8 +707,11 @@ def resolve_agent_pack(
|
||||
3. Catalog-installed cache
|
||||
4. Embedded in wheel
|
||||
|
||||
Raises ``PackResolutionError`` when no pack is found at any level.
|
||||
Raises ``PackResolutionError`` when *agent_id* is invalid or when
|
||||
no pack is found at any level.
|
||||
"""
|
||||
_validate_agent_id(agent_id)
|
||||
|
||||
candidates: List[tuple[str, Path]] = []
|
||||
|
||||
# Priority 1 — user level
|
||||
@@ -763,15 +847,23 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]:
|
||||
def load_bootstrap(pack_path: Path, manifest: AgentManifest) -> AgentBootstrap:
|
||||
"""Import ``bootstrap.py`` from *pack_path* and return the bootstrap instance.
|
||||
|
||||
The bootstrap module must define exactly one public subclass of
|
||||
``AgentBootstrap``. That class is instantiated with *manifest* and
|
||||
returned.
|
||||
When a ``bootstrap.py`` exists, the module must define exactly one
|
||||
public subclass of ``AgentBootstrap``. When it is absent the
|
||||
:class:`DefaultBootstrap` is used instead — it derives its directory
|
||||
layout from the manifest's ``commands_dir`` field.
|
||||
|
||||
.. warning::
|
||||
**Trust boundary:** ``bootstrap.py`` modules are dynamically
|
||||
imported and can execute arbitrary code. The 4-level resolution
|
||||
stack (user → project → catalog → embedded) means that *any*
|
||||
pack author whose pack is placed in one of these directories can
|
||||
run code with the privileges of the current process. Only
|
||||
install packs from trusted sources.
|
||||
"""
|
||||
bootstrap_file = pack_path / BOOTSTRAP_FILENAME
|
||||
if not bootstrap_file.is_file():
|
||||
raise AgentPackError(
|
||||
f"Bootstrap module not found: {bootstrap_file}"
|
||||
)
|
||||
# No bootstrap module — use the generic DefaultBootstrap
|
||||
return DefaultBootstrap(manifest)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"speckit_agent_{manifest.id}_bootstrap", bootstrap_file
|
||||
@@ -790,6 +882,7 @@ def load_bootstrap(pack_path: Path, manifest: AgentManifest) -> AgentBootstrap:
|
||||
isinstance(obj, type)
|
||||
and issubclass(obj, AgentBootstrap)
|
||||
and obj is not AgentBootstrap
|
||||
and obj is not DefaultBootstrap
|
||||
and not name.startswith("_")
|
||||
)
|
||||
]
|
||||
@@ -824,7 +917,7 @@ def validate_pack(pack_path: Path) -> List[str]:
|
||||
|
||||
bootstrap_file = pack_path / BOOTSTRAP_FILENAME
|
||||
if not bootstrap_file.is_file():
|
||||
warnings.append(f"Missing {BOOTSTRAP_FILENAME} (pack cannot be bootstrapped)")
|
||||
warnings.append(f"Missing {BOOTSTRAP_FILENAME} (DefaultBootstrap will be used)")
|
||||
|
||||
if not manifest.commands_dir:
|
||||
warnings.append("command_registration.commands_dir not set in manifest")
|
||||
|
||||
@@ -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)
|
||||
assert isinstance(b, AgentBootstrap)
|
||||
|
||||
def test_load_bootstrap_missing_file(self, tmp_path):
|
||||
def test_load_bootstrap_missing_file_uses_default(self, tmp_path):
|
||||
"""When bootstrap.py is absent, DefaultBootstrap is returned."""
|
||||
from specify_cli.agent_pack import DefaultBootstrap
|
||||
m = AgentManifest.from_dict(_minimal_manifest_dict())
|
||||
with pytest.raises(AgentPackError, match="Bootstrap module not found"):
|
||||
load_bootstrap(tmp_path, m)
|
||||
b = load_bootstrap(tmp_path, m)
|
||||
assert isinstance(b, DefaultBootstrap)
|
||||
|
||||
def test_bootstrap_setup_and_teardown(self, tmp_path):
|
||||
"""Verify a loaded bootstrap can set up and tear down via file tracking."""
|
||||
@@ -315,6 +317,37 @@ class TestBootstrapContract:
|
||||
load_bootstrap(pack_dir, m)
|
||||
|
||||
|
||||
class TestDefaultBootstrap:
|
||||
"""Verify the DefaultBootstrap class works for all embedded packs."""
|
||||
|
||||
def test_default_bootstrap_derives_dirs_from_manifest(self):
|
||||
from specify_cli.agent_pack import DefaultBootstrap
|
||||
data = _minimal_manifest_dict()
|
||||
m = AgentManifest.from_dict(data)
|
||||
b = DefaultBootstrap(m)
|
||||
assert b.AGENT_DIR == ".test-agent"
|
||||
assert b.COMMANDS_SUBDIR == "commands"
|
||||
|
||||
def test_default_bootstrap_empty_commands_dir(self):
|
||||
from specify_cli.agent_pack import DefaultBootstrap
|
||||
data = _minimal_manifest_dict()
|
||||
data["command_registration"]["commands_dir"] = ""
|
||||
m = AgentManifest.from_dict(data)
|
||||
b = DefaultBootstrap(m)
|
||||
assert b.AGENT_DIR == ""
|
||||
assert b.COMMANDS_SUBDIR == ""
|
||||
|
||||
def test_agent_dir_raises_on_empty_commands_dir(self, tmp_path):
|
||||
"""agent_dir() raises AgentPackError when commands_dir is empty."""
|
||||
from specify_cli.agent_pack import DefaultBootstrap
|
||||
data = _minimal_manifest_dict()
|
||||
data["command_registration"]["commands_dir"] = ""
|
||||
m = AgentManifest.from_dict(data)
|
||||
b = DefaultBootstrap(m)
|
||||
with pytest.raises(AgentPackError, match="empty commands_dir"):
|
||||
b.agent_dir(tmp_path)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Pack resolution
|
||||
# ===================================================================
|
||||
@@ -383,6 +416,43 @@ class TestResolutionOrder:
|
||||
assert resolved.manifest.version == "2.0.0"
|
||||
|
||||
|
||||
class TestAgentIdValidation:
|
||||
"""Verify that resolve_agent_pack rejects malicious/invalid IDs."""
|
||||
|
||||
@pytest.mark.parametrize("bad_id", [
|
||||
"../etc/passwd",
|
||||
"foo/bar",
|
||||
"agent..evil",
|
||||
"UPPERCASE",
|
||||
"has space",
|
||||
"has_underscore",
|
||||
"",
|
||||
"-leading-hyphen",
|
||||
"trailing-hyphen-",
|
||||
"agent@evil",
|
||||
])
|
||||
def test_invalid_ids_rejected(self, bad_id):
|
||||
with pytest.raises(PackResolutionError, match="Invalid agent ID"):
|
||||
resolve_agent_pack(bad_id)
|
||||
|
||||
@pytest.mark.parametrize("good_id", [
|
||||
"claude",
|
||||
"cursor-agent",
|
||||
"kiro-cli",
|
||||
"a",
|
||||
"a1",
|
||||
"my-agent-2",
|
||||
])
|
||||
def test_valid_ids_accepted(self, good_id):
|
||||
"""Valid IDs pass validation (they may not resolve, but don't fail validation)."""
|
||||
try:
|
||||
resolve_agent_pack(good_id)
|
||||
except PackResolutionError as exc:
|
||||
# May fail because agent doesn't exist, but NOT because of
|
||||
# invalid ID.
|
||||
assert "Invalid agent ID" not in str(exc)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# List and discovery
|
||||
# ===================================================================
|
||||
@@ -466,7 +536,8 @@ class TestExportPack:
|
||||
dest = tmp_path / "export"
|
||||
result = export_pack("claude", dest)
|
||||
assert (result / MANIFEST_FILENAME).is_file()
|
||||
assert (result / BOOTSTRAP_FILENAME).is_file()
|
||||
# bootstrap.py is optional — DefaultBootstrap handles
|
||||
# agents without one.
|
||||
|
||||
|
||||
# ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user