Add installed-file tracking with SHA-256 hashes for safe agent teardown

Setup records installed files and their SHA-256 hashes in
.specify/agent-manifest-<agent_id>.json. Teardown uses the manifest
to remove only individual files (never directories). If any tracked
file was modified since installation, teardown requires --force.

- Add record_installed_files(), check_modified_files(), remove_tracked_files()
  and AgentFileModifiedError to agent_pack.py
- Update all 25 bootstrap modules to use file-tracked setup/teardown
- Add --force flag to 'specify agent switch'
- Add 11 new tests for file tracking (record, check, remove, force,
  directory preservation, deleted-file handling, manifest structure)

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/779eabf6-21d5-428b-9f01-dd363df4c84a
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 21:15:48 +00:00
committed by GitHub
parent ec5471af61
commit b5a5e3fc35
28 changed files with 639 additions and 207 deletions

View File

@@ -2533,6 +2533,7 @@ def agent_export(
@agent_app.command("switch") @agent_app.command("switch")
def agent_switch( def agent_switch(
agent_id: str = typer.Argument(..., help="Agent pack ID to switch to"), agent_id: str = typer.Argument(..., help="Agent pack ID to switch to"),
force: bool = typer.Option(False, "--force", help="Remove agent files even if they were modified since installation"),
): ):
"""Switch the active AI agent in the current project. """Switch the active AI agent in the current project.
@@ -2544,6 +2545,7 @@ def agent_switch(
load_bootstrap, load_bootstrap,
PackResolutionError, PackResolutionError,
AgentPackError, AgentPackError,
AgentFileModifiedError,
) )
show_banner() show_banner()
@@ -2581,8 +2583,12 @@ def agent_switch(
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)
console.print(f" [dim]Tearing down {current_agent}...[/dim]") console.print(f" [dim]Tearing down {current_agent}...[/dim]")
current_bootstrap.teardown(project_path) current_bootstrap.teardown(project_path, force=force)
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, {})

View File

