refactor: setup reports files, CLI checks modifications before teardown, categorised manifest

- setup() returns List[Path] of installed files so CLI can record them
- finalize_setup() accepts agent_files + extension_files for combined tracking
- Install manifest categorises files: agent_files and extension_files
- get_tracked_files() returns (agent_files, extension_files) split
- remove_tracked_files() accepts explicit files dict for CLI-driven teardown
- agent_switch checks for modifications BEFORE teardown and prompts user
- _reregister_extension_commands() returns List[Path] of created files
- teardown() accepts files parameter to receive explicit file lists
- All 25 bootstraps updated with new signatures
- 5 new tests: categorised manifest, get_tracked_files, explicit file teardown,
  extension file modification detection

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/32e470fc-6bf5-453c-bf6c-79a8521efa56
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 21:34:59 +00:00
committed by GitHub
parent a63c248c80
commit e190116d13
28 changed files with 596 additions and 246 deletions

View File

@@ -36,7 +36,7 @@ import json5
import stat
import yaml
from pathlib import Path
from typing import Any, Optional, Tuple
from typing import Any, List, Optional, Tuple
import typer
import httpx
@@ -2543,9 +2543,10 @@ def agent_switch(
from .agent_pack import (
resolve_agent_pack,
load_bootstrap,
check_modified_files,
get_tracked_files,
PackResolutionError,
AgentPackError,
AgentFileModifiedError,
)
show_banner()
@@ -2582,13 +2583,28 @@ def agent_switch(
try:
current_resolved = resolve_agent_pack(current_agent, project_path=project_path)
current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest)
# Check for modified files BEFORE teardown and prompt for confirmation
modified = check_modified_files(project_path, current_agent)
if modified and not force:
console.print("[yellow]The following files have been modified since installation:[/yellow]")
for f in modified:
console.print(f" {f}")
if not typer.confirm("Remove these modified files?"):
console.print("[dim]Aborted. Use --force to skip this check.[/dim]")
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}
console.print(f" [dim]Tearing down {current_agent}...[/dim]")
current_bootstrap.teardown(project_path, force=force)
current_bootstrap.teardown(
project_path,
force=True, # already confirmed above
files=all_files if all_files else None,
)
console.print(f" [green]✓[/green] {current_agent} removed")
except AgentFileModifiedError as exc:
console.print(f"[red]Error:[/red] {exc}")
console.print("[yellow]Hint:[/yellow] Use --force to remove modified files.")
raise typer.Exit(1)
except AgentPackError:
# If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG
agent_config = AGENT_CONFIG.get(current_agent, {})
@@ -2603,9 +2619,7 @@ def agent_switch(
try:
new_bootstrap = load_bootstrap(resolved.path, resolved.manifest)
console.print(f" [dim]Setting up {agent_id}...[/dim]")
new_bootstrap.setup(project_path, script_type, options)
# Record all installed files for tracked teardown
new_bootstrap.finalize_setup(project_path)
agent_files = new_bootstrap.setup(project_path, script_type, options)
console.print(f" [green]✓[/green] {agent_id} installed")
except AgentPackError as exc:
console.print(f"[red]Error setting up {agent_id}:[/red] {exc}")
@@ -2614,32 +2628,54 @@ def agent_switch(
# Update init options
options["ai"] = agent_id
init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8")
console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]")
# Re-register extension commands for the new agent
_reregister_extension_commands(project_path, agent_id)
extension_files = _reregister_extension_commands(project_path, agent_id)
# Record all installed files (agent + extensions) for tracked teardown
new_bootstrap.finalize_setup(
project_path,
agent_files=agent_files,
extension_files=extension_files,
)
console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]")
def _reregister_extension_commands(project_path: Path, agent_id: str) -> None:
"""Re-register all installed extension commands for a new agent after switching."""
def _reregister_extension_commands(project_path: Path, agent_id: str) -> List[Path]:
"""Re-register all installed extension commands for a new agent after switching.
Returns:
List of absolute file paths created by extension registration.
"""
created_files: List[Path] = []
registry_file = project_path / ".specify" / "extensions" / ".registry"
if not registry_file.is_file():
return
return created_files
try:
registry_data = json.loads(registry_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
return created_files
extensions = registry_data.get("extensions", {})
if not extensions:
return
return created_files
try:
from .agents import CommandRegistrar
registrar = CommandRegistrar()
except ImportError:
return
return created_files
# Snapshot the commands directory before registration so we can
# detect which files were created by extension commands.
agent_config = registrar.AGENT_CONFIGS.get(agent_id)
if agent_config:
commands_dir = project_path / agent_config["dir"]
pre_existing = set(commands_dir.rglob("*")) if commands_dir.is_dir() else set()
else:
pre_existing = set()
reregistered = 0
for ext_id, ext_data in extensions.items():
@@ -2668,8 +2704,19 @@ def _reregister_extension_commands(project_path: Path, agent_id: str) -> None:
except Exception:
continue
# Collect files created by extension registration
if agent_config:
commands_dir = project_path / agent_config["dir"]
if commands_dir.is_dir():
for p in commands_dir.rglob("*"):
if p.is_file() and p not in pre_existing:
created_files.append(p)
if reregistered:
console.print(f" [green]✓[/green] Re-registered {reregistered} extension command(s)")
console.print(f" [green]✓[/green] Re-registered {reregistered} extension command(s)"
f" ({len(created_files)} file(s))")
return created_files
@agent_app.command("search")

View File

@@ -184,35 +184,58 @@ class AgentBootstrap:
# -- lifecycle -----------------------------------------------------------
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install agent files into *project_path*.
This is invoked by ``specify init --ai <agent>`` and
``specify agent switch <agent>``.
Implementations **must** return every file they create so that the
CLI can record both agent-installed files and extension-installed
files in a single install manifest.
Args:
project_path: Target project directory.
script_type: ``"sh"`` or ``"ps"``.
options: Arbitrary key/value options forwarded from the CLI.
Returns:
List of absolute paths of files created during setup.
"""
raise NotImplementedError
def teardown(self, project_path: Path, *, force: bool = False) -> None:
def teardown(
self,
project_path: Path,
*,
force: bool = False,
files: Optional[Dict[str, str]] = None,
) -> List[str]:
"""Remove agent-specific files from *project_path*.
Invoked by ``specify agent switch`` (for the *old* agent) and
``specify agent remove`` when the user explicitly uninstalls.
Must preserve shared infrastructure (specs, plans, tasks, etc.).
Only individual files recorded in the install manifest are removed
— directories are never deleted. If any tracked file has been
modified since installation and *force* is ``False``, raises
:class:`AgentFileModifiedError`.
Only individual files are removed — directories are **never**
deleted.
The caller (CLI) is expected to check for user-modified files
**before** invoking teardown and prompt for confirmation. If
*files* is provided, exactly those files are removed (values are
ignored but kept for forward compatibility). Otherwise the
install manifest is read.
Args:
project_path: Project directory to clean up.
force: When ``True``, remove files even if they were modified
after installation.
files: Mapping of project-relative path → SHA-256 hash.
When supplied, only these files are removed and the
install manifest is not consulted.
Returns:
List of project-relative paths that were actually deleted.
"""
raise NotImplementedError
@@ -222,21 +245,44 @@ class AgentBootstrap:
"""Return the agent's top-level directory inside the project."""
return project_path / self.manifest.commands_dir.split("/")[0]
def finalize_setup(self, project_path: Path) -> None:
"""Record all files in the agent directory for tracked teardown.
def finalize_setup(
self,
project_path: Path,
agent_files: Optional[List[Path]] = None,
extension_files: Optional[List[Path]] = None,
) -> None:
"""Record installed files for tracked teardown.
This must be called **after** the full init pipeline has finished
writing files (commands, context files, etc.) into the agent
directory. It scans ``self.manifest.commands_dir`` and records
every file with its SHA-256 hash so that :meth:`teardown` can
detect user modifications.
writing files (commands, context files, extensions) into the
project. It combines the files reported by :meth:`setup` with
any extra files (e.g. from extension registration), scans the
agent's ``commands_dir`` for anything additional, and writes the
install manifest.
Args:
agent_files: Files reported by :meth:`setup`.
extension_files: Files created by extension registration.
"""
if not self.manifest.commands_dir:
return
commands_dir = project_path / self.manifest.commands_dir
if commands_dir.is_dir():
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
all_agent = list(agent_files or [])
all_extension = list(extension_files or [])
# Also scan the commands directory for files created by the
# init pipeline that setup() did not report directly.
if self.manifest.commands_dir:
commands_dir = project_path / self.manifest.commands_dir
if commands_dir.is_dir():
agent_set = {p.resolve() for p in all_agent}
for p in commands_dir.rglob("*"):
if p.is_file() and p.resolve() not in agent_set:
all_agent.append(p)
record_installed_files(
project_path,
self.manifest.id,
agent_files=all_agent,
extension_files=all_extension,
)
# ---------------------------------------------------------------------------
@@ -257,41 +303,107 @@ def _sha256(path: Path) -> str:
return h.hexdigest()
def record_installed_files(
def _hash_file_list(
project_path: Path,
agent_id: str,
files: List[Path],
) -> Path:
"""Record the installed files and their SHA-256 hashes.
Writes ``.specify/agent-manifest-<agent_id>.json`` containing a
mapping of project-relative paths to their SHA-256 digests.
Args:
project_path: Project root directory.
agent_id: Agent identifier.
files: Absolute or project-relative paths of the files that
were created during ``setup()``.
Returns:
Path to the written manifest file.
"""
) -> Dict[str, str]:
"""Build a {relative_path: sha256} dict from a list of file paths."""
entries: Dict[str, str] = {}
for file_path in files:
abs_path = project_path / file_path if not file_path.is_absolute() else file_path
if abs_path.is_file():
rel = str(abs_path.relative_to(project_path))
entries[rel] = _sha256(abs_path)
return entries
def record_installed_files(
project_path: Path,
agent_id: str,
agent_files: Optional[List[Path]] = None,
extension_files: Optional[List[Path]] = None,
) -> Path:
"""Record the installed files and their SHA-256 hashes.
Writes ``.specify/agent-manifest-<agent_id>.json`` containing
categorised mappings of project-relative paths to SHA-256 digests.
Args:
project_path: Project root directory.
agent_id: Agent identifier.
agent_files: Files created by the agent's ``setup()`` and the
init pipeline (core commands / templates).
extension_files: Files created by extension registration.
Returns:
Path to the written manifest file.
"""
agent_entries = _hash_file_list(project_path, agent_files or [])
extension_entries = _hash_file_list(project_path, extension_files or [])
manifest_file = _manifest_path(project_path, agent_id)
manifest_file.parent.mkdir(parents=True, exist_ok=True)
manifest_file.write_text(
json.dumps({"agent_id": agent_id, "files": entries}, indent=2),
json.dumps(
{
"agent_id": agent_id,
"agent_files": agent_entries,
"extension_files": extension_entries,
},
indent=2,
),
encoding="utf-8",
)
return manifest_file
def _all_tracked_entries(data: dict) -> Dict[str, str]:
"""Return the combined file → hash mapping from a manifest dict.
Supports both the new categorised layout (``agent_files`` +
``extension_files``) and the legacy flat ``files`` key.
"""
combined: Dict[str, str] = {}
# Legacy flat format
if "files" in data and isinstance(data["files"], dict):
combined.update(data["files"])
# New categorised format
if "agent_files" in data and isinstance(data["agent_files"], dict):
combined.update(data["agent_files"])
if "extension_files" in data and isinstance(data["extension_files"], dict):
combined.update(data["extension_files"])
return combined
def get_tracked_files(
project_path: Path,
agent_id: str,
) -> tuple[Dict[str, str], Dict[str, str]]:
"""Return the tracked file hashes split by source.
Returns:
A tuple ``(agent_files, extension_files)`` where each is a
``{relative_path: sha256}`` dict. Returns two empty dicts
when no install manifest exists.
"""
manifest_file = _manifest_path(project_path, agent_id)
if not manifest_file.is_file():
return {}, {}
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}, {}
# Support legacy flat format
if "files" in data and "agent_files" not in data:
return dict(data["files"]), {}
agent_entries = data.get("agent_files", {})
ext_entries = data.get("extension_files", {})
return dict(agent_entries), dict(ext_entries)
def check_modified_files(
project_path: Path,
agent_id: str,
@@ -310,8 +422,10 @@ def check_modified_files(
except (json.JSONDecodeError, OSError):
return []
entries = _all_tracked_entries(data)
modified: List[str] = []
for rel_path, original_hash in data.get("files", {}).items():
for rel_path, original_hash in entries.items():
abs_path = project_path / rel_path
if abs_path.is_file():
if _sha256(abs_path) != original_hash:
@@ -327,11 +441,18 @@ def remove_tracked_files(
agent_id: str,
*,
force: bool = False,
files: Optional[Dict[str, str]] = None,
) -> List[str]:
"""Remove the individual files recorded in the install manifest.
"""Remove individual tracked files.
If *files* is provided, exactly those files are removed (the values
are ignored but accepted for forward compatibility). Otherwise the
install manifest for *agent_id* is read.
Raises :class:`AgentFileModifiedError` if any tracked file was
modified and *force* is ``False``.
modified and *force* is ``False`` (only when reading from the
manifest — callers that pass *files* are expected to have already
prompted the user).
Directories are **never** deleted — only individual files.
@@ -339,32 +460,37 @@ def remove_tracked_files(
project_path: Project root directory.
agent_id: Agent identifier.
force: When ``True``, delete even modified files.
files: Explicit mapping of project-relative path → hash. When
supplied, the install manifest is not consulted.
Returns:
List of project-relative paths that were removed.
"""
manifest_file = _manifest_path(project_path, agent_id)
if not manifest_file.is_file():
return []
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
if files is not None:
entries = files
else:
if not manifest_file.is_file():
return []
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
entries: Dict[str, str] = data.get("files", {})
if not entries:
manifest_file.unlink(missing_ok=True)
return []
entries = _all_tracked_entries(data)
if not entries:
manifest_file.unlink(missing_ok=True)
return []
if not force:
modified = check_modified_files(project_path, agent_id)
if modified:
raise AgentFileModifiedError(
f"The following agent files have been modified since installation:\n"
+ "\n".join(f" {p}" for p in modified)
+ "\nUse --force to remove them anyway."
)
if not force:
modified = check_modified_files(project_path, agent_id)
if modified:
raise AgentFileModifiedError(
f"The following agent files have been modified since installation:\n"
+ "\n".join(f" {p}" for p in modified)
+ "\nUse --force to remove them anyway."
)
removed: List[str] = []
for rel_path in entries:
@@ -374,7 +500,8 @@ def remove_tracked_files(
removed.append(rel_path)
# Clean up the install manifest itself
manifest_file.unlink(missing_ok=True)
if manifest_file.is_file():
manifest_file.unlink(missing_ok=True)
return removed

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Antigravity agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Agy(AgentBootstrap):
AGENT_DIR = ".agent"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Amp agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Amp(AgentBootstrap):
AGENT_DIR = ".agents"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Auggie CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Auggie(AgentBootstrap):
AGENT_DIR = ".augment"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for IBM Bob agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Bob(AgentBootstrap):
AGENT_DIR = ".bob"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Claude Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Claude(AgentBootstrap):
AGENT_DIR = ".claude"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for CodeBuddy agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Codebuddy(AgentBootstrap):
AGENT_DIR = ".codebuddy"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Codex CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Codex(AgentBootstrap):
AGENT_DIR = ".agents"
COMMANDS_SUBDIR = "skills"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for GitHub Copilot agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Copilot(AgentBootstrap):
AGENT_DIR = ".github"
COMMANDS_SUBDIR = "agents"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Cursor agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class CursorAgent(AgentBootstrap):
AGENT_DIR = ".cursor"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Gemini CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Gemini(AgentBootstrap):
AGENT_DIR = ".gemini"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for iFlow CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Iflow(AgentBootstrap):
AGENT_DIR = ".iflow"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Junie agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Junie(AgentBootstrap):
AGENT_DIR = ".junie"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Kilo Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Kilocode(AgentBootstrap):
AGENT_DIR = ".kilocode"
COMMANDS_SUBDIR = "workflows"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Kimi Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Kimi(AgentBootstrap):
AGENT_DIR = ".kimi"
COMMANDS_SUBDIR = "skills"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Kiro CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class KiroCli(AgentBootstrap):
AGENT_DIR = ".kiro"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for opencode agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Opencode(AgentBootstrap):
AGENT_DIR = ".opencode"
COMMANDS_SUBDIR = "command"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Pi Coding Agent agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Pi(AgentBootstrap):
AGENT_DIR = ".pi"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Qoder CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Qodercli(AgentBootstrap):
AGENT_DIR = ".qoder"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Qwen Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Qwen(AgentBootstrap):
AGENT_DIR = ".qwen"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Roo Code agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Roo(AgentBootstrap):
AGENT_DIR = ".roo"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for SHAI agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Shai(AgentBootstrap):
AGENT_DIR = ".shai"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Tabnine CLI agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Tabnine(AgentBootstrap):
AGENT_DIR = ".tabnine/agent"
COMMANDS_SUBDIR = "commands"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Trae agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Trae(AgentBootstrap):
AGENT_DIR = ".trae"
COMMANDS_SUBDIR = "rules"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Mistral Vibe agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Vibe(AgentBootstrap):
AGENT_DIR = ".vibe"
COMMANDS_SUBDIR = "prompts"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Windsurf agent pack."""
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Windsurf(AgentBootstrap):
AGENT_DIR = ".windsurf"
COMMANDS_SUBDIR = "workflows"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
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 [] # directories only — actual files are created by the init pipeline
def teardown(self, project_path: Path, *, force: bool = False) -> None:
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. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
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``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)

View File

@@ -27,6 +27,7 @@ from specify_cli.agent_pack import (
_sha256,
check_modified_files,
export_pack,
get_tracked_files,
list_all_agents,
list_embedded_agents,
load_bootstrap,
@@ -79,17 +80,18 @@ def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: s
bootstrap_file = pack_dir / BOOTSTRAP_FILENAME
bootstrap_file.write_text(textwrap.dedent(f"""\
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class {class_name}(AgentBootstrap):
AGENT_DIR = "{agent_dir}"
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
(project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True)
return []
def teardown(self, project_path: Path, *, force: bool = False) -> None:
remove_tracked_files(project_path, self.manifest.id, force=force)
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)
"""), encoding="utf-8")
return bootstrap_file
@@ -273,20 +275,31 @@ class TestBootstrapContract:
project = tmp_path / "project"
project.mkdir()
b.setup(project, "sh", {})
agent_files = b.setup(project, "sh", {})
assert isinstance(agent_files, list)
assert (project / ".test-agent" / "commands").is_dir()
# Simulate the init pipeline writing a file
cmd_file = project / ".test-agent" / "commands" / "hello.md"
cmd_file.write_text("hello", encoding="utf-8")
# finalize_setup records files for tracking
b.finalize_setup(project)
# Simulate extension registration writing a file
ext_file = project / ".test-agent" / "commands" / "ext-cmd.md"
ext_file.write_text("ext", encoding="utf-8")
# finalize_setup records both agent and extension files
b.finalize_setup(project, agent_files=agent_files, extension_files=[ext_file])
assert _manifest_path(project, "test-agent").is_file()
# Verify the manifest separates agent and extension files
manifest_data = json.loads(_manifest_path(project, "test-agent").read_text())
assert "agent_files" in manifest_data
assert "extension_files" in manifest_data
b.teardown(project)
# The tracked file should be removed
# The tracked files should be removed
assert not cmd_file.exists()
assert not ext_file.exists()
# Install manifest itself should be cleaned up
assert not _manifest_path(project, "test-agent").is_file()
# Directories are preserved (only files are removed)
@@ -557,7 +570,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True)
f.write_text("hello world", encoding="utf-8")
record_installed_files(project, "myagent", [f])
record_installed_files(project, "myagent", agent_files=[f])
# No modifications yet
assert check_modified_files(project, "myagent") == []
@@ -571,7 +584,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8")
record_installed_files(project, "myagent", [f])
record_installed_files(project, "myagent", agent_files=[f])
# Now modify the file
f.write_text("modified content", encoding="utf-8")
@@ -595,7 +608,7 @@ class TestFileTracking:
f1.write_text("aaa", encoding="utf-8")
f2.write_text("bbb", encoding="utf-8")
record_installed_files(project, "ag", [f1, f2])
record_installed_files(project, "ag", agent_files=[f1, f2])
removed = remove_tracked_files(project, "ag")
assert len(removed) == 2
@@ -615,7 +628,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8")
record_installed_files(project, "ag", [f])
record_installed_files(project, "ag", agent_files=[f])
f.write_text("user-edited", encoding="utf-8")
with pytest.raises(AgentFileModifiedError, match="modified"):
@@ -633,7 +646,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8")
record_installed_files(project, "ag", [f])
record_installed_files(project, "ag", agent_files=[f])
f.write_text("user-edited", encoding="utf-8")
removed = remove_tracked_files(project, "ag", force=True)
@@ -655,7 +668,7 @@ class TestFileTracking:
f = d / "deep.md"
f.write_text("deep", encoding="utf-8")
record_installed_files(project, "myagent", [f])
record_installed_files(project, "myagent", agent_files=[f])
remove_tracked_files(project, "myagent")
assert not f.exists()
@@ -672,7 +685,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True)
f.write_text("data", encoding="utf-8")
record_installed_files(project, "ag", [f])
record_installed_files(project, "ag", agent_files=[f])
# User deletes the file before teardown
f.unlink()
@@ -700,10 +713,98 @@ class TestFileTracking:
f.parent.mkdir(parents=True)
f.write_text("content", encoding="utf-8")
manifest_file = record_installed_files(project, "ag", [f])
manifest_file = record_installed_files(project, "ag", agent_files=[f])
data = json.loads(manifest_file.read_text(encoding="utf-8"))
assert data["agent_id"] == "ag"
assert isinstance(data["files"], dict)
assert ".ag/x.md" in data["files"]
assert len(data["files"][".ag/x.md"]) == 64
assert isinstance(data["agent_files"], dict)
assert ".ag/x.md" in data["agent_files"]
assert len(data["agent_files"][".ag/x.md"]) == 64
# -- New: categorised manifest & explicit file teardown --
def test_manifest_categorises_agent_and_extension_files(self, tmp_path):
"""record_installed_files stores agent and extension files separately."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
agent_f = project / ".ag" / "core.md"
ext_f = project / ".ag" / "ext-cmd.md"
agent_f.parent.mkdir(parents=True)
agent_f.write_text("core", encoding="utf-8")
ext_f.write_text("ext", encoding="utf-8")
manifest_file = record_installed_files(
project, "ag", agent_files=[agent_f], extension_files=[ext_f]
)
data = json.loads(manifest_file.read_text(encoding="utf-8"))
assert ".ag/core.md" in data["agent_files"]
assert ".ag/ext-cmd.md" in data["extension_files"]
assert ".ag/core.md" not in data.get("extension_files", {})
assert ".ag/ext-cmd.md" not in data.get("agent_files", {})
def test_get_tracked_files_returns_both_categories(self, tmp_path):
"""get_tracked_files splits agent and extension files."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
agent_f = project / ".ag" / "a.md"
ext_f = project / ".ag" / "e.md"
agent_f.parent.mkdir(parents=True)
agent_f.write_text("a", encoding="utf-8")
ext_f.write_text("e", encoding="utf-8")
record_installed_files(
project, "ag", agent_files=[agent_f], extension_files=[ext_f]
)
agent_files, extension_files = get_tracked_files(project, "ag")
assert ".ag/a.md" in agent_files
assert ".ag/e.md" in extension_files
def test_get_tracked_files_no_manifest(self, tmp_path):
"""get_tracked_files returns ({}, {}) when no manifest exists."""
agent_files, extension_files = get_tracked_files(tmp_path, "nope")
assert agent_files == {}
assert extension_files == {}
def test_teardown_with_explicit_files(self, tmp_path):
"""teardown accepts explicit files dict (CLI-driven teardown)."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
f1 = project / ".ag" / "a.md"
f2 = project / ".ag" / "b.md"
f1.parent.mkdir(parents=True)
f1.write_text("aaa", encoding="utf-8")
f2.write_text("bbb", encoding="utf-8")
# Record the files
record_installed_files(project, "ag", agent_files=[f1, f2])
# Get the tracked entries
agent_entries, _ = get_tracked_files(project, "ag")
# Pass explicit files to remove_tracked_files
removed = remove_tracked_files(project, "ag", files=agent_entries)
assert len(removed) == 2
assert not f1.exists()
assert not f2.exists()
def test_check_detects_extension_file_modification(self, tmp_path):
"""Modified extension files are also detected by check_modified_files."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
ext_f = project / ".ag" / "ext.md"
ext_f.parent.mkdir(parents=True)
ext_f.write_text("original", encoding="utf-8")
record_installed_files(project, "ag", extension_files=[ext_f])
ext_f.write_text("user-edited", encoding="utf-8")
modified = check_modified_files(project, "ag")
assert len(modified) == 1
assert ".ag/ext.md" in modified[0]