From b5a5e3fc356c9bd81337f82662a5ad037167c2c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:15:48 +0000 Subject: [PATCH] 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-.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 --- src/specify_cli/__init__.py | 8 +- src/specify_cli/agent_pack.py | 154 +++++++++++++- .../core_pack/agents/agy/bootstrap.py | 19 +- .../core_pack/agents/amp/bootstrap.py | 21 +- .../core_pack/agents/auggie/bootstrap.py | 19 +- .../core_pack/agents/bob/bootstrap.py | 19 +- .../core_pack/agents/claude/bootstrap.py | 19 +- .../core_pack/agents/codebuddy/bootstrap.py | 19 +- .../core_pack/agents/codex/bootstrap.py | 21 +- .../core_pack/agents/copilot/bootstrap.py | 21 +- .../agents/cursor-agent/bootstrap.py | 19 +- .../core_pack/agents/gemini/bootstrap.py | 19 +- .../core_pack/agents/iflow/bootstrap.py | 19 +- .../core_pack/agents/junie/bootstrap.py | 19 +- .../core_pack/agents/kilocode/bootstrap.py | 19 +- .../core_pack/agents/kimi/bootstrap.py | 19 +- .../core_pack/agents/kiro-cli/bootstrap.py | 19 +- .../core_pack/agents/opencode/bootstrap.py | 19 +- .../core_pack/agents/pi/bootstrap.py | 19 +- .../core_pack/agents/qodercli/bootstrap.py | 19 +- .../core_pack/agents/qwen/bootstrap.py | 19 +- .../core_pack/agents/roo/bootstrap.py | 19 +- .../core_pack/agents/shai/bootstrap.py | 19 +- .../core_pack/agents/tabnine/bootstrap.py | 21 +- .../core_pack/agents/trae/bootstrap.py | 19 +- .../core_pack/agents/vibe/bootstrap.py | 19 +- .../core_pack/agents/windsurf/bootstrap.py | 19 +- tests/test_agent_pack.py | 201 +++++++++++++++++- 28 files changed, 639 insertions(+), 207 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8623ed80..b2e905bc 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2533,6 +2533,7 @@ def agent_export( @agent_app.command("switch") def agent_switch( 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. @@ -2544,6 +2545,7 @@ def agent_switch( load_bootstrap, PackResolutionError, AgentPackError, + AgentFileModifiedError, ) show_banner() @@ -2581,8 +2583,12 @@ def agent_switch( current_resolved = resolve_agent_pack(current_agent, project_path=project_path) current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest) 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") + except AgentFileModifiedError as exc: + console.print(f"[red]Error:[/red] {exc}") + console.print("[yellow]Hint:[/yellow] Use --force to remove modified files.") + raise typer.Exit(1) except AgentPackError: # If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG agent_config = AGENT_CONFIG.get(current_agent, {}) diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index f3802136..e5fe05ca 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -14,7 +14,9 @@ The embedded packs ship inside the pip wheel so that `pip install specify-cli && specify init --ai claude` works offline. """ +import hashlib import importlib.util +import json import shutil from dataclasses import dataclass, field from pathlib import Path @@ -52,6 +54,10 @@ class PackResolutionError(AgentPackError): """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 # --------------------------------------------------------------------------- @@ -191,15 +197,22 @@ class AgentBootstrap: """ 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*. Invoked by ``specify agent switch`` (for the *old* agent) and ``specify agent remove`` when the user explicitly uninstalls. Must preserve shared infrastructure (specs, plans, tasks, etc.). + Only individual files recorded in the install manifest are removed + — directories are never deleted. If any tracked file has been + modified since installation and *force* is ``False``, raises + :class:`AgentFileModifiedError`. + Args: project_path: Project directory to clean up. + force: When ``True``, remove files even if they were modified + after installation. """ raise NotImplementedError @@ -210,6 +223,145 @@ class AgentBootstrap: 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-.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 # --------------------------------------------------------------------------- diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py index 4f0dd5a7..33bd5ba5 100644 --- a/src/specify_cli/core_pack/agents/agy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Agy(AgentBootstrap): """Install Antigravity agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Antigravity agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Antigravity agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py index e5e52021..236ec1c8 100644 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,18 +16,15 @@ class Amp(AgentBootstrap): """Install Amp agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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. - Only removes the commands/ subdirectory — preserves other .agents/ - content (e.g. Codex skills/) which shares the same parent directory. + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. """ - import shutil - 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() + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py index 7ff391b9..d05b3a3b 100644 --- a/src/specify_cli/core_pack/agents/auggie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Auggie(AgentBootstrap): """Install Auggie CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Auggie CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Auggie CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py index ab4052a8..876882b0 100644 --- a/src/specify_cli/core_pack/agents/bob/bootstrap.py +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Bob(AgentBootstrap): """Install IBM Bob agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove IBM Bob agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove IBM Bob agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py index a2a515ee..d4c255f2 100644 --- a/src/specify_cli/core_pack/agents/claude/bootstrap.py +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Claude(AgentBootstrap): """Install Claude Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Claude Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Claude Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py index a6f061ba..760741c1 100644 --- a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Codebuddy(AgentBootstrap): """Install CodeBuddy agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove CodeBuddy agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove CodeBuddy agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py index 82afbc64..ac7d2917 100644 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,18 +16,15 @@ class Codex(AgentBootstrap): """Install Codex CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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. - Only removes the skills/ subdirectory — preserves other .agents/ - content (e.g. Amp commands/) which shares the same parent directory. + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. """ - import shutil - 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() + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py index 052473d5..0eaa0dc4 100644 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,18 +16,15 @@ class Copilot(AgentBootstrap): """Install GitHub Copilot agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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. - Only removes the agents/ subdirectory — preserves other .github - content (workflows, issue templates, etc.). + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. """ - import shutil - 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() + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py index 0af4d914..b2573acd 100644 --- a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class CursorAgent(AgentBootstrap): """Install Cursor agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Cursor agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Cursor agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py index 8e18e5a7..5f20e31a 100644 --- a/src/specify_cli/core_pack/agents/gemini/bootstrap.py +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Gemini(AgentBootstrap): """Install Gemini CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Gemini CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Gemini CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py index d421924d..506cb79b 100644 --- a/src/specify_cli/core_pack/agents/iflow/bootstrap.py +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Iflow(AgentBootstrap): """Install iFlow CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove iFlow CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove iFlow CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py index 6748ec7d..5b3b1417 100644 --- a/src/specify_cli/core_pack/agents/junie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Junie(AgentBootstrap): """Install Junie agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Junie agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Junie agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py index f88f00f4..6b15f502 100644 --- a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Kilocode(AgentBootstrap): """Install Kilo Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Kilo Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Kilo Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py index 50b8ca29..6dbd5019 100644 --- a/src/specify_cli/core_pack/agents/kimi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Kimi(AgentBootstrap): """Install Kimi Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Kimi Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Kimi Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py index 1f2e1c21..b13a3669 100644 --- a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class KiroCli(AgentBootstrap): """Install Kiro CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Kiro CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Kiro CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py index b1cc30de..4a94a3ee 100644 --- a/src/specify_cli/core_pack/agents/opencode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Opencode(AgentBootstrap): """Install opencode agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove opencode agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove opencode agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py index 51b3cc7b..103f094c 100644 --- a/src/specify_cli/core_pack/agents/pi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Pi(AgentBootstrap): """Install Pi Coding Agent agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Pi Coding Agent agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Pi Coding Agent agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py index cbfb5c82..af170c99 100644 --- a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Qodercli(AgentBootstrap): """Install Qoder CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Qoder CLI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Qoder CLI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py index 186fe2ad..018ec1ae 100644 --- a/src/specify_cli/core_pack/agents/qwen/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Qwen(AgentBootstrap): """Install Qwen Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Qwen Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Qwen Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py index f1509314..c9cbcb37 100644 --- a/src/specify_cli/core_pack/agents/roo/bootstrap.py +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Roo(AgentBootstrap): """Install Roo Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Roo Code agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Roo Code agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py index 968618d1..49a45e82 100644 --- a/src/specify_cli/core_pack/agents/shai/bootstrap.py +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Shai(AgentBootstrap): """Install SHAI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove SHAI agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove SHAI agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py index 810a75c3..29780dfa 100644 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,18 +16,15 @@ class Tabnine(AgentBootstrap): """Install Tabnine CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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. - Removes the agent/ subdirectory under .tabnine/ to preserve - any other Tabnine configuration. + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. """ - import shutil - 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() + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py index 264be5b6..43c58b60 100644 --- a/src/specify_cli/core_pack/agents/trae/bootstrap.py +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Trae(AgentBootstrap): """Install Trae agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Trae agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Trae agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py index 955dece0..cb0ca8b5 100644 --- a/src/specify_cli/core_pack/agents/vibe/bootstrap.py +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Vibe(AgentBootstrap): """Install Mistral Vibe agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Mistral Vibe agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Mistral Vibe agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py index 13318618..1f8e4722 100644 --- a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -3,7 +3,7 @@ from pathlib import Path 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): @@ -16,10 +16,15 @@ class Windsurf(AgentBootstrap): """Install Windsurf agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + # 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: - """Remove Windsurf agent files from the project.""" - import shutil - agent_dir = project_path / self.AGENT_DIR - if agent_dir.is_dir(): - shutil.rmtree(agent_dir) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + """Remove Windsurf agent files from the project. + + Only removes individual tracked files — directories are never + deleted. Raises ``AgentFileModifiedError`` if any tracked file + was modified and *force* is ``False``. + """ + remove_tracked_files(project_path, self.manifest.id, force=force) diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index ae42f052..7df69a5c 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -17,15 +17,21 @@ from specify_cli.agent_pack import ( MANIFEST_FILENAME, MANIFEST_SCHEMA_VERSION, AgentBootstrap, + AgentFileModifiedError, AgentManifest, AgentPackError, ManifestValidationError, PackResolutionError, ResolvedPack, + _manifest_path, + _sha256, + check_modified_files, export_pack, list_all_agents, list_embedded_agents, load_bootstrap, + record_installed_files, + remove_tracked_files, resolve_agent_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"""\ from pathlib import Path 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): AGENT_DIR = "{agent_dir}" 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: - import shutil - d = project_path / self.AGENT_DIR - if d.is_dir(): - shutil.rmtree(d) + def teardown(self, project_path: Path, *, force: bool = False) -> None: + remove_tracked_files(project_path, self.manifest.id, force=force) """), encoding="utf-8") return bootstrap_file @@ -242,7 +248,7 @@ class TestBootstrapContract: m = AgentManifest.from_dict(_minimal_manifest_dict()) b = AgentBootstrap(m) with pytest.raises(NotImplementedError): - b.teardown(tmp_path) + b.teardown(tmp_path, force=False) def test_load_bootstrap(self, tmp_path): data = _minimal_manifest_dict() @@ -258,7 +264,7 @@ class TestBootstrapContract: load_bootstrap(tmp_path, m) 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" data = _minimal_manifest_dict() _write_manifest(pack_dir, data) @@ -273,8 +279,14 @@ class TestBootstrapContract: b.setup(project, "sh", {}) 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) - 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): """A bootstrap module without an AgentBootstrap subclass fails.""" @@ -522,3 +534,172 @@ class TestEmbeddedPacksConsistency: # Should not raise warnings = validate_pack(child) # 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