From e190116d13851869e85de987c24f50a64b639cf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:34:59 +0000 Subject: [PATCH] refactor: setup reports files, CLI checks modifications before teardown, categorised manifest - setup() returns List[Path] of installed files so CLI can record them - finalize_setup() accepts agent_files + extension_files for combined tracking - Install manifest categorises files: agent_files and extension_files - get_tracked_files() returns (agent_files, extension_files) split - remove_tracked_files() accepts explicit files dict for CLI-driven teardown - agent_switch checks for modifications BEFORE teardown and prompts user - _reregister_extension_commands() returns List[Path] of created files - teardown() accepts files parameter to receive explicit file lists - All 25 bootstraps updated with new signatures - 5 new tests: categorised manifest, get_tracked_files, explicit file teardown, extension file modification detection Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/32e470fc-6bf5-453c-bf6c-79a8521efa56 --- src/specify_cli/__init__.py | 85 ++++-- src/specify_cli/agent_pack.py | 243 +++++++++++++----- .../core_pack/agents/agy/bootstrap.py | 15 +- .../core_pack/agents/amp/bootstrap.py | 15 +- .../core_pack/agents/auggie/bootstrap.py | 15 +- .../core_pack/agents/bob/bootstrap.py | 15 +- .../core_pack/agents/claude/bootstrap.py | 15 +- .../core_pack/agents/codebuddy/bootstrap.py | 15 +- .../core_pack/agents/codex/bootstrap.py | 15 +- .../core_pack/agents/copilot/bootstrap.py | 15 +- .../agents/cursor-agent/bootstrap.py | 15 +- .../core_pack/agents/gemini/bootstrap.py | 15 +- .../core_pack/agents/iflow/bootstrap.py | 15 +- .../core_pack/agents/junie/bootstrap.py | 15 +- .../core_pack/agents/kilocode/bootstrap.py | 15 +- .../core_pack/agents/kimi/bootstrap.py | 15 +- .../core_pack/agents/kiro-cli/bootstrap.py | 15 +- .../core_pack/agents/opencode/bootstrap.py | 15 +- .../core_pack/agents/pi/bootstrap.py | 15 +- .../core_pack/agents/qodercli/bootstrap.py | 15 +- .../core_pack/agents/qwen/bootstrap.py | 15 +- .../core_pack/agents/roo/bootstrap.py | 15 +- .../core_pack/agents/shai/bootstrap.py | 15 +- .../core_pack/agents/tabnine/bootstrap.py | 15 +- .../core_pack/agents/trae/bootstrap.py | 15 +- .../core_pack/agents/vibe/bootstrap.py | 15 +- .../core_pack/agents/windsurf/bootstrap.py | 15 +- tests/test_agent_pack.py | 139 ++++++++-- 28 files changed, 596 insertions(+), 246 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 049aca95..845b3d2a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -36,7 +36,7 @@ import json5 import stat import yaml from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any, List, Optional, Tuple import typer import httpx @@ -2543,9 +2543,10 @@ def agent_switch( from .agent_pack import ( resolve_agent_pack, load_bootstrap, + check_modified_files, + get_tracked_files, PackResolutionError, AgentPackError, - AgentFileModifiedError, ) show_banner() @@ -2582,13 +2583,28 @@ def agent_switch( try: current_resolved = resolve_agent_pack(current_agent, project_path=project_path) current_bootstrap = load_bootstrap(current_resolved.path, current_resolved.manifest) + + # Check for modified files BEFORE teardown and prompt for confirmation + modified = check_modified_files(project_path, current_agent) + if modified and not force: + console.print("[yellow]The following files have been modified since installation:[/yellow]") + for f in modified: + console.print(f" {f}") + if not typer.confirm("Remove these modified files?"): + console.print("[dim]Aborted. Use --force to skip this check.[/dim]") + raise typer.Exit(0) + + # Retrieve tracked file lists and feed them into teardown + agent_files, extension_files = get_tracked_files(project_path, current_agent) + all_files = {**agent_files, **extension_files} + console.print(f" [dim]Tearing down {current_agent}...[/dim]") - current_bootstrap.teardown(project_path, force=force) + current_bootstrap.teardown( + project_path, + force=True, # already confirmed above + files=all_files if all_files else None, + ) console.print(f" [green]✓[/green] {current_agent} removed") - 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, {}) @@ -2603,9 +2619,7 @@ def agent_switch( try: new_bootstrap = load_bootstrap(resolved.path, resolved.manifest) console.print(f" [dim]Setting up {agent_id}...[/dim]") - new_bootstrap.setup(project_path, script_type, options) - # Record all installed files for tracked teardown - new_bootstrap.finalize_setup(project_path) + agent_files = new_bootstrap.setup(project_path, script_type, options) console.print(f" [green]✓[/green] {agent_id} installed") except AgentPackError as exc: console.print(f"[red]Error setting up {agent_id}:[/red] {exc}") @@ -2614,32 +2628,54 @@ def agent_switch( # Update init options options["ai"] = agent_id init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8") - console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]") # Re-register extension commands for the new agent - _reregister_extension_commands(project_path, agent_id) + extension_files = _reregister_extension_commands(project_path, agent_id) + + # Record all installed files (agent + extensions) for tracked teardown + new_bootstrap.finalize_setup( + project_path, + agent_files=agent_files, + extension_files=extension_files, + ) + + console.print(f"\n[bold green]Successfully switched to {resolved.manifest.name}[/bold green]") -def _reregister_extension_commands(project_path: Path, agent_id: str) -> None: - """Re-register all installed extension commands for a new agent after switching.""" +def _reregister_extension_commands(project_path: Path, agent_id: str) -> List[Path]: + """Re-register all installed extension commands for a new agent after switching. + + Returns: + List of absolute file paths created by extension registration. + """ + created_files: List[Path] = [] registry_file = project_path / ".specify" / "extensions" / ".registry" if not registry_file.is_file(): - return + return created_files try: registry_data = json.loads(registry_file.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): - return + return created_files extensions = registry_data.get("extensions", {}) if not extensions: - return + return created_files try: from .agents import CommandRegistrar registrar = CommandRegistrar() except ImportError: - return + return created_files + + # Snapshot the commands directory before registration so we can + # detect which files were created by extension commands. + agent_config = registrar.AGENT_CONFIGS.get(agent_id) + if agent_config: + commands_dir = project_path / agent_config["dir"] + pre_existing = set(commands_dir.rglob("*")) if commands_dir.is_dir() else set() + else: + pre_existing = set() reregistered = 0 for ext_id, ext_data in extensions.items(): @@ -2668,8 +2704,19 @@ def _reregister_extension_commands(project_path: Path, agent_id: str) -> None: except Exception: continue + # Collect files created by extension registration + if agent_config: + commands_dir = project_path / agent_config["dir"] + if commands_dir.is_dir(): + for p in commands_dir.rglob("*"): + if p.is_file() and p not in pre_existing: + created_files.append(p) + if reregistered: - console.print(f" [green]✓[/green] Re-registered {reregistered} extension command(s)") + console.print(f" [green]✓[/green] Re-registered {reregistered} extension command(s)" + f" ({len(created_files)} file(s))") + + return created_files @agent_app.command("search") diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index dac6a569..08229c93 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -184,35 +184,58 @@ class AgentBootstrap: # -- lifecycle ----------------------------------------------------------- - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install agent files into *project_path*. This is invoked by ``specify init --ai `` and ``specify agent switch ``. + Implementations **must** return every file they create so that the + CLI can record both agent-installed files and extension-installed + files in a single install manifest. + Args: project_path: Target project directory. script_type: ``"sh"`` or ``"ps"``. options: Arbitrary key/value options forwarded from the CLI. + + Returns: + List of absolute paths of files created during setup. """ raise NotImplementedError - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown( + self, + project_path: Path, + *, + force: bool = False, + files: Optional[Dict[str, str]] = None, + ) -> List[str]: """Remove agent-specific files from *project_path*. 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`. + Only individual files are removed — directories are **never** + deleted. + + The caller (CLI) is expected to check for user-modified files + **before** invoking teardown and prompt for confirmation. If + *files* is provided, exactly those files are removed (values are + ignored but kept for forward compatibility). Otherwise the + install manifest is read. Args: project_path: Project directory to clean up. force: When ``True``, remove files even if they were modified after installation. + files: Mapping of project-relative path → SHA-256 hash. + When supplied, only these files are removed and the + install manifest is not consulted. + + Returns: + List of project-relative paths that were actually deleted. """ raise NotImplementedError @@ -222,21 +245,44 @@ class AgentBootstrap: """Return the agent's top-level directory inside the project.""" return project_path / self.manifest.commands_dir.split("/")[0] - def finalize_setup(self, project_path: Path) -> None: - """Record all files in the agent directory for tracked teardown. + def finalize_setup( + self, + project_path: Path, + agent_files: Optional[List[Path]] = None, + extension_files: Optional[List[Path]] = None, + ) -> None: + """Record installed files for tracked teardown. This must be called **after** the full init pipeline has finished - writing files (commands, context files, etc.) into the agent - directory. It scans ``self.manifest.commands_dir`` and records - every file with its SHA-256 hash so that :meth:`teardown` can - detect user modifications. + writing files (commands, context files, extensions) into the + project. It combines the files reported by :meth:`setup` with + any extra files (e.g. from extension registration), scans the + agent's ``commands_dir`` for anything additional, and writes the + install manifest. + + Args: + agent_files: Files reported by :meth:`setup`. + extension_files: Files created by extension registration. """ - if not self.manifest.commands_dir: - return - commands_dir = project_path / self.manifest.commands_dir - if commands_dir.is_dir(): - installed = [p for p in commands_dir.rglob("*") if p.is_file()] - record_installed_files(project_path, self.manifest.id, installed) + all_agent = list(agent_files or []) + all_extension = list(extension_files or []) + + # Also scan the commands directory for files created by the + # init pipeline that setup() did not report directly. + if self.manifest.commands_dir: + commands_dir = project_path / self.manifest.commands_dir + if commands_dir.is_dir(): + agent_set = {p.resolve() for p in all_agent} + for p in commands_dir.rglob("*"): + if p.is_file() and p.resolve() not in agent_set: + all_agent.append(p) + + record_installed_files( + project_path, + self.manifest.id, + agent_files=all_agent, + extension_files=all_extension, + ) # --------------------------------------------------------------------------- @@ -257,41 +303,107 @@ def _sha256(path: Path) -> str: return h.hexdigest() -def record_installed_files( +def _hash_file_list( project_path: Path, - agent_id: str, files: List[Path], -) -> Path: - """Record the installed files and their SHA-256 hashes. - - Writes ``.specify/agent-manifest-.json`` containing a - mapping of project-relative paths to their SHA-256 digests. - - Args: - project_path: Project root directory. - agent_id: Agent identifier. - files: Absolute or project-relative paths of the files that - were created during ``setup()``. - - Returns: - Path to the written manifest file. - """ +) -> Dict[str, str]: + """Build a {relative_path: sha256} dict from a list of file paths.""" 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) + return entries + + +def record_installed_files( + project_path: Path, + agent_id: str, + agent_files: Optional[List[Path]] = None, + extension_files: Optional[List[Path]] = None, +) -> Path: + """Record the installed files and their SHA-256 hashes. + + Writes ``.specify/agent-manifest-.json`` containing + categorised mappings of project-relative paths to SHA-256 digests. + + Args: + project_path: Project root directory. + agent_id: Agent identifier. + agent_files: Files created by the agent's ``setup()`` and the + init pipeline (core commands / templates). + extension_files: Files created by extension registration. + + Returns: + Path to the written manifest file. + """ + agent_entries = _hash_file_list(project_path, agent_files or []) + extension_entries = _hash_file_list(project_path, extension_files or []) manifest_file = _manifest_path(project_path, agent_id) manifest_file.parent.mkdir(parents=True, exist_ok=True) manifest_file.write_text( - json.dumps({"agent_id": agent_id, "files": entries}, indent=2), + json.dumps( + { + "agent_id": agent_id, + "agent_files": agent_entries, + "extension_files": extension_entries, + }, + indent=2, + ), encoding="utf-8", ) return manifest_file +def _all_tracked_entries(data: dict) -> Dict[str, str]: + """Return the combined file → hash mapping from a manifest dict. + + Supports both the new categorised layout (``agent_files`` + + ``extension_files``) and the legacy flat ``files`` key. + """ + combined: Dict[str, str] = {} + # Legacy flat format + if "files" in data and isinstance(data["files"], dict): + combined.update(data["files"]) + # New categorised format + if "agent_files" in data and isinstance(data["agent_files"], dict): + combined.update(data["agent_files"]) + if "extension_files" in data and isinstance(data["extension_files"], dict): + combined.update(data["extension_files"]) + return combined + + +def get_tracked_files( + project_path: Path, + agent_id: str, +) -> tuple[Dict[str, str], Dict[str, str]]: + """Return the tracked file hashes split by source. + + Returns: + A tuple ``(agent_files, extension_files)`` where each is a + ``{relative_path: sha256}`` dict. Returns two empty dicts + when no install manifest exists. + """ + manifest_file = _manifest_path(project_path, agent_id) + if not manifest_file.is_file(): + return {}, {} + + try: + data = json.loads(manifest_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {}, {} + + # Support legacy flat format + if "files" in data and "agent_files" not in data: + return dict(data["files"]), {} + + agent_entries = data.get("agent_files", {}) + ext_entries = data.get("extension_files", {}) + return dict(agent_entries), dict(ext_entries) + + def check_modified_files( project_path: Path, agent_id: str, @@ -310,8 +422,10 @@ def check_modified_files( except (json.JSONDecodeError, OSError): return [] + entries = _all_tracked_entries(data) + modified: List[str] = [] - for rel_path, original_hash in data.get("files", {}).items(): + for rel_path, original_hash in entries.items(): abs_path = project_path / rel_path if abs_path.is_file(): if _sha256(abs_path) != original_hash: @@ -327,11 +441,18 @@ def remove_tracked_files( agent_id: str, *, force: bool = False, + files: Optional[Dict[str, str]] = None, ) -> List[str]: - """Remove the individual files recorded in the install manifest. + """Remove individual tracked files. + + If *files* is provided, exactly those files are removed (the values + are ignored but accepted for forward compatibility). Otherwise the + install manifest for *agent_id* is read. Raises :class:`AgentFileModifiedError` if any tracked file was - modified and *force* is ``False``. + modified and *force* is ``False`` (only when reading from the + manifest — callers that pass *files* are expected to have already + prompted the user). Directories are **never** deleted — only individual files. @@ -339,32 +460,37 @@ def remove_tracked_files( project_path: Project root directory. agent_id: Agent identifier. force: When ``True``, delete even modified files. + files: Explicit mapping of project-relative path → hash. When + supplied, the install manifest is not consulted. 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 [] + if files is not None: + entries = files + else: + 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 [] + entries = _all_tracked_entries(data) + 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." - ) + 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: @@ -374,7 +500,8 @@ def remove_tracked_files( removed.append(rel_path) # Clean up the install manifest itself - manifest_file.unlink(missing_ok=True) + if manifest_file.is_file(): + manifest_file.unlink(missing_ok=True) return removed diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py index 21e5be32..0434c2c4 100644 --- a/src/specify_cli/core_pack/agents/agy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Antigravity agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Agy(AgentBootstrap): AGENT_DIR = ".agent" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Antigravity agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Antigravity agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py index 3eebd24c..ab305ede 100644 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Amp agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Amp(AgentBootstrap): AGENT_DIR = ".agents" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Amp agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Amp agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py index c7c15a4f..8abd5618 100644 --- a/src/specify_cli/core_pack/agents/auggie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Auggie CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Auggie(AgentBootstrap): AGENT_DIR = ".augment" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Auggie CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Auggie CLI agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py index bac8f9c2..4f8e2cdb 100644 --- a/src/specify_cli/core_pack/agents/bob/bootstrap.py +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for IBM Bob agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Bob(AgentBootstrap): AGENT_DIR = ".bob" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install IBM Bob agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove IBM Bob agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py index 9a3fb0c7..917556c3 100644 --- a/src/specify_cli/core_pack/agents/claude/bootstrap.py +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Claude Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Claude(AgentBootstrap): AGENT_DIR = ".claude" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Claude Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Claude Code agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py index fbcc6439..f4921d54 100644 --- a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for CodeBuddy agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Codebuddy(AgentBootstrap): AGENT_DIR = ".codebuddy" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install CodeBuddy agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove CodeBuddy agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py index 7ecbef17..4accd01b 100644 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Codex CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Codex(AgentBootstrap): AGENT_DIR = ".agents" COMMANDS_SUBDIR = "skills" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Codex CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Codex CLI agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py index 63a28661..eb2c3cde 100644 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for GitHub Copilot agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Copilot(AgentBootstrap): AGENT_DIR = ".github" COMMANDS_SUBDIR = "agents" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install GitHub Copilot agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove GitHub Copilot agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py index e01062df..4a3d43de 100644 --- a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Cursor agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class CursorAgent(AgentBootstrap): AGENT_DIR = ".cursor" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Cursor agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Cursor agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py index eab6ad7e..48d0922a 100644 --- a/src/specify_cli/core_pack/agents/gemini/bootstrap.py +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Gemini CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Gemini(AgentBootstrap): AGENT_DIR = ".gemini" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Gemini CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Gemini CLI agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py index eea1e1bd..80770d0d 100644 --- a/src/specify_cli/core_pack/agents/iflow/bootstrap.py +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for iFlow CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Iflow(AgentBootstrap): AGENT_DIR = ".iflow" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install iFlow CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove iFlow CLI agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py index e8650a3b..63f99295 100644 --- a/src/specify_cli/core_pack/agents/junie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Junie agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Junie(AgentBootstrap): AGENT_DIR = ".junie" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Junie agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Junie agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py index 44a8a007..2f6aaa52 100644 --- a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Kilo Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Kilocode(AgentBootstrap): AGENT_DIR = ".kilocode" COMMANDS_SUBDIR = "workflows" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Kilo Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Kilo Code agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py index 0f7136a5..2e3c400c 100644 --- a/src/specify_cli/core_pack/agents/kimi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Kimi Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Kimi(AgentBootstrap): AGENT_DIR = ".kimi" COMMANDS_SUBDIR = "skills" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Kimi Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Kimi Code agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py index d51b4b6c..d5f8f298 100644 --- a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Kiro CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class KiroCli(AgentBootstrap): AGENT_DIR = ".kiro" COMMANDS_SUBDIR = "prompts" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Kiro CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Kiro CLI agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py index fbd76f53..223a0545 100644 --- a/src/specify_cli/core_pack/agents/opencode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for opencode agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Opencode(AgentBootstrap): AGENT_DIR = ".opencode" COMMANDS_SUBDIR = "command" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install opencode agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove opencode agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py index 591a0d86..0d760669 100644 --- a/src/specify_cli/core_pack/agents/pi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Pi Coding Agent agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Pi(AgentBootstrap): AGENT_DIR = ".pi" COMMANDS_SUBDIR = "prompts" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Pi Coding Agent agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Pi Coding Agent agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py index 40e892f0..728abd09 100644 --- a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Qoder CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Qodercli(AgentBootstrap): AGENT_DIR = ".qoder" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Qoder CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Qoder CLI agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py index 8e2d5902..baf4cf3e 100644 --- a/src/specify_cli/core_pack/agents/qwen/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Qwen Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Qwen(AgentBootstrap): AGENT_DIR = ".qwen" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Qwen Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Qwen Code agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py index fd8b66f2..cc018480 100644 --- a/src/specify_cli/core_pack/agents/roo/bootstrap.py +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Roo Code agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Roo(AgentBootstrap): AGENT_DIR = ".roo" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Roo Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Roo Code agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py index ed3c45b2..2b679f51 100644 --- a/src/specify_cli/core_pack/agents/shai/bootstrap.py +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for SHAI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Shai(AgentBootstrap): AGENT_DIR = ".shai" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install SHAI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove SHAI agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py index 0e79eff3..53024bd8 100644 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Tabnine CLI agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Tabnine(AgentBootstrap): AGENT_DIR = ".tabnine/agent" COMMANDS_SUBDIR = "commands" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Tabnine CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Tabnine CLI agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py index 3846b4dc..77b7c5d6 100644 --- a/src/specify_cli/core_pack/agents/trae/bootstrap.py +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Trae agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Trae(AgentBootstrap): AGENT_DIR = ".trae" COMMANDS_SUBDIR = "rules" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Trae agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Trae agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py index 1ae353b4..1b29fe43 100644 --- a/src/specify_cli/core_pack/agents/vibe/bootstrap.py +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Mistral Vibe agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Vibe(AgentBootstrap): AGENT_DIR = ".vibe" COMMANDS_SUBDIR = "prompts" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Mistral Vibe agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Mistral Vibe agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py index fccbae3a..192ca32d 100644 --- a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -1,7 +1,7 @@ """Bootstrap module for Windsurf agent pack.""" from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files @@ -12,16 +12,19 @@ class Windsurf(AgentBootstrap): AGENT_DIR = ".windsurf" COMMANDS_SUBDIR = "workflows" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: """Install Windsurf agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) + return [] # directories only — actual files are created by the init pipeline - def teardown(self, project_path: Path, *, force: bool = False) -> None: + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Windsurf agent files from the project. Only removes individual tracked files — directories are never - deleted. Raises ``AgentFileModifiedError`` if any tracked file - was modified and *force* is ``False``. + deleted. When *files* is provided, exactly those files are + removed. Otherwise the install manifest is consulted and + ``AgentFileModifiedError`` is raised if any tracked file was + modified and *force* is ``False``. """ - remove_tracked_files(project_path, self.manifest.id, force=force) + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index bc3f4bb4..b93cc65d 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -27,6 +27,7 @@ from specify_cli.agent_pack import ( _sha256, check_modified_files, export_pack, + get_tracked_files, list_all_agents, list_embedded_agents, load_bootstrap, @@ -79,17 +80,18 @@ def _write_bootstrap(pack_dir: Path, class_name: str = "TestAgent", agent_dir: s bootstrap_file = pack_dir / BOOTSTRAP_FILENAME bootstrap_file.write_text(textwrap.dedent(f"""\ from pathlib import Path - from typing import Any, Dict + from typing import Any, Dict, List, Optional from specify_cli.agent_pack import AgentBootstrap, remove_tracked_files class {class_name}(AgentBootstrap): AGENT_DIR = "{agent_dir}" - def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> None: + def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]: (project_path / self.AGENT_DIR / "commands").mkdir(parents=True, exist_ok=True) + return [] - def teardown(self, project_path: Path, *, force: bool = False) -> None: - remove_tracked_files(project_path, self.manifest.id, force=force) + def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: + return remove_tracked_files(project_path, self.manifest.id, force=force, files=files) """), encoding="utf-8") return bootstrap_file @@ -273,20 +275,31 @@ class TestBootstrapContract: project = tmp_path / "project" project.mkdir() - b.setup(project, "sh", {}) + agent_files = b.setup(project, "sh", {}) + assert isinstance(agent_files, list) assert (project / ".test-agent" / "commands").is_dir() # Simulate the init pipeline writing a file cmd_file = project / ".test-agent" / "commands" / "hello.md" cmd_file.write_text("hello", encoding="utf-8") - # finalize_setup records files for tracking - b.finalize_setup(project) + # Simulate extension registration writing a file + ext_file = project / ".test-agent" / "commands" / "ext-cmd.md" + ext_file.write_text("ext", encoding="utf-8") + + # finalize_setup records both agent and extension files + b.finalize_setup(project, agent_files=agent_files, extension_files=[ext_file]) assert _manifest_path(project, "test-agent").is_file() + # Verify the manifest separates agent and extension files + manifest_data = json.loads(_manifest_path(project, "test-agent").read_text()) + assert "agent_files" in manifest_data + assert "extension_files" in manifest_data + b.teardown(project) - # The tracked file should be removed + # The tracked files should be removed assert not cmd_file.exists() + assert not ext_file.exists() # Install manifest itself should be cleaned up assert not _manifest_path(project, "test-agent").is_file() # Directories are preserved (only files are removed) @@ -557,7 +570,7 @@ class TestFileTracking: f.parent.mkdir(parents=True) f.write_text("hello world", encoding="utf-8") - record_installed_files(project, "myagent", [f]) + record_installed_files(project, "myagent", agent_files=[f]) # No modifications yet assert check_modified_files(project, "myagent") == [] @@ -571,7 +584,7 @@ class TestFileTracking: f.parent.mkdir(parents=True) f.write_text("original", encoding="utf-8") - record_installed_files(project, "myagent", [f]) + record_installed_files(project, "myagent", agent_files=[f]) # Now modify the file f.write_text("modified content", encoding="utf-8") @@ -595,7 +608,7 @@ class TestFileTracking: f1.write_text("aaa", encoding="utf-8") f2.write_text("bbb", encoding="utf-8") - record_installed_files(project, "ag", [f1, f2]) + record_installed_files(project, "ag", agent_files=[f1, f2]) removed = remove_tracked_files(project, "ag") assert len(removed) == 2 @@ -615,7 +628,7 @@ class TestFileTracking: f.parent.mkdir(parents=True) f.write_text("original", encoding="utf-8") - record_installed_files(project, "ag", [f]) + record_installed_files(project, "ag", agent_files=[f]) f.write_text("user-edited", encoding="utf-8") with pytest.raises(AgentFileModifiedError, match="modified"): @@ -633,7 +646,7 @@ class TestFileTracking: f.parent.mkdir(parents=True) f.write_text("original", encoding="utf-8") - record_installed_files(project, "ag", [f]) + record_installed_files(project, "ag", agent_files=[f]) f.write_text("user-edited", encoding="utf-8") removed = remove_tracked_files(project, "ag", force=True) @@ -655,7 +668,7 @@ class TestFileTracking: f = d / "deep.md" f.write_text("deep", encoding="utf-8") - record_installed_files(project, "myagent", [f]) + record_installed_files(project, "myagent", agent_files=[f]) remove_tracked_files(project, "myagent") assert not f.exists() @@ -672,7 +685,7 @@ class TestFileTracking: f.parent.mkdir(parents=True) f.write_text("data", encoding="utf-8") - record_installed_files(project, "ag", [f]) + record_installed_files(project, "ag", agent_files=[f]) # User deletes the file before teardown f.unlink() @@ -700,10 +713,98 @@ class TestFileTracking: f.parent.mkdir(parents=True) f.write_text("content", encoding="utf-8") - manifest_file = record_installed_files(project, "ag", [f]) + manifest_file = record_installed_files(project, "ag", agent_files=[f]) data = json.loads(manifest_file.read_text(encoding="utf-8")) 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 + assert isinstance(data["agent_files"], dict) + assert ".ag/x.md" in data["agent_files"] + assert len(data["agent_files"][".ag/x.md"]) == 64 + + # -- New: categorised manifest & explicit file teardown -- + + def test_manifest_categorises_agent_and_extension_files(self, tmp_path): + """record_installed_files stores agent and extension files separately.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + agent_f = project / ".ag" / "core.md" + ext_f = project / ".ag" / "ext-cmd.md" + agent_f.parent.mkdir(parents=True) + agent_f.write_text("core", encoding="utf-8") + ext_f.write_text("ext", encoding="utf-8") + + manifest_file = record_installed_files( + project, "ag", agent_files=[agent_f], extension_files=[ext_f] + ) + data = json.loads(manifest_file.read_text(encoding="utf-8")) + + assert ".ag/core.md" in data["agent_files"] + assert ".ag/ext-cmd.md" in data["extension_files"] + assert ".ag/core.md" not in data.get("extension_files", {}) + assert ".ag/ext-cmd.md" not in data.get("agent_files", {}) + + def test_get_tracked_files_returns_both_categories(self, tmp_path): + """get_tracked_files splits agent and extension files.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + agent_f = project / ".ag" / "a.md" + ext_f = project / ".ag" / "e.md" + agent_f.parent.mkdir(parents=True) + agent_f.write_text("a", encoding="utf-8") + ext_f.write_text("e", encoding="utf-8") + + record_installed_files( + project, "ag", agent_files=[agent_f], extension_files=[ext_f] + ) + + agent_files, extension_files = get_tracked_files(project, "ag") + assert ".ag/a.md" in agent_files + assert ".ag/e.md" in extension_files + + def test_get_tracked_files_no_manifest(self, tmp_path): + """get_tracked_files returns ({}, {}) when no manifest exists.""" + agent_files, extension_files = get_tracked_files(tmp_path, "nope") + assert agent_files == {} + assert extension_files == {} + + def test_teardown_with_explicit_files(self, tmp_path): + """teardown accepts explicit files dict (CLI-driven teardown).""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + f1 = project / ".ag" / "a.md" + f2 = project / ".ag" / "b.md" + f1.parent.mkdir(parents=True) + f1.write_text("aaa", encoding="utf-8") + f2.write_text("bbb", encoding="utf-8") + + # Record the files + record_installed_files(project, "ag", agent_files=[f1, f2]) + + # Get the tracked entries + agent_entries, _ = get_tracked_files(project, "ag") + + # Pass explicit files to remove_tracked_files + removed = remove_tracked_files(project, "ag", files=agent_entries) + assert len(removed) == 2 + assert not f1.exists() + assert not f2.exists() + + def test_check_detects_extension_file_modification(self, tmp_path): + """Modified extension files are also detected by check_modified_files.""" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + ext_f = project / ".ag" / "ext.md" + ext_f.parent.mkdir(parents=True) + ext_f.write_text("original", encoding="utf-8") + + record_installed_files(project, "ag", extension_files=[ext_f]) + + ext_f.write_text("user-edited", encoding="utf-8") + + modified = check_modified_files(project, "ag") + assert len(modified) == 1 + assert ".ag/ext.md" in modified[0]