mirror of
https://github.com/github/spec-kit.git
synced 2026-03-23 22:03:08 +00:00
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:
committed by
GitHub
parent
ec5471af61
commit
b5a5e3fc35
@@ -2533,6 +2533,7 @@ def agent_export(
|
|||||||
@agent_app.command("switch")
|
@agent_app.command("switch")
|
||||||
def agent_switch(
|
def agent_switch(
|
||||||
agent_id: str = typer.Argument(..., help="Agent pack ID to switch to"),
|
agent_id: str = typer.Argument(..., help="Agent pack ID to switch to"),
|
||||||
|
force: bool = typer.Option(False, "--force", help="Remove agent files even if they were modified since installation"),
|
||||||
):
|
):
|
||||||
"""Switch the active AI agent in the current project.
|
"""Switch the active AI agent in the current project.
|
||||||
|
|
||||||
@@ -2544,6 +2545,7 @@ def agent_switch(
|
|||||||
load_bootstrap,
|
load_bootstrap,
|
||||||
PackResolutionError,
|
PackResolutionError,
|
||||||
AgentPackError,
|
AgentPackError,
|
||||||
|
AgentFileModifiedError,
|
||||||
)
|
)
|
||||||
|
|
||||||
show_banner()
|
show_banner()
|
||||||
@@ -2581,8 +2583,12 @@ def agent_switch(
|
|||||||
current_resolved = resolve_agent_pack(current_agent, project_path=project_path)
|
current_resolved = resolve_agent_pack(current_agent, project_path=project_path)
|
||||||
current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest)
|
current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest)
|
||||||
console.print(f" [dim]Tearing down {current_agent}...[/dim]")
|
console.print(f" [dim]Tearing down {current_agent}...[/dim]")
|
||||||
current_bootstrap.teardown(project_path)
|
current_bootstrap.teardown(project_path, force=force)
|
||||||
console.print(f" [green]✓[/green] {current_agent} removed")
|
console.print(f" [green]✓[/green] {current_agent} removed")
|
||||||
|
except AgentFileModifiedError as exc:
|
||||||
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
console.print("[yellow]Hint:[/yellow] Use --force to remove modified files.")
|
||||||
|
raise typer.Exit(1)
|
||||||
except AgentPackError:
|
except AgentPackError:
|
||||||
# If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG
|
# If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG
|
||||||
agent_config = AGENT_CONFIG.get(current_agent, {})
|
agent_config = AGENT_CONFIG.get(current_agent, {})
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ The embedded packs ship inside the pip wheel so that
|
|||||||
`pip install specify-cli && specify init --ai claude` works offline.
|
`pip install specify-cli && specify init --ai claude` works offline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -52,6 +54,10 @@ class PackResolutionError(AgentPackError):
|
|||||||
"""Raised when no pack can be found for the requested agent id."""
|
"""Raised when no pack can be found for the requested agent id."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentFileModifiedError(AgentPackError):
|
||||||
|
"""Raised when teardown finds user-modified files and ``--force`` is not set."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Manifest
|
# Manifest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -191,15 +197,22 @@ class AgentBootstrap:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove agent-specific files from *project_path*.
|
"""Remove agent-specific files from *project_path*.
|
||||||
|
|
||||||
Invoked by ``specify agent switch`` (for the *old* agent) and
|
Invoked by ``specify agent switch`` (for the *old* agent) and
|
||||||
``specify agent remove`` when the user explicitly uninstalls.
|
``specify agent remove`` when the user explicitly uninstalls.
|
||||||
Must preserve shared infrastructure (specs, plans, tasks, etc.).
|
Must preserve shared infrastructure (specs, plans, tasks, etc.).
|
||||||
|
|
||||||
|
Only individual files recorded in the install manifest are removed
|
||||||
|
— directories are never deleted. If any tracked file has been
|
||||||
|
modified since installation and *force* is ``False``, raises
|
||||||
|
:class:`AgentFileModifiedError`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_path: Project directory to clean up.
|
project_path: Project directory to clean up.
|
||||||
|
force: When ``True``, remove files even if they were modified
|
||||||
|
after installation.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -210,6 +223,145 @@ class AgentBootstrap:
|
|||||||
return project_path / self.manifest.commands_dir.split("/")[0]
|
return project_path / self.manifest.commands_dir.split("/")[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Installed-file tracking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _manifest_path(project_path: Path, agent_id: str) -> Path:
|
||||||
|
"""Return the path to the install manifest for *agent_id*."""
|
||||||
|
return project_path / ".specify" / f"agent-manifest-{agent_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
"""Return the hex SHA-256 of a file."""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(8192), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def record_installed_files(
|
||||||
|
project_path: Path,
|
||||||
|
agent_id: str,
|
||||||
|
files: List[Path],
|
||||||
|
) -> Path:
|
||||||
|
"""Record the installed files and their SHA-256 hashes.
|
||||||
|
|
||||||
|
Writes ``.specify/agent-manifest-<agent_id>.json`` containing a
|
||||||
|
mapping of project-relative paths to their SHA-256 digests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Project root directory.
|
||||||
|
agent_id: Agent identifier.
|
||||||
|
files: Absolute or project-relative paths of the files that
|
||||||
|
were created during ``setup()``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the written manifest file.
|
||||||
|
"""
|
||||||
|
entries: Dict[str, str] = {}
|
||||||
|
for file_path in files:
|
||||||
|
abs_path = project_path / file_path if not file_path.is_absolute() else file_path
|
||||||
|
if abs_path.is_file():
|
||||||
|
rel = str(abs_path.relative_to(project_path))
|
||||||
|
entries[rel] = _sha256(abs_path)
|
||||||
|
|
||||||
|
manifest_file = _manifest_path(project_path, agent_id)
|
||||||
|
manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest_file.write_text(
|
||||||
|
json.dumps({"agent_id": agent_id, "files": entries}, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return manifest_file
|
||||||
|
|
||||||
|
|
||||||
|
def check_modified_files(
|
||||||
|
project_path: Path,
|
||||||
|
agent_id: str,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Return project-relative paths of files modified since installation.
|
||||||
|
|
||||||
|
Returns an empty list when no install manifest exists or when every
|
||||||
|
tracked file still has its original hash.
|
||||||
|
"""
|
||||||
|
manifest_file = _manifest_path(project_path, agent_id)
|
||||||
|
if not manifest_file.is_file():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
modified: List[str] = []
|
||||||
|
for rel_path, original_hash in data.get("files", {}).items():
|
||||||
|
abs_path = project_path / rel_path
|
||||||
|
if abs_path.is_file():
|
||||||
|
if _sha256(abs_path) != original_hash:
|
||||||
|
modified.append(rel_path)
|
||||||
|
# If the file was deleted by the user, treat it as not needing
|
||||||
|
# removal — skip rather than flag as modified.
|
||||||
|
|
||||||
|
return modified
|
||||||
|
|
||||||
|
|
||||||
|
def remove_tracked_files(
|
||||||
|
project_path: Path,
|
||||||
|
agent_id: str,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Remove the individual files recorded in the install manifest.
|
||||||
|
|
||||||
|
Raises :class:`AgentFileModifiedError` if any tracked file was
|
||||||
|
modified and *force* is ``False``.
|
||||||
|
|
||||||
|
Directories are **never** deleted — only individual files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Project root directory.
|
||||||
|
agent_id: Agent identifier.
|
||||||
|
force: When ``True``, delete even modified files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of project-relative paths that were removed.
|
||||||
|
"""
|
||||||
|
manifest_file = _manifest_path(project_path, agent_id)
|
||||||
|
if not manifest_file.is_file():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries: Dict[str, str] = data.get("files", {})
|
||||||
|
if not entries:
|
||||||
|
manifest_file.unlink(missing_ok=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
modified = check_modified_files(project_path, agent_id)
|
||||||
|
if modified:
|
||||||
|
raise AgentFileModifiedError(
|
||||||
|
f"The following agent files have been modified since installation:\n"
|
||||||
|
+ "\n".join(f" {p}" for p in modified)
|
||||||
|
+ "\nUse --force to remove them anyway."
|
||||||
|
)
|
||||||
|
|
||||||
|
removed: List[str] = []
|
||||||
|
for rel_path in entries:
|
||||||
|
abs_path = project_path / rel_path
|
||||||
|
if abs_path.is_file():
|
||||||
|
abs_path.unlink()
|
||||||
|
removed.append(rel_path)
|
||||||
|
|
||||||
|
# Clean up the install manifest itself
|
||||||
|
manifest_file.unlink(missing_ok=True)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pack resolution
|
# Pack resolution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Agy(AgentBootstrap):
|
class Agy(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Agy(AgentBootstrap):
|
|||||||
"""Install Antigravity agent files into the project."""
|
"""Install Antigravity agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Antigravity agent files from the project."""
|
"""Remove Antigravity agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Amp(AgentBootstrap):
|
class Amp(AgentBootstrap):
|
||||||
@@ -16,18 +16,15 @@ class Amp(AgentBootstrap):
|
|||||||
"""Install Amp agent files into the project."""
|
"""Install Amp agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Amp agent files from the project.
|
"""Remove Amp agent files from the project.
|
||||||
|
|
||||||
Only removes the commands/ subdirectory — preserves other .agents/
|
Only removes individual tracked files — directories are never
|
||||||
content (e.g. Codex skills/) which shares the same parent directory.
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
|
was modified and *force* is ``False``.
|
||||||
"""
|
"""
|
||||||
import shutil
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
|
||||||
if commands_dir.is_dir():
|
|
||||||
shutil.rmtree(commands_dir)
|
|
||||||
# Remove .agents/ only if now empty
|
|
||||||
agents_dir = project_path / self.AGENT_DIR
|
|
||||||
if agents_dir.is_dir() and not any(agents_dir.iterdir()):
|
|
||||||
agents_dir.rmdir()
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Auggie(AgentBootstrap):
|
class Auggie(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Auggie(AgentBootstrap):
|
|||||||
"""Install Auggie CLI agent files into the project."""
|
"""Install Auggie CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Auggie CLI agent files from the project."""
|
"""Remove Auggie CLI agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Bob(AgentBootstrap):
|
class Bob(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Bob(AgentBootstrap):
|
|||||||
"""Install IBM Bob agent files into the project."""
|
"""Install IBM Bob agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove IBM Bob agent files from the project."""
|
"""Remove IBM Bob agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Claude(AgentBootstrap):
|
class Claude(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Claude(AgentBootstrap):
|
|||||||
"""Install Claude Code agent files into the project."""
|
"""Install Claude Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Claude Code agent files from the project."""
|
"""Remove Claude Code agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Codebuddy(AgentBootstrap):
|
class Codebuddy(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Codebuddy(AgentBootstrap):
|
|||||||
"""Install CodeBuddy agent files into the project."""
|
"""Install CodeBuddy agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove CodeBuddy agent files from the project."""
|
"""Remove CodeBuddy agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Codex(AgentBootstrap):
|
class Codex(AgentBootstrap):
|
||||||
@@ -16,18 +16,15 @@ class Codex(AgentBootstrap):
|
|||||||
"""Install Codex CLI agent files into the project."""
|
"""Install Codex CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Codex CLI agent files from the project.
|
"""Remove Codex CLI agent files from the project.
|
||||||
|
|
||||||
Only removes the skills/ subdirectory — preserves other .agents/
|
Only removes individual tracked files — directories are never
|
||||||
content (e.g. Amp commands/) which shares the same parent directory.
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
|
was modified and *force* is ``False``.
|
||||||
"""
|
"""
|
||||||
import shutil
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
skills_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
|
||||||
if skills_dir.is_dir():
|
|
||||||
shutil.rmtree(skills_dir)
|
|
||||||
# Remove .agents/ only if now empty
|
|
||||||
agents_dir = project_path / self.AGENT_DIR
|
|
||||||
if agents_dir.is_dir() and not any(agents_dir.iterdir()):
|
|
||||||
agents_dir.rmdir()
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Copilot(AgentBootstrap):
|
class Copilot(AgentBootstrap):
|
||||||
@@ -16,18 +16,15 @@ class Copilot(AgentBootstrap):
|
|||||||
"""Install GitHub Copilot agent files into the project."""
|
"""Install GitHub Copilot agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove GitHub Copilot agent files from the project.
|
"""Remove GitHub Copilot agent files from the project.
|
||||||
|
|
||||||
Only removes the agents/ subdirectory — preserves other .github
|
Only removes individual tracked files — directories are never
|
||||||
content (workflows, issue templates, etc.).
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
|
was modified and *force* is ``False``.
|
||||||
"""
|
"""
|
||||||
import shutil
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
agents_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
|
||||||
if agents_dir.is_dir():
|
|
||||||
shutil.rmtree(agents_dir)
|
|
||||||
# Also clean up companion .github/prompts/ if empty
|
|
||||||
prompts_dir = project_path / self.AGENT_DIR / "prompts"
|
|
||||||
if prompts_dir.is_dir() and not any(prompts_dir.iterdir()):
|
|
||||||
prompts_dir.rmdir()
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class CursorAgent(AgentBootstrap):
|
class CursorAgent(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class CursorAgent(AgentBootstrap):
|
|||||||
"""Install Cursor agent files into the project."""
|
"""Install Cursor agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Cursor agent files from the project."""
|
"""Remove Cursor agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Gemini(AgentBootstrap):
|
class Gemini(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Gemini(AgentBootstrap):
|
|||||||
"""Install Gemini CLI agent files into the project."""
|
"""Install Gemini CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Gemini CLI agent files from the project."""
|
"""Remove Gemini CLI agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Iflow(AgentBootstrap):
|
class Iflow(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Iflow(AgentBootstrap):
|
|||||||
"""Install iFlow CLI agent files into the project."""
|
"""Install iFlow CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove iFlow CLI agent files from the project."""
|
"""Remove iFlow CLI agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Junie(AgentBootstrap):
|
class Junie(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Junie(AgentBootstrap):
|
|||||||
"""Install Junie agent files into the project."""
|
"""Install Junie agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Junie agent files from the project."""
|
"""Remove Junie agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Kilocode(AgentBootstrap):
|
class Kilocode(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Kilocode(AgentBootstrap):
|
|||||||
"""Install Kilo Code agent files into the project."""
|
"""Install Kilo Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Kilo Code agent files from the project."""
|
"""Remove Kilo Code agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Kimi(AgentBootstrap):
|
class Kimi(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Kimi(AgentBootstrap):
|
|||||||
"""Install Kimi Code agent files into the project."""
|
"""Install Kimi Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Kimi Code agent files from the project."""
|
"""Remove Kimi Code agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class KiroCli(AgentBootstrap):
|
class KiroCli(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class KiroCli(AgentBootstrap):
|
|||||||
"""Install Kiro CLI agent files into the project."""
|
"""Install Kiro CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Kiro CLI agent files from the project."""
|
"""Remove Kiro CLI agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Opencode(AgentBootstrap):
|
class Opencode(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Opencode(AgentBootstrap):
|
|||||||
"""Install opencode agent files into the project."""
|
"""Install opencode agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove opencode agent files from the project."""
|
"""Remove opencode agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Pi(AgentBootstrap):
|
class Pi(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Pi(AgentBootstrap):
|
|||||||
"""Install Pi Coding Agent agent files into the project."""
|
"""Install Pi Coding Agent agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Pi Coding Agent agent files from the project."""
|
"""Remove Pi Coding Agent agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Qodercli(AgentBootstrap):
|
class Qodercli(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Qodercli(AgentBootstrap):
|
|||||||
"""Install Qoder CLI agent files into the project."""
|
"""Install Qoder CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Qoder CLI agent files from the project."""
|
"""Remove Qoder CLI agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Qwen(AgentBootstrap):
|
class Qwen(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Qwen(AgentBootstrap):
|
|||||||
"""Install Qwen Code agent files into the project."""
|
"""Install Qwen Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Qwen Code agent files from the project."""
|
"""Remove Qwen Code agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Roo(AgentBootstrap):
|
class Roo(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Roo(AgentBootstrap):
|
|||||||
"""Install Roo Code agent files into the project."""
|
"""Install Roo Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Roo Code agent files from the project."""
|
"""Remove Roo Code agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Shai(AgentBootstrap):
|
class Shai(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Shai(AgentBootstrap):
|
|||||||
"""Install SHAI agent files into the project."""
|
"""Install SHAI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove SHAI agent files from the project."""
|
"""Remove SHAI agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Tabnine(AgentBootstrap):
|
class Tabnine(AgentBootstrap):
|
||||||
@@ -16,18 +16,15 @@ class Tabnine(AgentBootstrap):
|
|||||||
"""Install Tabnine CLI agent files into the project."""
|
"""Install Tabnine CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Tabnine CLI agent files from the project.
|
"""Remove Tabnine CLI agent files from the project.
|
||||||
|
|
||||||
Removes the agent/ subdirectory under .tabnine/ to preserve
|
Only removes individual tracked files — directories are never
|
||||||
any other Tabnine configuration.
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
|
was modified and *force* is ``False``.
|
||||||
"""
|
"""
|
||||||
import shutil
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
agent_subdir = project_path / self.AGENT_DIR
|
|
||||||
if agent_subdir.is_dir():
|
|
||||||
shutil.rmtree(agent_subdir)
|
|
||||||
# Remove .tabnine/ only if now empty
|
|
||||||
tabnine_dir = project_path / ".tabnine"
|
|
||||||
if tabnine_dir.is_dir() and not any(tabnine_dir.iterdir()):
|
|
||||||
tabnine_dir.rmdir()
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Trae(AgentBootstrap):
|
class Trae(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Trae(AgentBootstrap):
|
|||||||
"""Install Trae agent files into the project."""
|
"""Install Trae agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Trae agent files from the project."""
|
"""Remove Trae agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Vibe(AgentBootstrap):
|
class Vibe(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Vibe(AgentBootstrap):
|
|||||||
"""Install Mistral Vibe agent files into the project."""
|
"""Install Mistral Vibe agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Mistral Vibe agent files from the project."""
|
"""Remove Mistral Vibe agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
|
|
||||||
class Windsurf(AgentBootstrap):
|
class Windsurf(AgentBootstrap):
|
||||||
@@ -16,10 +16,15 @@ class Windsurf(AgentBootstrap):
|
|||||||
"""Install Windsurf agent files into the project."""
|
"""Install Windsurf agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Record installed files for tracked teardown
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
"""Remove Windsurf agent files from the project."""
|
"""Remove Windsurf agent files from the project.
|
||||||
import shutil
|
|
||||||
agent_dir = project_path / self.AGENT_DIR
|
Only removes individual tracked files — directories are never
|
||||||
if agent_dir.is_dir():
|
deleted. Raises ``AgentFileModifiedError`` if any tracked file
|
||||||
shutil.rmtree(agent_dir)
|
was modified and *force* is ``False``.
|
||||||
|
"""
|
||||||
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
|
|||||||
@@ -17,15 +17,21 @@ from specify_cli.agent_pack import (
|
|||||||
MANIFEST_FILENAME,
|
MANIFEST_FILENAME,
|
||||||
MANIFEST_SCHEMA_VERSION,
|
MANIFEST_SCHEMA_VERSION,
|
||||||
AgentBootstrap,
|
AgentBootstrap,
|
||||||
|
AgentFileModifiedError,
|
||||||
AgentManifest,
|
AgentManifest,
|
||||||
AgentPackError,
|
AgentPackError,
|
||||||
ManifestValidationError,
|
ManifestValidationError,
|
||||||
PackResolutionError,
|
PackResolutionError,
|
||||||
ResolvedPack,
|
ResolvedPack,
|
||||||
|
_manifest_path,
|
||||||
|
_sha256,
|
||||||
|
check_modified_files,
|
||||||
export_pack,
|
export_pack,
|
||||||
list_all_agents,
|
list_all_agents,
|
||||||
list_embedded_agents,
|
list_embedded_agents,
|
||||||
load_bootstrap,
|
load_bootstrap,
|
||||||
|
record_installed_files,
|
||||||
|
remove_tracked_files,
|
||||||
resolve_agent_pack,
|
resolve_agent_pack,
|
||||||
validate_pack,
|
validate_pack,
|
||||||
)
|
)
|
||||||
@@ -74,19 +80,19 @@ def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: s
|
|||||||
bootstrap_file.write_text(textwrap.dedent(f"""\
|
bootstrap_file.write_text(textwrap.dedent(f"""\
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
from specify_cli.agent_pack import AgentBootstrap
|
from specify_cli.agent_pack import AgentBootstrap, record_installed_files, remove_tracked_files
|
||||||
|
|
||||||
class {class_name}(AgentBootstrap):
|
class {class_name}(AgentBootstrap):
|
||||||
AGENT_DIR = "{agent_dir}"
|
AGENT_DIR = "{agent_dir}"
|
||||||
|
|
||||||
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
|
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None:
|
||||||
(project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True)
|
commands_dir = project_path / self.AGENT_DIR / "commands"
|
||||||
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
installed = [p for p in commands_dir.rglob("*") if p.is_file()]
|
||||||
|
record_installed_files(project_path, self.manifest.id, installed)
|
||||||
|
|
||||||
def teardown(self, project_path: Path) -> None:
|
def teardown(self, project_path: Path, *, force: bool = False) -> None:
|
||||||
import shutil
|
remove_tracked_files(project_path, self.manifest.id, force=force)
|
||||||
d = project_path / self.AGENT_DIR
|
|
||||||
if d.is_dir():
|
|
||||||
shutil.rmtree(d)
|
|
||||||
"""), encoding="utf-8")
|
"""), encoding="utf-8")
|
||||||
return bootstrap_file
|
return bootstrap_file
|
||||||
|
|
||||||
@@ -242,7 +248,7 @@ class TestBootstrapContract:
|
|||||||
m = AgentManifest.from_dict(_minimal_manifest_dict())
|
m = AgentManifest.from_dict(_minimal_manifest_dict())
|
||||||
b = AgentBootstrap(m)
|
b = AgentBootstrap(m)
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
b.teardown(tmp_path)
|
b.teardown(tmp_path, force=False)
|
||||||
|
|
||||||
def test_load_bootstrap(self, tmp_path):
|
def test_load_bootstrap(self, tmp_path):
|
||||||
data = _minimal_manifest_dict()
|
data = _minimal_manifest_dict()
|
||||||
@@ -258,7 +264,7 @@ class TestBootstrapContract:
|
|||||||
load_bootstrap(tmp_path, m)
|
load_bootstrap(tmp_path, m)
|
||||||
|
|
||||||
def test_bootstrap_setup_and_teardown(self, tmp_path):
|
def test_bootstrap_setup_and_teardown(self, tmp_path):
|
||||||
"""Verify a loaded bootstrap can set up and tear down."""
|
"""Verify a loaded bootstrap can set up and tear down via file tracking."""
|
||||||
pack_dir = tmp_path / "pack"
|
pack_dir = tmp_path / "pack"
|
||||||
data = _minimal_manifest_dict()
|
data = _minimal_manifest_dict()
|
||||||
_write_manifest(pack_dir, data)
|
_write_manifest(pack_dir, data)
|
||||||
@@ -273,8 +279,14 @@ class TestBootstrapContract:
|
|||||||
b.setup(project, "sh", {})
|
b.setup(project, "sh", {})
|
||||||
assert (project / ".test-agent" / "commands").is_dir()
|
assert (project / ".test-agent" / "commands").is_dir()
|
||||||
|
|
||||||
|
# The install manifest should exist in .specify/
|
||||||
|
assert _manifest_path(project, "test-agent").is_file()
|
||||||
|
|
||||||
b.teardown(project)
|
b.teardown(project)
|
||||||
assert not (project / ".test-agent").exists()
|
# Install manifest itself should be cleaned up
|
||||||
|
assert not _manifest_path(project, "test-agent").is_file()
|
||||||
|
# Directories are preserved (only files are removed)
|
||||||
|
assert (project / ".test-agent" / "commands").is_dir()
|
||||||
|
|
||||||
def test_load_bootstrap_no_subclass(self, tmp_path):
|
def test_load_bootstrap_no_subclass(self, tmp_path):
|
||||||
"""A bootstrap module without an AgentBootstrap subclass fails."""
|
"""A bootstrap module without an AgentBootstrap subclass fails."""
|
||||||
@@ -522,3 +534,172 @@ class TestEmbeddedPacksConsistency:
|
|||||||
# Should not raise
|
# Should not raise
|
||||||
warnings = validate_pack(child)
|
warnings = validate_pack(child)
|
||||||
# Warnings are acceptable; hard errors are not
|
# Warnings are acceptable; hard errors are not
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# File tracking (record / check / remove)
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
class TestFileTracking:
|
||||||
|
"""Verify installed-file tracking with hashes."""
|
||||||
|
|
||||||
|
def test_record_and_check_unmodified(self, tmp_path):
|
||||||
|
"""Files recorded at install time are reported as unmodified."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
(project / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
# Create a file to track
|
||||||
|
f = project / ".myagent" / "commands" / "hello.md"
|
||||||
|
f.parent.mkdir(parents=True)
|
||||||
|
f.write_text("hello world", encoding="utf-8")
|
||||||
|
|
||||||
|
record_installed_files(project, "myagent", [f])
|
||||||
|
|
||||||
|
# No modifications yet
|
||||||
|
assert check_modified_files(project, "myagent") == []
|
||||||
|
|
||||||
|
def test_check_detects_modification(self, tmp_path):
|
||||||
|
"""A modified file is reported by check_modified_files()."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
(project / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
f = project / ".myagent" / "cmd.md"
|
||||||
|
f.parent.mkdir(parents=True)
|
||||||
|
f.write_text("original", encoding="utf-8")
|
||||||
|
|
||||||
|
record_installed_files(project, "myagent", [f])
|
||||||
|
|
||||||
|
# Now modify the file
|
||||||
|
f.write_text("modified content", encoding="utf-8")
|
||||||
|
|
||||||
|
modified = check_modified_files(project, "myagent")
|
||||||
|
assert len(modified) == 1
|
||||||
|
assert ".myagent/cmd.md" in modified[0]
|
||||||
|
|
||||||
|
def test_check_no_manifest(self, tmp_path):
|
||||||
|
"""check_modified_files returns [] when no manifest exists."""
|
||||||
|
assert check_modified_files(tmp_path, "nonexistent") == []
|
||||||
|
|
||||||
|
def test_remove_tracked_unmodified(self, tmp_path):
|
||||||
|
"""remove_tracked_files deletes unmodified files."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
(project / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
f1 = project / ".ag" / "a.md"
|
||||||
|
f2 = project / ".ag" / "b.md"
|
||||||
|
f1.parent.mkdir(parents=True)
|
||||||
|
f1.write_text("aaa", encoding="utf-8")
|
||||||
|
f2.write_text("bbb", encoding="utf-8")
|
||||||
|
|
||||||
|
record_installed_files(project, "ag", [f1, f2])
|
||||||
|
|
||||||
|
removed = remove_tracked_files(project, "ag")
|
||||||
|
assert len(removed) == 2
|
||||||
|
assert not f1.exists()
|
||||||
|
assert not f2.exists()
|
||||||
|
# Directories are preserved
|
||||||
|
assert f1.parent.is_dir()
|
||||||
|
# Install manifest is cleaned up
|
||||||
|
assert not _manifest_path(project, "ag").is_file()
|
||||||
|
|
||||||
|
def test_remove_tracked_modified_without_force_raises(self, tmp_path):
|
||||||
|
"""Removing modified files without --force raises AgentFileModifiedError."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
(project / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
f = project / ".ag" / "c.md"
|
||||||
|
f.parent.mkdir(parents=True)
|
||||||
|
f.write_text("original", encoding="utf-8")
|
||||||
|
|
||||||
|
record_installed_files(project, "ag", [f])
|
||||||
|
f.write_text("user-edited", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(AgentFileModifiedError, match="modified"):
|
||||||
|
remove_tracked_files(project, "ag", force=False)
|
||||||
|
|
||||||
|
# File should still exist
|
||||||
|
assert f.is_file()
|
||||||
|
|
||||||
|
def test_remove_tracked_modified_with_force(self, tmp_path):
|
||||||
|
"""Removing modified files with --force succeeds."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
(project / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
f = project / ".ag" / "d.md"
|
||||||
|
f.parent.mkdir(parents=True)
|
||||||
|
f.write_text("original", encoding="utf-8")
|
||||||
|
|
||||||
|
record_installed_files(project, "ag", [f])
|
||||||
|
f.write_text("user-edited", encoding="utf-8")
|
||||||
|
|
||||||
|
removed = remove_tracked_files(project, "ag", force=True)
|
||||||
|
assert len(removed) == 1
|
||||||
|
assert not f.is_file()
|
||||||
|
|
||||||
|
def test_remove_no_manifest(self, tmp_path):
|
||||||
|
"""remove_tracked_files returns [] when no manifest exists."""
|
||||||
|
removed = remove_tracked_files(tmp_path, "nonexistent")
|
||||||
|
assert removed == []
|
||||||
|
|
||||||
|
def test_remove_preserves_directories(self, tmp_path):
|
||||||
|
"""Directories are never deleted, even when all files are removed."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
(project / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
d = project / ".myagent" / "commands" / "sub"
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
f = d / "deep.md"
|
||||||
|
f.write_text("deep", encoding="utf-8")
|
||||||
|
|
||||||
|
record_installed_files(project, "myagent", [f])
|
||||||
|
remove_tracked_files(project, "myagent")
|
||||||
|
|
||||||
|
assert not f.exists()
|
||||||
|
# All parent directories remain
|
||||||
|
assert d.is_dir()
|
||||||
|
assert (project / ".myagent").is_dir()
|
||||||
|
|
||||||
|
def test_deleted_file_skipped_gracefully(self, tmp_path):
|
||||||
|
"""A file deleted by the user before teardown is silently skipped."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
(project / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
f = project / ".ag" / "gone.md"
|
||||||
|
f.parent.mkdir(parents=True)
|
||||||
|
f.write_text("data", encoding="utf-8")
|
||||||
|
|
||||||
|
record_installed_files(project, "ag", [f])
|
||||||
|
|
||||||
|
# User deletes the file before teardown
|
||||||
|
f.unlink()
|
||||||
|
|
||||||
|
# Should not raise, and should not report as modified
|
||||||
|
assert check_modified_files(project, "ag") == []
|
||||||
|
removed = remove_tracked_files(project, "ag")
|
||||||
|
assert removed == []
|
||||||
|
|
||||||
|
def test_sha256_consistency(self, tmp_path):
|
||||||
|
"""_sha256 produces consistent hashes."""
|
||||||
|
f = tmp_path / "test.txt"
|
||||||
|
f.write_text("hello", encoding="utf-8")
|
||||||
|
h1 = _sha256(f)
|
||||||
|
h2 = _sha256(f)
|
||||||
|
assert h1 == h2
|
||||||
|
assert len(h1) == 64 # SHA-256 hex length
|
||||||
|
|
||||||
|
def test_manifest_json_structure(self, tmp_path):
|
||||||
|
"""The install manifest has the expected JSON structure."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
(project / ".specify").mkdir(parents=True)
|
||||||
|
|
||||||
|
f = project / ".ag" / "x.md"
|
||||||
|
f.parent.mkdir(parents=True)
|
||||||
|
f.write_text("content", encoding="utf-8")
|
||||||
|
|
||||||
|
manifest_file = record_installed_files(project, "ag", [f])
|
||||||
|
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
assert data["agent_id"] == "ag"
|
||||||
|
assert isinstance(data["files"], dict)
|
||||||
|
assert ".ag/x.md" in data["files"]
|
||||||
|
assert len(data["files"][".ag/x.md"]) == 64
|
||||||
|
|||||||
Reference in New Issue
Block a user