@@ -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
0 commit comments