Skip to content

Commit 4b9719a

Browse files
committed
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.
1 parent 9483e5c commit 4b9719a

3 files changed

Lines changed: 282 additions & 0 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2365,6 +2365,20 @@ def integration_switch(
23652365
)
23662366
raise typer.Exit(1)
23672367

2368+
# Unregister extension commands for the old agent so they don't
2369+
# remain as orphans in the old agent's directory.
2370+
if installed_key:
2371+
try:
2372+
from .extensions import ExtensionManager
2373+
2374+
ext_mgr = ExtensionManager(project_root)
2375+
ext_mgr.unregister_agent_artifacts(installed_key)
2376+
except Exception as ext_err:
2377+
console.print(
2378+
f"[yellow]Warning:[/yellow] Could not clean up extension commands "
2379+
f"for '{installed_key}': {ext_err}"
2380+
)
2381+
23682382
# Clear metadata so a failed Phase 2 doesn't leave stale references
23692383
_remove_integration_json(project_root)
23702384
opts = load_init_options(project_root)
@@ -2404,6 +2418,19 @@ def integration_switch(
24042418
_write_integration_json(project_root, target_integration.key)
24052419
_update_init_options_for_integration(project_root, target_integration, script_type=selected_script)
24062420

2421+
# Re-register extension commands for the new agent so that
2422+
# previously-installed extensions are available in the new integration.
2423+
try:
2424+
from .extensions import ExtensionManager
2425+
2426+
ext_mgr = ExtensionManager(project_root)
2427+
ext_mgr.register_enabled_extensions_for_agent(target)
2428+
except Exception as ext_err:
2429+
console.print(
2430+
f"[yellow]Warning:[/yellow] Could not register extension commands "
2431+
f"for '{target}': {ext_err}"
2432+
)
2433+
24072434
except Exception as e:
24082435
# Attempt rollback of any files written by setup
24092436
try:

src/specify_cli/extensions.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,107 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool:
13321332

13331333
return True
13341334

1335+
@staticmethod
1336+
def _valid_name_list(value: Any) -> List[str]:
1337+
"""Return string entries from a registry list, ignoring corrupt values."""
1338+
if not isinstance(value, list):
1339+
return []
1340+
return [item for item in value if isinstance(item, str)]
1341+
1342+
def unregister_agent_artifacts(self, agent_name: str) -> None:
1343+
"""Remove extension files registered for a specific agent.
1344+
1345+
Extension command files are tracked per agent in ``registered_commands``.
1346+
Extension skills are older/current-agent scoped and tracked separately in
1347+
``registered_skills``; when present, remove them from the active skills
1348+
directory or verified fallback locations and clear that registry field.
1349+
"""
1350+
if not agent_name:
1351+
return
1352+
1353+
registrar = CommandRegistrar()
1354+
1355+
for ext_id, metadata in self.registry.list().items():
1356+
updates: Dict[str, Any] = {}
1357+
1358+
registered_commands = metadata.get("registered_commands", {})
1359+
if isinstance(registered_commands, dict) and agent_name in registered_commands:
1360+
command_names = self._valid_name_list(registered_commands.get(agent_name))
1361+
if command_names:
1362+
registrar.unregister_commands({agent_name: command_names}, self.project_root)
1363+
1364+
new_registered = copy.deepcopy(registered_commands)
1365+
new_registered.pop(agent_name, None)
1366+
updates["registered_commands"] = new_registered
1367+
1368+
registered_skills = self._valid_name_list(metadata.get("registered_skills", []))
1369+
if registered_skills:
1370+
self._unregister_extension_skills(registered_skills, ext_id)
1371+
updates["registered_skills"] = []
1372+
1373+
if updates:
1374+
self.registry.update(ext_id, updates)
1375+
1376+
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
1377+
"""Register installed, enabled extensions for the active agent.
1378+
1379+
This is used after integration switches. It mirrors extension install
1380+
behavior while avoiding stale default-mode command directories when an
1381+
agent is currently running in skills mode (notably Copilot ``--skills``).
1382+
"""
1383+
if not agent_name:
1384+
return
1385+
1386+
from . import load_init_options
1387+
1388+
registrar = CommandRegistrar()
1389+
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
1390+
init_options = load_init_options(self.project_root)
1391+
if not isinstance(init_options, dict):
1392+
init_options = {}
1393+
1394+
active_agent = init_options.get("ai")
1395+
skills_mode_active = (
1396+
active_agent == agent_name
1397+
and bool(init_options.get("ai_skills"))
1398+
and bool(agent_config)
1399+
and agent_config.get("extension") != "/SKILL.md"
1400+
)
1401+
1402+
for ext_id, metadata in self.registry.list().items():
1403+
if not metadata.get("enabled", True):
1404+
continue
1405+
1406+
manifest = self.get_extension(ext_id)
1407+
if manifest is None:
1408+
continue
1409+
1410+
ext_dir = self.extensions_dir / ext_id
1411+
updates: Dict[str, Any] = {}
1412+
1413+
if agent_config and not skills_mode_active:
1414+
commands_dir = self.project_root / agent_config["dir"]
1415+
if commands_dir.is_dir():
1416+
registered = registrar.register_commands_for_agent(
1417+
agent_name, manifest, ext_dir, self.project_root
1418+
)
1419+
if registered:
1420+
registered_commands = metadata.get("registered_commands", {})
1421+
if not isinstance(registered_commands, dict):
1422+
registered_commands = {}
1423+
new_registered = copy.deepcopy(registered_commands)
1424+
new_registered[agent_name] = registered
1425+
updates["registered_commands"] = new_registered
1426+
1427+
registered_skills = self._register_extension_skills(manifest, ext_dir)
1428+
if registered_skills:
1429+
existing_skills = self._valid_name_list(metadata.get("registered_skills", []))
1430+
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
1431+
updates["registered_skills"] = merged_skills
1432+
1433+
if updates:
1434+
self.registry.update(ext_id, updates)
1435+
13351436
def list_installed(self) -> List[Dict[str, Any]]:
13361437
"""List all installed extensions with metadata.
13371438

tests/integrations/test_integration_subcommand.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ def _init_project(tmp_path, integration="copilot"):
3131
return project
3232

3333

34+
def _run_in_project(project, args):
35+
"""Run a CLI command from inside a generated project."""
36+
old_cwd = os.getcwd()
37+
try:
38+
os.chdir(project)
39+
return runner.invoke(app, args, catch_exceptions=False)
40+
finally:
41+
os.chdir(old_cwd)
42+
43+
3444
# ── list ─────────────────────────────────────────────────────────────
3545

3646

@@ -334,6 +344,150 @@ def test_switch_between_integrations(self, tmp_path):
334344
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
335345
assert data["integration"] == "copilot"
336346

347+
def test_switch_migrates_extension_commands(self, tmp_path):
348+
"""Switching should migrate extension commands to the new agent directory."""
349+
project = _init_project(tmp_path, "kimi")
350+
351+
# Install the bundled git extension
352+
old_cwd = os.getcwd()
353+
try:
354+
os.chdir(project)
355+
result = runner.invoke(app, [
356+
"extension", "add", "git",
357+
], catch_exceptions=False)
358+
finally:
359+
os.chdir(old_cwd)
360+
assert result.exit_code == 0, f"extension add failed: {result.output}"
361+
362+
# Verify git extension skills exist for kimi
363+
kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md"
364+
assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"
365+
366+
try:
367+
os.chdir(project)
368+
result = runner.invoke(app, [
369+
"integration", "switch", "opencode",
370+
"--script", "sh",
371+
], catch_exceptions=False)
372+
finally:
373+
os.chdir(old_cwd)
374+
assert result.exit_code == 0, result.output
375+
376+
# Git extension commands should exist for opencode
377+
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
378+
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
379+
380+
# Old kimi extension skills should be removed
381+
assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed"
382+
383+
# Extension registry should be updated
384+
registry = json.loads(
385+
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
386+
)
387+
registered_commands = registry["extensions"]["git"]["registered_commands"]
388+
assert "opencode" in registered_commands
389+
assert "kimi" not in registered_commands
390+
391+
# Switch to claude
392+
try:
393+
os.chdir(project)
394+
result = runner.invoke(app, [
395+
"integration", "switch", "claude",
396+
"--script", "sh",
397+
], catch_exceptions=False)
398+
finally:
399+
os.chdir(old_cwd)
400+
assert result.exit_code == 0, result.output
401+
402+
# Git extension skills should exist for claude
403+
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
404+
assert claude_git_feature.exists(), "Git extension skill should exist for claude"
405+
406+
# Old opencode extension commands should be removed
407+
assert not opencode_git_feature.exists(), "Old opencode extension command should be removed"
408+
409+
# Extension registry should be updated
410+
registry = json.loads(
411+
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
412+
)
413+
registered_commands = registry["extensions"]["git"]["registered_commands"]
414+
assert "claude" in registered_commands
415+
assert "opencode" not in registered_commands
416+
417+
def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path):
418+
"""Copilot --skills should receive extension skills, not .agent.md files."""
419+
project = _init_project(tmp_path, "opencode")
420+
421+
result = _run_in_project(project, ["extension", "add", "git"])
422+
assert result.exit_code == 0, f"extension add failed: {result.output}"
423+
424+
result = _run_in_project(project, [
425+
"integration", "switch", "copilot",
426+
"--script", "sh",
427+
"--integration-options", "--skills",
428+
])
429+
assert result.exit_code == 0, result.output
430+
431+
copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
432+
copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md"
433+
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
434+
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
435+
436+
registry = json.loads(
437+
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
438+
)
439+
git_meta = registry["extensions"]["git"]
440+
assert "speckit-git-feature" in git_meta["registered_skills"]
441+
assert "copilot" not in git_meta["registered_commands"]
442+
443+
result = _run_in_project(project, [
444+
"integration", "switch", "opencode",
445+
"--script", "sh",
446+
])
447+
assert result.exit_code == 0, result.output
448+
449+
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
450+
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
451+
assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"
452+
453+
registry = json.loads(
454+
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
455+
)
456+
git_meta = registry["extensions"]["git"]
457+
assert git_meta["registered_skills"] == []
458+
assert "opencode" in git_meta["registered_commands"]
459+
assert "copilot" not in git_meta["registered_commands"]
460+
461+
def test_switch_does_not_register_disabled_extensions(self, tmp_path):
462+
"""Disabled extensions should stay disabled and should not migrate commands."""
463+
project = _init_project(tmp_path, "opencode")
464+
465+
result = _run_in_project(project, ["extension", "add", "git"])
466+
assert result.exit_code == 0, f"extension add failed: {result.output}"
467+
result = _run_in_project(project, ["extension", "disable", "git"])
468+
assert result.exit_code == 0, result.output
469+
470+
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
471+
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
472+
473+
result = _run_in_project(project, [
474+
"integration", "switch", "claude",
475+
"--script", "sh",
476+
])
477+
assert result.exit_code == 0, result.output
478+
479+
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
480+
assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent"
481+
assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch"
482+
483+
registry = json.loads(
484+
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
485+
)
486+
git_meta = registry["extensions"]["git"]
487+
assert git_meta["enabled"] is False
488+
assert "claude" not in git_meta["registered_commands"]
489+
assert "opencode" not in git_meta["registered_commands"]
490+
337491
def test_switch_preserves_shared_infra(self, tmp_path):
338492
"""Switching preserves shared scripts, templates, and memory."""
339493
project = _init_project(tmp_path, "claude")

0 commit comments

Comments
 (0)