Skip to content

Commit 5edc9a5

Browse files
cyliu0Copilot
andauthored
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>
1 parent da1bf02 commit 5edc9a5

4 files changed

Lines changed: 342 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
@@ -2376,6 +2376,19 @@ def integration_switch(
23762376
)
23772377
raise typer.Exit(1)
23782378

2379+
# Unregister extension commands for the old agent so they don't
2380+
# remain as orphans in the old agent's directory.
2381+
try:
2382+
from .extensions import ExtensionManager
2383+
2384+
ext_mgr = ExtensionManager(project_root)
2385+
ext_mgr.unregister_agent_artifacts(installed_key)
2386+
except Exception as ext_err:
2387+
console.print(
2388+
f"[yellow]Warning:[/yellow] Could not clean up extension artifacts "
2389+
f"(commands, skills, registry entries) for '{installed_key}': {ext_err}"
2390+
)
2391+
23792392
# Clear metadata so a failed Phase 2 doesn't leave stale references
23802393
_remove_integration_json(project_root)
23812394
opts = load_init_options(project_root)
@@ -2415,6 +2428,19 @@ def integration_switch(
24152428
_write_integration_json(project_root, target_integration.key)
24162429
_update_init_options_for_integration(project_root, target_integration, script_type=selected_script)
24172430

2431+
# Re-register extension commands for the new agent so that
2432+
# previously-installed extensions are available in the new integration.
2433+
try:
2434+
from .extensions import ExtensionManager
2435+
2436+
ext_mgr = ExtensionManager(project_root)
2437+
ext_mgr.register_enabled_extensions_for_agent(target)
2438+
except Exception as ext_err:
2439+
console.print(
2440+
f"[yellow]Warning:[/yellow] Could not register extension commands, skills, "
2441+
f"or related artifacts for '{target}': {ext_err}"
2442+
)
2443+
24182444
except Exception as e:
24192445
# Attempt rollback of any files written by setup
24202446
try:

src/specify_cli/extensions.py

Lines changed: 170 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,156 @@ 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).is_dir()
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 ``agent_name``.
1426+
1427+
This is intended to be called after switching integrations. Command
1428+
registration is scoped to the explicit ``agent_name`` argument, but some
1429+
behavior still depends on the current init-options state (for example,
1430+
skills-mode handling uses the active ``ai`` / ``ai_skills`` settings).
1431+
1432+
Callers should therefore pass the agent that has just been made active
1433+
in init-options; in normal use, ``agent_name`` is expected to match the
1434+
current ``ai`` value. This mirrors extension install behavior while
1435+
avoiding stale default-mode command directories when that active agent
1436+
is running in skills mode (notably Copilot ``--skills``).
1437+
"""
1438+
if not agent_name:
1439+
return
1440+
1441+
from . import load_init_options
1442+
1443+
registrar = CommandRegistrar()
1444+
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
1445+
init_options = load_init_options(self.project_root)
1446+
if not isinstance(init_options, dict):
1447+
init_options = {}
1448+
1449+
active_agent = init_options.get("ai")
1450+
skills_mode_active = (
1451+
active_agent == agent_name
1452+
and bool(init_options.get("ai_skills"))
1453+
and bool(agent_config)
1454+
and agent_config.get("extension") != "/SKILL.md"
1455+
)
1456+
1457+
for ext_id, metadata in self.registry.list().items():
1458+
if not metadata.get("enabled", True):
1459+
continue
1460+
1461+
manifest = self.get_extension(ext_id)
1462+
if manifest is None:
1463+
continue
1464+
1465+
ext_dir = self.extensions_dir / ext_id
1466+
updates: Dict[str, Any] = {}
1467+
1468+
if agent_config and not skills_mode_active:
1469+
registered = registrar.register_commands_for_agent(
1470+
agent_name, manifest, ext_dir, self.project_root
1471+
)
1472+
registered_commands = metadata.get("registered_commands", {})
1473+
if not isinstance(registered_commands, dict):
1474+
registered_commands = {}
1475+
new_registered = copy.deepcopy(registered_commands)
1476+
if registered:
1477+
new_registered[agent_name] = registered
1478+
else:
1479+
# Registration returned empty list (e.g., corrupted
1480+
# manifest pointing at missing command files). Clear
1481+
# stale entry so later cleanup doesn't try to remove
1482+
# files that were never written.
1483+
new_registered.pop(agent_name, None)
1484+
if new_registered != registered_commands:
1485+
updates["registered_commands"] = new_registered
1486+
1487+
registered_skills = self._register_extension_skills(manifest, ext_dir)
1488+
if registered_skills:
1489+
existing_skills = self._valid_name_list(metadata.get("registered_skills", []))
1490+
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
1491+
updates["registered_skills"] = merged_skills
1492+
1493+
if updates:
1494+
self.registry.update(ext_id, updates)
1495+
13351496
def list_installed(self) -> List[Dict[str, Any]]:
13361497
"""List all installed extensions with metadata.
13371498

0 commit comments

Comments
 (0)