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
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 22:29:33 +00:00
committed by GitHub
parent d6016ab9db
commit 9b580a536b
28 changed files with 598 additions and 127 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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