mirror of
https://github.com/github/spec-kit.git
synced 2026-03-21 12:53:08 +00:00
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:
committed by
GitHub
parent
d6016ab9db
commit
9b580a536b
@@ -1984,7 +1984,10 @@ def init(
|
|||||||
"This will become the default in v0.6.0."
|
"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 [
|
for key, label in [
|
||||||
("fetch", "Fetch latest release"),
|
("fetch", "Fetch latest release"),
|
||||||
("download", "Download template"),
|
("download", "Download template"),
|
||||||
@@ -2019,7 +2022,26 @@ def init(
|
|||||||
verify = not skip_tls
|
verify = not skip_tls
|
||||||
local_ssl_context = ssl_context if verify else False
|
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:
|
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)
|
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:
|
else:
|
||||||
@@ -2162,11 +2184,14 @@ def init(
|
|||||||
tracker.skip("cleanup", "not needed (no download)")
|
tracker.skip("cleanup", "not needed (no download)")
|
||||||
|
|
||||||
# When --agent is used, record all installed agent files for
|
# When --agent is used, record all installed agent files for
|
||||||
# tracked teardown. This runs AFTER the full init pipeline has
|
# tracked teardown. setup() already returned the files it
|
||||||
# finished creating files (scaffolding, skills, presets,
|
# created; pass them to finalize_setup so the manifest is
|
||||||
# extensions) so finalize_setup captures everything.
|
# 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:
|
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")
|
tracker.complete("final", "project ready")
|
||||||
except (typer.Exit, SystemExit):
|
except (typer.Exit, SystemExit):
|
||||||
|
|||||||
@@ -245,6 +245,57 @@ class AgentBootstrap:
|
|||||||
"""Return the agent's top-level directory inside the project."""
|
"""Return the agent's top-level directory inside the project."""
|
||||||
return project_path / self.manifest.commands_dir.split("/")[0]
|
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(
|
def finalize_setup(
|
||||||
self,
|
self,
|
||||||
project_path: Path,
|
project_path: Path,
|
||||||
@@ -257,25 +308,51 @@ class AgentBootstrap:
|
|||||||
writing files (commands, context files, extensions) into the
|
writing files (commands, context files, extensions) into the
|
||||||
project. It combines the files reported by :meth:`setup` with
|
project. It combines the files reported by :meth:`setup` with
|
||||||
any extra files (e.g. from extension registration), scans the
|
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.
|
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:
|
Args:
|
||||||
agent_files: Files reported by :meth:`setup`.
|
agent_files: Files reported by :meth:`setup`.
|
||||||
extension_files: Files created by extension registration.
|
extension_files: Files created by extension registration.
|
||||||
"""
|
"""
|
||||||
all_agent = list(agent_files or [])
|
|
||||||
all_extension = list(extension_files or [])
|
all_extension = list(extension_files or [])
|
||||||
|
|
||||||
# Also scan the commands directory for files created by the
|
# Filter agent_files: only keep files under the agent's directory
|
||||||
# init pipeline that setup() did not report directly.
|
# 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:
|
if self.manifest.commands_dir:
|
||||||
commands_dir = project_path / self.manifest.commands_dir
|
commands_dir = project_path / self.manifest.commands_dir
|
||||||
if commands_dir.is_dir():
|
# Scan the agent root (e.g. .claude/) so we catch both
|
||||||
agent_set = {p.resolve() for p in all_agent}
|
# commands and skills directories.
|
||||||
for p in commands_dir.rglob("*"):
|
agent_root = commands_dir.parent
|
||||||
if p.is_file() and p.resolve() not in agent_set:
|
agent_set = {p.resolve() for p in all_agent}
|
||||||
all_agent.append(p)
|
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(
|
record_installed_files(
|
||||||
project_path,
|
project_path,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Agy(AgentBootstrap):
|
|||||||
"""Install Antigravity agent files into the project."""
|
"""Install Antigravity agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove Antigravity agent files from the project.
|
"""Remove Antigravity agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Amp(AgentBootstrap):
|
|||||||
"""Install Amp agent files into the project."""
|
"""Install Amp agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove Amp agent files from the project.
|
"""Remove Amp agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Auggie(AgentBootstrap):
|
|||||||
"""Install Auggie CLI agent files into the project."""
|
"""Install Auggie CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Auggie CLI agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Bob(AgentBootstrap):
|
|||||||
"""Install IBM Bob agent files into the project."""
|
"""Install IBM Bob agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove IBM Bob agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Claude(AgentBootstrap):
|
|||||||
"""Install Claude Code agent files into the project."""
|
"""Install Claude Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Claude Code agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Codebuddy(AgentBootstrap):
|
|||||||
"""Install CodeBuddy agent files into the project."""
|
"""Install CodeBuddy agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove CodeBuddy agent files from the project.
|
"""Remove CodeBuddy agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Codex(AgentBootstrap):
|
|||||||
"""Install Codex CLI agent files into the project."""
|
"""Install Codex CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Codex CLI agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Copilot(AgentBootstrap):
|
|||||||
"""Install GitHub Copilot agent files into the project."""
|
"""Install GitHub Copilot agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove GitHub Copilot agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CursorAgent(AgentBootstrap):
|
|||||||
"""Install Cursor agent files into the project."""
|
"""Install Cursor agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove Cursor agent files from the project.
|
"""Remove Cursor agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Gemini(AgentBootstrap):
|
|||||||
"""Install Gemini CLI agent files into the project."""
|
"""Install Gemini CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Gemini CLI agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Iflow(AgentBootstrap):
|
|||||||
"""Install iFlow CLI agent files into the project."""
|
"""Install iFlow CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove iFlow CLI agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Junie(AgentBootstrap):
|
|||||||
"""Install Junie agent files into the project."""
|
"""Install Junie agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove Junie agent files from the project.
|
"""Remove Junie agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Kilocode(AgentBootstrap):
|
|||||||
"""Install Kilo Code agent files into the project."""
|
"""Install Kilo Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Kilo Code agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Kimi(AgentBootstrap):
|
|||||||
"""Install Kimi Code agent files into the project."""
|
"""Install Kimi Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Kimi Code agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class KiroCli(AgentBootstrap):
|
|||||||
"""Install Kiro CLI agent files into the project."""
|
"""Install Kiro CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Kiro CLI agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Opencode(AgentBootstrap):
|
|||||||
"""Install opencode agent files into the project."""
|
"""Install opencode agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove opencode agent files from the project.
|
"""Remove opencode agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Pi(AgentBootstrap):
|
|||||||
"""Install Pi Coding Agent agent files into the project."""
|
"""Install Pi Coding Agent agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Pi Coding Agent agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Qodercli(AgentBootstrap):
|
|||||||
"""Install Qoder CLI agent files into the project."""
|
"""Install Qoder CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Qoder CLI agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Qwen(AgentBootstrap):
|
|||||||
"""Install Qwen Code agent files into the project."""
|
"""Install Qwen Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Qwen Code agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Roo(AgentBootstrap):
|
|||||||
"""Install Roo Code agent files into the project."""
|
"""Install Roo Code agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Roo Code agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Shai(AgentBootstrap):
|
|||||||
"""Install SHAI agent files into the project."""
|
"""Install SHAI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove SHAI agent files from the project.
|
"""Remove SHAI agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Tabnine(AgentBootstrap):
|
|||||||
"""Install Tabnine CLI agent files into the project."""
|
"""Install Tabnine CLI agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Tabnine CLI agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Trae(AgentBootstrap):
|
|||||||
"""Install Trae agent files into the project."""
|
"""Install Trae agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove Trae agent files from the project.
|
"""Remove Trae agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Vibe(AgentBootstrap):
|
|||||||
"""Install Mistral Vibe agent files into the project."""
|
"""Install Mistral Vibe agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
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.
|
"""Remove Mistral Vibe agent files from the project.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Windsurf(AgentBootstrap):
|
|||||||
"""Install Windsurf agent files into the project."""
|
"""Install Windsurf agent files into the project."""
|
||||||
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
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]:
|
def teardown(self, project_path: Path, *, force: bool = False, files: Optional[Dict[str, str]] = None) -> List[str]:
|
||||||
"""Remove Windsurf agent files from the project.
|
"""Remove Windsurf agent files from the project.
|
||||||
|
|||||||
@@ -811,106 +811,475 @@ class TestFileTracking:
|
|||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# --agent flag on init (pack-based flow parity)
|
# setup() returns actual files (not empty list)
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
||||||
class TestInitAgentFlag:
|
class TestSetupReturnsFiles:
|
||||||
"""Verify the --agent flag on ``specify init`` resolves through the
|
"""Verify that every embedded agent's ``setup()`` calls the shared
|
||||||
pack system and that pack metadata is consistent with AGENT_CONFIG."""
|
scaffolding and returns the actual files it created — not an empty
|
||||||
|
list."""
|
||||||
|
|
||||||
def test_agent_resolves_same_agent_as_ai(self):
|
@pytest.mark.parametrize("agent", [
|
||||||
"""--agent <id> resolves the same agent as --ai <id> for all
|
a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic"
|
||||||
agents in AGENT_CONFIG (except 'generic')."""
|
])
|
||||||
from specify_cli import AGENT_CONFIG
|
def test_setup_returns_nonempty_file_list(self, agent, tmp_path):
|
||||||
|
"""setup() must return at least one Path (the scaffolded command
|
||||||
for agent_id in AGENT_CONFIG:
|
files, scripts, templates, etc.)."""
|
||||||
if agent_id == "generic":
|
resolved = resolve_agent_pack(agent)
|
||||||
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")
|
|
||||||
bootstrap = load_bootstrap(resolved.path, resolved.manifest)
|
bootstrap = load_bootstrap(resolved.path, resolved.manifest)
|
||||||
|
|
||||||
project = tmp_path / "project"
|
project = tmp_path / f"setup_{agent}"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
(project / ".specify").mkdir()
|
|
||||||
|
|
||||||
# setup() creates the directory structure
|
files = bootstrap.setup(project, "sh", {})
|
||||||
setup_files = bootstrap.setup(project, "sh", {})
|
assert isinstance(files, list)
|
||||||
assert isinstance(setup_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
|
@pytest.mark.parametrize("agent", [
|
||||||
commands_dir = project / resolved.manifest.commands_dir
|
a for a in __import__("specify_cli").AGENT_CONFIG if a != "generic"
|
||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
])
|
||||||
cmd_file = commands_dir / "speckit-plan.md"
|
def test_setup_returns_only_existing_paths(self, agent, tmp_path):
|
||||||
cmd_file.write_text("plan command", encoding="utf-8")
|
"""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 <project_dir> --ai/--agent <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-<id>.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 <agent>`` 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 <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)
|
bootstrap.finalize_setup(project)
|
||||||
|
|
||||||
manifest_file = _manifest_path(project, "claude")
|
# 3. Read tracked files
|
||||||
assert manifest_file.is_file()
|
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"))
|
# 4. Teardown
|
||||||
all_tracked = {
|
removed = remove_tracked_files(
|
||||||
**data.get("agent_files", {}),
|
project, agent, force=True, files=all_tracked)
|
||||||
**data.get("extension_files", {}),
|
assert len(removed) > 0, f"{agent}: teardown removed nothing"
|
||||||
}
|
|
||||||
assert any("speckit-plan.md" in p for p in all_tracked), (
|
|
||||||
"finalize_setup should record files created by the init pipeline"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_pack_metadata_enables_same_extension_registration(self):
|
# 5. Verify all tracked files are gone
|
||||||
"""Pack command_registration metadata matches CommandRegistrar
|
for rel_path in all_tracked:
|
||||||
configuration, ensuring that extension registration via the pack
|
assert not (project / rel_path).exists(), (
|
||||||
system writes to the same directories and with the same format as
|
f"{agent}: '{rel_path}' still present after teardown")
|
||||||
the old AGENT_CONFIG-based flow."""
|
|
||||||
|
# -- 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
|
from specify_cli.agents import CommandRegistrar
|
||||||
|
|
||||||
for manifest in list_embedded_agents():
|
try:
|
||||||
registrar_config = CommandRegistrar.AGENT_CONFIGS.get(manifest.id)
|
resolved = resolve_agent_pack(agent)
|
||||||
if registrar_config is None:
|
except PackResolutionError:
|
||||||
continue
|
pytest.skip(f"No pack for {agent}")
|
||||||
|
|
||||||
# These four fields are what CommandRegistrar uses to render
|
reg = CommandRegistrar.AGENT_CONFIGS.get(agent)
|
||||||
# extension commands — they must match exactly.
|
if reg is None:
|
||||||
assert manifest.commands_dir == registrar_config["dir"]
|
pytest.skip(f"No CommandRegistrar config for {agent}")
|
||||||
assert manifest.command_format == registrar_config["format"]
|
|
||||||
assert manifest.arg_placeholder == registrar_config["args"]
|
m = resolved.manifest
|
||||||
assert manifest.file_extension == registrar_config["extension"]
|
assert m.commands_dir == reg["dir"]
|
||||||
|
assert m.command_format == reg["format"]
|
||||||
|
assert m.arg_placeholder == reg["args"]
|
||||||
|
assert m.file_extension == reg["extension"]
|
||||||
|
|||||||
Reference in New Issue
Block a user