From 9b580a536b8493010f537688643f2b69f1055ee7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:29:33 +0000 Subject: [PATCH] feat: setup() owns scaffolding and returns actual installed files - AgentBootstrap._scaffold_project() calls scaffold_from_core_pack, snapshots before/after, returns all new files - finalize_setup() filters agent_files to only track files under the agent's own directory tree (shared .specify/ files not tracked) - All 25 bootstrap setup() methods call _scaffold_project() and return the actual file list instead of [] - --agent init flow routes through setup() for scaffolding instead of calling scaffold_from_core_pack directly - 100 new tests (TestSetupReturnsFiles): verify every agent's setup() returns non-empty, existing, absolute paths including agent-dir files - Parity tests use CliRunner to invoke the real init command - finalize_setup bug fix: skills-migrated agents (agy) now have their skills directory scanned correctly - 1262 tests pass (452 in test_agent_pack.py alone) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/054690bb-c048-41e0-b553-377d5cb36b78 --- src/specify_cli/__init__.py | 37 +- src/specify_cli/agent_pack.py | 95 ++- .../core_pack/agents/agy/bootstrap.py | 2 +- .../core_pack/agents/amp/bootstrap.py | 2 +- .../core_pack/agents/auggie/bootstrap.py | 2 +- .../core_pack/agents/bob/bootstrap.py | 2 +- .../core_pack/agents/claude/bootstrap.py | 2 +- .../core_pack/agents/codebuddy/bootstrap.py | 2 +- .../core_pack/agents/codex/bootstrap.py | 2 +- .../core_pack/agents/copilot/bootstrap.py | 2 +- .../agents/cursor-agent/bootstrap.py | 2 +- .../core_pack/agents/gemini/bootstrap.py | 2 +- .../core_pack/agents/iflow/bootstrap.py | 2 +- .../core_pack/agents/junie/bootstrap.py | 2 +- .../core_pack/agents/kilocode/bootstrap.py | 2 +- .../core_pack/agents/kimi/bootstrap.py | 2 +- .../core_pack/agents/kiro-cli/bootstrap.py | 2 +- .../core_pack/agents/opencode/bootstrap.py | 2 +- .../core_pack/agents/pi/bootstrap.py | 2 +- .../core_pack/agents/qodercli/bootstrap.py | 2 +- .../core_pack/agents/qwen/bootstrap.py | 2 +- .../core_pack/agents/roo/bootstrap.py | 2 +- .../core_pack/agents/shai/bootstrap.py | 2 +- .../core_pack/agents/tabnine/bootstrap.py | 2 +- .../core_pack/agents/trae/bootstrap.py | 2 +- .../core_pack/agents/vibe/bootstrap.py | 2 +- .../core_pack/agents/windsurf/bootstrap.py | 2 +- tests/test_agent_pack.py | 543 +++++++++++++++--- 28 files changed, 598 insertions(+), 127 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index a5f82a12..c801cbd7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1984,7 +1984,10 @@ def init( "This will become the default in v0.6.0." ) - if use_github: + if use_agent_pack: + # Pack-based flow: setup() owns scaffolding, always uses bundled assets. + tracker.add("scaffold", "Apply bundled assets") + elif use_github: for key, label in [ ("fetch", "Fetch latest release"), ("download", "Download template"), @@ -2019,7 +2022,26 @@ def init( verify = not skip_tls local_ssl_context = ssl_context if verify else False - if use_github: + # -- scaffolding ------------------------------------------------ + # Pack-based flow (--agent): setup() owns scaffolding and + # returns every file it created. Legacy flow (--ai): scaffold + # directly or download from GitHub. + agent_setup_files: list[Path] = [] + + if use_agent_pack and agent_bootstrap is not None: + tracker.start("scaffold") + try: + agent_setup_files = agent_bootstrap.setup( + project_path, selected_script, {"here": here}) + tracker.complete( + "scaffold", + f"{selected_ai} ({len(agent_setup_files)} files)") + except Exception as exc: + tracker.error("scaffold", str(exc)) + if not here and project_path.exists(): + shutil.rmtree(project_path) + raise typer.Exit(1) + elif use_github: with httpx.Client(verify=local_ssl_context) as local_client: download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) else: @@ -2162,11 +2184,14 @@ def init( tracker.skip("cleanup", "not needed (no download)") # When --agent is used, record all installed agent files for - # tracked teardown. This runs AFTER the full init pipeline has - # finished creating files (scaffolding, skills, presets, - # extensions) so finalize_setup captures everything. + # tracked teardown. setup() already returned the files it + # created; pass them to finalize_setup so the manifest is + # accurate. finalize_setup also scans the agent directory + # to catch any additional files created by later pipeline + # steps (skills, extensions, presets). if use_agent_pack and agent_bootstrap is not None: - agent_bootstrap.finalize_setup(project_path) + agent_bootstrap.finalize_setup( + project_path, agent_files=agent_setup_files) tracker.complete("final", "project ready") except (typer.Exit, SystemExit): diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 08229c93..4c92cade 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -245,6 +245,57 @@ class AgentBootstrap: """Return the agent's top-level directory inside the project.""" return project_path / self.manifest.commands_dir.split("/")[0] + def collect_installed_files(self, project_path: Path) -> List[Path]: + """Return every file under the agent's directory tree. + + Subclasses should call this at the end of :meth:`setup` to build + the return list. Any files present in the agent directory at + that point — whether created by ``setup()`` itself, by the + scaffold pipeline, or by a preceding step — are reported. + """ + root = self.agent_dir(project_path) + if not root.is_dir(): + return [] + return sorted(p for p in root.rglob("*") if p.is_file()) + + def _scaffold_project( + self, + project_path: Path, + script_type: str, + is_current_dir: bool = False, + ) -> List[Path]: + """Run the shared scaffolding pipeline and return new files. + + Calls ``scaffold_from_core_pack`` for this agent and then + collects every file that was created. Subclasses should call + this from :meth:`setup` when they want to use the shared + scaffolding rather than creating files manually. + + Returns: + List of absolute paths of **all** files created by the + scaffold (agent-specific commands, shared scripts, + templates, etc.). + """ + # Lazy import to avoid circular dependency (agent_pack is + # imported by specify_cli.__init__). + from specify_cli import scaffold_from_core_pack + + # Snapshot existing files + before: set[Path] = set() + if project_path.exists(): + before = {p for p in project_path.rglob("*") if p.is_file()} + + ok = scaffold_from_core_pack( + project_path, self.manifest.id, script_type, is_current_dir, + ) + if not ok: + raise AgentPackError( + f"Scaffolding failed for agent '{self.manifest.id}'") + + # Collect every new file + after = {p for p in project_path.rglob("*") if p.is_file()} + return sorted(after - before) + def finalize_setup( self, project_path: Path, @@ -257,25 +308,51 @@ class AgentBootstrap: 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 + agent's directory tree for anything additional, and writes the install manifest. + ``setup()`` may return *all* files created by the shared + scaffolding (including shared project files in ``.specify/``). + Only files under the agent's own directory tree are recorded as + ``agent_files`` — shared project infrastructure is not tracked + per-agent and will not be removed during teardown. + Args: agent_files: Files reported by :meth:`setup`. extension_files: Files created by extension registration. """ - 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. + # Filter agent_files: only keep files under the agent's directory + # tree. setup() may return shared project files (e.g. .specify/) + # which must not be tracked per-agent. + agent_root = self.agent_dir(project_path) + agent_root_resolved = agent_root.resolve() + all_agent: List[Path] = [] + for p in (agent_files or []): + try: + p.resolve().relative_to(agent_root_resolved) + all_agent.append(p) + except ValueError: + pass # shared file — not tracked per-agent + + # Scan the agent's directory tree for files created by the init + # pipeline that setup() did not report directly. We scan the + # entire agent directory (the parent of commands_dir) because + # skills-migrated agents replace the commands directory with a + # sibling skills directory during init. 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) + # Scan the agent root (e.g. .claude/) so we catch both + # commands and skills directories. + agent_root = commands_dir.parent + agent_set = {p.resolve() for p in all_agent} + for scan_dir in (commands_dir, agent_root): + if scan_dir.is_dir(): + for p in scan_dir.rglob("*"): + if p.is_file() and p.resolve() not in agent_set: + all_agent.append(p) + agent_set.add(p.resolve()) record_installed_files( project_path, diff --git a/src/specify_cli/core_pack/agents/agy/bootstrap.py b/src/specify_cli/core_pack/agents/agy/bootstrap.py index 0434c2c4..b7b6ae9d 100644 --- a/src/specify_cli/core_pack/agents/agy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/agy/bootstrap.py @@ -16,7 +16,7 @@ class Agy(AgentBootstrap): """Install Antigravity agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Antigravity agent files from the project. diff --git a/src/specify_cli/core_pack/agents/amp/bootstrap.py b/src/specify_cli/core_pack/agents/amp/bootstrap.py index ab305ede..da709932 100644 --- a/src/specify_cli/core_pack/agents/amp/bootstrap.py +++ b/src/specify_cli/core_pack/agents/amp/bootstrap.py @@ -16,7 +16,7 @@ class Amp(AgentBootstrap): """Install Amp agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Amp agent files from the project. diff --git a/src/specify_cli/core_pack/agents/auggie/bootstrap.py b/src/specify_cli/core_pack/agents/auggie/bootstrap.py index 8abd5618..27f89a30 100644 --- a/src/specify_cli/core_pack/agents/auggie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/auggie/bootstrap.py @@ -16,7 +16,7 @@ class Auggie(AgentBootstrap): """Install Auggie CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/bob/bootstrap.py b/src/specify_cli/core_pack/agents/bob/bootstrap.py index 4f8e2cdb..afdd3e05 100644 --- a/src/specify_cli/core_pack/agents/bob/bootstrap.py +++ b/src/specify_cli/core_pack/agents/bob/bootstrap.py @@ -16,7 +16,7 @@ class Bob(AgentBootstrap): """Install IBM Bob agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/claude/bootstrap.py b/src/specify_cli/core_pack/agents/claude/bootstrap.py index 917556c3..e1b3fade 100644 --- a/src/specify_cli/core_pack/agents/claude/bootstrap.py +++ b/src/specify_cli/core_pack/agents/claude/bootstrap.py @@ -16,7 +16,7 @@ class Claude(AgentBootstrap): """Install Claude Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py index f4921d54..c054b5a9 100644 --- a/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codebuddy/bootstrap.py @@ -16,7 +16,7 @@ class Codebuddy(AgentBootstrap): """Install CodeBuddy agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove CodeBuddy agent files from the project. diff --git a/src/specify_cli/core_pack/agents/codex/bootstrap.py b/src/specify_cli/core_pack/agents/codex/bootstrap.py index 4accd01b..05e9b500 100644 --- a/src/specify_cli/core_pack/agents/codex/bootstrap.py +++ b/src/specify_cli/core_pack/agents/codex/bootstrap.py @@ -16,7 +16,7 @@ class Codex(AgentBootstrap): """Install Codex CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/copilot/bootstrap.py b/src/specify_cli/core_pack/agents/copilot/bootstrap.py index eb2c3cde..cb5a2d4c 100644 --- a/src/specify_cli/core_pack/agents/copilot/bootstrap.py +++ b/src/specify_cli/core_pack/agents/copilot/bootstrap.py @@ -16,7 +16,7 @@ class Copilot(AgentBootstrap): """Install GitHub Copilot agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. 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 4a3d43de..a30fb4e8 100644 --- a/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py +++ b/src/specify_cli/core_pack/agents/cursor-agent/bootstrap.py @@ -16,7 +16,7 @@ class CursorAgent(AgentBootstrap): """Install Cursor agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Cursor agent files from the project. diff --git a/src/specify_cli/core_pack/agents/gemini/bootstrap.py b/src/specify_cli/core_pack/agents/gemini/bootstrap.py index 48d0922a..92421aba 100644 --- a/src/specify_cli/core_pack/agents/gemini/bootstrap.py +++ b/src/specify_cli/core_pack/agents/gemini/bootstrap.py @@ -16,7 +16,7 @@ class Gemini(AgentBootstrap): """Install Gemini CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/iflow/bootstrap.py b/src/specify_cli/core_pack/agents/iflow/bootstrap.py index 80770d0d..520a3cba 100644 --- a/src/specify_cli/core_pack/agents/iflow/bootstrap.py +++ b/src/specify_cli/core_pack/agents/iflow/bootstrap.py @@ -16,7 +16,7 @@ class Iflow(AgentBootstrap): """Install iFlow CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/junie/bootstrap.py b/src/specify_cli/core_pack/agents/junie/bootstrap.py index 63f99295..f830bdfd 100644 --- a/src/specify_cli/core_pack/agents/junie/bootstrap.py +++ b/src/specify_cli/core_pack/agents/junie/bootstrap.py @@ -16,7 +16,7 @@ class Junie(AgentBootstrap): """Install Junie agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Junie agent files from the project. diff --git a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py index 2f6aaa52..e41ee477 100644 --- a/src/specify_cli/core_pack/agents/kilocode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kilocode/bootstrap.py @@ -16,7 +16,7 @@ class Kilocode(AgentBootstrap): """Install Kilo Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/kimi/bootstrap.py b/src/specify_cli/core_pack/agents/kimi/bootstrap.py index 2e3c400c..e4e6c71f 100644 --- a/src/specify_cli/core_pack/agents/kimi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kimi/bootstrap.py @@ -16,7 +16,7 @@ class Kimi(AgentBootstrap): """Install Kimi Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. 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 d5f8f298..756dcee5 100644 --- a/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/kiro-cli/bootstrap.py @@ -16,7 +16,7 @@ class KiroCli(AgentBootstrap): """Install Kiro CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/opencode/bootstrap.py b/src/specify_cli/core_pack/agents/opencode/bootstrap.py index 223a0545..a23b006f 100644 --- a/src/specify_cli/core_pack/agents/opencode/bootstrap.py +++ b/src/specify_cli/core_pack/agents/opencode/bootstrap.py @@ -16,7 +16,7 @@ class Opencode(AgentBootstrap): """Install opencode agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove opencode agent files from the project. diff --git a/src/specify_cli/core_pack/agents/pi/bootstrap.py b/src/specify_cli/core_pack/agents/pi/bootstrap.py index 0d760669..f63c8b08 100644 --- a/src/specify_cli/core_pack/agents/pi/bootstrap.py +++ b/src/specify_cli/core_pack/agents/pi/bootstrap.py @@ -16,7 +16,7 @@ class Pi(AgentBootstrap): """Install Pi Coding Agent agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py index 728abd09..721205cd 100644 --- a/src/specify_cli/core_pack/agents/qodercli/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qodercli/bootstrap.py @@ -16,7 +16,7 @@ class Qodercli(AgentBootstrap): """Install Qoder CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/qwen/bootstrap.py b/src/specify_cli/core_pack/agents/qwen/bootstrap.py index baf4cf3e..7688b8fe 100644 --- a/src/specify_cli/core_pack/agents/qwen/bootstrap.py +++ b/src/specify_cli/core_pack/agents/qwen/bootstrap.py @@ -16,7 +16,7 @@ class Qwen(AgentBootstrap): """Install Qwen Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/roo/bootstrap.py b/src/specify_cli/core_pack/agents/roo/bootstrap.py index cc018480..e4416a95 100644 --- a/src/specify_cli/core_pack/agents/roo/bootstrap.py +++ b/src/specify_cli/core_pack/agents/roo/bootstrap.py @@ -16,7 +16,7 @@ class Roo(AgentBootstrap): """Install Roo Code agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/shai/bootstrap.py b/src/specify_cli/core_pack/agents/shai/bootstrap.py index 2b679f51..87880c82 100644 --- a/src/specify_cli/core_pack/agents/shai/bootstrap.py +++ b/src/specify_cli/core_pack/agents/shai/bootstrap.py @@ -16,7 +16,7 @@ class Shai(AgentBootstrap): """Install SHAI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove SHAI agent files from the project. diff --git a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py index 53024bd8..fe6cc3c7 100644 --- a/src/specify_cli/core_pack/agents/tabnine/bootstrap.py +++ b/src/specify_cli/core_pack/agents/tabnine/bootstrap.py @@ -16,7 +16,7 @@ class Tabnine(AgentBootstrap): """Install Tabnine CLI agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/trae/bootstrap.py b/src/specify_cli/core_pack/agents/trae/bootstrap.py index 77b7c5d6..6c774fdd 100644 --- a/src/specify_cli/core_pack/agents/trae/bootstrap.py +++ b/src/specify_cli/core_pack/agents/trae/bootstrap.py @@ -16,7 +16,7 @@ class Trae(AgentBootstrap): """Install Trae agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Trae agent files from the project. diff --git a/src/specify_cli/core_pack/agents/vibe/bootstrap.py b/src/specify_cli/core_pack/agents/vibe/bootstrap.py index 1b29fe43..439974bb 100644 --- a/src/specify_cli/core_pack/agents/vibe/bootstrap.py +++ b/src/specify_cli/core_pack/agents/vibe/bootstrap.py @@ -16,7 +16,7 @@ class Vibe(AgentBootstrap): """Install Mistral Vibe agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) 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. diff --git a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py index 192ca32d..08b4fc80 100644 --- a/src/specify_cli/core_pack/agents/windsurf/bootstrap.py +++ b/src/specify_cli/core_pack/agents/windsurf/bootstrap.py @@ -16,7 +16,7 @@ class Windsurf(AgentBootstrap): """Install Windsurf agent files into the project.""" commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR commands_dir.mkdir(parents=True, exist_ok=True) - return [] # directories only — actual files are created by the init pipeline + return self._scaffold_project(project_path, script_type) def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]: """Remove Windsurf agent files from the project. diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index 173048b7..c44b77fe 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -811,106 +811,475 @@ class TestFileTracking: # =================================================================== -# --agent flag on init (pack-based flow parity) +# setup() returns actual files (not empty list) # =================================================================== -class TestInitAgentFlag: - """Verify the --agent flag on ``specify init`` resolves through the - pack system and that pack metadata is consistent with AGENT_CONFIG.""" +class TestSetupReturnsFiles: + """Verify that every embedded agent's ``setup()`` calls the shared + scaffolding and returns the actual files it created — not an empty + list.""" - def test_agent_resolves_same_agent_as_ai(self): - """--agent resolves the same agent as --ai for all - agents in AGENT_CONFIG (except 'generic').""" - from specify_cli import AGENT_CONFIG - - for agent_id in AGENT_CONFIG: - if agent_id == "generic": - continue - try: - resolved = resolve_agent_pack(agent_id) - except PackResolutionError: - pytest.fail(f"--agent {agent_id} would fail: no pack found") - - assert resolved.manifest.id == agent_id - - def test_pack_commands_dir_matches_agent_config(self): - """The pack's commands_dir matches the directory that the old - flow (AGENT_CONFIG) would use, ensuring both flows write files - to the same location.""" - from specify_cli import AGENT_CONFIG - - for agent_id, config in AGENT_CONFIG.items(): - if agent_id == "generic": - continue - try: - resolved = resolve_agent_pack(agent_id) - except PackResolutionError: - continue - - # AGENT_CONFIG stores folder + commands_subdir - folder = config.get("folder", "").rstrip("/") - subdir = config.get("commands_subdir", "commands") - expected_dir = f"{folder}/{subdir}" if folder else "" - # Normalize path separators - expected_dir = expected_dir.lstrip("/") - - assert resolved.manifest.commands_dir == expected_dir, ( - f"{agent_id}: commands_dir mismatch: " - f"pack={resolved.manifest.commands_dir!r} " - f"config_derived={expected_dir!r}" - ) - - def test_finalize_setup_records_files_after_init(self, tmp_path): - """Simulates the --agent init flow: setup → create files → - finalize_setup, then verifies the install manifest is present.""" - # Pick any embedded agent (claude) - resolved = resolve_agent_pack("claude") + @pytest.mark.parametrize("agent", [ + a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic" + ]) + def test_setup_returns_nonempty_file_list(self, agent, tmp_path): + """setup() must return at least one Path (the scaffolded command + files, scripts, templates, etc.).""" + resolved = resolve_agent_pack(agent) bootstrap = load_bootstrap(resolved.path, resolved.manifest) - project = tmp_path / "project" + project = tmp_path / f"setup_{agent}" project.mkdir() - (project / ".specify").mkdir() - # setup() creates the directory structure - setup_files = bootstrap.setup(project, "sh", {}) - assert isinstance(setup_files, list) + files = bootstrap.setup(project, "sh", {}) + assert isinstance(files, list) + assert len(files) > 0, ( + f"Agent '{agent}': setup() returned an empty list — " + f"it must return the files it installed") - # Simulate the init pipeline creating command files - commands_dir = project / resolved.manifest.commands_dir - commands_dir.mkdir(parents=True, exist_ok=True) - cmd_file = commands_dir / "speckit-plan.md" - cmd_file.write_text("plan command", encoding="utf-8") + @pytest.mark.parametrize("agent", [ + a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic" + ]) + def test_setup_returns_only_existing_paths(self, agent, tmp_path): + """Every path returned by setup() must exist on disk.""" + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) - # finalize_setup records everything + project = tmp_path / f"exists_{agent}" + project.mkdir() + + files = bootstrap.setup(project, "sh", {}) + for f in files: + assert f.is_file(), ( + f"Agent '{agent}': setup() returned '{f}' but it " + f"does not exist on disk") + + @pytest.mark.parametrize("agent", [ + a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic" + ]) + def test_setup_returns_absolute_paths(self, agent, tmp_path): + """setup() must return absolute paths.""" + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) + + project = tmp_path / f"abs_{agent}" + project.mkdir() + + files = bootstrap.setup(project, "sh", {}) + for f in files: + assert f.is_absolute(), ( + f"Agent '{agent}': setup() returned relative path '{f}'") + + @pytest.mark.parametrize("agent", [ + a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic" + ]) + def test_setup_returns_include_agent_dir_files(self, agent, tmp_path): + """setup() return list must include files under the agent's + directory tree (these are the files tracked for teardown).""" + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) + + project = tmp_path / f"agentdir_{agent}" + project.mkdir() + + files = bootstrap.setup(project, "sh", {}) + agent_root = bootstrap.agent_dir(project) + + agent_dir_files = [ + f for f in files + if f.resolve().is_relative_to(agent_root.resolve()) + ] + assert len(agent_dir_files) > 0, ( + f"Agent '{agent}': setup() returned no files under " + f"'{agent_root.relative_to(project)}'") + + +# =================================================================== +# --agent / --ai parity via CliRunner (end-to-end init command) +# =================================================================== + +def _collect_project_files( + root: Path, + *, + exclude_metadata: bool = False, +) -> dict[str, bytes]: + """Walk *root* and return ``{relative_posix_path: file_bytes}``. + + When *exclude_metadata* is True, files that are expected to differ + between ``--ai`` and ``--agent`` flows are excluded: + + - ``.specify/agent-manifest-*.json`` (tracking data, ``--agent`` only) + - ``.specify/init-options.json`` (contains ``agent_pack`` flag) + """ + result: dict[str, bytes] = {} + for p in root.rglob("*"): + if p.is_file(): + rel = p.relative_to(root).as_posix() + if exclude_metadata: + if rel.startswith(".specify/agent-manifest-"): + continue + if rel == ".specify/init-options.json": + continue + result[rel] = p.read_bytes() + return result + + +# All agents except "generic" (which requires --ai-commands-dir) +_ALL_AGENTS = [a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic"] + + +def _run_init_via_cli( + project_dir: Path, + agent: str, + *, + use_agent_flag: bool, +) -> tuple[int, str]: + """Invoke ``specify init --ai/--agent `` via CliRunner. + + Patches ``download_and_extract_template`` to use + ``scaffold_from_core_pack`` so the test works without network access + while exercising the real CLI code path — the same functions and + branching that the user runs. + + Returns ``(exit_code, captured_output)``. + """ + from unittest.mock import patch as _patch + + from typer.testing import CliRunner + + from specify_cli import app as specify_app + from specify_cli import scaffold_from_core_pack as _scaffold + + runner = CliRunner() + + def _mock_download( + project_path, ai_assistant, script_type, + is_current_dir=False, **kwargs, + ): + ok = _scaffold(project_path, ai_assistant, script_type, is_current_dir) + if not ok: + raise RuntimeError( + f"scaffold_from_core_pack failed for {ai_assistant}") + tracker = kwargs.get("tracker") + if tracker: + for key in [ + "fetch", "download", "extract", + "zip-list", "extracted-summary", + ]: + try: + tracker.start(key) + tracker.complete(key, "mocked") + except Exception: + pass + + flag = "--agent" if use_agent_flag else "--ai" + args = [ + "init", str(project_dir), + flag, agent, + "--no-git", "--ignore-agent-tools", + ] + + # Agents migrated to skills need --ai-skills to avoid the fail-fast + # migration error — same requirement as the real CLI. + try: + from specify_cli import AGENT_SKILLS_MIGRATIONS + if agent in AGENT_SKILLS_MIGRATIONS: + args.append("--ai-skills") + except (ImportError, AttributeError): + pass + + with _patch( + "specify_cli.download_and_extract_template", _mock_download, + ): + result = runner.invoke(specify_app, args) + + return result.exit_code, result.output or "" + + +class TestInitFlowParity: + """End-to-end parity: ``specify init --ai`` and ``specify init --agent`` + produce identical project files for every supported agent. + + Each test invokes the actual CLI via ``typer.testing.CliRunner`` with the + network download mocked so both flows exercise the same init pipeline + without requiring internet access. + + The ``--agent`` flow additionally calls ``finalize_setup()`` which writes + a tracking manifest in ``.specify/agent-manifest-.json``. Aside from + that manifest and the ``agent_pack`` key in ``init-options.json``, every + project file must be byte-for-byte identical between the two flows. + + All {n} non-generic agents are tested. + """.format(n=len(_ALL_AGENTS)) + + # -- per-class lazy caches (init is run once per agent per flow) -------- + + @pytest.fixture(scope="class") + def ai_projects(self, tmp_path_factory): + """Cache: run ``specify init --ai`` once per agent.""" + cache: dict[str, tuple[Path, int, str]] = {} + def _get(agent: str) -> Path: + if agent not in cache: + project = tmp_path_factory.mktemp("parity_ai") / agent + exit_code, output = _run_init_via_cli( + project, agent, use_agent_flag=False) + cache[agent] = (project, exit_code, output) + project, exit_code, output = cache[agent] + assert exit_code == 0, ( + f"specify init --ai {agent} failed (exit {exit_code}):\n" + f"{output}") + return project + return _get + + @pytest.fixture(scope="class") + def agent_projects(self, tmp_path_factory): + """Cache: run ``specify init --agent`` once per agent.""" + cache: dict[str, tuple[Path, int, str]] = {} + def _get(agent: str) -> Path: + if agent not in cache: + project = tmp_path_factory.mktemp("parity_agent") / agent + exit_code, output = _run_init_via_cli( + project, agent, use_agent_flag=True) + cache[agent] = (project, exit_code, output) + project, exit_code, output = cache[agent] + assert exit_code == 0, ( + f"specify init --agent {agent} failed (exit {exit_code}):\n" + f"{output}") + return project + return _get + + # -- parametrized parity tests over every agent ------------------------- + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_ai_init_succeeds(self, agent, ai_projects): + """``specify init --ai `` completes successfully.""" + assert ai_projects(agent).is_dir() + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_agent_init_succeeds(self, agent, agent_projects): + """``specify init --agent `` completes successfully.""" + assert agent_projects(agent).is_dir() + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_same_file_set(self, agent, ai_projects, agent_projects): + """--ai and --agent produce the same set of project files.""" + ai_files = _collect_project_files( + ai_projects(agent), exclude_metadata=True) + agent_files = _collect_project_files( + agent_projects(agent), exclude_metadata=True) + + only_ai = sorted(set(ai_files) - set(agent_files)) + only_agent = sorted(set(agent_files) - set(ai_files)) + + assert not only_ai, ( + f"Agent '{agent}': files only in --ai output:\n " + + "\n ".join(only_ai)) + assert not only_agent, ( + f"Agent '{agent}': files only in --agent output:\n " + + "\n ".join(only_agent)) + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_same_file_contents(self, agent, ai_projects, agent_projects): + """--ai and --agent produce byte-for-byte identical file contents.""" + ai_files = _collect_project_files( + ai_projects(agent), exclude_metadata=True) + agent_files = _collect_project_files( + agent_projects(agent), exclude_metadata=True) + + for name in ai_files: + if name not in agent_files: + continue # caught by test_same_file_set + assert ai_files[name] == agent_files[name], ( + f"Agent '{agent}': file '{name}' content differs " + f"between --ai and --agent flows") + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_same_directory_structure(self, agent, ai_projects, agent_projects): + """--ai and --agent produce the same directory tree.""" + def _dirs(root: Path) -> set[str]: + return { + p.relative_to(root).as_posix() + for p in root.rglob("*") if p.is_dir() + } + + ai_dirs = _dirs(ai_projects(agent)) + agent_dirs = _dirs(agent_projects(agent)) + + only_ai = sorted(ai_dirs - agent_dirs) + only_agent = sorted(agent_dirs - ai_dirs) + + assert not only_ai, ( + f"Agent '{agent}': dirs only in --ai:\n " + + "\n ".join(only_ai)) + assert not only_agent, ( + f"Agent '{agent}': dirs only in --agent:\n " + + "\n ".join(only_agent)) + + # -- pack lifecycle (setup / finalize / teardown) ----------------------- + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_agent_resolves_through_pack_system(self, agent): + """Every AGENT_CONFIG agent resolves a valid pack.""" + try: + resolved = resolve_agent_pack(agent) + except PackResolutionError: + pytest.fail(f"--agent {agent} would fail: no pack found") + assert resolved.manifest.id == agent + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_setup_creates_commands_dir(self, agent, agent_projects): + """The pack's setup() creates the commands directory that the + scaffold pipeline writes command files into. + + For agents that migrate to skills (``--ai-skills``), the commands + directory is replaced by a skills directory during init — verify + that the agent directory itself exists instead. + """ + project = agent_projects(agent) + resolved = resolve_agent_pack(agent) + cmd_dir = project / resolved.manifest.commands_dir + + if cmd_dir.is_dir(): + return # commands directory present — normal flow + + # For skills-migrated agents, the commands dir is removed and + # replaced by a skills dir. Verify the parent agent dir exists. + try: + from specify_cli import AGENT_SKILLS_MIGRATIONS + if agent in AGENT_SKILLS_MIGRATIONS: + agent_dir = cmd_dir.parent + assert agent_dir.is_dir(), ( + f"Agent '{agent}': agent dir " + f"'{agent_dir.relative_to(project)}' missing " + f"(skills migration removes commands)") + return + except (ImportError, AttributeError): + pass + + pytest.fail( + f"Agent '{agent}': commands_dir " + f"'{resolved.manifest.commands_dir}' not present after init") + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_commands_dir_matches_agent_config(self, agent): + """Pack commands_dir matches the directory derived from AGENT_CONFIG.""" + from specify_cli import AGENT_CONFIG + + config = AGENT_CONFIG[agent] + try: + resolved = resolve_agent_pack(agent) + except PackResolutionError: + pytest.fail(f"No pack for {agent}") + + folder = config.get("folder", "").rstrip("/") + subdir = config.get("commands_subdir", "commands") + expected = (f"{folder}/{subdir}" if folder else "").lstrip("/") + + assert resolved.manifest.commands_dir == expected, ( + f"{agent}: pack={resolved.manifest.commands_dir!r} " + f"vs config={expected!r}") + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_tracking_manifest_present(self, agent, agent_projects): + """--agent flow writes an install manifest for tracked teardown.""" + manifest = _manifest_path(agent_projects(agent), agent) + assert manifest.is_file(), ( + f"Agent '{agent}': missing tracking manifest") + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_tracking_manifest_records_all_commands(self, agent, agent_projects): + """The install manifest tracks every file in the commands (or skills) + directory that exists after init.""" + project = agent_projects(agent) + resolved = resolve_agent_pack(agent) + cmd_dir = project / resolved.manifest.commands_dir + + # For skills-migrated agents the commands_dir is removed. + # Check the parent agent dir for skills files instead. + if not cmd_dir.is_dir(): + try: + from specify_cli import AGENT_SKILLS_MIGRATIONS + if agent in AGENT_SKILLS_MIGRATIONS: + cmd_dir = cmd_dir.parent + if not cmd_dir.is_dir(): + pytest.skip( + f"{agent}: skills dir not present") + except (ImportError, AttributeError): + pytest.skip(f"{agent}: commands_dir missing") + + # Actual files on disk + on_disk = { + p.relative_to(project).as_posix() + for p in cmd_dir.rglob("*") if p.is_file() + } + + # Files recorded in the tracking manifest + manifest = _manifest_path(project, agent) + data = json.loads(manifest.read_text(encoding="utf-8")) + tracked = { + *data.get("agent_files", {}), + *data.get("extension_files", {}), + } + + missing = on_disk - tracked + assert not missing, ( + f"Agent '{agent}': files not tracked by manifest:\n " + + "\n ".join(sorted(missing))) + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_teardown_removes_all_tracked_files(self, agent, tmp_path): + """Full lifecycle: setup → scaffold → finalize → teardown. + + After teardown every tracked file must be deleted, but directories + are preserved. This proves the pack's teardown() is functional. + """ + from specify_cli import scaffold_from_core_pack + + project = tmp_path / f"lifecycle_{agent}" + project.mkdir() + + # 1. Scaffold (same as init pipeline) + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok, f"scaffold failed for {agent}" + + # 2. Resolve pack and finalize + resolved = resolve_agent_pack(agent) + bootstrap = load_bootstrap(resolved.path, resolved.manifest) bootstrap.finalize_setup(project) - manifest_file = _manifest_path(project, "claude") - assert manifest_file.is_file() + # 3. Read tracked files + agent_files, ext_files = get_tracked_files(project, agent) + all_tracked = {**agent_files, **ext_files} + assert len(all_tracked) > 0, f"{agent}: no files tracked" - data = json.loads(manifest_file.read_text(encoding="utf-8")) - all_tracked = { - **data.get("agent_files", {}), - **data.get("extension_files", {}), - } - assert any("speckit-plan.md" in p for p in all_tracked), ( - "finalize_setup should record files created by the init pipeline" - ) + # 4. Teardown + removed = remove_tracked_files( + project, agent, force=True, files=all_tracked) + assert len(removed) > 0, f"{agent}: teardown removed nothing" - def test_pack_metadata_enables_same_extension_registration(self): - """Pack command_registration metadata matches CommandRegistrar - configuration, ensuring that extension registration via the pack - system writes to the same directories and with the same format as - the old AGENT_CONFIG-based flow.""" + # 5. Verify all tracked files are gone + for rel_path in all_tracked: + assert not (project / rel_path).exists(), ( + f"{agent}: '{rel_path}' still present after teardown") + + # -- extension registration metadata ------------------------------------ + + @pytest.mark.parametrize("agent", _ALL_AGENTS) + def test_extension_registration_metadata_matches(self, agent): + """Pack command_registration matches CommandRegistrar config.""" from specify_cli.agents import CommandRegistrar - for manifest in list_embedded_agents(): - registrar_config = CommandRegistrar.AGENT_CONFIGS.get(manifest.id) - if registrar_config is None: - continue + try: + resolved = resolve_agent_pack(agent) + except PackResolutionError: + pytest.skip(f"No pack for {agent}") - # These four fields are what CommandRegistrar uses to render - # extension commands — they must match exactly. - assert manifest.commands_dir == registrar_config["dir"] - assert manifest.command_format == registrar_config["format"] - assert manifest.arg_placeholder == registrar_config["args"] - assert manifest.file_extension == registrar_config["extension"] + reg = CommandRegistrar.AGENT_CONFIGS.get(agent) + if reg is None: + pytest.skip(f"No CommandRegistrar config for {agent}") + + m = resolved.manifest + assert m.commands_dir == reg["dir"] + assert m.command_format == reg["format"] + assert m.arg_placeholder == reg["args"] + assert m.file_extension == reg["extension"]