From c2227a7ffd736fe9d4753ba4ae3f2ea665ab048c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:53:03 +0000 Subject: [PATCH] feat: add --agent flag to init for pack-based flow with file tracking - `specify init --agent claude` resolves through the pack system and records all installed files in .specify/agent-manifest-.json via finalize_setup() after the init pipeline finishes - --agent and --ai are mutually exclusive; --agent additionally enables tracked teardown/switch - init-options.json gains "agent_pack" key when --agent is used - 4 new parity tests verify: pack resolution matches AGENT_CONFIG, commands_dir parity, finalize_setup records pipeline-created files, pack metadata matches CommandRegistrar configuration Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/930d8c4d-ce42-41fb-a40f-561fb1468e81 --- src/specify_cli/__init__.py | 37 ++++++++++++- tests/test_agent_pack.py | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index dd1fb1f5..a2351299 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1715,6 +1715,7 @@ def _handle_agent_skills_migration(console: Console, agent_key: str) -> None: def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), + agent: str = typer.Option(None, "--agent", help="AI agent to use (pack-based flow — resolves through the agent pack system and records installed files for tracked teardown). Accepts the same agent IDs as --ai."), ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), @@ -1753,6 +1754,7 @@ def init( Examples: specify init my-project specify init my-project --ai claude + specify init my-project --agent claude # Pack-based flow (with file tracking) specify init my-project --ai copilot --no-git specify init --ignore-agent-tools my-project specify init . --ai claude # Initialize in current directory @@ -1765,6 +1767,7 @@ def init( specify init --here --force # Skip confirmation when current directory not empty specify init my-project --ai claude --ai-skills # Install agent skills specify init --here --ai gemini --ai-skills + specify init my-project --agent claude --ai-skills # Pack-based flow with skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent specify init my-project --offline # Use bundled assets (no network access) specify init my-project --ai claude --preset healthcare-compliance # With preset @@ -1772,6 +1775,17 @@ def init( show_banner() + # --agent and --ai are interchangeable for agent selection, but --agent + # additionally opts into the pack-based flow (file tracking via + # finalize_setup for tracked teardown/switch). + use_agent_pack = False + if agent: + if ai_assistant: + console.print("[red]Error:[/red] --agent and --ai cannot both be specified. Use one or the other.") + raise typer.Exit(1) + ai_assistant = agent + use_agent_pack = True + # Detect when option values are likely misinterpreted flags (parameter ordering issue) if ai_assistant and ai_assistant.startswith("--"): console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") @@ -1802,7 +1816,7 @@ def init( raise typer.Exit(1) if ai_skills and not ai_assistant: - console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") + console.print("[red]Error:[/red] --ai-skills requires --ai or --agent to be specified") console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") raise typer.Exit(1) @@ -1854,6 +1868,19 @@ def init( "copilot" ) + # When --agent is used, validate that the agent resolves through the pack + # system and prepare the bootstrap for post-init file tracking. + agent_bootstrap = None + if use_agent_pack: + from .agent_pack import resolve_agent_pack, load_bootstrap, PackResolutionError, AgentPackError + try: + resolved = resolve_agent_pack(selected_ai) + agent_bootstrap = load_bootstrap(resolved.path, resolved.manifest) + console.print(f"[dim]Pack-based flow: {resolved.manifest.name} ({resolved.source})[/dim]") + except (PackResolutionError, AgentPackError) as exc: + console.print(f"[red]Error resolving agent pack:[/red] {exc}") + raise typer.Exit(1) + # Agents that have moved from explicit commands/prompts to agent skills. if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: # If selected interactively (no --ai provided), automatically enable @@ -2090,6 +2117,7 @@ def init( "ai": selected_ai, "ai_skills": ai_skills, "ai_commands_dir": ai_commands_dir, + "agent_pack": use_agent_pack, "branch_numbering": branch_numbering or "sequential", "here": here, "preset": preset, @@ -2133,6 +2161,13 @@ def init( if not use_github: 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. + if use_agent_pack and agent_bootstrap is not None: + agent_bootstrap.finalize_setup(project_path) + tracker.complete("final", "project ready") except (typer.Exit, SystemExit): raise diff --git a/tests/test_agent_pack.py b/tests/test_agent_pack.py index b93cc65d..c622bb2b 100644 --- a/tests/test_agent_pack.py +++ b/tests/test_agent_pack.py @@ -808,3 +808,109 @@ class TestFileTracking: modified = check_modified_files(project, "ag") assert len(modified) == 1 assert ".ag/ext.md" in modified[0] + + +# =================================================================== +# --agent flag on init (pack-based flow parity) +# =================================================================== + +class TestInitAgentFlag: + """Verify the --agent flag on ``specify init`` resolves through the + pack system and that pack metadata is consistent with AGENT_CONFIG.""" + + 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 "" + # Normalise 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) + + project = tmp_path / "project" + project.mkdir() + (project / ".specify").mkdir() + + # setup() creates the directory structure + setup_files = bootstrap.setup(project, "sh", {}) + assert isinstance(setup_files, list) + + # 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") + + # finalize_setup records everything + bootstrap.finalize_setup(project) + + manifest_file = _manifest_path(project, "claude") + assert manifest_file.is_file() + + 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" + ) + + 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.""" + 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 + + # 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"]