Skip to content

Commit fed6c19

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 fed6c19

4 files changed

Lines changed: 335 additions & 9 deletions

File tree

EOF

Whitespace-only changes.

src/specify_cli/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2365,6 +2365,19 @@ 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+
try:
2371+
from .extensions import ExtensionManager
2372+
2373+
ext_mgr = ExtensionManager(project_root)
2374+
ext_mgr.unregister_agent_artifacts(installed_key)
2375+
except Exception as ext_err:
2376+
console.print(
2377+
f"[yellow]Warning:[/yellow] Could not clean up extension commands "
2378+
f"for '{installed_key}': {ext_err}"
2379+
)
2380+
23682381
# Clear metadata so a failed Phase 2 doesn't leave stale references
23692382
_remove_integration_json(project_root)
23702383
opts = load_init_options(project_root)
@@ -2404,6 +2417,19 @@ def integration_switch(
24042417
_write_integration_json(project_root, target_integration.key)
24052418
_update_init_options_for_integration(project_root, target_integration, script_type=selected_script)
24062419

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

src/specify_cli/extensions.py

Lines changed: 163 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -962,29 +962,40 @@ def _register_extension_skills(
962962

963963
return written
964964

965-
def _unregister_extension_skills(self, skill_names: List[str], extension_id: str) -> None:
965+
def _unregister_extension_skills(
966+
self,
967+
skill_names: List[str],
968+
extension_id: str,
969+
skills_dir: Optional[Path] = None,
970+
) -> None:
966971
"""Remove SKILL.md directories for extension skills.
967972
968973
Called during extension removal to clean up skill files that
969974
were created by ``_register_extension_skills()``.
970975
971-
If ``_get_skills_dir()`` returns ``None`` (e.g. the user removed
972-
init-options.json or toggled ai_skills after installation), we
973-
fall back to scanning all known agent skills directories so that
974-
orphaned skill directories are still cleaned up. In that case
975-
each candidate directory is verified against the SKILL.md
976-
``metadata.source`` field before removal to avoid accidentally
977-
deleting user-created skills with the same name.
976+
If *skills_dir* is not provided and ``_get_skills_dir()`` returns
977+
``None`` (e.g. the user removed init-options.json or toggled
978+
ai_skills after installation), we fall back to scanning all known
979+
agent skills directories so that orphaned skill directories are
980+
still cleaned up. In that case each candidate directory is
981+
verified against the SKILL.md ``metadata.source`` field before
982+
removal to avoid accidentally deleting user-created skills with
983+
the same name.
978984
979985
Args:
980986
skill_names: List of skill names to remove.
981987
extension_id: Extension ID used to verify ownership during
982988
fallback candidate scanning.
989+
skills_dir: Optional explicit skills directory to use instead
990+
of resolving via ``_get_skills_dir()``. Useful when the
991+
caller needs to target a specific agent's skills directory
992+
regardless of the currently-active agent in init-options.
983993
"""
984994
if not skill_names:
985995
return
986996

987-
skills_dir = self._get_skills_dir()
997+
if skills_dir is None:
998+
skills_dir = self._get_skills_dir()
988999

9891000
if skills_dir:
9901001
# Fast path: we know the exact skills directory
@@ -1332,6 +1343,149 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool:
13321343

13331344
return True
13341345

1346+
@staticmethod
1347+
def _valid_name_list(value: Any) -> List[str]:
1348+
"""Return string entries from a registry list, ignoring corrupt values."""
1349+
if not isinstance(value, list):
1350+
return []
1351+
return [item for item in value if isinstance(item, str)]
1352+
1353+
def unregister_agent_artifacts(self, agent_name: str) -> None:
1354+
"""Remove extension files registered for a specific agent.
1355+
1356+
Extension command files are tracked per agent in ``registered_commands``.
1357+
Extension skills are scoped to the provided *agent_name*; they are removed
1358+
from that agent's skills directory (resolved via its integration config)
1359+
and the registry field is cleared.
1360+
1361+
Skips cleanup when *agent_name* is not a supported agent to avoid
1362+
losing registry entries while leaving orphaned files on disk.
1363+
"""
1364+
if not agent_name:
1365+
return
1366+
1367+
registrar = CommandRegistrar()
1368+
if agent_name not in registrar.AGENT_CONFIGS:
1369+
return
1370+
1371+
# Resolve the skills directory for the specific agent so cleanup is
1372+
# agent-scoped and does not depend on the currently-active agent in
1373+
# init-options. Use the same helper that extension install uses.
1374+
from . import _get_skills_dir as resolve_skills_dir
1375+
1376+
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
1377+
1378+
for ext_id, metadata in self.registry.list().items():
1379+
updates: Dict[str, Any] = {}
1380+
1381+
registered_commands = metadata.get("registered_commands", {})
1382+
if isinstance(registered_commands, dict) and agent_name in registered_commands:
1383+
command_names = self._valid_name_list(registered_commands.get(agent_name))
1384+
if command_names:
1385+
registrar.unregister_commands({agent_name: command_names}, self.project_root)
1386+
1387+
new_registered = copy.deepcopy(registered_commands)
1388+
new_registered.pop(agent_name, None)
1389+
updates["registered_commands"] = new_registered
1390+
1391+
registered_skills = self._valid_name_list(metadata.get("registered_skills", []))
1392+
if registered_skills:
1393+
# Only pass the resolved skills_dir when it actually exists.
1394+
# Otherwise let _unregister_extension_skills fall back to
1395+
# scanning all known agent skills directories, which is useful
1396+
# for cleaning up stale entries created by earlier installs.
1397+
skills_dir = agent_skills_dir if agent_skills_dir.is_dir() else None
1398+
self._unregister_extension_skills(
1399+
registered_skills, ext_id, skills_dir=skills_dir
1400+
)
1401+
1402+
# Only reconcile registry state when cleanup was scoped to a
1403+
# specific existing directory. When skills_dir is None,
1404+
# _unregister_extension_skills falls back to scanning multiple
1405+
# candidate directories, so agent_skills_dir cannot be used to
1406+
# infer what was removed. When skills_dir is set,
1407+
# _unregister_extension_skills may intentionally skip deletion
1408+
# when ownership cannot be verified (e.g., corrupted/missing
1409+
# SKILL.md or mismatching metadata.source). Only drop registry
1410+
# entries for skill directories that were actually removed so
1411+
# future cleanup attempts can still find skipped ones.
1412+
if skills_dir is not None:
1413+
remaining_skills = [
1414+
skill_name
1415+
for skill_name in registered_skills
1416+
if (skills_dir / skill_name / "SKILL.md").exists()
1417+
]
1418+
if remaining_skills != registered_skills:
1419+
updates["registered_skills"] = remaining_skills
1420+
1421+
if updates:
1422+
self.registry.update(ext_id, updates)
1423+
1424+
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
1425+
"""Register installed, enabled extensions for the active agent.
1426+
1427+
This is used after integration switches. It mirrors extension install
1428+
behavior while avoiding stale default-mode command directories when an
1429+
agent is currently running in skills mode (notably Copilot ``--skills``).
1430+
"""
1431+
if not agent_name:
1432+
return
1433+
1434+
from . import load_init_options
1435+
1436+
registrar = CommandRegistrar()
1437+
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
1438+
init_options = load_init_options(self.project_root)
1439+
if not isinstance(init_options, dict):
1440+
init_options = {}
1441+
1442+
active_agent = init_options.get("ai")
1443+
skills_mode_active = (
1444+
active_agent == agent_name
1445+
and bool(init_options.get("ai_skills"))
1446+
and bool(agent_config)
1447+
and agent_config.get("extension") != "/SKILL.md"
1448+
)
1449+
1450+
for ext_id, metadata in self.registry.list().items():
1451+
if not metadata.get("enabled", True):
1452+
continue
1453+
1454+
manifest = self.get_extension(ext_id)
1455+
if manifest is None:
1456+
continue
1457+
1458+
ext_dir = self.extensions_dir / ext_id
1459+
updates: Dict[str, Any] = {}
1460+
1461+
if agent_config and not skills_mode_active:
1462+
registered = registrar.register_commands_for_agent(
1463+
agent_name, manifest, ext_dir, self.project_root
1464+
)
1465+
registered_commands = metadata.get("registered_commands", {})
1466+
if not isinstance(registered_commands, dict):
1467+
registered_commands = {}
1468+
new_registered = copy.deepcopy(registered_commands)
1469+
if registered:
1470+
new_registered[agent_name] = registered
1471+
else:
1472+
# Registration returned empty list (e.g., corrupted
1473+
# manifest pointing at missing command files). Clear
1474+
# stale entry so later cleanup doesn't try to remove
1475+
# files that were never written.
1476+
new_registered.pop(agent_name, None)
1477+
if new_registered != registered_commands:
1478+
updates["registered_commands"] = new_registered
1479+
1480+
registered_skills = self._register_extension_skills(manifest, ext_dir)
1481+
if registered_skills:
1482+
existing_skills = self._valid_name_list(metadata.get("registered_skills", []))
1483+
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
1484+
updates["registered_skills"] = merged_skills
1485+
1486+
if updates:
1487+
self.registry.update(ext_id, updates)
1488+
13351489
def list_installed(self) -> List[Dict[str, Any]]:
13361490
"""List all installed extensions with metadata.
13371491

tests/integrations/test_integration_subcommand.py

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

0 commit comments

Comments
 (0)