Files
github-spec-kit/tests/integrations/test_integration_subcommand.py
Chengyou Liu 5edc9a5358 fix: migrate extension commands on integration switch (#2404)
* fix: migrate extension commands on integration switch

When switching integrations (e.g. kimi → opencode), extension commands
were not re-registered for the new agent, leaving the new agent without
extension support and orphaning files in the old agent's directory.

Changes:
- Add ExtensionManager.unregister_agent_artifacts() to clean up old
  agent extension files and registry entries during switch
- Add ExtensionManager.register_enabled_extensions_for_agent() to
  re-register all enabled extensions for the new agent
- Wire both into integration_switch() after uninstall/install phases
- Handle skills mode (Copilot --skills) correctly
- Add tests for kimi→opencode→claude migration, Copilot skills mode,
  and disabled extension handling

Fixes extension commands not appearing after integration switch.

* Update src/specify_cli/extensions.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-01 07:41:38 -05:00

687 lines
26 KiB
Python

"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch)."""
import json
import os
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
def _init_project(tmp_path, integration="copilot"):
"""Helper: init a spec-kit project with the given integration."""
project = tmp_path / "proj"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here",
"--integration", integration,
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
return project
def _run_in_project(project, args):
"""Run a CLI command from inside a generated project."""
old_cwd = os.getcwd()
try:
os.chdir(project)
return runner.invoke(app, args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
# ── list ─────────────────────────────────────────────────────────────
class TestIntegrationList:
def test_list_requires_speckit_project(self, tmp_path):
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_list_shows_installed(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "copilot" in result.output
assert "installed" in result.output
def test_list_shows_available_integrations(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Should show multiple integrations
assert "claude" in result.output
assert "gemini" in result.output
# ── install ──────────────────────────────────────────────────────────
class TestIntegrationInstall:
def test_install_requires_speckit_project(self, tmp_path):
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "install", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_install_unknown_integration(self, tmp_path):
project = _init_project(tmp_path)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "install", "nonexistent"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Unknown integration" in result.output
def test_install_already_installed(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "install", "copilot"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "already installed" in result.output
assert "uninstall" in result.output
def test_install_different_when_one_exists(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "install", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "already installed" in result.output
assert "uninstall" in result.output
def test_install_into_bare_project(self, tmp_path):
"""Install into a project with .specify/ but no integration."""
project = tmp_path / "bare"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "installed successfully" in result.output
# integration.json written
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
# Manifest created
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
# Claude uses skills directory (not commands)
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_install_bare_project_gets_shared_infra(self, tmp_path):
"""Installing into a bare project should create shared scripts and templates."""
project = tmp_path / "bare"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
# Shared infrastructure should be present
assert (project / ".specify" / "scripts").is_dir()
assert (project / ".specify" / "templates").is_dir()
# ── uninstall ────────────────────────────────────────────────────────
class TestIntegrationUninstall:
def test_uninstall_requires_speckit_project(self, tmp_path):
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "uninstall"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_uninstall_no_integration(self, tmp_path):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "No integration" in result.output
def test_uninstall_removes_files(self, tmp_path):
project = _init_project(tmp_path, "claude")
# Claude uses skills directory
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "uninstalled" in result.output
# Command files removed
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
# Manifest removed
assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists()
# integration.json removed
assert not (project / ".specify" / "integration.json").exists()
def test_uninstall_preserves_modified_files(self, tmp_path):
"""Full lifecycle: install → modify → uninstall → modified file kept."""
project = _init_project(tmp_path, "claude")
plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists()
# Modify a file
plan_file.write_text("# My custom plan command\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "preserved" in result.output
# Modified file kept
assert plan_file.exists()
assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n"
def test_uninstall_wrong_key(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "not the currently installed" in result.output
def test_uninstall_preserves_shared_infra(self, tmp_path):
"""Shared scripts and templates are not removed by integration uninstall."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
assert shared_script.exists()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Shared infrastructure preserved
assert shared_script.exists()
assert (project / ".specify" / "templates").is_dir()
# ── switch ───────────────────────────────────────────────────────────
class TestIntegrationSwitch:
def test_switch_requires_speckit_project(self, tmp_path):
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "switch", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_switch_unknown_target(self, tmp_path):
project = _init_project(tmp_path)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "switch", "nonexistent"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Unknown integration" in result.output
def test_switch_same_noop(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "switch", "copilot"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "already installed" in result.output
def test_switch_between_integrations(self, tmp_path):
project = _init_project(tmp_path, "claude")
# Verify claude files exist (claude uses skills)
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "Switched to" in result.output
# Old claude files removed
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
# New copilot files created
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
# integration.json updated
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
def test_switch_migrates_extension_commands(self, tmp_path):
"""Switching should migrate extension commands to the new agent directory."""
project = _init_project(tmp_path, "kimi")
# Install the bundled git extension
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
# Verify git extension skills exist for kimi
kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md"
assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"
result = _run_in_project(project, [
"integration", "switch", "opencode",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Git extension commands should exist for opencode
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
# Old kimi extension skills should be removed
assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed"
# Extension registry should be updated
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
registered_commands = registry["extensions"]["git"]["registered_commands"]
assert "opencode" in registered_commands
assert "kimi" not in registered_commands
# Switch to claude
result = _run_in_project(project, [
"integration", "switch", "claude",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Git extension skills should exist for claude
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
assert claude_git_feature.exists(), "Git extension skill should exist for claude"
# Old opencode extension commands should be removed
assert not opencode_git_feature.exists(), "Old opencode extension command should be removed"
# Extension registry should be updated
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
registered_commands = registry["extensions"]["git"]["registered_commands"]
assert "claude" in registered_commands
assert "opencode" not in registered_commands
def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path):
"""Copilot --skills should receive extension skills, not .agent.md files."""
project = _init_project(tmp_path, "opencode")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, [
"integration", "switch", "copilot",
"--script", "sh",
"--integration-options", "--skills",
])
assert result.exit_code == 0, result.output
copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md"
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
# Verify Copilot-specific frontmatter: mode field should map from
# skill name (speckit-git-feature) back to dot notation (speckit.git-feature)
skill_content = copilot_git_feature.read_text(encoding="utf-8")
assert "mode: speckit.git-feature" in skill_content, (
"Copilot skill frontmatter should contain mode mapped from skill name"
)
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert "speckit-git-feature" in git_meta["registered_skills"]
assert "copilot" not in git_meta["registered_commands"]
result = _run_in_project(project, [
"integration", "switch", "opencode",
"--script", "sh",
])
assert result.exit_code == 0, result.output
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert git_meta["registered_skills"] == []
assert "opencode" in git_meta["registered_commands"]
assert "copilot" not in git_meta["registered_commands"]
def test_switch_does_not_register_disabled_extensions(self, tmp_path):
"""Disabled extensions should stay disabled and should not migrate commands."""
project = _init_project(tmp_path, "opencode")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, ["extension", "disable", "git"])
assert result.exit_code == 0, result.output
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
result = _run_in_project(project, [
"integration", "switch", "claude",
"--script", "sh",
])
assert result.exit_code == 0, result.output
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent"
assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch"
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert git_meta["enabled"] is False
assert "claude" not in git_meta["registered_commands"]
assert "opencode" not in git_meta["registered_commands"]
def test_switch_preserves_shared_infra(self, tmp_path):
"""Switching preserves shared scripts, templates, and memory."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
assert shared_script.exists()
shared_content = shared_script.read_text(encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Shared infra untouched
assert shared_script.exists()
assert shared_script.read_text(encoding="utf-8") == shared_content
def test_switch_from_nothing(self, tmp_path):
"""Switch when no integration is installed should just install the target."""
project = tmp_path / "bare"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "Switched to" in result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
# ── Full lifecycle ───────────────────────────────────────────────────
class TestIntegrationLifecycle:
def test_install_modify_uninstall_preserves_modified(self, tmp_path):
"""Full lifecycle: install → modify file → uninstall → verify modified file kept."""
project = tmp_path / "lifecycle"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
# Install
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
assert result.exit_code == 0
assert "installed successfully" in result.output
# Claude uses skills directory
plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists()
# Modify one file
plan_file.write_text("# user customization\n", encoding="utf-8")
# Uninstall
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
assert result.exit_code == 0
assert "preserved" in result.output
# Modified file kept
assert plan_file.exists()
assert plan_file.read_text(encoding="utf-8") == "# user customization\n"
finally:
os.chdir(old_cwd)
# ── Edge-case fixes ─────────────────────────────────────────────────
class TestScriptTypeValidation:
def test_invalid_script_type_rejected(self, tmp_path):
"""--script with an invalid value should fail with a clear error."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "bash",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Invalid script type" in result.output
def test_valid_script_types_accepted(self, tmp_path):
"""Both 'sh' and 'ps' should be accepted."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
class TestParseIntegrationOptionsEqualsForm:
def test_equals_form_parsed(self):
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
from specify_cli import _parse_integration_options
from specify_cli.integrations import get_integration
integration = get_integration("generic")
assert integration is not None
result_space = _parse_integration_options(integration, "--commands-dir ./mydir")
result_equals = _parse_integration_options(integration, "--commands-dir=./mydir")
assert result_space is not None
assert result_equals is not None
assert result_space["commands_dir"] == "./mydir"
assert result_equals["commands_dir"] == "./mydir"
class TestUninstallNoManifestClearsInitOptions:
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
"""When no manifest exists, uninstall should still clear init-options.json."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
# Write integration.json and init-options.json without a manifest
int_json = project / ".specify" / "integration.json"
int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8")
opts_json = project / ".specify" / "init-options.json"
opts_json.write_text(json.dumps({
"integration": "claude",
"ai": "claude",
"ai_skills": True,
"script": "sh",
}), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# init-options.json should have integration keys cleared
opts = json.loads(opts_json.read_text(encoding="utf-8"))
assert "integration" not in opts
assert "ai" not in opts
assert "ai_skills" not in opts
# Non-integration keys preserved
assert opts.get("script") == "sh"
class TestSwitchClearsMetadataAfterTeardown:
def test_metadata_cleared_between_phases(self, tmp_path):
"""After a successful switch, metadata should reference the new integration."""
project = _init_project(tmp_path, "claude")
# Verify initial state
int_json = project / ".specify" / "integration.json"
assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude"
old_cwd = os.getcwd()
try:
os.chdir(project)
# Switch to copilot — should succeed and update metadata
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# integration.json should reference copilot, not claude
data = json.loads(int_json.read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
# init-options.json should reference copilot
opts_json = project / ".specify" / "init-options.json"
opts = json.loads(opts_json.read_text(encoding="utf-8"))
assert opts.get("ai") == "copilot"