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")
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, {})

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.
"""
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-<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
# ---------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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