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

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/40d5aec5-d8e9-4e3f-ae60-6cf67ff491f3
This commit is contained in:
copilot-swe-agent[bot]
2026-03-23 14:32:46 +00:00
committed by GitHub
parent 795f1e7703
commit 00117c5074
55 changed files with 312 additions and 777 deletions

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Antigravity agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Agy(AgentBootstrap):
"""Bootstrap for Antigravity."""
AGENT_DIR = ".agent"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Antigravity agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Antigravity agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Amp agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Amp(AgentBootstrap):
"""Bootstrap for Amp."""
AGENT_DIR = ".agents"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Amp agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Amp agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Auggie CLI agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Auggie(AgentBootstrap):
"""Bootstrap for Auggie CLI."""
AGENT_DIR = ".augment"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Auggie CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Auggie CLI agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for IBM Bob agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Bob(AgentBootstrap):
"""Bootstrap for IBM Bob."""
AGENT_DIR = ".bob"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install IBM Bob agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove IBM Bob agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Claude Code agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Claude(AgentBootstrap):
"""Bootstrap for Claude Code."""
AGENT_DIR = ".claude"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Claude Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Claude Code agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for CodeBuddy agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Codebuddy(AgentBootstrap):
"""Bootstrap for CodeBuddy."""
AGENT_DIR = ".codebuddy"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install CodeBuddy agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove CodeBuddy agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Codex CLI agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Codex(AgentBootstrap):
"""Bootstrap for Codex CLI."""
AGENT_DIR = ".agents"
COMMANDS_SUBDIR = "skills"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Codex CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Codex CLI agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for GitHub Copilot agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Copilot(AgentBootstrap):
"""Bootstrap for GitHub Copilot."""
AGENT_DIR = ".github"
COMMANDS_SUBDIR = "agents"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install GitHub Copilot agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove GitHub Copilot agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Cursor agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class CursorAgent(AgentBootstrap):
"""Bootstrap for Cursor."""
AGENT_DIR = ".cursor"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Cursor agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Cursor agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Gemini CLI agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Gemini(AgentBootstrap):
"""Bootstrap for Gemini CLI."""
AGENT_DIR = ".gemini"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Gemini CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Gemini CLI agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for iFlow CLI agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Iflow(AgentBootstrap):
"""Bootstrap for iFlow CLI."""
AGENT_DIR = ".iflow"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install iFlow CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove iFlow CLI agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Junie agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Junie(AgentBootstrap):
"""Bootstrap for Junie."""
AGENT_DIR = ".junie"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Junie agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Junie agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Kilo Code agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Kilocode(AgentBootstrap):
"""Bootstrap for Kilo Code."""
AGENT_DIR = ".kilocode"
COMMANDS_SUBDIR = "workflows"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Kilo Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Kilo Code agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Kimi Code agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Kimi(AgentBootstrap):
"""Bootstrap for Kimi Code."""
AGENT_DIR = ".kimi"
COMMANDS_SUBDIR = "skills"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Kimi Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Kimi Code agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Kiro CLI agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class KiroCli(AgentBootstrap):
"""Bootstrap for Kiro CLI."""
AGENT_DIR = ".kiro"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Kiro CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Kiro CLI agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for opencode agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Opencode(AgentBootstrap):
"""Bootstrap for opencode."""
AGENT_DIR = ".opencode"
COMMANDS_SUBDIR = "command"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install opencode agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove opencode agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Pi Coding Agent agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Pi(AgentBootstrap):
"""Bootstrap for Pi Coding Agent."""
AGENT_DIR = ".pi"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Pi Coding Agent agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Pi Coding Agent agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Qoder CLI agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Qodercli(AgentBootstrap):
"""Bootstrap for Qoder CLI."""
AGENT_DIR = ".qoder"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Qoder CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Qoder CLI agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Qwen Code agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Qwen(AgentBootstrap):
"""Bootstrap for Qwen Code."""
AGENT_DIR = ".qwen"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Qwen Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Qwen Code agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Roo Code agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Roo(AgentBootstrap):
"""Bootstrap for Roo Code."""
AGENT_DIR = ".roo"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Roo Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Roo Code agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for SHAI agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Shai(AgentBootstrap):
"""Bootstrap for SHAI."""
AGENT_DIR = ".shai"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install SHAI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove SHAI agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Tabnine CLI agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Tabnine(AgentBootstrap):
"""Bootstrap for Tabnine CLI."""
AGENT_DIR = ".tabnine/agent"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Tabnine CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Tabnine CLI agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Trae agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Trae(AgentBootstrap):
"""Bootstrap for Trae."""
AGENT_DIR = ".trae"
COMMANDS_SUBDIR = "rules"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Trae agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Trae agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Mistral Vibe agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Vibe(AgentBootstrap):
"""Bootstrap for Mistral Vibe."""
AGENT_DIR = ".vibe"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Mistral Vibe agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Mistral Vibe agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,30 +0,0 @@
"""Bootstrap module for Windsurf agent pack."""
from pathlib import Path
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class Windsurf(AgentBootstrap):
"""Bootstrap for Windsurf."""
AGENT_DIR = ".windsurf"
COMMANDS_SUBDIR = "workflows"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install Windsurf agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
"""Remove Windsurf agent files from the project.
Only removes individual tracked files — directories are never
deleted. When *files* is provided, exactly those files are
removed. Otherwise the install manifest is consulted and
``AgentFileModifiedError`` is raised if any tracked file was
modified and *force* is ``False``.
"""
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)