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 stat
import yaml import yaml
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Tuple from typing import Any, List, Optional, Tuple
import typer import typer
import httpx import httpx
@@ -2543,9 +2543,10 @@ def agent_switch(
from .agent_pack import ( from .agent_pack import (
resolve_agent_pack, resolve_agent_pack,
load_bootstrap, load_bootstrap,
check_modified_files,
get_tracked_files,
PackResolutionError, PackResolutionError,
AgentPackError, AgentPackError,
AgentFileModifiedError,
) )
show_banner() show_banner()
@@ -2582,13 +2583,28 @@ def agent_switch(
try: try:
current_resolved = resolve_agent_pack(current_agent, project_path=project_path) current_resolved = resolve_agent_pack(current_agent, project_path=project_path)
current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest) 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]") 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") 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: except AgentPackError:
# If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG # If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG
agent_config = AGENT_CONFIG.get(current_agent, {}) agent_config = AGENT_CONFIG.get(current_agent, {})
@@ -2603,9 +2619,7 @@ def agent_switch(
try: try:
new_bootstrap = load_bootstrap(resolved.path, resolved.manifest) new_bootstrap = load_bootstrap(resolved.path, resolved.manifest)
console.print(f" [dim]Setting up {agent_id}...[/dim]") console.print(f" [dim]Setting up {agent_id}...[/dim]")
new_bootstrap.setup(project_path, script_type, options) agent_files = new_bootstrap.setup(project_path, script_type, options)
# Record all installed files for tracked teardown
new_bootstrap.finalize_setup(project_path)
console.print(f" [green]✓[/green] {agent_id} installed") console.print(f" [green]✓[/green] {agent_id} installed")
except AgentPackError as exc: except AgentPackError as exc:
console.print(f"[red]Error setting up {agent_id}:[/red] {exc}") console.print(f"[red]Error setting up {agent_id}:[/red] {exc}")
@@ -2614,32 +2628,54 @@ def agent_switch(
# Update init options # Update init options
options["ai"] = agent_id options["ai"] = agent_id
init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8") init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8")
console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]")
# Re-register extension commands for the new agent # 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: def _reregister_extension_commands(project_path: Path, agent_id: str) -> List[Path]:
"""Re-register all installed extension commands for a new agent after switching.""" """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" registry_file = project_path / ".specify" / "extensions" / ".registry"
if not registry_file.is_file(): if not registry_file.is_file():
return return created_files
try: try:
registry_data = json.loads(registry_file.read_text(encoding="utf-8")) registry_data = json.loads(registry_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
return return created_files
extensions = registry_data.get("extensions", {}) extensions = registry_data.get("extensions", {})
if not extensions: if not extensions:
return return created_files
try: try:
from .agents import CommandRegistrar from .agents import CommandRegistrar
registrar = CommandRegistrar() registrar = CommandRegistrar()
except ImportError: 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 reregistered = 0
for ext_id, ext_data in extensions.items(): 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: except Exception:
continue 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: 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") @agent_app.command("search")

View File

@@ -184,35 +184,58 @@ class AgentBootstrap:
# -- lifecycle ----------------------------------------------------------- # -- 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*. """Install agent files into *project_path*.
This is invoked by ``specify init --ai <agent>`` and This is invoked by ``specify init --ai <agent>`` and
``specify agent switch <agent>``. ``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: Args:
project_path: Target project directory. project_path: Target project directory.
script_type: ``"sh"`` or ``"ps"``. script_type: ``"sh"`` or ``"ps"``.
options: Arbitrary key/value options forwarded from the CLI. options: Arbitrary key/value options forwarded from the CLI.
Returns:
List of absolute paths of files created during setup.
""" """
raise NotImplementedError 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*. """Remove agent-specific files from *project_path*.
Invoked by ``specify agent switch`` (for the *old* agent) and Invoked by ``specify agent switch`` (for the *old* agent) and
``specify agent remove`` when the user explicitly uninstalls. ``specify agent remove`` when the user explicitly uninstalls.
Must preserve shared infrastructure (specs, plans, tasks, etc.). Must preserve shared infrastructure (specs, plans, tasks, etc.).
Only individual files recorded in the install manifest are removed Only individual files are removed — directories are **never**
— directories are never deleted. If any tracked file has been deleted.
modified since installation and *force* is ``False``, raises
:class:`AgentFileModifiedError`. 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: Args:
project_path: Project directory to clean up. project_path: Project directory to clean up.
force: When ``True``, remove files even if they were modified force: When ``True``, remove files even if they were modified
after installation. 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 raise NotImplementedError
@@ -222,21 +245,44 @@ class AgentBootstrap:
"""Return the agent's top-level directory inside the project.""" """Return the agent's top-level directory inside the project."""
return project_path / self.manifest.commands_dir.split("/")[0] return project_path / self.manifest.commands_dir.split("/")[0]
def finalize_setup(self, project_path: Path) -> None: def finalize_setup(
"""Record all files in the agent directory for tracked teardown. 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 This must be called **after** the full init pipeline has finished
writing files (commands, context files, etc.) into the agent writing files (commands, context files, extensions) into the
directory. It scans ``self.manifest.commands_dir`` and records project. It combines the files reported by :meth:`setup` with
every file with its SHA-256 hash so that :meth:`teardown` can any extra files (e.g. from extension registration), scans the
detect user modifications. 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: all_agent = list(agent_files or [])
return all_extension = list(extension_files or [])
commands_dir = project_path / self.manifest.commands_dir
if commands_dir.is_dir(): # Also scan the commands directory for files created by the
installed = [p for p in commands_dir.rglob("*") if p.is_file()] # init pipeline that setup() did not report directly.
record_installed_files(project_path, self.manifest.id, installed) 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() return h.hexdigest()
def record_installed_files( def _hash_file_list(
project_path: Path, project_path: Path,
agent_id: str,
files: List[Path], files: List[Path],
) -> Path: ) -> Dict[str, str]:
"""Record the installed files and their SHA-256 hashes. """Build a {relative_path: sha256} dict from a list of file paths."""
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.
"""
entries: Dict[str, str] = {} entries: Dict[str, str] = {}
for file_path in files: for file_path in files:
abs_path = project_path / file_path if not file_path.is_absolute() else file_path abs_path = project_path / file_path if not file_path.is_absolute() else file_path
if abs_path.is_file(): if abs_path.is_file():
rel = str(abs_path.relative_to(project_path)) rel = str(abs_path.relative_to(project_path))
entries[rel] = _sha256(abs_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 = _manifest_path(project_path, agent_id)
manifest_file.parent.mkdir(parents=True, exist_ok=True) manifest_file.parent.mkdir(parents=True, exist_ok=True)
manifest_file.write_text( 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", encoding="utf-8",
) )
return manifest_file 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( def check_modified_files(
project_path: Path, project_path: Path,
agent_id: str, agent_id: str,
@@ -310,8 +422,10 @@ def check_modified_files(
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
return [] return []
entries = _all_tracked_entries(data)
modified: List[str] = [] 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 abs_path = project_path / rel_path
if abs_path.is_file(): if abs_path.is_file():
if _sha256(abs_path) != original_hash: if _sha256(abs_path) != original_hash:
@@ -327,11 +441,18 @@ def remove_tracked_files(
agent_id: str, agent_id: str,
*, *,
force: bool = False, force: bool = False,
files: Optional[Dict[str, str]] = None,
) -> List[str]: ) -> 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 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. Directories are **never** deleted — only individual files.
@@ -339,32 +460,37 @@ def remove_tracked_files(
project_path: Project root directory. project_path: Project root directory.
agent_id: Agent identifier. agent_id: Agent identifier.
force: When ``True``, delete even modified files. force: When ``True``, delete even modified files.
files: Explicit mapping of project-relative path → hash. When
supplied, the install manifest is not consulted.
Returns: Returns:
List of project-relative paths that were removed. List of project-relative paths that were removed.
""" """
manifest_file = _manifest_path(project_path, agent_id) manifest_file = _manifest_path(project_path, agent_id)
if not manifest_file.is_file():
return []
try: if files is not None:
data = json.loads(manifest_file.read_text(encoding="utf-8")) entries = files
except (json.JSONDecodeError, OSError): else:
return [] 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", {}) entries = _all_tracked_entries(data)
if not entries: if not entries:
manifest_file.unlink(missing_ok=True) manifest_file.unlink(missing_ok=True)
return [] return []
if not force: if not force:
modified = check_modified_files(project_path, agent_id) modified = check_modified_files(project_path, agent_id)
if modified: if modified:
raise AgentFileModifiedError( raise AgentFileModifiedError(
f"The following agent files have been modified since installation:\n" f"The following agent files have been modified since installation:\n"
+ "\n".join(f" {p}" for p in modified) + "\n".join(f" {p}" for p in modified)
+ "\nUse --force to remove them anyway." + "\nUse --force to remove them anyway."
) )
removed: List[str] = [] removed: List[str] = []
for rel_path in entries: for rel_path in entries:
@@ -374,7 +500,8 @@ def remove_tracked_files(
removed.append(rel_path) removed.append(rel_path)
# Clean up the install manifest itself # 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 return removed

View File

@@ -1,7 +1,7 @@
"""Bootstrap module for Antigravity agent pack.""" """Bootstrap module for Antigravity agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Agy(AgentBootstrap):
AGENT_DIR = ".agent" AGENT_DIR = ".agent"
COMMANDS_SUBDIR = "commands" 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.""" """Install Antigravity agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Antigravity agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Amp agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Amp(AgentBootstrap):
AGENT_DIR = ".agents" AGENT_DIR = ".agents"
COMMANDS_SUBDIR = "commands" 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.""" """Install Amp agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Amp agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Auggie CLI agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Auggie(AgentBootstrap):
AGENT_DIR = ".augment" AGENT_DIR = ".augment"
COMMANDS_SUBDIR = "commands" 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.""" """Install Auggie CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Auggie CLI agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for IBM Bob agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Bob(AgentBootstrap):
AGENT_DIR = ".bob" AGENT_DIR = ".bob"
COMMANDS_SUBDIR = "commands" 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.""" """Install IBM Bob agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove IBM Bob agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Claude Code agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Claude(AgentBootstrap):
AGENT_DIR = ".claude" AGENT_DIR = ".claude"
COMMANDS_SUBDIR = "commands" 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.""" """Install Claude Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Claude Code agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for CodeBuddy agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Codebuddy(AgentBootstrap):
AGENT_DIR = ".codebuddy" AGENT_DIR = ".codebuddy"
COMMANDS_SUBDIR = "commands" 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.""" """Install CodeBuddy agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove CodeBuddy agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Codex CLI agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Codex(AgentBootstrap):
AGENT_DIR = ".agents" AGENT_DIR = ".agents"
COMMANDS_SUBDIR = "skills" 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.""" """Install Codex CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Codex CLI agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for GitHub Copilot agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Copilot(AgentBootstrap):
AGENT_DIR = ".github" AGENT_DIR = ".github"
COMMANDS_SUBDIR = "agents" 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.""" """Install GitHub Copilot agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove GitHub Copilot agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Cursor agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class CursorAgent(AgentBootstrap):
AGENT_DIR = ".cursor" AGENT_DIR = ".cursor"
COMMANDS_SUBDIR = "commands" 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.""" """Install Cursor agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Cursor agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Gemini CLI agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Gemini(AgentBootstrap):
AGENT_DIR = ".gemini" AGENT_DIR = ".gemini"
COMMANDS_SUBDIR = "commands" 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.""" """Install Gemini CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Gemini CLI agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for iFlow CLI agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Iflow(AgentBootstrap):
AGENT_DIR = ".iflow" AGENT_DIR = ".iflow"
COMMANDS_SUBDIR = "commands" 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.""" """Install iFlow CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove iFlow CLI agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Junie agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Junie(AgentBootstrap):
AGENT_DIR = ".junie" AGENT_DIR = ".junie"
COMMANDS_SUBDIR = "commands" 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.""" """Install Junie agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Junie agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Kilo Code agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Kilocode(AgentBootstrap):
AGENT_DIR = ".kilocode" AGENT_DIR = ".kilocode"
COMMANDS_SUBDIR = "workflows" 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.""" """Install Kilo Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Kilo Code agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Kimi Code agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Kimi(AgentBootstrap):
AGENT_DIR = ".kimi" AGENT_DIR = ".kimi"
COMMANDS_SUBDIR = "skills" 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.""" """Install Kimi Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Kimi Code agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Kiro CLI agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class KiroCli(AgentBootstrap):
AGENT_DIR = ".kiro" AGENT_DIR = ".kiro"
COMMANDS_SUBDIR = "prompts" 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.""" """Install Kiro CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Kiro CLI agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for opencode agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Opencode(AgentBootstrap):
AGENT_DIR = ".opencode" AGENT_DIR = ".opencode"
COMMANDS_SUBDIR = "command" 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.""" """Install opencode agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove opencode agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Pi Coding Agent agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Pi(AgentBootstrap):
AGENT_DIR = ".pi" AGENT_DIR = ".pi"
COMMANDS_SUBDIR = "prompts" 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.""" """Install Pi Coding Agent agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Pi Coding Agent agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Qoder CLI agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Qodercli(AgentBootstrap):
AGENT_DIR = ".qoder" AGENT_DIR = ".qoder"
COMMANDS_SUBDIR = "commands" 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.""" """Install Qoder CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Qoder CLI agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Qwen Code agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Qwen(AgentBootstrap):
AGENT_DIR = ".qwen" AGENT_DIR = ".qwen"
COMMANDS_SUBDIR = "commands" 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.""" """Install Qwen Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Qwen Code agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Roo Code agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Roo(AgentBootstrap):
AGENT_DIR = ".roo" AGENT_DIR = ".roo"
COMMANDS_SUBDIR = "commands" 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.""" """Install Roo Code agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Roo Code agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for SHAI agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Shai(AgentBootstrap):
AGENT_DIR = ".shai" AGENT_DIR = ".shai"
COMMANDS_SUBDIR = "commands" 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.""" """Install SHAI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove SHAI agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Tabnine CLI agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Tabnine(AgentBootstrap):
AGENT_DIR = ".tabnine/agent" AGENT_DIR = ".tabnine/agent"
COMMANDS_SUBDIR = "commands" 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.""" """Install Tabnine CLI agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Tabnine CLI agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Trae agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Trae(AgentBootstrap):
AGENT_DIR = ".trae" AGENT_DIR = ".trae"
COMMANDS_SUBDIR = "rules" 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.""" """Install Trae agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Trae agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Mistral Vibe agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Vibe(AgentBootstrap):
AGENT_DIR = ".vibe" AGENT_DIR = ".vibe"
COMMANDS_SUBDIR = "prompts" 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.""" """Install Mistral Vibe agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Mistral Vibe agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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.""" """Bootstrap module for Windsurf agent pack."""
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
@@ -12,16 +12,19 @@ class Windsurf(AgentBootstrap):
AGENT_DIR = ".windsurf" AGENT_DIR = ".windsurf"
COMMANDS_SUBDIR = "workflows" 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.""" """Install Windsurf agent files into the project."""
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True) 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. """Remove Windsurf agent files from the project.
Only removes individual tracked files — directories are never Only removes individual tracked files — directories are never
deleted. Raises ``AgentFileModifiedError`` if any tracked file deleted. When *files* is provided, exactly those files are
was modified and *force* is ``False``. 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, _sha256,
check_modified_files, check_modified_files,
export_pack, export_pack,
get_tracked_files,
list_all_agents, list_all_agents,
list_embedded_agents, list_embedded_agents,
load_bootstrap, 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 = pack_dir / BOOTSTRAP_FILENAME
bootstrap_file.write_text(textwrap.dedent(f"""\ bootstrap_file.write_text(textwrap.dedent(f"""\
from pathlib import Path 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 from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files
class {class_name}(AgentBootstrap): class {class_name}(AgentBootstrap):
AGENT_DIR = "{agent_dir}" 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) (project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True)
return []
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_tracked_files(project_path, self.manifest.id, force=force) return remove_tracked_files(project_path, self.manifest.id, force=force, files=files)
"""), encoding="utf-8") """), encoding="utf-8")
return bootstrap_file return bootstrap_file
@@ -273,20 +275,31 @@ class TestBootstrapContract:
project = tmp_path / "project" project = tmp_path / "project"
project.mkdir() project.mkdir()
b.setup(project, "sh", {}) agent_files = b.setup(project, "sh", {})
assert isinstance(agent_files, list)
assert (project / ".test-agent" / "commands").is_dir() assert (project / ".test-agent" / "commands").is_dir()
# Simulate the init pipeline writing a file # Simulate the init pipeline writing a file
cmd_file = project / ".test-agent" / "commands" / "hello.md" cmd_file = project / ".test-agent" / "commands" / "hello.md"
cmd_file.write_text("hello", encoding="utf-8") cmd_file.write_text("hello", encoding="utf-8")
# finalize_setup records files for tracking # Simulate extension registration writing a file
b.finalize_setup(project) 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() 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) b.teardown(project)
# The tracked file should be removed # The tracked files should be removed
assert not cmd_file.exists() assert not cmd_file.exists()
assert not ext_file.exists()
# Install manifest itself should be cleaned up # Install manifest itself should be cleaned up
assert not _manifest_path(project, "test-agent").is_file() assert not _manifest_path(project, "test-agent").is_file()
# Directories are preserved (only files are removed) # Directories are preserved (only files are removed)
@@ -557,7 +570,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True) f.parent.mkdir(parents=True)
f.write_text("hello world", encoding="utf-8") 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 # No modifications yet
assert check_modified_files(project, "myagent") == [] assert check_modified_files(project, "myagent") == []
@@ -571,7 +584,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True) f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8") 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 # Now modify the file
f.write_text("modified content", encoding="utf-8") f.write_text("modified content", encoding="utf-8")
@@ -595,7 +608,7 @@ class TestFileTracking:
f1.write_text("aaa", encoding="utf-8") f1.write_text("aaa", encoding="utf-8")
f2.write_text("bbb", 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") removed = remove_tracked_files(project, "ag")
assert len(removed) == 2 assert len(removed) == 2
@@ -615,7 +628,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True) f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8") 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") f.write_text("user-edited", encoding="utf-8")
with pytest.raises(AgentFileModifiedError, match="modified"): with pytest.raises(AgentFileModifiedError, match="modified"):
@@ -633,7 +646,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True) f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8") 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") f.write_text("user-edited", encoding="utf-8")
removed = remove_tracked_files(project, "ag", force=True) removed = remove_tracked_files(project, "ag", force=True)
@@ -655,7 +668,7 @@ class TestFileTracking:
f = d / "deep.md" f = d / "deep.md"
f.write_text("deep", encoding="utf-8") 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") remove_tracked_files(project, "myagent")
assert not f.exists() assert not f.exists()
@@ -672,7 +685,7 @@ class TestFileTracking:
f.parent.mkdir(parents=True) f.parent.mkdir(parents=True)
f.write_text("data", encoding="utf-8") 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 # User deletes the file before teardown
f.unlink() f.unlink()
@@ -700,10 +713,98 @@ class TestFileTracking:
f.parent.mkdir(parents=True) f.parent.mkdir(parents=True)
f.write_text("content", encoding="utf-8") 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")) data = json.loads(manifest_file.read_text(encoding="utf-8"))
assert data["agent_id"] == "ag" assert data["agent_id"] == "ag"
assert isinstance(data["files"], dict) assert isinstance(data["agent_files"], dict)
assert ".ag/x.md" in data["files"] assert ".ag/x.md" in data["agent_files"]
assert len(data["files"][".ag/x.md"]) == 64 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]