mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 13:12:23 +08:00
* Stage 3: Standard markdown integrations — 19 agents migrated to plugin architecture
Migrate all standard markdown integrations to self-contained subpackages
under integrations/. Each subclasses MarkdownIntegration with config-only
overrides (~10 lines per __init__.py).
Integrations migrated (19):
claude, qwen, opencode, junie, kilocode, auggie, roo, codebuddy,
qodercli, amp, shai, bob, trae, pi, iflow, kiro-cli, windsurf,
vibe, cursor-agent
Changes:
- Create integrations/<key>/ subpackage with __init__.py and scripts/
(update-context.sh, update-context.ps1) for each integration
- Register all 19 in INTEGRATION_REGISTRY (20 total with copilot)
- MarkdownIntegration.setup() processes templates (replaces {SCRIPT},
{ARGS}, __AGENT__; strips frontmatter blocks; rewrites paths)
- Extract install_scripts() to IntegrationBase; refactor copilot to use it
- Generalize --ai auto-promote from copilot-only to registry-driven:
any integration registered in INTEGRATION_REGISTRY auto-promotes.
Unregistered agents (gemini, tabnine, codex, kimi, agy, generic)
continue through the legacy --ai path unchanged.
- Fix cursor/cursor-agent key mismatch in CommandRegistrar.AGENT_CONFIGS
- Add missing vibe entry to CommandRegistrar.AGENT_CONFIGS
- Update kiro alias test to reflect auto-promote behavior
Testing:
- Per-agent test files (test_integration_<agent>.py) with shared mixin
- 1316 tests passing, 0 failures
- Complete file inventory tests for both sh and ps variants
- Byte-for-byte validated against v0.4.3 release packages (684 files)
* Address PR review: fix repo root detection and no-op test
- Fix repo root fallback in all 20 update-context.sh scripts: walk up
from script location to find .specify/ instead of falling back to pwd
- Fix repo root fallback in all 20 update-context.ps1 scripts: walk up
from script location to find .specify/ instead of falling back to $PWD
- Add assertions to test_setup_writes_to_correct_directory: verify
expected_dir exists and all command files reside under it
* Fix REPO_ROOT priority: prefer .specify walk-up over git root
In monorepos the git toplevel may differ from the project root that
contains .specify/. The previous fix still preferred git rev-parse
over the walk-up result.
Bash scripts (20): prefer the discovered _root when it contains
.specify/; only accept git root if it also contains .specify/.
PowerShell scripts (20): validate git root contains .specify/ before
using it; fall back to walking up from script directory otherwise.
* Guard git call with try/catch in PowerShell scripts
With $ErrorActionPreference = 'Stop', an unguarded git rev-parse
throws a terminating CommandNotFoundException when git is not
installed, preventing the .specify walk-up fallback from running.
Wrap the git call in try/catch across all 20 update-context.ps1
scripts so the fallback works reliably without git.
* Rename hyphenated package dirs to valid Python identifiers
Rename kiro-cli → kiro_cli and cursor-agent → cursor_agent so the
packages can be imported with normal Python syntax instead of
importlib. The user-facing integration key (IntegrationBase.key)
stays hyphenated to match the actual CLI tool / binary name.
Also reorganize _register_builtins(): imports and registrations
are now grouped alphabetically with clear section comments.
* Reuse CommandRegistrar path rewriting in process_template()
Replace the duplicated regex-based path rewriting in
MarkdownIntegration.process_template() with a call to the shared
CommandRegistrar._rewrite_project_relative_paths() implementation.
This ensures extension-local paths are preserved and boundary rules
stay consistent across the codebase.
* Promote _rewrite_project_relative_paths to public API
Rename CommandRegistrar._rewrite_project_relative_paths() to
rewrite_project_relative_paths() (drop leading underscore) so
integrations can call it without reaching into a private method
across subsystem boundaries.
Addresses PR review feedback:
https://github.com/github/spec-kit/pull/2038#discussion_r3022105627
* Broaden TestRegistrarKeyAlignment to cover all integration keys
Parametrize across ALL_INTEGRATION_KEYS instead of only checking
cursor-agent and vibe. Keeps a separate negative test for the
stale 'cursor' shorthand.
Addresses PR review feedback:
https://github.com/github/spec-kit/pull/2038#discussion_r3022269032
267 lines
12 KiB
Python
267 lines
12 KiB
Python
"""Tests for CopilotIntegration."""
|
|
|
|
import json
|
|
import os
|
|
|
|
from specify_cli.integrations import get_integration
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
|
|
class TestCopilotIntegration:
|
|
def test_copilot_key_and_config(self):
|
|
copilot = get_integration("copilot")
|
|
assert copilot is not None
|
|
assert copilot.key == "copilot"
|
|
assert copilot.config["folder"] == ".github/"
|
|
assert copilot.config["commands_subdir"] == "agents"
|
|
assert copilot.registrar_config["extension"] == ".agent.md"
|
|
assert copilot.context_file == ".github/copilot-instructions.md"
|
|
|
|
def test_command_filename_agent_md(self):
|
|
copilot = get_integration("copilot")
|
|
assert copilot.command_filename("plan") == "speckit.plan.agent.md"
|
|
|
|
def test_setup_creates_agent_md_files(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m)
|
|
assert len(created) > 0
|
|
agent_files = [f for f in created if ".agent." in f.name]
|
|
assert len(agent_files) > 0
|
|
for f in agent_files:
|
|
assert f.parent == tmp_path / ".github" / "agents"
|
|
assert f.name.endswith(".agent.md")
|
|
|
|
def test_setup_creates_companion_prompts(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m)
|
|
prompt_files = [f for f in created if f.parent.name == "prompts"]
|
|
assert len(prompt_files) > 0
|
|
for f in prompt_files:
|
|
assert f.name.endswith(".prompt.md")
|
|
content = f.read_text(encoding="utf-8")
|
|
assert content.startswith("---\nagent: speckit.")
|
|
|
|
def test_agent_and_prompt_counts_match(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m)
|
|
agents = [f for f in created if ".agent.md" in f.name]
|
|
prompts = [f for f in created if ".prompt.md" in f.name]
|
|
assert len(agents) == len(prompts)
|
|
|
|
def test_setup_creates_vscode_settings_new(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
assert copilot._vscode_settings_path() is not None
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m)
|
|
settings = tmp_path / ".vscode" / "settings.json"
|
|
assert settings.exists()
|
|
assert settings in created
|
|
assert any("settings.json" in k for k in m.files)
|
|
|
|
def test_setup_merges_existing_vscode_settings(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
vscode_dir = tmp_path / ".vscode"
|
|
vscode_dir.mkdir(parents=True)
|
|
existing = {"editor.fontSize": 14, "custom.setting": True}
|
|
(vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8")
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m)
|
|
settings = tmp_path / ".vscode" / "settings.json"
|
|
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
assert data["editor.fontSize"] == 14
|
|
assert data["custom.setting"] is True
|
|
assert settings not in created
|
|
assert not any("settings.json" in k for k in m.files)
|
|
|
|
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m)
|
|
for f in created:
|
|
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
|
assert rel in m.files, f"Created file {rel} not tracked in manifest"
|
|
|
|
def test_install_uninstall_roundtrip(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.install(tmp_path, m)
|
|
assert len(created) > 0
|
|
m.save()
|
|
for f in created:
|
|
assert f.exists()
|
|
removed, skipped = copilot.uninstall(tmp_path, m)
|
|
assert len(removed) == len(created)
|
|
assert skipped == []
|
|
|
|
def test_modified_file_survives_uninstall(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.install(tmp_path, m)
|
|
m.save()
|
|
modified_file = created[0]
|
|
modified_file.write_text("user modified this", encoding="utf-8")
|
|
removed, skipped = copilot.uninstall(tmp_path, m)
|
|
assert modified_file.exists()
|
|
assert modified_file in skipped
|
|
|
|
def test_directory_structure(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
copilot.setup(tmp_path, m)
|
|
agents_dir = tmp_path / ".github" / "agents"
|
|
assert agents_dir.is_dir()
|
|
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
|
|
assert len(agent_files) == 9
|
|
expected_commands = {
|
|
"analyze", "checklist", "clarify", "constitution",
|
|
"implement", "plan", "specify", "tasks", "taskstoissues",
|
|
}
|
|
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
|
|
assert actual_commands == expected_commands
|
|
|
|
def test_templates_are_processed(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
copilot.setup(tmp_path, m)
|
|
agents_dir = tmp_path / ".github" / "agents"
|
|
for agent_file in agents_dir.glob("speckit.*.agent.md"):
|
|
content = agent_file.read_text(encoding="utf-8")
|
|
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
|
|
assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
|
|
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
|
|
assert "\nscripts:\n" not in content
|
|
assert "\nagent_scripts:\n" not in content
|
|
|
|
def test_complete_file_inventory_sh(self, tmp_path):
|
|
"""Every file produced by specify init --integration copilot --script sh."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "inventory-sh"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
|
expected = sorted([
|
|
".github/agents/speckit.analyze.agent.md",
|
|
".github/agents/speckit.checklist.agent.md",
|
|
".github/agents/speckit.clarify.agent.md",
|
|
".github/agents/speckit.constitution.agent.md",
|
|
".github/agents/speckit.implement.agent.md",
|
|
".github/agents/speckit.plan.agent.md",
|
|
".github/agents/speckit.specify.agent.md",
|
|
".github/agents/speckit.tasks.agent.md",
|
|
".github/agents/speckit.taskstoissues.agent.md",
|
|
".github/prompts/speckit.analyze.prompt.md",
|
|
".github/prompts/speckit.checklist.prompt.md",
|
|
".github/prompts/speckit.clarify.prompt.md",
|
|
".github/prompts/speckit.constitution.prompt.md",
|
|
".github/prompts/speckit.implement.prompt.md",
|
|
".github/prompts/speckit.plan.prompt.md",
|
|
".github/prompts/speckit.specify.prompt.md",
|
|
".github/prompts/speckit.tasks.prompt.md",
|
|
".github/prompts/speckit.taskstoissues.prompt.md",
|
|
".vscode/settings.json",
|
|
".specify/integration.json",
|
|
".specify/init-options.json",
|
|
".specify/integrations/copilot.manifest.json",
|
|
".specify/integrations/speckit.manifest.json",
|
|
".specify/integrations/copilot/scripts/update-context.ps1",
|
|
".specify/integrations/copilot/scripts/update-context.sh",
|
|
".specify/scripts/bash/check-prerequisites.sh",
|
|
".specify/scripts/bash/common.sh",
|
|
".specify/scripts/bash/create-new-feature.sh",
|
|
".specify/scripts/bash/setup-plan.sh",
|
|
".specify/scripts/bash/update-agent-context.sh",
|
|
".specify/templates/agent-file-template.md",
|
|
".specify/templates/checklist-template.md",
|
|
".specify/templates/constitution-template.md",
|
|
".specify/templates/plan-template.md",
|
|
".specify/templates/spec-template.md",
|
|
".specify/templates/tasks-template.md",
|
|
".specify/memory/constitution.md",
|
|
])
|
|
assert actual == expected, (
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
|
)
|
|
|
|
def test_complete_file_inventory_ps(self, tmp_path):
|
|
"""Every file produced by specify init --integration copilot --script ps."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "inventory-ps"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", "copilot", "--script", "ps", "--no-git",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
|
expected = sorted([
|
|
".github/agents/speckit.analyze.agent.md",
|
|
".github/agents/speckit.checklist.agent.md",
|
|
".github/agents/speckit.clarify.agent.md",
|
|
".github/agents/speckit.constitution.agent.md",
|
|
".github/agents/speckit.implement.agent.md",
|
|
".github/agents/speckit.plan.agent.md",
|
|
".github/agents/speckit.specify.agent.md",
|
|
".github/agents/speckit.tasks.agent.md",
|
|
".github/agents/speckit.taskstoissues.agent.md",
|
|
".github/prompts/speckit.analyze.prompt.md",
|
|
".github/prompts/speckit.checklist.prompt.md",
|
|
".github/prompts/speckit.clarify.prompt.md",
|
|
".github/prompts/speckit.constitution.prompt.md",
|
|
".github/prompts/speckit.implement.prompt.md",
|
|
".github/prompts/speckit.plan.prompt.md",
|
|
".github/prompts/speckit.specify.prompt.md",
|
|
".github/prompts/speckit.tasks.prompt.md",
|
|
".github/prompts/speckit.taskstoissues.prompt.md",
|
|
".vscode/settings.json",
|
|
".specify/integration.json",
|
|
".specify/init-options.json",
|
|
".specify/integrations/copilot.manifest.json",
|
|
".specify/integrations/speckit.manifest.json",
|
|
".specify/integrations/copilot/scripts/update-context.ps1",
|
|
".specify/integrations/copilot/scripts/update-context.sh",
|
|
".specify/scripts/powershell/check-prerequisites.ps1",
|
|
".specify/scripts/powershell/common.ps1",
|
|
".specify/scripts/powershell/create-new-feature.ps1",
|
|
".specify/scripts/powershell/setup-plan.ps1",
|
|
".specify/scripts/powershell/update-agent-context.ps1",
|
|
".specify/templates/agent-file-template.md",
|
|
".specify/templates/checklist-template.md",
|
|
".specify/templates/constitution-template.md",
|
|
".specify/templates/plan-template.md",
|
|
".specify/templates/spec-template.md",
|
|
".specify/templates/tasks-template.md",
|
|
".specify/memory/constitution.md",
|
|
])
|
|
assert actual == expected, (
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
|
)
|