mirror of
https://github.com/github/spec-kit.git
synced 2026-03-21 12:53:08 +00:00
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-<id>.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
This commit is contained in:
committed by
GitHub
parent
c3efd1fb71
commit
c2227a7ffd
@@ -1715,6 +1715,7 @@ def _handle_agent_skills_migration(console: Console, agent_key: str) -> None:
|
|||||||
def init(
|
def init(
|
||||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
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),
|
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/)"),
|
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"),
|
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"),
|
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:
|
Examples:
|
||||||
specify init my-project
|
specify init my-project
|
||||||
specify init my-project --ai claude
|
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 my-project --ai copilot --no-git
|
||||||
specify init --ignore-agent-tools my-project
|
specify init --ignore-agent-tools my-project
|
||||||
specify init . --ai claude # Initialize in current directory
|
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 --here --force # Skip confirmation when current directory not empty
|
||||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||||
specify init --here --ai gemini --ai-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 --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||||
specify init my-project --offline # Use bundled assets (no network access)
|
specify init my-project --offline # Use bundled assets (no network access)
|
||||||
specify init my-project --ai claude --preset healthcare-compliance # With preset
|
specify init my-project --ai claude --preset healthcare-compliance # With preset
|
||||||
@@ -1772,6 +1775,17 @@ def init(
|
|||||||
|
|
||||||
show_banner()
|
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)
|
# Detect when option values are likely misinterpreted flags (parameter ordering issue)
|
||||||
if ai_assistant and ai_assistant.startswith("--"):
|
if ai_assistant and ai_assistant.startswith("--"):
|
||||||
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
|
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
|
||||||
@@ -1802,7 +1816,7 @@ def init(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if ai_skills and not ai_assistant:
|
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 <project> --ai <agent> --ai-skills")
|
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@@ -1854,6 +1868,19 @@ def init(
|
|||||||
"copilot"
|
"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.
|
# Agents that have moved from explicit commands/prompts to agent skills.
|
||||||
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
|
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
|
||||||
# If selected interactively (no --ai provided), automatically enable
|
# If selected interactively (no --ai provided), automatically enable
|
||||||
@@ -2090,6 +2117,7 @@ def init(
|
|||||||
"ai": selected_ai,
|
"ai": selected_ai,
|
||||||
"ai_skills": ai_skills,
|
"ai_skills": ai_skills,
|
||||||
"ai_commands_dir": ai_commands_dir,
|
"ai_commands_dir": ai_commands_dir,
|
||||||
|
"agent_pack": use_agent_pack,
|
||||||
"branch_numbering": branch_numbering or "sequential",
|
"branch_numbering": branch_numbering or "sequential",
|
||||||
"here": here,
|
"here": here,
|
||||||
"preset": preset,
|
"preset": preset,
|
||||||
@@ -2133,6 +2161,13 @@ def init(
|
|||||||
if not use_github:
|
if not use_github:
|
||||||
tracker.skip("cleanup", "not needed (no download)")
|
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")
|
tracker.complete("final", "project ready")
|
||||||
except (typer.Exit, SystemExit):
|
except (typer.Exit, SystemExit):
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -808,3 +808,109 @@ class TestFileTracking:
|
|||||||
modified = check_modified_files(project, "ag")
|
modified = check_modified_files(project, "ag")
|
||||||
assert len(modified) == 1
|
assert len(modified) == 1
|
||||||
assert ".ag/ext.md" in modified[0]
|
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 <id> resolves the same agent as --ai <id> 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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user