@@ -14,7 +14,9 @@ The embedded packs ship inside the pip wheel so that
`pip install specify-cli && specify init --ai claude` works offline. `pip install specify-cli && specify init --ai claude` works offline.
""" """
import hashlib
import importlib.util import importlib.util
import json
import shutil import shutil
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -52,6 +54,10 @@ class PackResolutionError(AgentPackError):
"""Raised when no pack can be found for the requested agent id.""" """Raised when no pack can be found for the requested agent id."""
class AgentFileModifiedError(AgentPackError):
"""Raised when teardown finds user-modified files and ``--force`` is not set."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Manifest # Manifest
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -191,15 +197,22 @@ class AgentBootstrap:
""" """
raise NotImplementedError raise NotImplementedError
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""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
— directories are never deleted. If any tracked file has been
modified since installation and *force* is ``False``, raises
:class:`AgentFileModifiedError`.
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
after installation.
""" """
raise NotImplementedError raise NotImplementedError
@@ -210,6 +223,145 @@ class AgentBootstrap:
return project_path / self.manifest.commands_dir.split("/")[0] return project_path / self.manifest.commands_dir.split("/")[0]
# ---------------------------------------------------------------------------
# Installed-file tracking
# ---------------------------------------------------------------------------
def _manifest_path(project_path: Path, agent_id: str) -> Path:
"""Return the path to the install manifest for *agent_id*."""
return project_path / ".specify" / f"agent-manifest-{agent_id}.json"
def _sha256(path: Path) -> str:
"""Return the hex SHA-256 of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def record_installed_files(
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.
"""
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)
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),
encoding="utf-8",
)
return manifest_file
def check_modified_files(
project_path: Path,
agent_id: str,
) -> List[str]:
"""Return project-relative paths of files modified since installation.
Returns an empty list when no install manifest exists or when every
tracked file still has its original hash.
"""
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 []
modified: List[str] = []
for rel_path, original_hash in data.get("files", {}).items():
abs_path = project_path / rel_path
if abs_path.is_file():
if _sha256(abs_path) != original_hash:
modified.append(rel_path)
# If the file was deleted by the user, treat it as not needing
# removal — skip rather than flag as modified.
return modified
def remove_tracked_files(
project_path: Path,
agent_id: str,
*,
force: bool = False,
) -> List[str]:
"""Remove the individual files recorded in the install manifest.
Raises :class:`AgentFileModifiedError` if any tracked file was
modified and *force* is ``False``.
Directories are **never** deleted — only individual files.
Args:
project_path: Project root directory.
agent_id: Agent identifier.
force: When ``True``, delete even modified files.
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 []
entries: Dict[str, str] = data.get("files", {})
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."
)
removed: List[str] = []
for rel_path in entries:
abs_path = project_path / rel_path
if abs_path.is_file():
abs_path.unlink()
removed.append(rel_path)
# Clean up the install manifest itself
manifest_file.unlink(missing_ok=True)
return removed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pack resolution # Pack resolution
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Agy(AgentBootstrap): class Agy(AgentBootstrap):
@@ -16,10 +16,15 @@ class Agy(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Antigravity agent files from the project.""" """Remove Antigravity agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Amp(AgentBootstrap): class Amp(AgentBootstrap):
@@ -16,18 +16,15 @@ class Amp(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Amp agent files from the project. """Remove Amp agent files from the project.
Only removes the commands/ subdirectory — preserves other .agents/ Only removes individual tracked files — directories are never
content (e.g. Codex skills/) which shares the same parent directory. deleted. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
""" """
import shutil remove_tracked_files(project_path, self.manifest.id, force=force)
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
if commands_dir.is_dir():
shutil.rmtree(commands_dir)
# Remove .agents/ only if now empty
agents_dir = project_path / self.AGENT_DIR
if agents_dir.is_dir() and not any(agents_dir.iterdir()):
agents_dir.rmdir()

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Auggie(AgentBootstrap): class Auggie(AgentBootstrap):
@@ -16,10 +16,15 @@ class Auggie(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Auggie CLI agent files from the project.""" """Remove Auggie CLI agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Bob(AgentBootstrap): class Bob(AgentBootstrap):
@@ -16,10 +16,15 @@ class Bob(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove IBM Bob agent files from the project.""" """Remove IBM Bob agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Claude(AgentBootstrap): class Claude(AgentBootstrap):
@@ -16,10 +16,15 @@ class Claude(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Claude Code agent files from the project.""" """Remove Claude Code agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Codebuddy(AgentBootstrap): class Codebuddy(AgentBootstrap):
@@ -16,10 +16,15 @@ class Codebuddy(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove CodeBuddy agent files from the project.""" """Remove CodeBuddy agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Codex(AgentBootstrap): class Codex(AgentBootstrap):
@@ -16,18 +16,15 @@ class Codex(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Codex CLI agent files from the project. """Remove Codex CLI agent files from the project.
Only removes the skills/ subdirectory — preserves other .agents/ Only removes individual tracked files — directories are never
content (e.g. Amp commands/) which shares the same parent directory. deleted. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
""" """
import shutil remove_tracked_files(project_path, self.manifest.id, force=force)
skills_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
if skills_dir.is_dir():
shutil.rmtree(skills_dir)
# Remove .agents/ only if now empty
agents_dir = project_path / self.AGENT_DIR
if agents_dir.is_dir() and not any(agents_dir.iterdir()):
agents_dir.rmdir()

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Copilot(AgentBootstrap): class Copilot(AgentBootstrap):
@@ -16,18 +16,15 @@ class Copilot(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove GitHub Copilot agent files from the project. """Remove GitHub Copilot agent files from the project.
Only removes the agents/ subdirectory — preserves other .github Only removes individual tracked files — directories are never
content (workflows, issue templates, etc.). deleted. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
""" """
import shutil remove_tracked_files(project_path, self.manifest.id, force=force)
agents_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
if agents_dir.is_dir():
shutil.rmtree(agents_dir)
# Also clean up companion .github/prompts/ if empty
prompts_dir = project_path / self.AGENT_DIR / "prompts"
if prompts_dir.is_dir() and not any(prompts_dir.iterdir()):
prompts_dir.rmdir()

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class CursorAgent(AgentBootstrap): class CursorAgent(AgentBootstrap):
@@ -16,10 +16,15 @@ class CursorAgent(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Cursor agent files from the project.""" """Remove Cursor agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Gemini(AgentBootstrap): class Gemini(AgentBootstrap):
@@ -16,10 +16,15 @@ class Gemini(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Gemini CLI agent files from the project.""" """Remove Gemini CLI agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Iflow(AgentBootstrap): class Iflow(AgentBootstrap):
@@ -16,10 +16,15 @@ class Iflow(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove iFlow CLI agent files from the project.""" """Remove iFlow CLI agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Junie(AgentBootstrap): class Junie(AgentBootstrap):
@@ -16,10 +16,15 @@ class Junie(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Junie agent files from the project.""" """Remove Junie agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Kilocode(AgentBootstrap): class Kilocode(AgentBootstrap):
@@ -16,10 +16,15 @@ class Kilocode(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Kilo Code agent files from the project.""" """Remove Kilo Code agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Kimi(AgentBootstrap): class Kimi(AgentBootstrap):
@@ -16,10 +16,15 @@ class Kimi(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Kimi Code agent files from the project.""" """Remove Kimi Code agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class KiroCli(AgentBootstrap): class KiroCli(AgentBootstrap):
@@ -16,10 +16,15 @@ class KiroCli(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Kiro CLI agent files from the project.""" """Remove Kiro CLI agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Opencode(AgentBootstrap): class Opencode(AgentBootstrap):
@@ -16,10 +16,15 @@ class Opencode(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove opencode agent files from the project.""" """Remove opencode agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Pi(AgentBootstrap): class Pi(AgentBootstrap):
@@ -16,10 +16,15 @@ class Pi(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Pi Coding Agent agent files from the project.""" """Remove Pi Coding Agent agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Qodercli(AgentBootstrap): class Qodercli(AgentBootstrap):
@@ -16,10 +16,15 @@ class Qodercli(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Qoder CLI agent files from the project.""" """Remove Qoder CLI agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Qwen(AgentBootstrap): class Qwen(AgentBootstrap):
@@ -16,10 +16,15 @@ class Qwen(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Qwen Code agent files from the project.""" """Remove Qwen Code agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Roo(AgentBootstrap): class Roo(AgentBootstrap):
@@ -16,10 +16,15 @@ class Roo(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Roo Code agent files from the project.""" """Remove Roo Code agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Shai(AgentBootstrap): class Shai(AgentBootstrap):
@@ -16,10 +16,15 @@ class Shai(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove SHAI agent files from the project.""" """Remove SHAI agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Tabnine(AgentBootstrap): class Tabnine(AgentBootstrap):
@@ -16,18 +16,15 @@ class Tabnine(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Tabnine CLI agent files from the project. """Remove Tabnine CLI agent files from the project.
Removes the agent/ subdirectory under .tabnine/ to preserve Only removes individual tracked files — directories are never
any other Tabnine configuration. deleted. Raises ``AgentFileModifiedError`` if any tracked file
was modified and *force* is ``False``.
""" """
import shutil remove_tracked_files(project_path, self.manifest.id, force=force)
agent_subdir = project_path / self.AGENT_DIR
if agent_subdir.is_dir():
shutil.rmtree(agent_subdir)
# Remove .tabnine/ only if now empty
tabnine_dir = project_path / ".tabnine"
if tabnine_dir.is_dir() and not any(tabnine_dir.iterdir()):
tabnine_dir.rmdir()

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Trae(AgentBootstrap): class Trae(AgentBootstrap):
@@ -16,10 +16,15 @@ class Trae(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Trae agent files from the project.""" """Remove Trae agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Vibe(AgentBootstrap): class Vibe(AgentBootstrap):
@@ -16,10 +16,15 @@ class Vibe(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Mistral Vibe agent files from the project.""" """Remove Mistral Vibe agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
class Windsurf(AgentBootstrap): class Windsurf(AgentBootstrap):
@@ -16,10 +16,15 @@ class Windsurf(AgentBootstrap):
"""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)
# Record installed files for tracked teardown
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
"""Remove Windsurf agent files from the project.""" """Remove Windsurf agent files from the project.
import shutil
agent_dir = project_path / self.AGENT_DIR Only removes individual tracked files — directories are never
if agent_dir.is_dir(): deleted. Raises ``AgentFileModifiedError`` if any tracked file
shutil.rmtree(agent_dir) was modified and *force* is ``False``.
"""
remove_tracked_files(project_path, self.manifest.id, force=force)

View File

@@ -17,15 +17,21 @@ from specify_cli.agent_pack import (
MANIFEST_FILENAME, MANIFEST_FILENAME,
MANIFEST_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION,
AgentBootstrap, AgentBootstrap,
AgentFileModifiedError,
AgentManifest, AgentManifest,
AgentPackError, AgentPackError,
ManifestValidationError, ManifestValidationError,
PackResolutionError, PackResolutionError,
ResolvedPack, ResolvedPack,
_manifest_path,
_sha256,
check_modified_files,
export_pack, export_pack,
list_all_agents, list_all_agents,
list_embedded_agents, list_embedded_agents,
load_bootstrap, load_bootstrap,
record_installed_files,
remove_tracked_files,
resolve_agent_pack, resolve_agent_pack,
validate_pack, validate_pack,
) )
@@ -74,19 +80,19 @@ def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: s
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
from specify_cli.agent_pack import AgentBootstrap from specify_cli.agent_pack import AgentBootstrap, record_installed_files, 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]) -> None:
(project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True) commands_dir = project_path / self.AGENT_DIR / "commands"
commands_dir.mkdir(parents=True, exist_ok=True)
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
record_installed_files(project_path, self.manifest.id, installed)
def teardown(self, project_path: Path) -> None: def teardown(self, project_path: Path, *, force: bool = False) -> None:
import shutil remove_tracked_files(project_path, self.manifest.id, force=force)
d = project_path / self.AGENT_DIR
if d.is_dir():
shutil.rmtree(d)
"""), encoding="utf-8") """), encoding="utf-8")
return bootstrap_file return bootstrap_file
@@ -242,7 +248,7 @@ class TestBootstrapContract:
m = AgentManifest.from_dict(_minimal_manifest_dict()) m = AgentManifest.from_dict(_minimal_manifest_dict())
b = AgentBootstrap(m) b = AgentBootstrap(m)
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
b.teardown(tmp_path) b.teardown(tmp_path, force=False)
def test_load_bootstrap(self, tmp_path): def test_load_bootstrap(self, tmp_path):
data = _minimal_manifest_dict() data = _minimal_manifest_dict()
@@ -258,7 +264,7 @@ class TestBootstrapContract:
load_bootstrap(tmp_path, m) load_bootstrap(tmp_path, m)
def test_bootstrap_setup_and_teardown(self, tmp_path): def test_bootstrap_setup_and_teardown(self, tmp_path):
"""Verify a loaded bootstrap can set up and tear down.""" """Verify a loaded bootstrap can set up and tear down via file tracking."""
pack_dir = tmp_path / "pack" pack_dir = tmp_path / "pack"
data = _minimal_manifest_dict() data = _minimal_manifest_dict()
_write_manifest(pack_dir, data) _write_manifest(pack_dir, data)
@@ -273,8 +279,14 @@ class TestBootstrapContract:
b.setup(project, "sh", {}) b.setup(project, "sh", {})
assert (project / ".test-agent" / "commands").is_dir() assert (project / ".test-agent" / "commands").is_dir()
# The install manifest should exist in .specify/
assert _manifest_path(project, "test-agent").is_file()
b.teardown(project) b.teardown(project)
assert not (project / ".test-agent").exists() # Install manifest itself should be cleaned up
assert not _manifest_path(project, "test-agent").is_file()
# Directories are preserved (only files are removed)
assert (project / ".test-agent" / "commands").is_dir()
def test_load_bootstrap_no_subclass(self, tmp_path): def test_load_bootstrap_no_subclass(self, tmp_path):
"""A bootstrap module without an AgentBootstrap subclass fails.""" """A bootstrap module without an AgentBootstrap subclass fails."""
@@ -522,3 +534,172 @@ class TestEmbeddedPacksConsistency:
# Should not raise # Should not raise
warnings = validate_pack(child) warnings = validate_pack(child)
# Warnings are acceptable; hard errors are not # Warnings are acceptable; hard errors are not
# ===================================================================
# File tracking (record / check / remove)
# ===================================================================
class TestFileTracking:
"""Verify installed-file tracking with hashes."""
def test_record_and_check_unmodified(self, tmp_path):
"""Files recorded at install time are reported as unmodified."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
# Create a file to track
f = project / ".myagent" / "commands" / "hello.md"
f.parent.mkdir(parents=True)
f.write_text("hello world", encoding="utf-8")
record_installed_files(project, "myagent", [f])
# No modifications yet
assert check_modified_files(project, "myagent") == []
def test_check_detects_modification(self, tmp_path):
"""A modified file is reported by check_modified_files()."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
f = project / ".myagent" / "cmd.md"
f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8")
record_installed_files(project, "myagent", [f])
# Now modify the file
f.write_text("modified content", encoding="utf-8")
modified = check_modified_files(project, "myagent")
assert len(modified) == 1
assert ".myagent/cmd.md" in modified[0]
def test_check_no_manifest(self, tmp_path):
"""check_modified_files returns [] when no manifest exists."""
assert check_modified_files(tmp_path, "nonexistent") == []
def test_remove_tracked_unmodified(self, tmp_path):
"""remove_tracked_files deletes unmodified files."""
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_installed_files(project, "ag", [f1, f2])
removed = remove_tracked_files(project, "ag")
assert len(removed) == 2
assert not f1.exists()
assert not f2.exists()
# Directories are preserved
assert f1.parent.is_dir()
# Install manifest is cleaned up
assert not _manifest_path(project, "ag").is_file()
def test_remove_tracked_modified_without_force_raises(self, tmp_path):
"""Removing modified files without --force raises AgentFileModifiedError."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
f = project / ".ag" / "c.md"
f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8")
record_installed_files(project, "ag", [f])
f.write_text("user-edited", encoding="utf-8")
with pytest.raises(AgentFileModifiedError, match="modified"):
remove_tracked_files(project, "ag", force=False)
# File should still exist
assert f.is_file()
def test_remove_tracked_modified_with_force(self, tmp_path):
"""Removing modified files with --force succeeds."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
f = project / ".ag" / "d.md"
f.parent.mkdir(parents=True)
f.write_text("original", encoding="utf-8")
record_installed_files(project, "ag", [f])
f.write_text("user-edited", encoding="utf-8")
removed = remove_tracked_files(project, "ag", force=True)
assert len(removed) == 1
assert not f.is_file()
def test_remove_no_manifest(self, tmp_path):
"""remove_tracked_files returns [] when no manifest exists."""
removed = remove_tracked_files(tmp_path, "nonexistent")
assert removed == []
def test_remove_preserves_directories(self, tmp_path):
"""Directories are never deleted, even when all files are removed."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
d = project / ".myagent" / "commands" / "sub"
d.mkdir(parents=True)
f = d / "deep.md"
f.write_text("deep", encoding="utf-8")
record_installed_files(project, "myagent", [f])
remove_tracked_files(project, "myagent")
assert not f.exists()
# All parent directories remain
assert d.is_dir()
assert (project / ".myagent").is_dir()
def test_deleted_file_skipped_gracefully(self, tmp_path):
"""A file deleted by the user before teardown is silently skipped."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
f = project / ".ag" / "gone.md"
f.parent.mkdir(parents=True)
f.write_text("data", encoding="utf-8")
record_installed_files(project, "ag", [f])
# User deletes the file before teardown
f.unlink()
# Should not raise, and should not report as modified
assert check_modified_files(project, "ag") == []
removed = remove_tracked_files(project, "ag")
assert removed == []
def test_sha256_consistency(self, tmp_path):
"""_sha256 produces consistent hashes."""
f = tmp_path / "test.txt"
f.write_text("hello", encoding="utf-8")
h1 = _sha256(f)
h2 = _sha256(f)
assert h1 == h2
assert len(h1) == 64 # SHA-256 hex length
def test_manifest_json_structure(self, tmp_path):
"""The install manifest has the expected JSON structure."""
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
f = project / ".ag" / "x.md"
f.parent.mkdir(parents=True)
f.write_text("content", encoding="utf-8")
manifest_file = record_installed_files(project, "ag", [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