@@ -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