From af9205fc2cd75acad0f0d148e54fb118d2416fff Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:23:09 +0200 Subject: [PATCH 01/27] fix: register extensions for the active integration only extension add registered commands for every detected agent, and integration upgrade back-filled enabled extensions for non-active integrations. Maintainer direction on #2948: treat the project as single-active. Only the active integration gets extension artifacts; use/switch rescaffold the target when the user selects it. - extension add now routes through the all-agents pass restricted to the active integration (only_agent), keeping detection and missing-skills-dir recovery safeguards. Projects without recorded init-options fall back to detection-based registration. - integration upgrade re-registers extensions only when upgrading the active integration, reversing the #2886 back-fill for non-active targets at maintainer request. Fixes #2948 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/agents.py | 5 + src/specify_cli/extensions/__init__.py | 91 +++++++++++---- src/specify_cli/integrations/_helpers.py | 21 ++-- .../integrations/_migrate_commands.py | 24 ++-- .../test_integration_subcommand.py | 105 ++++++++++++++---- tests/test_extensions.py | 7 +- 6 files changed, 186 insertions(+), 67 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c535869121..26add257fe 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -932,6 +932,7 @@ def register_commands_for_all_agents( link_outputs: bool = False, create_missing_active_skills_dir: bool = False, extension_id: Optional[str] = None, + only_agent: Optional[str] = None, ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -949,6 +950,8 @@ def register_commands_for_all_agents( skills directory) and is skipped when safe resolution or creation fails. extension_id: Extension id when rendering extension-owned commands. + only_agent: If set, restrict registration to this single agent + while keeping all detection and recovery safeguards (#2948). Returns: Dictionary mapping agent names to list of registered commands @@ -972,6 +975,8 @@ def register_commands_for_all_agents( ) active_created_skills_dir: Optional[Path] = None for agent_name, agent_config in self.AGENT_CONFIGS.items(): + if only_agent is not None and agent_name != only_agent: + continue active_skills_output = ( agent_name == active_skills_agent and agent_config.get("extension") == "/SKILL.md" diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index b84b836545..ea1ce43642 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -975,6 +975,64 @@ def _ensure_usable(skills_dir: Path) -> Optional[Path]: return _ensure_usable(agent_skills_dir) return _ensure_usable(skills_dir) + def _register_commands_for_active_agent( + self, + manifest: ExtensionManifest, + extension_dir: Path, + link_outputs: bool = False, + ) -> Dict[str, List[str]]: + """Register extension commands for the active integration only. + + Maintainer-requested behavior for #2948: ``extension add`` treats the + project as single-active — only the integration recorded in + init-options gets command files. Non-active integrations receive them + when selected via ``integration use`` / ``switch`` (rescaffold). + + Projects without a recorded active integration (pre-init-options + layouts or direct library use) fall back to detection-based + registration for all agents. + + Returns: + Mapping of agent name to registered command names, matching the + ``registered_commands`` registry shape. + """ + from .. import load_init_options + + registrar = CommandRegistrar() + init_options = load_init_options(self.project_root) + if not isinstance(init_options, dict): + init_options = {} + active_agent = init_options.get("ai") + + if not active_agent or active_agent not in registrar.AGENT_CONFIGS: + return registrar.register_commands_for_all_agents( + manifest, + extension_dir, + self.project_root, + link_outputs=link_outputs, + create_missing_active_skills_dir=True, + ) + + agent_config = registrar.AGENT_CONFIGS[active_agent] + if ( + is_ai_skills_enabled(init_options) + and agent_config.get("extension") != "/SKILL.md" + ): + # Active agent runs skills mode: extension artifacts render as + # skills via _register_extension_skills, not as command files. + return {} + + # Route through the all-agents pass restricted to the active agent so + # detection and missing-skills-dir recovery safeguards still apply. + return registrar.register_commands_for_all_agents( + manifest, + extension_dir, + self.project_root, + link_outputs=link_outputs, + create_missing_active_skills_dir=True, + only_agent=active_agent, + ) + def _register_extension_skills( self, manifest: ExtensionManifest, @@ -1404,17 +1462,11 @@ def install_from_directory( ignore_fn = self._load_extensionignore(source_dir) shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) - # Register commands with AI agents + # Register commands with AI agents (active integration only, #2948) registered_commands = {} if register_commands: - registrar = CommandRegistrar() - # Register for all detected agents - registered_commands = registrar.register_commands_for_all_agents( - manifest, - dest_dir, - self.project_root, - link_outputs=link_commands, - create_missing_active_skills_dir=True, + registered_commands = self._register_commands_for_active_agent( + manifest, dest_dir, link_outputs=link_commands ) # Auto-register extension commands as agent skills when skills mode @@ -1701,11 +1753,10 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: """Register installed, enabled extensions for ``agent_name``. Command-file registration is scoped to the explicit ``agent_name`` - argument, so this method can be used after install, upgrade, or switch. - Extension skill rendering is still scoped to the active ``ai`` / - ``ai_skills`` settings in init-options, so non-active skills-mode - targets receive command files here. Per-agent skills parity is tracked - separately in #2948. + argument. Since #2948, callers pass the active agent only (``use`` / + ``switch`` activate the target first; ``upgrade`` calls it only for + the active integration), so extension skill rendering — scoped to the + active ``ai`` / ``ai_skills`` init-options — matches ``agent_name``. """ if not agent_name: return @@ -1765,13 +1816,11 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: # Extension *skills* are only ever rendered for the active agent: # `_register_extension_skills` resolves the skills dir and # frontmatter from init-options["ai"], ignoring ``agent_name``. - # When this method runs for a non-active agent — as install/upgrade - # now do for a secondary integration (#2886) — the skills pass would - # re-render the *active* agent's extension skills as a side effect, + # Running the skills pass for a non-active agent would re-render + # the *active* agent's extension skills as a side effect, # resurrecting skill files the user deliberately deleted. Skip it - # unless the target is the active agent; `switch` is unaffected - # because it activates the target before registering. (Rendering - # skills for a non-active target is tracked separately in #2948.) + # unless the target is the active agent (defense in depth: since + # #2948 callers only pass the active agent anyway). if agent_name == active_agent: try: registered_skills = self._register_extension_skills( @@ -1970,6 +2019,7 @@ def register_commands_for_all_agents( project_root: Path, link_outputs: bool = False, create_missing_active_skills_dir: bool = False, + only_agent: Optional[str] = None, ) -> Dict[str, List[str]]: """Register extension commands for all detected agents.""" context_note = f"\n\n\n" @@ -1981,6 +2031,7 @@ def register_commands_for_all_agents( context_note=context_note, link_outputs=link_outputs, create_missing_active_skills_dir=create_missing_active_skills_dir, + only_agent=only_agent, extension_id=manifest.id, ) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index d1bf051f77..1ba2287234 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -376,19 +376,14 @@ def _register_extensions_for_agent( """Register all enabled extensions' commands/skills for ``agent_key``. ``use`` / ``switch`` re-register enabled extensions for the agent they - activate; ``upgrade`` backfills them for the refreshed agent. Plain - ``install`` deliberately does not call this helper so adding a secondary - integration has no extension side effects until it is selected or upgraded. - See issue #2886. - - Known limitation: extension *skill* rendering is scoped to the active - agent (init-options track a single ``ai`` / ``ai_skills`` pair). A - skills-mode agent registered while it is *not* the active agent (e.g. - Copilot ``--skills`` registered while non-active) therefore - receives command files rather than skills here — matching ``extension - add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they - make the target the active agent first. Per-agent skills parity is tracked in - #2948. + activate (rescaffold); ``upgrade`` does so only for the *active* + integration. Plain ``install`` and upgrade of a non-active integration + deliberately skip this helper so a secondary integration has no extension + side effects until it is selected. See issues #2886 and #2948. + + Callers always pass the active agent (use/switch activate the target + before registering), so extension *skill* rendering — which is scoped to + the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``. Best-effort: never aborts the surrounding integration operation. Callers invoke it *after* the use/upgrade/switch transaction has committed so a diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 6568d1af18..f8f5e16c5a 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -491,17 +491,19 @@ def integration_upgrade( if stale_removed: console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") - # Re-register enabled extensions for the upgraded agent so its extension - # commands are (re)created — including agents installed before this - # back-fill existed. Mirrors switch for command registration; see #2886. - # Done after the upgrade has fully settled (Phase 2 included) and outside - # the try/except above so this best-effort step cannot affect upgrade - # success. - _register_extensions_for_agent( - project_root, - key, - continuing="The integration was upgraded, but installed extensions may need re-registration.", - ) + # Re-register enabled extensions only when upgrading the *active* + # integration, so its extension commands are (re)created after the + # upgrade settled (Phase 2 included). Done outside the try/except above + # so this best-effort step cannot affect upgrade success. Non-active + # integrations are rescaffolded by `use` / `switch` instead — the #2886 + # back-fill for non-active agents was removed at maintainer request + # (#2948). + if key == installed_key: + _register_extensions_for_agent( + project_root, + key, + continuing="The integration was upgraded, but installed extensions may need re-registration.", + ) name = (integration.config or {}).get("name", key) console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 56f338cc82..d4b9085e45 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1371,6 +1371,53 @@ def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tm project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" ).exists() + def test_extension_add_registers_active_integration_only(self, tmp_path): + """``extension add`` registers commands for the active integration only. + + Maintainer-requested behavior for #2948: with multiple integrations + installed, ``extension add`` must treat the project as single-active — + only the current integration gets the new extension's commands. + Non-active integrations receive them when selected via + ``integration use`` / ``switch`` (rescaffold). + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "claude" in registered, "active integration gets the extension" + assert "codex" not in registered, ( + "non-active integration must not be registered on add (#2948)" + ) + assert ( + project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + assert not ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + # Selecting the other integration rescaffolds it with the extension. + result = _run_in_project(project, ["integration", "use", "codex"]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" in registered, "use registers extensions for the new active agent" + assert ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + # ── uninstall ──────────────────────────────────────────────────────── @@ -2492,13 +2539,13 @@ def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path): "shared .sh scripts must be executable after upgrade" ) - def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path): - """Upgrade re-registers enabled extensions for the upgraded agent. + def test_upgrade_does_not_backfill_non_active_integration(self, tmp_path): + """Upgrading a non-active integration must not register extensions for it. - Regression for #2886: agents installed before extension back-fill - existed (or whose extension artifacts went missing) should regain the - enabled extensions' commands on ``upgrade``, reaching parity with - ``switch``. + Maintainer-requested behavior for #2948 (reverses the #2886 upgrade + back-fill): non-active integrations only receive extension artifacts + when selected via ``integration use`` / ``switch``. Upgrade of a + non-active integration refreshes its own files and nothing else. """ project = _init_project(tmp_path, "claude") @@ -2511,21 +2558,10 @@ def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path): ]) assert result.exit_code == 0, result.output - # Simulate a project created before the install/upgrade back-fill: drop - # codex's extension registration and its rendered artifacts. registry_path = project / ".specify" / "extensions" / ".registry" - registry = json.loads(registry_path.read_text(encoding="utf-8")) - registry["extensions"]["git"]["registered_commands"].pop("codex", None) - registry_path.write_text(json.dumps(registry), encoding="utf-8") - agents_skills = project / ".agents" / "skills" - for skill_dir in agents_skills.glob("speckit-git-*"): - shutil.rmtree(skill_dir) - - # Precondition: codex is now missing the git extension. assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[ "extensions" ]["git"]["registered_commands"] - assert not (agents_skills / "speckit-git-feature" / "SKILL.md").exists() result = _run_in_project(project, [ "integration", "upgrade", "codex", @@ -2533,12 +2569,41 @@ def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path): ]) assert result.exit_code == 0, result.output - # Upgrade back-filled the git extension for codex. registered = json.loads(registry_path.read_text(encoding="utf-8"))[ "extensions" ]["git"]["registered_commands"] - assert "codex" in registered, "upgrade should re-register extension commands (#2886)" - assert (agents_skills / "speckit-git-feature" / "SKILL.md").exists() + assert "codex" not in registered, ( + "upgrade must not back-fill non-active integrations (#2948)" + ) + assert not ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_upgrade_active_integration_reregisters_extensions(self, tmp_path): + """Upgrading the active integration restores its extension commands. + + The active integration keeps the re-registration pass on upgrade so + missing or stale extension command files are recreated (#2948 scopes + the pass to the active integration; #2886 introduced it). + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + cmd_file = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert cmd_file.exists(), "precondition: extension command registered" + cmd_file.unlink() + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + assert cmd_file.exists(), ( + "upgrade of the active integration re-registers extension commands" + ) def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path): """Upgrading a non-active agent must not touch the active agent's skills. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 2885180360..98be75dd11 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1148,6 +1148,7 @@ def fake_register_all( link_outputs=False, create_missing_active_skills_dir=False, extension_id=None, + only_agent=None, ): captured["create_missing_active_skills_dir"] = ( create_missing_active_skills_dir @@ -7448,7 +7449,7 @@ def test_non_cline_extension_no_hyphenation(self, tmp_path): from specify_cli import app from specify_cli.agents import CommandRegistrar - project_dir, ext_dir, claude_commands_dir = self._setup_mock_extension(tmp_path, "claude") + project_dir, ext_dir, agents_commands_dir = self._setup_mock_extension(tmp_path, "amp") # 3. Run specify extension add runner = CliRunner() @@ -7465,8 +7466,8 @@ def test_non_cline_extension_no_hyphenation(self, tmp_path): assert "speckit-mock-ext-hello" not in result.output # Verify on-disk command names are dotted - hello_file = claude_commands_dir / "speckit.mock-ext.hello.md" - greet_file = claude_commands_dir / "speckit.mock-ext.greet.md" + hello_file = agents_commands_dir / "speckit.mock-ext.hello.md" + greet_file = agents_commands_dir / "speckit.mock-ext.greet.md" assert hello_file.exists() assert greet_file.exists() From e9e90727acae4e6b7f6c17ad0045e4b700f5d01f Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:52:56 +0200 Subject: [PATCH 02/27] fix: address review feedback on active-only extension registration - Restrict the extension-add active-integration fallback to projects with no recorded active key at all. A recorded but unsupported key (e.g. "generic", deliberately excluded from AGENT_CONFIGS) no longer falls back to registering every detected agent. - Apply the same single-active rule to preset command overrides: PresetManager._register_commands now scopes registration to the active integration via only_agent. - Add PresetManager.register_enabled_presets_for_agent, mirroring ExtensionManager.register_enabled_extensions_for_agent, and call it from integration use/switch/upgrade (active only) alongside the existing extension re-registration so presets are rescaffolded on activation instead of being written for inactive integrations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 19 +++- src/specify_cli/integrations/_helpers.py | 32 ++++++ .../integrations/_migrate_commands.py | 27 ++++- .../integrations/_query_commands.py | 6 ++ src/specify_cli/presets/__init__.py | 78 +++++++++++++- .../test_integration_subcommand.py | 101 ++++++++++++++++++ 6 files changed, 252 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index ea1ce43642..ffdd37ba1e 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -988,9 +988,12 @@ def _register_commands_for_active_agent( init-options gets command files. Non-active integrations receive them when selected via ``integration use`` / ``switch`` (rescaffold). - Projects without a recorded active integration (pre-init-options + Projects without a recorded active integration at all (pre-init-options layouts or direct library use) fall back to detection-based - registration for all agents. + registration for all agents. A *recorded* active key that has no + registrar config (e.g. ``generic``, which is deliberately excluded + from ``AGENT_CONFIGS``) is not treated as "no active integration" — + it must not cause registration to target other detected agents. Returns: Mapping of agent name to registered command names, matching the @@ -1004,7 +1007,7 @@ def _register_commands_for_active_agent( init_options = {} active_agent = init_options.get("ai") - if not active_agent or active_agent not in registrar.AGENT_CONFIGS: + if not active_agent: return registrar.register_commands_for_all_agents( manifest, extension_dir, @@ -1013,9 +1016,15 @@ def _register_commands_for_active_agent( create_missing_active_skills_dir=True, ) - agent_config = registrar.AGENT_CONFIGS[active_agent] + # A recorded active key with no registrar config (e.g. "generic", + # deliberately excluded from AGENT_CONFIGS) has nothing to register + # through this path, but it is still an active integration. Passing + # it as only_agent below naturally yields no matches instead of + # falling back to registering every detected agent. + agent_config = registrar.AGENT_CONFIGS.get(active_agent) if ( - is_ai_skills_enabled(init_options) + agent_config + and is_ai_skills_enabled(init_options) and agent_config.get("extension") != "/SKILL.md" ): # Active agent runs skills mode: extension artifacts render as diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 1ba2287234..91d53e01a0 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -419,6 +419,38 @@ def _unregister_extensions_for_agent( ) +def _register_presets_for_agent( + project_root: Path, + agent_key: str, + *, + continuing: str, +) -> None: + """Register all enabled presets' command overrides/skills for ``agent_key``. + + Presets follow the same single-active rule as extensions (#2948): + ``use`` / ``switch`` re-register enabled presets for the agent they + activate (rescaffold), so a preset installed while a different + integration was active is not left targeting that inactive integration. + + Best-effort: never aborts the surrounding integration operation. + """ + try: + from ..presets import PresetManager + + preset_mgr = PresetManager(project_root) + preset_mgr.register_enabled_presets_for_agent(agent_key) + except Exception as preset_err: + from .. import _print_cli_warning + + _print_cli_warning( + "register preset artifacts for", + "integration", + agent_key, + preset_err, + continuing=continuing, + ) + + # --------------------------------------------------------------------------- # CLI formatting helpers (re-exported from _commands.py) # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index f8f5e16c5a..9ae2af3bc4 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -28,6 +28,7 @@ _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, + _register_presets_for_agent, _remove_integration_json, _resolve_integration_options, _resolve_integration_script_type, @@ -130,6 +131,14 @@ def integration_switch( "need re-registration." ), ) + _register_presets_for_agent( + project_root, + target, + continuing=( + "The integration switch succeeded, but installed presets may " + "need re-registration." + ), + ) console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") raise typer.Exit(0) @@ -327,6 +336,11 @@ def integration_switch( target, continuing="The integration switch succeeded, but installed extensions may need re-registration.", ) + _register_presets_for_agent( + project_root, + target, + continuing="The integration switch succeeded, but installed presets may need re-registration.", + ) name = (target_integration.config or {}).get("name", target) console.print(f"\n[green]✓[/green] Switched to integration '{name}'") @@ -491,10 +505,10 @@ def integration_upgrade( if stale_removed: console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") - # Re-register enabled extensions only when upgrading the *active* - # integration, so its extension commands are (re)created after the - # upgrade settled (Phase 2 included). Done outside the try/except above - # so this best-effort step cannot affect upgrade success. Non-active + # Re-register enabled extensions and presets only when upgrading the + # *active* integration, so its command artifacts are (re)created after + # the upgrade settled (Phase 2 included). Done outside the try/except + # above so this best-effort step cannot affect upgrade success. Non-active # integrations are rescaffolded by `use` / `switch` instead — the #2886 # back-fill for non-active agents was removed at maintainer request # (#2948). @@ -504,6 +518,11 @@ def integration_upgrade( key, continuing="The integration was upgraded, but installed extensions may need re-registration.", ) + _register_presets_for_agent( + project_root, + key, + continuing="The integration was upgraded, but installed presets may need re-registration.", + ) name = (integration.config or {}).get("name", key) console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py index bb47e6142e..2e8cd9fc27 100644 --- a/src/specify_cli/integrations/_query_commands.py +++ b/src/specify_cli/integrations/_query_commands.py @@ -18,6 +18,7 @@ from ._helpers import ( _read_integration_json, _register_extensions_for_agent, + _register_presets_for_agent, _resolve_integration_options, _set_default_integration_or_exit, ) @@ -248,6 +249,11 @@ def integration_use( key, continuing="The integration was selected, but installed extensions may need re-registration.", ) + _register_presets_for_agent( + project_root, + key, + continuing="The integration was selected, but installed presets may need re-registration.", + ) console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 863b6ef7dc..05406bf8ab 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -28,7 +28,7 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority -from .._init_options import is_ai_skills_enabled +from .._init_options import is_ai_skills_enabled, load_init_options from ..integrations.base import IntegrationBase from .._utils import dump_frontmatter, version_satisfies from ..shared_infra import verify_archive_sha256 @@ -680,10 +680,84 @@ def _register_commands( return {} registrar = CommandRegistrar() + + # Single-active rule (#2948): preset command overrides register for + # the active integration only. A project without a recorded active + # integration falls back to detection-based registration for all + # agents; a recorded key with no registrar config (e.g. "generic") + # naturally yields no matches via only_agent instead of falling back. + active_agent = load_init_options(self.project_root).get("ai") return registrar.register_commands_for_all_agents( - commands_to_register, manifest.id, preset_dir, self.project_root + commands_to_register, + manifest.id, + preset_dir, + self.project_root, + only_agent=active_agent, ) + def register_enabled_presets_for_agent(self, agent_name: str) -> None: + """Re-register enabled presets' command overrides and skills for ``agent_name``. + + Mirrors ``ExtensionManager.register_enabled_extensions_for_agent`` for + presets (#2948): ``integration use`` / ``switch`` call this for the + newly active agent so a preset installed while a different + integration was active gets rescaffolded on activation, instead of + writing artifacts for inactive integrations at install time. + ``_register_commands`` / ``_register_skills`` already resolve the + active integration from init-options themselves, so this re-runs them + for every enabled preset and merges the fresh result for + ``agent_name`` into its stored registry metadata. + """ + if not agent_name: + return + + resolver = PresetResolver(self.project_root) + for pack_id, metadata in self.registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + if manifest is None: + continue + + # Isolate per-preset failures: one preset that fails to register + # must not abort registration of the remaining enabled presets. + try: + updates: Dict[str, Any] = {} + + registered_commands = self._register_commands(manifest, pack_dir) + existing_commands = metadata.get("registered_commands", {}) + if not isinstance(existing_commands, dict): + existing_commands = {} + merged_commands = copy.deepcopy(existing_commands) + if registered_commands.get(agent_name): + merged_commands[agent_name] = registered_commands[agent_name] + if merged_commands != existing_commands: + updates["registered_commands"] = merged_commands + + registered_skills = self._register_skills(manifest, pack_dir) + if registered_skills: + existing_skills = metadata.get("registered_skills", []) + if not isinstance(existing_skills, list): + existing_skills = [] + merged_skills = list( + dict.fromkeys(existing_skills + registered_skills) + ) + if merged_skills != existing_skills: + updates["registered_skills"] = merged_skills + + if updates: + self.registry.update(pack_id, updates) + except Exception as pack_err: + from .. import _print_cli_warning + + _print_cli_warning( + "register preset artifacts for", + "preset", + pack_id, + pack_err, + continuing="Continuing with the remaining presets.", + ) + continue + def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: """Remove previously registered command files from agent directories. diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index d4b9085e45..9936a4e8b4 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1418,6 +1418,41 @@ def test_extension_add_registers_active_integration_only(self, tmp_path): project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" ).exists() + def test_extension_add_generic_active_does_not_backfill_other_agents(self, tmp_path): + """A recorded but unsupported active key (``generic``) must not + fall back to registering every detected agent. + + ``generic`` is deliberately excluded from ``AGENT_CONFIGS`` because + its output directory is only known via ``--commands-dir``, not a + static config. Before the fix, treating that active key like "no + active integration recorded" made the fallback register the + extension for every other detected agent — exactly the multi-target + behavior #2948 is meant to stop. + """ + project = _init_project( + tmp_path, "generic", + integration_options="--commands-dir .myagent/commands", + ) + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, result.output + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" not in registered, ( + "a recorded but unsupported active key must not target other " + "detected agents (#2948)" + ) + # ── uninstall ──────────────────────────────────────────────────────── @@ -1624,6 +1659,72 @@ def test_use_requires_installed_integration(self, tmp_path): assert result.exit_code != 0 assert "not installed" in result.output + def test_use_registers_presets_for_the_newly_active_agent(self, tmp_path): + """``integration use`` is the single rescaffold point for presets too. + + Mirrors the extension single-active rule (#2948): a preset command + override installed while ``claude`` was active must not target the + inactive ``codex`` integration, and switching via ``integration use`` + must rescaffold it there. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + preset_src = tmp_path / "cmd-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Overridden specify\n---\nOverridden content\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "cmd-preset", + "name": "Command Preset", + "version": "1.0.0", + "description": "Test preset with a command override", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + import yaml + + (preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") + + result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) + assert result.exit_code == 0, f"preset add failed: {result.output}" + + registry_path = project / ".specify" / "presets" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "presets" + ]["cmd-preset"]["registered_commands"] + assert "claude" in registered, "active integration gets the preset command override" + assert "codex" not in registered, ( + "non-active integration must not be registered on preset add (#2948)" + ) + + result = _run_in_project(project, ["integration", "use", "codex"]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "presets" + ]["cmd-preset"]["registered_commands"] + assert "codex" in registered, "use registers presets for the new active agent" + assert "claude" in registered, "the previous agent's registration is preserved" + def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): project = _init_project(tmp_path, "claude") template = project / ".specify" / "templates" / "plan-template.md" From 2486c08c5738a04aaeb4aab3a40374ff5c42495b Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:08:40 +0200 Subject: [PATCH 03/27] fix: address second round of review feedback (priority order, fail-closed, docs) - register_enabled_presets_for_agent now processes presets in reverse priority order (lowest-precedence first) so the highest-precedence preset is written last and actually wins after `integration use` rescaffolds two overlapping preset command overrides. Verified this reproduces the previously reported reversed-priority bug and that the fix resolves it. - _register_commands_for_active_agent now checks for the "ai" key's presence separately from its value: a missing key still falls back to detection-based registration for all agents, but a recorded, malformed value (non-string or empty, e.g. [] or null) now fails closed (registers nothing) instead of being treated as "no active integration" or reaching AGENT_CONFIGS.get() with an unhashable key and raising TypeError. - Updated docs/reference/presets.md and docs/reference/integrations.md to describe active-only preset/extension registration and clarify that `integration use`/`switch` is the activation point for installed extensions and presets, and that `upgrade` only re-registers them for the active integration. Adds regression tests: two enabled presets overriding the same command with different priorities (priority winner must survive `use` rescaffolding), and a malformed recorded `ai` value ([]) for `extension add`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/integrations.md | 12 +- docs/reference/presets.md | 2 +- src/specify_cli/extensions/__init__.py | 16 ++- src/specify_cli/presets/__init__.py | 8 +- .../test_integration_subcommand.py | 121 ++++++++++++++++++ 5 files changed, 154 insertions(+), 5 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index f538fac86f..3f576bb392 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -88,6 +88,8 @@ Installs the specified integration into the current project. If another integrat Installing an additional integration does not change the default integration. Use `specify integration use ` to change the default. +Installed extensions and presets are not registered for a non-default integration at install time — they follow the currently active (default) integration only. `specify integration use ` (or `switch `) is what rescaffolds them for the newly active integration. + > **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init --integration ` instead. **Version note:** Controlled multi-install support was introduced in Spec Kit 0.8.5. If `specify integration install ` says another integration is already installed and only suggests `switch` or `uninstall`, check your local CLI with `specify version` and upgrade it. Running a one-shot command such as `uvx --from git+https://github.com/github/spec-kit.git specify ...` uses a temporary copy for that command only; it does not update the persistent `specify` executable on your `PATH`. @@ -121,7 +123,7 @@ specify integration switch | `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) | | `--integration-options` | Options for the target integration when it is not already installed | -If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade --integration-options ...` first, then `use `. +If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade --integration-options ...` first, then `use `. Like `use`, `switch` rescaffolds installed extensions and presets for the target integration once it becomes the default. ## Use an Installed Integration @@ -135,6 +137,8 @@ specify integration use Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used. +`use` is also the activation point for installed extensions and presets: it re-registers every enabled extension's and preset's command overrides (and skills, for skills-mode agents) for the newly active integration, so artifacts installed while a different integration was active are rescaffolded here rather than at install time. + ## Upgrade an Integration ```bash @@ -149,6 +153,8 @@ specify integration upgrade [] Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration. +Enabled extensions and presets are re-registered only when upgrading the currently active (default) integration; upgrading a non-default integration does not touch its extension or preset artifacts — `use`/`switch` that integration afterward to rescaffold them. + ## Report Integration Status ```bash @@ -288,3 +294,7 @@ CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be ins ### When should I use `upgrade` vs `switch`? Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`. + +### Do extensions and presets I install apply to every installed integration? + +No. Extensions (`specify extension add`) and presets (`specify preset add`) register their command overrides for the currently active (default) integration only, even if other integrations are installed. A non-default integration does not receive those artifacts until it becomes the default: `specify integration use ` (or `switch `) rescaffolds every enabled extension and preset for the newly active integration. `specify integration upgrade` follows the same rule — it only re-registers extensions and presets when upgrading the active integration. diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 549177c1d6..a92adee906 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -139,7 +139,7 @@ catalogs: Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers. -Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command. +Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into the active integration's directory only, instead of being re-resolved by agents or written to every detected agent directory (#2948). During preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. A non-active installed integration does not receive these command files until it becomes the default — `specify integration use ` (or `switch `) rescaffolds enabled presets for the newly active integration. Agents do not re-resolve the stack each time they run a command. By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder. diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index ffdd37ba1e..1375dd60d2 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -995,6 +995,11 @@ def _register_commands_for_active_agent( from ``AGENT_CONFIGS``) is not treated as "no active integration" — it must not cause registration to target other detected agents. + A recorded but malformed ``ai`` value (non-string, e.g. ``[]`` or + ``null``) is also not "no active integration" — corrupted + init-options must fail closed (register nothing) rather than + fall back to registering every detected agent. + Returns: Mapping of agent name to registered command names, matching the ``registered_commands`` registry shape. @@ -1005,9 +1010,8 @@ def _register_commands_for_active_agent( init_options = load_init_options(self.project_root) if not isinstance(init_options, dict): init_options = {} - active_agent = init_options.get("ai") - if not active_agent: + if "ai" not in init_options: return registrar.register_commands_for_all_agents( manifest, extension_dir, @@ -1016,6 +1020,14 @@ def _register_commands_for_active_agent( create_missing_active_skills_dir=True, ) + active_agent = init_options.get("ai") + if not isinstance(active_agent, str) or not active_agent: + # A recorded key was found but it is malformed (not a non-empty + # string). Fail closed instead of falling back to all agents or + # passing a non-string key into AGENT_CONFIGS.get() below, which + # would raise TypeError for unhashable values like a list. + return {} + # A recorded active key with no registrar config (e.g. "generic", # deliberately excluded from AGENT_CONFIGS) has nothing to register # through this path, but it is still an active integration. Passing diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 05406bf8ab..5160831367 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -707,12 +707,18 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: active integration from init-options themselves, so this re-runs them for every enabled preset and merges the fresh result for ``agent_name`` into its stored registry metadata. + + Presets are processed in *reverse* priority order (lowest-precedence + first). Each pass overwrites the same target command/skill files, so + writing the highest-precedence preset last is what makes it win when + two enabled presets override the same command — matching the + priority stack documented for ``list_by_priority()``. """ if not agent_name: return resolver = PresetResolver(self.project_root) - for pack_id, metadata in self.registry.list_by_priority(): + for pack_id, metadata in reversed(self.registry.list_by_priority()): pack_dir = self.presets_dir / pack_id manifest = resolver._get_manifest(pack_dir) if manifest is None: diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 9936a4e8b4..32c51494b9 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1453,6 +1453,43 @@ def test_extension_add_generic_active_does_not_backfill_other_agents(self, tmp_p "detected agents (#2948)" ) + def test_extension_add_malformed_ai_value_fails_closed(self, tmp_path): + """A recorded but malformed ``ai`` value (e.g. a list) must not be + treated as "no active integration recorded" and must not crash. + + Before the fix, ``init_options.get("ai")`` being falsy (``[]``, + ``""``, ``0``) triggered the same all-agents fallback as a missing + key, and a *truthy* non-string value (e.g. a non-empty list) would + reach ``AGENT_CONFIGS.get(active_agent)`` and raise ``TypeError`` + because a list is unhashable. Corrupted init-options must instead + fail closed: register nothing rather than crash or back-fill every + detected agent. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + init_options_path = project / ".specify" / "init-options.json" + init_options = json.loads(init_options_path.read_text(encoding="utf-8")) + init_options["ai"] = [] + init_options_path.write_text(json.dumps(init_options), encoding="utf-8") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert registered == {}, ( + "a malformed recorded 'ai' value must fail closed, not " + "back-fill every detected agent (#2948)" + ) + # ── uninstall ──────────────────────────────────────────────────────── @@ -1725,6 +1762,90 @@ def test_use_registers_presets_for_the_newly_active_agent(self, tmp_path): assert "codex" in registered, "use registers presets for the new active agent" assert "claude" in registered, "the previous agent's registration is preserved" + def test_use_reregisters_presets_highest_precedence_last(self, tmp_path): + """When two enabled presets override the same command, the + higher-precedence preset (lower priority number) must win the + materialized file after ``integration use`` rescaffolds them. + + ``register_enabled_presets_for_agent`` iterates presets and each + pass overwrites the same target file, so the write order matters. + Before the fix, presets were processed lowest-number-first (highest + precedence first), so the lower-precedence preset was written last + and won -- reversing the documented priority stack (#2948). + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + import yaml + + def _make_preset(pack_id: str, content: str) -> Path: + src = tmp_path / pack_id + (src / "commands").mkdir(parents=True) + (src / "commands" / "speckit.specify.md").write_text( + f"---\ndescription: {pack_id}\n---\n{content}\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": f"Test preset {pack_id}", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + (src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") + return src + + # Lower-precedence preset (higher priority number), installed first. + low_precedence_src = _make_preset("low-precedence-preset", "LOW PRECEDENCE CONTENT") + result = _run_in_project(project, [ + "preset", "add", "--dev", str(low_precedence_src), "--priority", "20", + ]) + assert result.exit_code == 0, f"preset add (low) failed: {result.output}" + + # Higher-precedence preset (lower priority number), installed second. + high_precedence_src = _make_preset("high-precedence-preset", "HIGH PRECEDENCE CONTENT") + result = _run_in_project(project, [ + "preset", "add", "--dev", str(high_precedence_src), "--priority", "1", + ]) + assert result.exit_code == 0, f"preset add (high) failed: {result.output}" + + # Sanity: the priority stack already picks the high-precedence + # preset's content for the active (claude) integration. + claude_skill = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + assert "HIGH PRECEDENCE CONTENT" in claude_skill.read_text(encoding="utf-8") + assert "LOW PRECEDENCE CONTENT" not in claude_skill.read_text(encoding="utf-8") + + result = _run_in_project(project, ["integration", "use", "codex"]) + assert result.exit_code == 0, result.output + + # After rescaffolding for the newly active codex integration, the + # high-precedence preset must still win -- not whichever preset + # register_enabled_presets_for_agent happened to write last. + codex_skill = project / ".agents" / "skills" / "speckit-specify" / "SKILL.md" + content = codex_skill.read_text(encoding="utf-8") + assert "HIGH PRECEDENCE CONTENT" in content, ( + "highest-precedence preset must win after `use` rescaffolds " + "presets for the newly active integration (#2948)" + ) + assert "LOW PRECEDENCE CONTENT" not in content + def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): project = _init_project(tmp_path, "claude") template = project / ".specify" / "templates" / "plan-template.md" From 12a3d67731ef6f5e302a9d9ca5bf4bbddfed0088 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:44:15 +0200 Subject: [PATCH 04/27] fix: address third round of review feedback (multi-integration semantics) Fixes five deeper active-only registration bugs surfaced by Copilot review after 2486c08, all in the presets/extensions single-active integration rule (#2948): 1. presets: _reconcile_composed_commands (run after install/remove) bypassed the active-only filter entirely, writing composition-winner command files for every detected non-skill agent via register_commands_for_non_skill_agents. Added an only_agent param to that registrar method (mirroring register_commands_for_all_agents) and threaded it through all 5 reconciliation call sites. 2. presets: `integration use copilot` with --skills (ai_skills: true) wrote both the static .agent.md command file AND the SKILL.md mirror for the same override. Mirrored the extension path's ai_skills guard in both _register_commands and the reconciliation pass: a command-backed active agent running in skills mode is excluded from non-skill command registration. 3. presets: registered_skills was a flat list, so switching between two skill-mode agents (e.g. Claude -> Codex) and then removing the preset only restored the currently active agent's directory, permanently orphaning the other. _unregister_skills now restores every existing skill-mode agent directory instead of only the active one. 4. extensions: load_init_options() collapses "no file" and "corrupted file" into the same {}, so the round-2 fail-closed fix didn't actually distinguish them. Added a shared resolve_active_agent_for_registration() helper in _init_options.py that checks file existence separately from parse success, returning a distinct sentinel for "file absent" vs None for "corrupted or invalid". extensions/__init__.py now uses this helper. 5. presets: same corruption-collapsing bug in _register_commands's active_agent resolution. Now uses the same shared helper as (4). Adds regression tests for all five: reconciliation active-only filtering, copilot --skills dual-write prevention, multi-skill-agent switch+remove, and corrupted init-options fail-closed behavior for both extension add and preset add. Each test was verified to fail against the pre-fix code and pass with the fix. Targeted (883) and full (3923 passed, 109 skipped) suites pass; ruff check clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_init_options.py | 44 +++- src/specify_cli/agents.py | 6 + src/specify_cli/extensions/__init__.py | 47 ++-- src/specify_cli/presets/__init__.py | 164 ++++++++++-- .../test_integration_subcommand.py | 35 +++ tests/test_presets.py | 243 ++++++++++++++++++ 6 files changed, 502 insertions(+), 37 deletions(-) diff --git a/src/specify_cli/_init_options.py b/src/specify_cli/_init_options.py index 6acf4a000b..7aa594b0cd 100644 --- a/src/specify_cli/_init_options.py +++ b/src/specify_cli/_init_options.py @@ -3,12 +3,22 @@ import json from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import Any, Union INIT_OPTIONS_FILE = ".specify/init-options.json" +class _MissingInitOptionsFile: + """Sentinel: init-options.json does not exist at all (legacy layout).""" + + def __repr__(self) -> str: # pragma: no cover - debug aid only + return "MISSING_INIT_OPTIONS_FILE" + + +MISSING_INIT_OPTIONS_FILE = _MissingInitOptionsFile() + + def save_init_options(project_path: Path, options: dict[str, Any]) -> None: """Persist the CLI options used during ``specify init``.""" dest = project_path / INIT_OPTIONS_FILE @@ -34,3 +44,35 @@ def load_init_options(project_path: Path) -> dict[str, Any]: def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool: """Return True only when init options explicitly enable AI skills.""" return isinstance(opts, Mapping) and opts.get("ai_skills") is True + + +def resolve_active_agent_for_registration( + project_path: Path, +) -> Union[str, None, _MissingInitOptionsFile]: + """Resolve the active integration key for active-only registration (#2948). + + ``load_init_options`` collapses "no file", "unreadable/malformed file", + and "valid file with no recorded active agent" into the same ``{}`` + result, which previously made corrupted-but-present init-options behave + like a legacy pre-init-options project and fall back to registering + every detected agent. This helper distinguishes those cases explicitly: + + - Returns :data:`MISSING_INIT_OPTIONS_FILE` when init-options.json does + not exist at all (pre-init-options layout or direct library use). + Callers should fall back to detection-based registration for all + agents, matching the original pre-#2948 behavior for such projects. + - Returns ``None`` when init-options.json exists but could not provide a + valid non-empty string active agent (malformed/unreadable JSON, + non-object payload, or a non-string/empty ``ai`` value). Callers must + fail closed (register nothing) rather than treat this like "no file" + or pass a non-string key into agent-config lookups. + - Returns the active agent key (a non-empty string) otherwise. + """ + path = project_path / INIT_OPTIONS_FILE + if not path.exists(): + return MISSING_INIT_OPTIONS_FILE + + active_agent = load_init_options(project_path).get("ai") + if isinstance(active_agent, str) and active_agent: + return active_agent + return None diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 26add257fe..735032039b 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1082,6 +1082,7 @@ def register_commands_for_non_skill_agents( context_note: Optional[str] = None, link_outputs: bool = False, extension_id: Optional[str] = None, + only_agent: Optional[str] = None, ) -> Dict[str, List[str]]: """Register commands for all non-skill agents in the project. @@ -1098,6 +1099,9 @@ def register_commands_for_non_skill_agents( link_outputs: If True, create dev-mode symlinks for rendered command files when supported by the OS. extension_id: Extension id when rendering extension-owned commands. + only_agent: If set, restrict registration to this single agent + (#2948). An agent name that matches no configured agent + (e.g. an empty string) yields no registrations at all. Returns: Dictionary mapping agent names to list of registered commands @@ -1105,6 +1109,8 @@ def register_commands_for_non_skill_agents( results = {} self._ensure_configs() for agent_name, agent_config in self.AGENT_CONFIGS.items(): + if only_agent is not None and agent_name != only_agent: + continue if agent_config.get("extension") == "/SKILL.md": continue detect_dir_str = agent_config.get("detect_dir") diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 1375dd60d2..2891ad6947 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -989,29 +989,34 @@ def _register_commands_for_active_agent( when selected via ``integration use`` / ``switch`` (rescaffold). Projects without a recorded active integration at all (pre-init-options - layouts or direct library use) fall back to detection-based - registration for all agents. A *recorded* active key that has no - registrar config (e.g. ``generic``, which is deliberately excluded - from ``AGENT_CONFIGS``) is not treated as "no active integration" — - it must not cause registration to target other detected agents. - - A recorded but malformed ``ai`` value (non-string, e.g. ``[]`` or - ``null``) is also not "no active integration" — corrupted - init-options must fail closed (register nothing) rather than - fall back to registering every detected agent. + layouts or direct library use, i.e. init-options.json does not + exist) fall back to detection-based registration for all agents. A + *recorded* active key that has no registrar config (e.g. ``generic``, + which is deliberately excluded from ``AGENT_CONFIGS``) is not treated + as "no active integration" — it must not cause registration to + target other detected agents. + + An init-options.json that exists but is corrupted, unreadable, or + has a malformed/empty ``ai`` value (e.g. ``[]`` or ``null``) is also + not "no active integration" — fail closed (register nothing) rather + than fall back to registering every detected agent, which would + otherwise happen because a corrupted file loads the same as an + absent one. Returns: Mapping of agent name to registered command names, matching the ``registered_commands`` registry shape. """ from .. import load_init_options + from .._init_options import ( + MISSING_INIT_OPTIONS_FILE, + resolve_active_agent_for_registration, + ) registrar = CommandRegistrar() - init_options = load_init_options(self.project_root) - if not isinstance(init_options, dict): - init_options = {} + active_agent = resolve_active_agent_for_registration(self.project_root) - if "ai" not in init_options: + if active_agent is MISSING_INIT_OPTIONS_FILE: return registrar.register_commands_for_all_agents( manifest, extension_dir, @@ -1020,14 +1025,16 @@ def _register_commands_for_active_agent( create_missing_active_skills_dir=True, ) - active_agent = init_options.get("ai") - if not isinstance(active_agent, str) or not active_agent: - # A recorded key was found but it is malformed (not a non-empty - # string). Fail closed instead of falling back to all agents or - # passing a non-string key into AGENT_CONFIGS.get() below, which - # would raise TypeError for unhashable values like a list. + if active_agent is None: + # init-options.json exists but could not provide a valid active + # agent (corrupted/unreadable/non-object JSON, or a malformed + # "ai" value). Fail closed instead of falling back to all agents + # or passing a non-string key into AGENT_CONFIGS.get() below, + # which would raise TypeError for unhashable values like a list. return {} + init_options = load_init_options(self.project_root) + # A recorded active key with no registrar config (e.g. "generic", # deliberately excluded from AGENT_CONFIGS) has nothing to register # through this path, but it is still an active integration. Passing diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 5160831367..df76828cba 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -28,7 +28,12 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority -from .._init_options import is_ai_skills_enabled, load_init_options +from .._init_options import ( + MISSING_INIT_OPTIONS_FILE, + is_ai_skills_enabled, + load_init_options, + resolve_active_agent_for_registration, +) from ..integrations.base import IntegrationBase from .._utils import dump_frontmatter, version_satisfies from ..shared_infra import verify_archive_sha256 @@ -683,10 +688,39 @@ def _register_commands( # Single-active rule (#2948): preset command overrides register for # the active integration only. A project without a recorded active - # integration falls back to detection-based registration for all - # agents; a recorded key with no registrar config (e.g. "generic") - # naturally yields no matches via only_agent instead of falling back. - active_agent = load_init_options(self.project_root).get("ai") + # integration (init-options.json does not exist at all — a legacy + # pre-init-options layout or direct library use) falls back to + # detection-based registration for all agents. A recorded key with + # no registrar config (e.g. "generic") naturally yields no matches + # via only_agent instead of falling back. + # + # An init-options.json that exists but is corrupted, unreadable, or + # has a malformed/empty "ai" value must not be treated the same as + # "no file" — that would silently reintroduce all-agent + # registration. Fail closed (register nothing) instead. + resolved_agent = resolve_active_agent_for_registration(self.project_root) + if resolved_agent is MISSING_INIT_OPTIONS_FILE: + active_agent = None + elif resolved_agent is None: + return {} + else: + active_agent = resolved_agent + # Mirror the extension path's ai_skills guard: when the active + # agent is a command-backed integration (extension != "/SKILL.md") + # running in skills mode, its preset command overrides render as + # skills via _register_skills, not as command files. Command-mode + # and skills-mode artifacts are mutually exclusive — writing both + # (e.g. `integration use copilot` with `--skills`) leaves a stale + # command file alongside the SKILL.md that is actually active. + init_options = load_init_options(self.project_root) + agent_config = registrar.AGENT_CONFIGS.get(active_agent) + if ( + agent_config + and is_ai_skills_enabled(init_options) + and agent_config.get("extension") != "/SKILL.md" + ): + return {} + return registrar.register_commands_for_all_agents( commands_to_register, manifest.id, @@ -787,6 +821,15 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: reflect the current priority stack rather than depending on install/remove order. + Single-active rule (#2948): non-skill command-file registration + performed by this pass is restricted to the active integration, the + same as ``_register_commands``. Without this, reconciliation after + install/remove would write command files for every detected + non-skill agent even though registration itself is active-only, + leaving inactive integrations with artifacts that are never + recorded in ``registered_commands`` (and therefore never cleaned up + on removal). + Args: command_names: List of command names to reconcile """ @@ -801,6 +844,30 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: resolver = PresetResolver(self.project_root) registrar = CommandRegistrar() + # Resolve the active-only restriction once. MISSING_INIT_OPTIONS_FILE + # (legacy pre-init-options project) keeps the pre-#2948 fallback of + # registering every detected non-skill agent; a corrupted/malformed + # init-options.json fails closed via a sentinel that matches no real + # agent name instead of silently falling back to "no restriction". + resolved_agent = resolve_active_agent_for_registration(self.project_root) + if resolved_agent is MISSING_INIT_OPTIONS_FILE: + only_agent: Optional[str] = None + elif resolved_agent is None: + only_agent = "" + else: + only_agent = resolved_agent + # Mirror _register_commands's ai_skills guard: a command-backed + # active agent running in skills mode renders preset/extension + # overrides as skills, not command files, so this non-skill + # command reconciliation pass must not target it either. + agent_config = registrar.AGENT_CONFIGS.get(only_agent) + if ( + agent_config + and is_ai_skills_enabled(load_init_options(self.project_root)) + and agent_config.get("extension") != "/SKILL.md" + ): + only_agent = "" + # Cache registry and manifests outside the loop to avoid # repeated filesystem reads for each command name. presets_by_priority = list(self.registry.list_by_priority()) @@ -830,7 +897,8 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: for tmpl in manifest.templates: if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": self._register_for_non_skill_agents( - registrar, [tmpl], manifest.id, pack_dir + registrar, [tmpl], manifest.id, pack_dir, + only_agent=only_agent, ) registered = True break @@ -858,6 +926,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: matching_cmds, ext_id, ext_dir, self.project_root, context_note=f"\n\n\n", + only_agent=only_agent, ) registered = True except Exception: @@ -869,6 +938,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: self._register_command_from_path( registrar, cmd_name, top_path, source_id=source_id, + only_agent=only_agent, ) else: # Composed command — resolve from full stack @@ -919,6 +989,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: registrar, [{**tmpl, "file": f".composed/{cmd_name}.md"}], manifest.id, pack_dir, + only_agent=only_agent, ) registered = True break @@ -940,6 +1011,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: self._register_command_from_path( registrar, cmd_name, composed_file, source_id=source_id, + only_agent=only_agent, ) def _register_command_from_path( @@ -948,6 +1020,7 @@ def _register_command_from_path( cmd_name: str, cmd_path: Path, source_id: str = "reconciled", + only_agent: Optional[str] = None, ) -> None: """Register a single command from a file path (non-preset source). @@ -959,6 +1032,7 @@ def _register_command_from_path( cmd_name: Command name cmd_path: Path to the command file source_id: Source attribution for rendered output + only_agent: If set, restrict registration to this single agent (#2948). """ if not cmd_path.exists(): return @@ -988,7 +1062,8 @@ def _register_command_from_path( except Exception: pass # best-effort alias loading self._register_for_non_skill_agents( - registrar, [cmd_tmpl], source_id, cmd_path.parent + registrar, [cmd_tmpl], source_id, cmd_path.parent, + only_agent=only_agent, ) def _register_for_non_skill_agents( @@ -997,6 +1072,7 @@ def _register_for_non_skill_agents( commands: List[Dict[str, Any]], source_id: str, source_dir: Path, + only_agent: Optional[str] = None, ) -> None: """Register commands for non-skill agents during reconciliation. @@ -1010,9 +1086,15 @@ def _register_for_non_skill_agents( Writing raw command content to skill agents would produce invalid SKILL.md files (missing skill frontmatter, descriptions, etc.). + + Args: + only_agent: If set, restrict registration to this single agent, + matching the active-only rule applied by ``_register_commands`` + (#2948). """ registrar.register_commands_for_non_skill_agents( - commands, source_id, source_dir, self.project_root + commands, source_id, source_dir, self.project_root, + only_agent=only_agent, ) class _FilteredManifest: @@ -1445,6 +1527,45 @@ def _register_skills( return written + def _tracked_skill_agent_dirs(self) -> List[tuple]: + """Return (skills_dir, agent_name) pairs for every skill-mode + integration directory that currently exists under the project root. + + ``registered_skills`` only tracks skill *names*, not which agent + directories they were written under, so a preset used first under + one skill-mode agent and later switched to another can have live + overrides in both directories at removal time. Restoring every + existing skill-mode directory (instead of only the currently active + one) ensures none of them are left permanently orphaned. + + Multiple integration keys can share the same physical directory + (e.g. ``agy``/``codex``/``zed`` all use ``.agents/skills``); only one + representative agent name is kept per unique resolved directory so + each physical directory is processed exactly once. + """ + from .. import _get_skills_dir as _resolve_skills_dir + from ..integrations import INTEGRATION_REGISTRY + from ..integrations.base import SkillsIntegration + + seen: Dict[Path, str] = {} + for key in sorted(INTEGRATION_REGISTRY): + integration = INTEGRATION_REGISTRY[key] + if not ( + isinstance(integration, SkillsIntegration) + or getattr(integration, "_skills_mode", False) + ): + continue + skills_dir = _resolve_skills_dir(self.project_root, key) + if not skills_dir.is_dir(): + continue + try: + resolved = skills_dir.resolve() + except OSError: + continue + seen.setdefault(resolved, key) + + return [(path, agent) for path, agent in seen.items()] + def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: """Restore original SKILL.md files after a preset is removed. @@ -1452,6 +1573,11 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: regenerate the skill from the core command template. If no core template exists, the skill directory is removed. + Restores across every existing skill-mode agent directory (see + :meth:`_tracked_skill_agent_dirs`), not just the currently active + integration, so switching integrations before removal can't leave a + preset override behind permanently. + Args: skill_names: List of skill names written by the preset. preset_dir: The preset's installed directory (may already be deleted). @@ -1459,20 +1585,26 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: if not skill_names: return - skills_dir = self._get_skills_dir() - if not skills_dir: - return + for skills_dir, agent_name in self._tracked_skill_agent_dirs(): + self._unregister_skills_in_dir(skill_names, skills_dir, agent_name) - from .. import SKILL_DESCRIPTIONS, load_init_options + def _unregister_skills_in_dir( + self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str] + ) -> None: + """Restore original SKILL.md files within a single skills directory. + + Args: + skill_names: List of skill names written by the preset. + skills_dir: The skills directory to restore within. + selected_ai: The agent name that owns ``skills_dir``, used for + placeholder resolution and argument-hint formatting. + """ + from .. import SKILL_DESCRIPTIONS from ..agents import CommandRegistrar from ..integrations import get_integration # Locate core command templates from the project's installed templates core_templates_dir = self.project_root / ".specify" / "templates" / "commands" - init_opts = load_init_options(self.project_root) - if not isinstance(init_opts, dict): - init_opts = {} - selected_ai = init_opts.get("ai") registrar = CommandRegistrar() integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None extension_restore_index = self._build_extension_skill_restore_index() diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 32c51494b9..8720e9ea73 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1490,6 +1490,41 @@ def test_extension_add_malformed_ai_value_fails_closed(self, tmp_path): "back-fill every detected agent (#2948)" ) + def test_extension_add_corrupted_init_options_file_fails_closed(self, tmp_path): + """A present-but-unparseable init-options.json must fail closed too, + not be treated the same as "no file at all". + + ``load_init_options`` returns ``{}`` for a corrupted/unreadable + file just like it does for a missing file, so a naive "no active + agent recorded" check based on ``load_init_options`` alone can't + tell a legacy pre-init-options project (legitimate all-agent + fallback) apart from a corrupted-but-present file for a #2948 + project (must fail closed). Corrupting the file after a normal + init must not reintroduce the all-agent fallback. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + init_options_path = project / ".specify" / "init-options.json" + init_options_path.write_text("{not valid json", encoding="utf-8") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert registered == {}, ( + "a corrupted init-options.json must fail closed, not be " + "treated like a legacy project missing the file entirely (#2948)" + ) + # ── uninstall ──────────────────────────────────────────────────────── diff --git a/tests/test_presets.py b/tests/test_presets.py index 54cb9dd5b3..6c468e4447 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3063,6 +3063,57 @@ def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected): assert is_ai_skills_enabled({"ai_skills": value}) is expected +class TestResolveActiveAgentForRegistration: + """Tests for the shared #2948 active-agent resolution helper. + + ``load_init_options`` collapses "no file", "corrupted file", and + "valid file with no active agent" into the same ``{}``. Extensions and + presets both need to tell those apart: no file means "legacy project, + fall back to all detected agents"; a corrupted or malformed file means + "fail closed, register nothing" so a corrupted init-options.json can't + silently reintroduce all-agent registration. + """ + + def test_missing_file_returns_sentinel(self, project_dir): + from specify_cli._init_options import ( + MISSING_INIT_OPTIONS_FILE, + resolve_active_agent_for_registration, + ) + + assert ( + resolve_active_agent_for_registration(project_dir) + is MISSING_INIT_OPTIONS_FILE + ) + + def test_valid_active_agent_returns_string(self, project_dir): + from specify_cli import save_init_options + from specify_cli._init_options import resolve_active_agent_for_registration + + save_init_options(project_dir, {"ai": "claude"}) + + assert resolve_active_agent_for_registration(project_dir) == "claude" + + def test_corrupted_json_fails_closed(self, project_dir): + """A present-but-unparseable file must not behave like "no file".""" + from specify_cli._init_options import resolve_active_agent_for_registration + + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("{bad json", encoding="utf-8") + + assert resolve_active_agent_for_registration(project_dir) is None + + @pytest.mark.parametrize("value", [[], {}, "", 0, None, ["claude"]]) + def test_malformed_ai_value_fails_closed(self, project_dir, value): + """A recorded but non-string/empty ``ai`` value fails closed too.""" + from specify_cli import save_init_options + from specify_cli._init_options import resolve_active_agent_for_registration + + save_init_options(project_dir, {"ai": value}) + + assert resolve_active_agent_for_registration(project_dir) is None + + class TestPresetSkills: """Tests for preset skill registration and unregistration. @@ -4011,6 +4062,198 @@ def test_preset_skill_registration_handles_non_dict_init_options(self, project_d skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "untouched" in skill_content + def test_preset_add_corrupted_init_options_fails_closed(self, project_dir, temp_dir): + """Corrupted (but present) init-options.json must not back-fill every + detected agent for preset command registration. + + Before the shared ``resolve_active_agent_for_registration`` fix, + ``load_init_options`` returning ``{}`` for a corrupted file was + indistinguishable from "no file at all", so ``_register_commands`` + treated it like a legacy pre-init-options project and registered + the preset's command override for every detected agent (#2948). + """ + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text("{not valid json", encoding="utf-8") + + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "corrupt-init-preset", "speckit.specify", + "Corrupt init test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + metadata = manager.registry.get("corrupt-init-preset") + assert metadata.get("registered_commands") == {}, ( + "a corrupted init-options.json must fail closed, not " + "back-fill every detected agent (#2948)" + ) + assert not list(gemini_dir.glob("*specify*")), ( + "no command file should be written for any agent when " + "init-options.json is corrupted" + ) + + def test_reconciliation_restricted_to_active_agent(self, project_dir, temp_dir): + """Reconciliation after install/remove must also respect the + single-active rule, not just the initial registration. + + ``_reconcile_composed_commands`` (invoked after + ``install_from_directory``/``remove``) resolves composition winners + via ``register_commands_for_non_skill_agents``, a separate code + path from ``_register_commands``'s initial registration. Before the + fix it ignored the active-agent restriction entirely and wrote the + winning content for every detected non-skill agent, leaving + untracked orphaned artifacts in inactive integrations (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + # A non-replace (append) strategy command forces reconciliation to + # run register_commands_for_non_skill_agents for every non-skill + # agent directory it detects. + preset_dir = temp_dir / "reconcile-active-only" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Appended\nstrategy: append\n---\n\nAppended body\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "reconcile-active-only", + "name": "Reconcile Active Only", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "append", + }] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + assert not list(gemini_dir.glob("*specify*")), ( + "reconciliation must not write command files for a detected " + "but inactive non-skill agent (#2948)" + ) + + def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_dir): + """``integration use copilot`` with skills mode enabled must only + write the SKILL.md mirror, not also copilot's static command file. + + Copilot is command-backed (``extension: ".agent.md"``), but when + ``ai_skills`` is enabled its preset overrides are meant to render + exclusively as skills via ``_register_skills``. Before the fix, + ``_register_commands`` had no ``ai_skills`` guard (unlike the + extensions path), so both a stale ``.agent.md`` command file and + the ``SKILL.md`` mirror were written for the same override (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "copilot-skills-preset", "speckit.specify", + "Copilot skills test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + assert not list(copilot_commands_dir.glob("*specify*")), ( + "command-mode and skills-mode artifacts are mutually exclusive: " + "no .agent.md command file should be written when copilot is " + "running in skills mode (#2948)" + ) + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:copilot-skills-preset" in skill_file.read_text() + + def test_skill_switch_then_remove_restores_every_skill_agent_dir( + self, project_dir, temp_dir + ): + """Switching between two skill-mode agents before removing a preset + must restore both agents' directories, not just the currently + active one. + + ``registered_skills`` is a flat list of skill names shared across + agents, so it can't tell which agent directories a preset actually + touched. Before the fix, ``_unregister_skills`` only restored the + currently active agent's skills directory; a preset used first + under Claude and later switched to Codex would have its Claude + override left behind permanently on removal (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + # Native skill agents only materialize a *brand-new* preset skill + # when their skills directory already exists (mirrors every other + # skill test in this class); pre-create both agents' directories so + # install and the later switch both find an existing skill to + # overwrite via _register_commands/_register_skills. + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "multi-skill-agent-preset", "speckit.specify", + "Multi skill agent test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:multi-skill-agent-preset" in claude_skill.read_text() + + # Switch the active agent to codex (a different skill-mode agent) + # and re-register enabled presets for it, mirroring what + # `integration use codex` does. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:multi-skill-agent-preset" in codex_skill.read_text(), ( + "sanity: switching to codex should rescaffold the preset there" + ) + assert "preset:multi-skill-agent-preset" in claude_skill.read_text(), ( + "sanity: the previous agent's registration is preserved on switch" + ) + + assert manager.remove("multi-skill-agent-preset") is True + + for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:multi-skill-agent-preset" not in content, ( + f"{label}'s preset override must be restored on removal, " + "not orphaned permanently (#2948)" + ) + assert "Core specify body" in content + class TestPresetSetPriority: """Test preset set-priority CLI command.""" From db78903dfb321d05635859cebb0f74ad40e8b3da Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:05:57 +0200 Subject: [PATCH 05/27] fix: address fourth round of review feedback (skill registration provenance) Replace the "enumerate every skill-mode directory and restore all of them" approach from the previous round with precise per-agent provenance tracking, per reviewer feedback that the enumerate-and-restore-everything design was unsound: - registered_skills changes from a flat List[str] to Dict[str, List[str]] (agent name -> skill names actually written), mirroring the shape registered_commands already uses. _register_skills now returns this per-agent mapping instead of a bare list, and every call site (register_enabled_presets_for_agent, install_from_directory, the _reconcile_skills "was this skill previously managed" check) is updated to read/merge the new shape. Legacy flat-list registry entries from before this change are still readable: writes self-migrate the format, and _normalize_registered_skills() handles the transitional read paths. - _unregister_skills now restores exactly the agent directories recorded for a preset instead of guessing at every skill-mode integration that happens to exist on disk. This fixes two problems with the old enumerate-everything design: (1) it could silently overwrite or delete another preset's (or a user's) override in an agent directory the current preset never actually touched, and (2) it depended on transient per-process integration state (_skills_mode), which is unset in a fresh CLI invocation for mode-selectable integrations like Copilot --skills, permanently orphaning their overrides after a process restart. Registries written before this change (flat list, no agent provenance) fall back to best-effort restoration under only the currently active agent, matching the pre-existing guarantee level. - Every directory resolved from persisted provenance is now validated through the project's shared symlink/containment guard (_ensure_safe_shared_directory) before any file in it is read, written, or removed, since restoration may target an agent that isn't currently active and its directory can't be assumed safe just because a name was recorded for it. - _tracked_skill_agent_dirs() (the enumeration helper introduced last round) is removed; it's superseded by the provenance-based design. Adds regression tests: a symlinked skills directory is rejected during removal; removing one preset does not disturb a different preset's override in another agent's directory; and a Copilot --skills registration installed, then removed after switching agents in a fresh PresetManager instance (simulating a new process), is still correctly restored. Updates existing skill-registration assertions across test_presets.py and test_integration_claude.py for the new per-agent registry shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 188 +++++++++++------ tests/integrations/test_integration_claude.py | 2 +- tests/test_presets.py | 191 ++++++++++++++++-- 3 files changed, 301 insertions(+), 80 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index df76828cba..e80375033c 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -16,7 +16,7 @@ import shutil from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Optional, Dict, List, Any +from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union if TYPE_CHECKING: from ..agents import CommandRegistrar @@ -774,15 +774,14 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: updates["registered_commands"] = merged_commands registered_skills = self._register_skills(manifest, pack_dir) - if registered_skills: - existing_skills = metadata.get("registered_skills", []) - if not isinstance(existing_skills, list): - existing_skills = [] - merged_skills = list( - dict.fromkeys(existing_skills + registered_skills) - ) - if merged_skills != existing_skills: - updates["registered_skills"] = merged_skills + existing_skills = self._normalize_registered_skills( + metadata.get("registered_skills"), fallback_agent=agent_name + ) + merged_skills = copy.deepcopy(existing_skills) + if registered_skills.get(agent_name): + merged_skills[agent_name] = registered_skills[agent_name] + if merged_skills != existing_skills: + updates["registered_skills"] = merged_skills if updates: self.registry.update(pack_id, updates) @@ -1159,7 +1158,16 @@ def _reconcile_skills(self, command_names: List[str]) -> None: for _pid, meta in presets_by_priority: if not isinstance(meta, dict): continue - if skill_name in meta.get("registered_skills", []): + recorded = meta.get("registered_skills", []) + if isinstance(recorded, dict): + in_any_agent = any( + skill_name in names + for names in recorded.values() + if isinstance(names, list) + ) + else: + in_any_agent = skill_name in recorded + if in_any_agent: was_managed = True break if was_managed: @@ -1373,7 +1381,7 @@ def _register_skills( self, manifest: "PresetManifest", preset_dir: Path, - ) -> List[str]: + ) -> Dict[str, List[str]]: """Generate SKILL.md files for preset command overrides. For every command template in the preset, checks whether a @@ -1388,13 +1396,16 @@ def _register_skills( preset_dir: Installed preset directory. Returns: - List of skill names that were written (for registry storage). + ``{agent_name: [skill_name, ...]}`` for the single active + agent skills were written for (empty if none were written), + matching the shape ``registered_commands`` already uses so the + two can be tracked/restored consistently (#2948). """ command_templates = [ t for t in manifest.templates if t.get("type") == "command" ] if not command_templates: - return [] + return {} # Filter out extension command overrides if the extension isn't installed, # matching the same logic used by _register_commands(). @@ -1409,11 +1420,11 @@ def _register_skills( filtered.append(cmd) if not filtered: - return [] + return {} skills_dir = self._get_skills_dir() if not skills_dir: - return [] + return {} from .. import SKILL_DESCRIPTIONS, load_init_options from ..agents import CommandRegistrar @@ -1423,8 +1434,8 @@ def _register_skills( if not isinstance(init_opts, dict): init_opts = {} selected_ai = init_opts.get("ai") - if not isinstance(selected_ai, str): - return [] + if not isinstance(selected_ai, str) or not selected_ai: + return {} ai_skills_enabled = is_ai_skills_enabled(init_opts) registrar = CommandRegistrar() integration = get_integration(selected_ai) @@ -1525,68 +1536,115 @@ def _register_skills( skill_file.write_text(skill_content, encoding="utf-8") written.append(target_skill_name) - return written + return {selected_ai: written} if written else {} - def _tracked_skill_agent_dirs(self) -> List[tuple]: - """Return (skills_dir, agent_name) pairs for every skill-mode - integration directory that currently exists under the project root. - - ``registered_skills`` only tracks skill *names*, not which agent - directories they were written under, so a preset used first under - one skill-mode agent and later switched to another can have live - overrides in both directories at removal time. Restoring every - existing skill-mode directory (instead of only the currently active - one) ensures none of them are left permanently orphaned. - - Multiple integration keys can share the same physical directory - (e.g. ``agy``/``codex``/``zed`` all use ``.agents/skills``); only one - representative agent name is kept per unique resolved directory so - each physical directory is processed exactly once. + @staticmethod + def _normalize_registered_skills( + value: Any, fallback_agent: Optional[str] = None + ) -> Dict[str, List[str]]: + """Normalize a ``registered_skills`` registry value to per-agent form. + + The registry stores ``registered_skills`` as ``Dict[str, List[str]]`` + (agent name -> skill names actually written for that agent), + mirroring ``registered_commands``. Older registries predate that + provenance and stored a flat ``List[str]`` with no record of which + agent directory the names were written under; since that can't be + recovered, ``fallback_agent`` (when given) attributes the legacy + list to the agent currently being processed so the format + self-migrates on the next write. Without a fallback agent, legacy + lists are dropped rather than guessed at. + """ + if isinstance(value, dict): + return { + agent: list(names) + for agent, names in value.items() + if isinstance(agent, str) and isinstance(names, list) + } + if isinstance(value, list) and value and fallback_agent: + return {fallback_agent: [n for n in value if isinstance(n, str)]} + return {} + + def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]: + """Resolve ``agent_name``'s skills directory, validated for safety. + + Unlike :meth:`_get_skills_dir` (which resolves only the *currently + active* integration via init-options), this resolves an arbitrary + agent's directory from persisted provenance so a preset's skill + registrations can be restored/cleaned up under an agent that isn't + currently active. The candidate directory is validated through the + project's shared symlink/containment guard before any file in it is + touched; directories that don't exist or fail validation are + skipped rather than raising. """ from .. import _get_skills_dir as _resolve_skills_dir - from ..integrations import INTEGRATION_REGISTRY - from ..integrations.base import SkillsIntegration - - seen: Dict[Path, str] = {} - for key in sorted(INTEGRATION_REGISTRY): - integration = INTEGRATION_REGISTRY[key] - if not ( - isinstance(integration, SkillsIntegration) - or getattr(integration, "_skills_mode", False) - ): - continue - skills_dir = _resolve_skills_dir(self.project_root, key) - if not skills_dir.is_dir(): - continue - try: - resolved = skills_dir.resolve() - except OSError: - continue - seen.setdefault(resolved, key) + from ..shared_infra import _ensure_safe_shared_directory - return [(path, agent) for path, agent in seen.items()] + skills_dir = _resolve_skills_dir(self.project_root, agent_name) + try: + _ensure_safe_shared_directory( + self.project_root, skills_dir, + create=False, context="preset skills directory", + ) + except (ValueError, OSError): + return None + return skills_dir - def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: + def _unregister_skills( + self, + registered_skills: Union[Dict[str, List[str]], List[str]], + preset_dir: Path, + ) -> None: """Restore original SKILL.md files after a preset is removed. For each skill that was overridden by the preset, attempts to regenerate the skill from the core command template. If no core template exists, the skill directory is removed. - Restores across every existing skill-mode agent directory (see - :meth:`_tracked_skill_agent_dirs`), not just the currently active - integration, so switching integrations before removal can't leave a - preset override behind permanently. + ``registered_skills`` records exactly which agent directories this + preset actually wrote to (see :meth:`_register_skills`), so removal + restores precisely those directories rather than guessing at every + skill-mode agent that happens to exist on disk. Each directory is + re-resolved and safety-validated at removal time (see + :meth:`_safe_skills_dir_for_agent`) since it may belong to an agent + that isn't currently active. Args: - skill_names: List of skill names written by the preset. + registered_skills: Per-agent skill names written by the preset + (``{agent_name: [skill_name, ...]}``), or a legacy flat + ``List[str]`` from a registry written before this + provenance tracking existed. preset_dir: The preset's installed directory (may already be deleted). """ - if not skill_names: + if not registered_skills: + return + + if isinstance(registered_skills, dict): + for agent_name, skill_names in registered_skills.items(): + if not skill_names: + continue + skills_dir = self._safe_skills_dir_for_agent(agent_name) + if skills_dir is None: + continue + self._unregister_skills_in_dir(skill_names, skills_dir, agent_name) + return + + # Legacy flat-list format: no record of which agent directory these + # names were written under, so best-effort restore is limited to the + # currently active agent's directory (the pre-provenance behaviour). + skills_dir = self._get_skills_dir() + if not skills_dir: return + from .. import load_init_options - for skills_dir, agent_name in self._tracked_skill_agent_dirs(): - self._unregister_skills_in_dir(skill_names, skills_dir, agent_name) + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = init_opts.get("ai") + self._unregister_skills_in_dir( + registered_skills, + skills_dir, + selected_ai if isinstance(selected_ai, str) else None, + ) def _unregister_skills_in_dir( self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str] @@ -1760,11 +1818,11 @@ def install_from_directory( "enabled": True, "priority": priority, "registered_commands": {}, - "registered_skills": [], + "registered_skills": {}, }) registered_commands: Dict[str, List[str]] = {} - registered_skills: List[str] = [] + registered_skills: Dict[str, List[str]] = {} try: # Register command overrides with AI agents and persist the result # immediately so cleanup can recover even if installation stops diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 1b1b2308d7..7916fdeba9 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -303,7 +303,7 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): assert "disable-model-invocation: false" in content metadata = manager.registry.get("claude-skill-command") - assert "speckit-research" in metadata.get("registered_skills", []) + assert "speckit-research" in metadata.get("registered_skills", {}).get("claude", []) class TestClaudeArgumentHints: diff --git a/tests/test_presets.py b/tests/test_presets.py index 6c468e4447..bf09b87440 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3186,9 +3186,9 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): assert "preset:self-test" in content, "Skill should reference preset source" assert "disable-model-invocation: false" in content - # Verify it was recorded in registry + # Verify it was recorded in registry, keyed by the active agent metadata = manager.registry.get("self-test") - assert "speckit-specify" in metadata.get("registered_skills", []) + assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", []) def _install_arg_hint_preset(self, project_dir, temp_dir, ai, skills_dir, description, arg_hint): """Install a preset whose command declares argument-hint; return the SKILL.md path.""" @@ -3645,7 +3645,7 @@ def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): assert (skills_dir / "speckit-specify").is_file() metadata = manager.registry.get("self-test") - assert "speckit-specify" not in metadata.get("registered_skills", []) + assert "speckit-specify" not in metadata.get("registered_skills", {}).get("qwen", []) def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir): """Skills should not be created when no existing skill dir is found.""" @@ -3656,7 +3656,7 @@ def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_d install_self_test_preset(manager) metadata = manager.registry.get("self-test") - assert metadata.get("registered_skills", []) == [] + assert metadata.get("registered_skills", {}) == {} def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir): """Preset overrides for speckit.. should target speckit-- skills.""" @@ -3704,7 +3704,7 @@ def test_extension_skill_override_matches_hyphenated_multisegment_name(self, pro assert "# Speckit Fakeext Cmd Skill" in content metadata = manager.registry.get("ext-skill-override") - assert "speckit-fakeext-cmd" in metadata.get("registered_skills", []) + assert "speckit-fakeext-cmd" in metadata.get("registered_skills", {}).get("codex", []) def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): """Preset removal should restore an extension-backed skill instead of deleting it.""" @@ -3867,7 +3867,7 @@ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp assert "name: speckit.specify" in content metadata = manager.registry.get("self-test") - assert "speckit.specify" in metadata.get("registered_skills", []) + assert "speckit.specify" in metadata.get("registered_skills", {}).get("kimi", []) def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi presets should still propagate command overrides to existing skills.""" @@ -3887,7 +3887,7 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp assert "name: speckit-specify" in content metadata = manager.registry.get("self-test") - assert "speckit-specify" in metadata.get("registered_skills", []) + assert "speckit-specify" in metadata.get("registered_skills", {}).get("kimi", []) def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi native skills should still receive brand-new preset commands.""" @@ -3936,7 +3936,7 @@ def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, assert "name: speckit-research" in content metadata = manager.registry.get("kimi-new-skill") - assert "speckit-research" in metadata.get("registered_skills", []) + assert "speckit-research" in metadata.get("registered_skills", {}).get("kimi", []) def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" @@ -4192,12 +4192,14 @@ def test_skill_switch_then_remove_restores_every_skill_agent_dir( must restore both agents' directories, not just the currently active one. - ``registered_skills`` is a flat list of skill names shared across - agents, so it can't tell which agent directories a preset actually - touched. Before the fix, ``_unregister_skills`` only restored the - currently active agent's skills directory; a preset used first - under Claude and later switched to Codex would have its Claude - override left behind permanently on removal (#2948). + ``registered_skills`` records exactly which agent directories the + preset wrote to (``{agent_name: [skill_name, ...]}``); switching to + codex and re-registering adds a "codex" entry alongside the + original "claude" entry, so removal restores both. Before the + provenance fix, ``_unregister_skills`` only restored the currently + active agent's skills directory; a preset used first under Claude + and later switched to Codex would have its Claude override left + behind permanently on removal (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) @@ -4243,6 +4245,13 @@ def test_skill_switch_then_remove_restores_every_skill_agent_dir( "sanity: the previous agent's registration is preserved on switch" ) + metadata = manager.registry.get("multi-skill-agent-preset") + registered_skills = metadata.get("registered_skills", {}) + assert set(registered_skills) == {"claude", "codex"}, ( + "registered_skills must record both agent directories this " + "preset actually wrote to (#2948)" + ) + assert manager.remove("multi-skill-agent-preset") is True for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): @@ -4254,6 +4263,160 @@ def test_skill_switch_then_remove_restores_every_skill_agent_dir( ) assert "Core specify body" in content + def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir): + """Removal must validate a recorded skill directory before touching it. + + If an agent's skills directory is replaced with a symlink escaping + the project root between install and removal, restoration must + refuse to write/rmtree through it rather than trusting the + recorded agent name blindly. The unsafe directory is skipped + best-effort; removal still succeeds and doesn't crash (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "symlink-guard-preset", "speckit.specify", + "Symlink guard test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + metadata = manager.registry.get("symlink-guard-preset") + assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", []) + + # Simulate the claude skills directory being replaced with a symlink + # that escapes the project root, containing an external + # "speckit-specify" directory that must not be touched. + outside_target = temp_dir / "outside-claude-skills" + outside_skill_dir = outside_target / "speckit-specify" + outside_skill_dir.mkdir(parents=True) + sentinel = outside_skill_dir / "SKILL.md" + sentinel.write_text("do-not-touch") + shutil.rmtree(claude_skills_dir) + claude_skills_dir.symlink_to(outside_target, target_is_directory=True) + + assert manager.remove("symlink-guard-preset") is True + + assert sentinel.read_text() == "do-not-touch", ( + "removal must not follow a symlinked skills directory outside " + "the project root (#2948)" + ) + assert outside_skill_dir.is_dir(), ( + "the external directory must not be rmtree'd through a " + "symlinked skills path" + ) + assert claude_skills_dir.is_symlink(), ( + "the symlink itself should be left alone, not rmtree'd through" + ) + + def test_preset_removal_does_not_touch_other_presets_skill_dir( + self, project_dir, temp_dir + ): + """Removing a preset must only touch directories it actually wrote to. + + Preset A is installed while Claude is active and preset B is + installed while Codex is active; both override the same command + name, so both materialize a ``speckit-specify`` skill, but in + *different* agent directories. Before the provenance fix, removing + B enumerated every existing skill-mode directory (including + Claude's) and restored/overwrote anything named ``speckit-specify`` + found there, corrupting A's override even though B never touched + Claude's directory (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + + preset_a_dir = self._create_command_preset( + temp_dir, "preset-a", "speckit.specify", "Preset A", "preset A body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_a_dir, "0.1.5") + + claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:preset-a" in claude_skill_file.read_text() + + # Switch to codex and install a second preset overriding the same + # command; codex's skills directory is entirely separate. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_b_dir = self._create_command_preset( + temp_dir, "preset-b", "speckit.specify", "Preset B", "preset B body", + ) + manager.install_from_directory(preset_b_dir, "0.1.5") + + metadata_b = manager.registry.get("preset-b") + assert "claude" not in metadata_b.get("registered_skills", {}), ( + "preset B never wrote to claude's skills directory and must " + "not record it as touched" + ) + + assert manager.remove("preset-b") is True + + assert "preset:preset-a" in claude_skill_file.read_text(), ( + "removing preset B must not disturb preset A's Claude override (#2948)" + ) + + def test_copilot_skills_registration_restored_after_process_restart( + self, project_dir, temp_dir + ): + """Copilot skills-mode registrations must restore even when the + transient ``_skills_mode`` integration attribute has been reset, + simulating a fresh CLI process. + + ``_skills_mode`` is set during ``setup()`` and is never persisted; + after switching the active agent and running ``preset remove`` in + a brand-new process, a naive "is this integration currently in + skills mode" check would be False even though Copilot's + ``.github/skills`` directory holds a live override this preset + wrote. Restoration must rely on the persisted per-agent provenance + recorded at write time, not on runtime integration state (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + copilot_skills_dir = project_dir / ".github" / "skills" + self._create_skill(copilot_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "copilot-fresh-process-preset", "speckit.specify", + "Copilot fresh process test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = copilot_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:copilot-fresh-process-preset" in skill_file.read_text() + + metadata = manager.registry.get("copilot-fresh-process-preset") + assert "copilot" in metadata.get("registered_skills", {}) + + # Switch the active agent away from copilot, then simulate a fresh + # CLI process (a brand-new PresetManager, so any transient + # `_skills_mode` state set during a prior setup() call is gone) + # removing the preset. + self._write_init_options(project_dir, ai="claude", ai_skills=True) + fresh_manager = PresetManager(project_dir) + + assert fresh_manager.remove("copilot-fresh-process-preset") is True + + assert "preset:copilot-fresh-process-preset" not in skill_file.read_text(), ( + "removal must restore copilot's .github/skills override even " + "when copilot's transient skills-mode state isn't set in this " + "process (#2948)" + ) + assert "Core specify body" in skill_file.read_text() + class TestPresetSetPriority: """Test preset set-priority CLI command.""" From e5d55535d5923c98862a504d5912c6202a2de5c3 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:24:43 +0200 Subject: [PATCH 06/27] fix: address fifth round of review feedback (symlink presence, rescaffold reconciliation, shared skills dir) - _init_options.py: resolve_active_agent_for_registration() now treats a dangling init-options.json symlink as present (path.is_symlink() check alongside path.exists()), since Path.exists() follows symlinks and returns False for a broken one. Previously a broken symlink fell back to the legacy "no file" path and registered every detected agent instead of failing closed. - presets/__init__.py (register_enabled_presets_for_agent): the integration use/switch rescaffold path now collects affected command names across all presets processed and runs _reconcile_composed_commands/_reconcile_skills once after the loop, matching install/remove. Previously rescaffolding wrote each preset's raw content directly with no follow-up reconciliation, so a project-level override (the highest-priority layer) could be clobbered by a lower-precedence preset after switching agents. - presets/__init__.py (_unregister_skills): multiple integrations can share one physical skills directory (agy/codex/zed all resolve to .agents/skills). Provenance restoration now groups recorded agent entries by resolved directory and restores each physical directory exactly once, preferring the currently active agent's renderer when it owns that directory (otherwise any recorded owner, chosen deterministically). Previously each recorded agent key triggered its own restore pass against the same directory, with whichever agent was iterated last silently winning regardless of which agent was active. Adds regression tests for each: a dangling init-options.json symlink failing closed for both preset resolution and extension add; integration use rescaffold preserving a project override over a lower-priority preset; and a codex/agy shared-directory removal restoring the directory exactly once in the active agent's format. Targeted (tests/integrations/test_integration_subcommand.py, tests/test_presets.py, tests/test_extensions.py, tests/test_extension_skills.py, tests/integrations/test_integration_opencode.py, tests/integrations/test_integration_claude.py): 930 passed. Full suite: 3930 passed, 109 skipped. ruff check: clean on files touched by this change. Refs #2948 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_init_options.py | 7 +- src/specify_cli/presets/__init__.py | 60 ++++++- .../test_integration_subcommand.py | 35 +++++ tests/test_presets.py | 148 ++++++++++++++++++ 4 files changed, 248 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/_init_options.py b/src/specify_cli/_init_options.py index 7aa594b0cd..7b6917ef85 100644 --- a/src/specify_cli/_init_options.py +++ b/src/specify_cli/_init_options.py @@ -69,7 +69,12 @@ def resolve_active_agent_for_registration( - Returns the active agent key (a non-empty string) otherwise. """ path = project_path / INIT_OPTIONS_FILE - if not path.exists(): + # A dangling symlink's target doesn't exist, so Path.exists() (which + # follows symlinks) returns False even though the path itself is + # present as a broken/corrupted entry. Treat any symlink as "present" + # so a dangling one fails closed via the invalid-file branch below + # instead of being mistaken for "no file at all" (legacy fallback). + if not path.is_symlink() and not path.exists(): return MISSING_INIT_OPTIONS_FILE active_agent = load_init_options(project_path).get("ai") diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index e80375033c..550f7fa772 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -752,6 +752,7 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: return resolver = PresetResolver(self.project_root) + affected_cmd_names: set = set() for pack_id, metadata in reversed(self.registry.list_by_priority()): pack_dir = self.presets_dir / pack_id manifest = resolver._get_manifest(pack_dir) @@ -785,6 +786,10 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: if updates: self.registry.update(pack_id, updates) + + for tmpl in manifest.templates: + if tmpl.get("type") == "command": + affected_cmd_names.add(tmpl["name"]) except Exception as pack_err: from .. import _print_cli_warning @@ -797,6 +802,29 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: ) continue + # _register_commands/_register_skills write each preset's own + # content directly and rely on reconciliation to resolve the final + # winner across the *entire* priority stack (including project + # overrides, which always outrank presets). install/remove already + # call this; without it here, rescaffolding on `integration use` / + # `switch` could leave the highest-precedence preset's raw content + # in place even when a project override or a higher-precedence + # preset should win (#2948). + if affected_cmd_names: + try: + self._reconcile_composed_commands(list(affected_cmd_names)) + self._reconcile_skills(list(affected_cmd_names)) + except Exception as exc: + import warnings + + warnings.warn( + f"Post-rescaffold reconciliation failed for '{agent_name}': " + f"{exc}. Agent command files may be stale; re-run " + f"'specify integration use {agent_name}' or reinstall " + f"affected presets to refresh.", + stacklevel=2, + ) + def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: """Remove previously registered command files from agent directories. @@ -1619,13 +1647,43 @@ def _unregister_skills( return if isinstance(registered_skills, dict): + from .. import load_init_options + + init_opts = load_init_options(self.project_root) + active_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None + if not isinstance(active_agent, str) or not active_agent: + active_agent = None + + # Multiple integration keys can share the same physical + # directory (e.g. agy/codex/zed all resolve to + # ``.agents/skills``). Restoring that directory once per + # recorded agent would have each pass's agent-specific + # rendering (frontmatter, post-processing) overwrite the + # previous one, with whichever agent is iterated *last* silently + # winning regardless of which agent is actually active. Group + # provenance by resolved directory so each physical directory is + # restored exactly once, using the active agent's renderer when + # it shares that directory (otherwise any recorded owner, + # chosen deterministically). + groups: Dict[Path, Dict[str, Any]] = {} for agent_name, skill_names in registered_skills.items(): if not skill_names: continue skills_dir = self._safe_skills_dir_for_agent(agent_name) if skills_dir is None: continue - self._unregister_skills_in_dir(skill_names, skills_dir, agent_name) + group = groups.setdefault(skills_dir, {"agents": [], "names": []}) + group["agents"].append(agent_name) + for name in skill_names: + if name not in group["names"]: + group["names"].append(name) + + for skills_dir, group in groups.items(): + agents = group["agents"] + renderer_agent = ( + active_agent if active_agent in agents else sorted(agents)[0] + ) + self._unregister_skills_in_dir(group["names"], skills_dir, renderer_agent) return # Legacy flat-list format: no record of which agent directory these diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 8720e9ea73..92b0212d11 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1525,6 +1525,41 @@ def test_extension_add_corrupted_init_options_file_fails_closed(self, tmp_path): "treated like a legacy project missing the file entirely (#2948)" ) + def test_extension_add_dangling_init_options_symlink_fails_closed(self, tmp_path): + """A dangling init-options.json symlink must fail closed too, not be + treated the same as "no file at all". + + ``Path.exists()`` follows symlinks and returns False for a broken + symlink whose target doesn't exist, so a naive presence check based + on ``Path.exists()`` alone mistakes a dangling symlink for "no file" + and falls back to registering every detected agent. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + init_options_path = project / ".specify" / "init-options.json" + init_options_path.unlink() + init_options_path.symlink_to(project / ".specify" / "does-not-exist.json") + assert not init_options_path.exists() # sanity: dangling + assert init_options_path.is_symlink() + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert registered == {}, ( + "a dangling init-options.json symlink must fail closed, not be " + "treated like a legacy project missing the file entirely (#2948)" + ) + # ── uninstall ──────────────────────────────────────────────────────── diff --git a/tests/test_presets.py b/tests/test_presets.py index bf09b87440..25172a09b5 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3113,6 +3113,26 @@ def test_malformed_ai_value_fails_closed(self, project_dir, value): assert resolve_active_agent_for_registration(project_dir) is None + def test_dangling_symlink_fails_closed(self, project_dir): + """A dangling init-options.json symlink must fail closed, not fall + back to "no file" (#2948). + + ``Path.exists()`` follows symlinks and returns False for a broken + symlink whose target is missing, so a naive presence check treats a + dangling symlink the same as "no file at all" and falls back to + legacy all-agent registration. The path is present (just broken), + so it must be treated as a corrupted file and fail closed instead. + """ + from specify_cli._init_options import resolve_active_agent_for_registration + + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.symlink_to(project_dir / ".specify" / "does-not-exist.json") + + assert not opts_file.exists() # sanity: this is what makes it dangling + assert opts_file.is_symlink() + assert resolve_active_agent_for_registration(project_dir) is None + class TestPresetSkills: """Tests for preset skill registration and unregistration. @@ -4152,6 +4172,53 @@ def test_reconciliation_restricted_to_active_agent(self, project_dir, temp_dir): "but inactive non-skill agent (#2948)" ) + def test_use_rescaffold_reconciles_project_override(self, project_dir, temp_dir): + """``integration use``/``switch`` rescaffolding must reconcile the + full priority stack, not just write each preset's own content. + + Project overrides are the highest-priority layer, above every + preset. ``register_enabled_presets_for_agent`` (invoked by + ``integration use``/``switch``) calls ``_register_commands`` for + each enabled preset directly, the same as ``install_from_directory`` + — but unlike install/remove, it never followed up with + ``_reconcile_composed_commands``. Before the fix, rescaffolding a + newly activated agent could leave the preset's raw content in + place instead of resolving the real winner (the project override) + from the full stack (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override specify\n---\n\nOverride body\n", + encoding="utf-8", + ) + + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "use-reconcile-preset", "speckit.specify", + "Preset specify", "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Simulate `integration use gemini`: switch the active agent and + # rescaffold enabled presets for it, mirroring what the CLI does. + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + manager.register_enabled_presets_for_agent("gemini") + + cmd_file = gemini_dir / "speckit.specify.toml" + assert cmd_file.exists(), "sanity: gemini should get a command file at all" + content = cmd_file.read_text() + assert "Override body" in content, ( + "the project override must still win after rescaffold " + "reconciliation, not the preset's raw content (#2948)" + ) + assert "Preset body" not in content + def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_dir): """``integration use copilot`` with skills mode enabled must only write the SKILL.md mirror, not also copilot's static command file. @@ -4362,6 +4429,87 @@ def test_preset_removal_does_not_touch_other_presets_skill_dir( "removing preset B must not disturb preset A's Claude override (#2948)" ) + def test_shared_skills_dir_restored_once_using_active_agent( + self, project_dir, temp_dir + ): + """Removal must restore a physical skills directory shared by + multiple agents exactly once, using the active agent's renderer. + + Codex and Antigravity (agy) both resolve their skills directory to + ``.agents/skills``. Registering a preset under codex, switching to + agy, then switching back to codex records provenance for *both* + agent keys even though they share one physical directory. Before + the fix, ``_unregister_skills`` restored once per recorded agent + key rather than once per unique directory, so the directory was + written twice on removal with whichever agent was iterated *last* + silently winning — regardless of which agent is actually active + (#2948). + """ + self._write_init_options(project_dir, ai="codex", ai_skills=True) + shared_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(shared_skills_dir, "speckit-specify") + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_dir = self._create_command_preset( + temp_dir, "shared-dir-preset", "speckit.specify", + "Shared dir test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Switch to agy (shares .agents/skills with codex) and back to + # codex, mirroring `integration use agy` then `integration use + # codex`. Both agent keys end up recorded in registered_skills even + # though they refer to the same physical directory. + self._write_init_options(project_dir, ai="agy", ai_skills=True) + manager.register_enabled_presets_for_agent("agy") + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + metadata = manager.registry.get("shared-dir-preset") + registered_skills = metadata.get("registered_skills", {}) + assert set(registered_skills) == {"codex", "agy"}, ( + "both agent keys must be recorded even though they share one " + "physical directory (#2948)" + ) + + from unittest.mock import patch + + # Exercise `_unregister_skills` directly (the method this fix + # changed) rather than the full `remove()` flow, which separately + # triggers post-removal reconciliation that may also touch the + # active agent's directory — an unrelated call this test isn't + # targeting. + with patch.object( + manager, + "_unregister_skills_in_dir", + wraps=manager._unregister_skills_in_dir, + ) as spy: + manager._unregister_skills(registered_skills, preset_dir) + + assert spy.call_count == 1, ( + "a physical directory shared by multiple recorded agents must " + "be restored exactly once, not once per agent key (#2948)" + ) + (_names, called_dir, called_agent), _kwargs = spy.call_args + assert called_dir == shared_skills_dir + assert called_agent == "codex", ( + "the currently active agent must be used as the renderer when " + "it shares the restored directory, not whichever agent was " + "recorded last (#2948)" + ) + + skill_file = shared_skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "preset:shared-dir-preset" not in content + assert "Core specify body" in content + def test_copilot_skills_registration_restored_after_process_restart( self, project_dir, temp_dir ): From fc7b2e2c8050fcaf44009dbafe67a8b97fd71244 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:47:38 +0200 Subject: [PATCH 07/27] fix: guard skill subdirectories and active-agent scoping in preset reconciliation Fix 4 issues from round-6 review of the active-only integration registration work (#2948): - remove(): removed_cmd_names only collected primary command names from registered_commands + manifest aliases, missing commands that were only ever registered via skills mode (ai_skills guard returns no command names for command-backed integrations in skills mode). This skipped reconciliation entirely when removing a higher-priority skills-mode preset, causing _unregister_skills() to fall back to core/extension content instead of the surviving lower-priority preset's override. Now every command template's primary name is added to removed_cmd_names unconditionally. - _reconcile_composed_commands(): the "composed is None" branch (fires when no replace-strategy layer remains for a command, e.g. after removing a wrap/append preset's base) called unregister_commands() across every configured non-skill agent, ignoring only_agent. This deleted historical artifacts from integrations that were never active for the preset. Now filtered by only_agent like the rest of the file. - Added _validate_skill_subdir() helper (reusing _ensure_safe_shared_directory/_validate_safe_shared_directory from shared_infra.py) and applied it at every site that reads or writes an individual skill subdirectory (_register_skills, _unregister_skills_in_dir, _reconcile_skills' override_skills restoration loop). _safe_skills_dir_for_agent only validated the parent skills directory; a symlinked leaf subdirectory (e.g. .claude/skills/speckit-specify) would slip past that check since is_dir()/exists() follow symlinks, letting write_text/rmtree operate through it to an arbitrary location outside the project. Added regression tests: removing a higher-priority skills-only preset restores the surviving lower-priority preset's content; composed-is-None unregistration only touches the active agent; symlinked skill subdirectory rejected on restore; symlinked skill subdirectory rejected on write. Targeted (934) and full (3934 passed, 109 skipped) test suites and ruff check pass clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 78 ++++++++++- tests/test_presets.py | 204 ++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 550f7fa772..cdc15a19f9 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -992,9 +992,18 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: if isinstance(alias, str): cmd_names_to_unregister.append(alias) break + # Mirror the active-only restriction used elsewhere in + # this pass: without it, unregistering a stale composed + # command would touch every non-skill agent's directory, + # deleting historical artifacts from integrations that + # were never active when this preset registered (#2948). registrar.unregister_commands( - {agent: cmd_names_to_unregister for agent in registrar.AGENT_CONFIGS - if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"}, + { + agent: cmd_names_to_unregister + for agent in registrar.AGENT_CONFIGS + if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md" + and (only_agent is None or agent == only_agent) + }, self.project_root, ) continue @@ -1236,7 +1245,12 @@ def _reconcile_skills(self, command_names: List[str]) -> None: for skill_name, cmd_name, top_layer in override_skills: skill_subdir = skills_dir / skill_name - skill_subdir.mkdir(parents=True, exist_ok=True) + # Same symlink guard as _register_skills's registration path + # (#2948): mkdir(exist_ok=True) alone would silently follow an + # existing symlinked subdirectory before writing SKILL.md + # through it. + if not self._validate_skill_subdir(skill_subdir, create=True): + continue skill_file = skill_subdir / "SKILL.md" try: from ..agents import CommandRegistrar @@ -1539,7 +1553,13 @@ def _register_skills( skill_subdir = skills_dir / target_skill_name if skill_subdir.exists() and not skill_subdir.is_dir(): continue - skill_subdir.mkdir(parents=True, exist_ok=True) + # Validate (and create, if missing) the skill's own + # subdirectory under the same symlink guard as its parent — + # is_dir() above follows symlinks, so a symlinked subdir + # with a real parent would otherwise slip through and have + # SKILL.md written through it to an arbitrary location (#2948). + if not self._validate_skill_subdir(skill_subdir, create=True): + continue frontmatter_data = registrar.build_skill_frontmatter( selected_ai, target_skill_name, @@ -1617,6 +1637,35 @@ def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]: return None return skills_dir + def _validate_skill_subdir(self, skill_subdir: Path, *, create: bool) -> bool: + """Validate a single skill's subdirectory is symlink-free. + + Unlike :meth:`_safe_skills_dir_for_agent` (which only validates the + *parent* skills directory), this validates the skill's own + subdirectory — e.g. ``.claude/skills/speckit-specify`` — so a + symlink planted at that level (with a safe parent) can't be used to + write or delete through to a location outside the project. Shared by + both the registration path (``create=True``, so a missing directory + is created component-by-component under the same guard) and the + restore/removal path (``create=False``, so a missing directory is + left for the caller's own existence check to skip). Returns + ``False`` rather than raising when the path escapes the project + root or crosses a symlink. + """ + from ..shared_infra import _ensure_safe_shared_directory, _validate_safe_shared_directory + + try: + if create: + _ensure_safe_shared_directory( + self.project_root, skill_subdir, + create=True, context="preset skill directory", + ) + else: + _validate_safe_shared_directory(self.project_root, skill_subdir) + except (ValueError, OSError): + return False + return True + def _unregister_skills( self, registered_skills: Union[Dict[str, List[str]], List[str]], @@ -1737,6 +1786,12 @@ def _unregister_skills_in_dir( skill_file = skill_subdir / "SKILL.md" if not skill_subdir.is_dir(): continue + # is_dir() follows symlinks, so a symlinked skill subdirectory + # (with a safe, non-symlinked parent) would otherwise slip past + # _safe_skills_dir_for_agent's parent-only check and have + # write_text/rmtree operate through it (#2948). + if not self._validate_skill_subdir(skill_subdir, create=False): + continue if not skill_file.is_file(): # Only manage directories that contain the expected skill entrypoint. continue @@ -2020,9 +2075,15 @@ def remove(self, pack_id: str) -> bool: pack_dir = self.presets_dir / pack_id # Collect ALL command names before filtering for reconciliation, - # so commands registered only for skill-based agents are also reconciled. - # Also include aliases from the manifest as a safety net for registries - # populated by older versions that may not track aliases. + # so commands registered only for skill-based agents are also + # reconciled. Every command-type template's primary name is added + # unconditionally (not just aliases) since ai_skills-mode presets + # never populate registered_commands for command-backed + # integrations (see _register_commands's ai_skills guard) — without + # this, removing a skills-mode preset that overrides a command no + # other preset registered "the normal way" would skip reconciliation + # entirely and _unregister_skills would restore core/extension + # content instead of a surviving lower-priority preset's override. removed_cmd_names = set() for cmd_names in registered_commands.values(): removed_cmd_names.update(cmd_names) @@ -2032,6 +2093,9 @@ def remove(self, pack_id: str) -> bool: manifest = PresetManifest(manifest_path) for tmpl in manifest.templates: if tmpl.get("type") == "command": + name = tmpl.get("name") + if isinstance(name, str): + removed_cmd_names.add(name) for alias in tmpl.get("aliases", []): if isinstance(alias, str): removed_cmd_names.add(alias) diff --git a/tests/test_presets.py b/tests/test_presets.py index 25172a09b5..48ee39d8ea 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4510,6 +4510,210 @@ def test_shared_skills_dir_restored_once_using_active_agent( assert "preset:shared-dir-preset" not in content assert "Core specify body" in content + def test_remove_higher_priority_skills_only_preset_restores_lower_preset( + self, project_dir, temp_dir + ): + """Removing a skills-mode preset must reconcile against the surviving + stack, not fall back to core/extension content. + + Copilot in skills mode never populates ``registered_commands`` for + its overrides (``_register_commands``'s ``ai_skills`` guard skips + command-file registration entirely), so with two presets overriding + the same command, the removed preset's command name was never added + to ``removed_cmd_names`` and reconciliation was skipped outright. + ``_unregister_skills`` then restored straight to core/extension + content instead of resolving the lower-priority preset that should + now win (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + preset_a_dir = self._create_command_preset( + temp_dir, "skills-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + preset_b_dir = self._create_command_preset( + temp_dir, "skills-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + + manager = PresetManager(project_dir) + # Lower priority number = higher precedence. + manager.install_from_directory(preset_a_dir, "0.1.5", priority=5) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + + skills_dir = project_dir / ".github" / "skills" + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:skills-preset-a" in skill_file.read_text(), ( + "sanity: the higher-precedence preset should win initially" + ) + + assert manager.remove("skills-preset-a") is True + + content = skill_file.read_text() + assert "preset:skills-preset-b" in content, ( + "removing the higher-precedence skills-mode preset must " + "restore the surviving lower-precedence preset's override, " + "not fall back to core/extension content (#2948)" + ) + assert "preset:skills-preset-a" not in content + + def test_composed_none_unregister_respects_active_agent( + self, project_dir, temp_dir + ): + """Unregistering a stale composed command must only touch the + active agent's directory, not every configured non-skill agent. + + When a wrap preset's base layer is removed, ``resolve_content`` can + no longer find a replace layer to compose onto and returns + ``None``, triggering the "composed is None" branch of + ``_reconcile_composed_commands``. Before the fix, that + unregistration mapping covered every configured non-skill agent + regardless of ``only_agent``, deleting historical artifacts from + integrations that were never active for this preset (#2948). + """ + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_commands_dir = project_dir / ".gemini" / "commands" + gemini_commands_dir.mkdir(parents=True) + + # A made-up command name with no bundled/core equivalent, so the + # *only* base layer is the "compose-base" preset installed below — + # once it's removed, no base remains for the wrap preset to compose + # onto. + cmd_name = "speckit.fake-compose-test" + base_dir = self._create_command_preset( + temp_dir, "compose-base", cmd_name, "Base", "base body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(base_dir, "0.1.5", priority=10) + + wrap_dir = temp_dir / "compose-wrap" + wrap_dir.mkdir() + (wrap_dir / "commands").mkdir() + (wrap_dir / "commands" / f"{cmd_name}.md").write_text( + "---\ndescription: Wrap\nstrategy: wrap\n---\n\n" + "wrap start\n{CORE_TEMPLATE}\nwrap end\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "compose-wrap", + "name": "compose-wrap", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": cmd_name, + "file": f"commands/{cmd_name}.md", + "strategy": "wrap", + } + ] + }, + } + with open(wrap_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + manager.install_from_directory(wrap_dir, "0.1.5", priority=5) + + claude_dir = project_dir / ".gemini" / "commands" + cmd_file = claude_dir / f"{cmd_name}.toml" + assert cmd_file.exists(), ( + "sanity: the composed command should register for the active agent" + ) + + # Simulate a pre-existing artifact for an inactive agent, predating + # this preset entirely — active-only unregistration must never + # touch it. + opencode_dir = project_dir / ".opencode" / "commands" + opencode_dir.mkdir(parents=True, exist_ok=True) + opencode_stale_file = opencode_dir / f"{cmd_name}.md" + opencode_stale_file.write_text("stale opencode content\n") + + assert manager.remove("compose-base") is True + + assert not cmd_file.exists(), ( + "sanity: the active agent's now-uncomposable command file must " + "be unregistered" + ) + assert opencode_stale_file.read_text() == "stale opencode content\n", ( + "unregistering a stale composed command must not touch an " + "inactive agent's directory (#2948)" + ) + + def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir): + """Restore must validate each per-skill subdirectory, not just its parent. + + ``_safe_skills_dir_for_agent`` only validates the parent skills + directory (e.g. ``.claude/skills``); a symlink planted one level + deeper at the individual skill's own subdirectory (e.g. + ``.claude/skills/speckit-specify``) has a perfectly safe parent and + would otherwise slip past that check, since ``is_dir()`` follows + symlinks. Restoration must refuse to write/rmtree through it (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + claude_skills_dir.mkdir(parents=True) + + outside_target = temp_dir / "outside-skill-subdir" + outside_target.mkdir() + sentinel = outside_target / "SKILL.md" + sentinel.write_text("do-not-touch") + (claude_skills_dir / "speckit-specify").symlink_to( + outside_target, target_is_directory=True + ) + + manager = PresetManager(project_dir) + manager._unregister_skills_in_dir( + ["speckit-specify"], claude_skills_dir, "claude" + ) + + assert sentinel.read_text() == "do-not-touch", ( + "restoration must not follow a symlinked skill subdirectory " + "to write/delete outside the project (#2948)" + ) + assert (claude_skills_dir / "speckit-specify").is_symlink(), ( + "the symlink itself should be left alone, not rmtree'd through" + ) + + def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir): + """Registration must validate each per-skill subdirectory before writing. + + A symlink planted at an individual skill's own subdirectory (safe + parent, unsafe leaf) would otherwise pass the existing + ``skill_subdir.exists() and not skill_subdir.is_dir()`` guard + (``is_dir()`` follows symlinks) and have ``SKILL.md`` written + through it to an arbitrary location (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + skills_dir.mkdir(parents=True) + + outside_target = temp_dir / "outside-skill-write-target" + outside_target.mkdir() + (skills_dir / "speckit-specify").symlink_to( + outside_target, target_is_directory=True + ) + + preset_dir = self._create_command_preset( + temp_dir, "symlink-write-preset", "speckit.specify", + "Symlink write test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + assert not (outside_target / "SKILL.md").exists(), ( + "registration must not follow a symlinked skill subdirectory " + "to write outside the project (#2948)" + ) + assert (skills_dir / "speckit-specify").is_symlink(), ( + "the symlink itself should be left alone" + ) + def test_copilot_skills_registration_restored_after_process_restart( self, project_dir, temp_dir ): From 5b0e55946dabba4d59782ad427651d88e4e3aa7c Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:35:52 +0200 Subject: [PATCH 08/27] fix: persist command registration before fallible skills phase on rescaffold Fix remaining round-6 review findings on the active-only integration registration work (#2948): - register_enabled_presets_for_agent(): registered_commands and registered_skills were merged and persisted together in a single registry.update() call after both the commands and skills phases ran. If _register_skills() raised, the per-preset try/except swallowed it before that update() call was reached, even though _register_commands() had already written a real command file to disk. That file became untracked, so preset removal could no longer clean it up. install_from_directory() already persists registered_commands immediately after the commands phase, before starting the independently fallible skills phase; rescaffold now does the same. - test_presets.py: renamed a misleading claude_dir variable (pointing at Gemini's command directory) in test_composed_none_unregister_respects_active_agent to reuse the existing gemini_commands_dir variable already defined earlier in the same test. Added regression test test_rescaffold_persists_commands_before_fallible_skills_phase: simulates a skills-phase failure during rescaffold and asserts the command file already written to disk is still tracked in registered_commands. Verified all other round-6 findings (preset active-integration scoping, preset reconciliation/remove paths, skills-mode switching, override precedence during rescaffold, skill-subdirectory symlink safety) are already addressed by prior commits in this branch; re-checked each against current code before concluding no further change was needed. Targeted (tests/test_presets.py, tests/test_extensions.py: 689 passed) and full (3935 passed, 109 skipped) suites and ruff check on changed files pass clean. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 14 +++---- tests/test_presets.py | 58 ++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index cdc15a19f9..1e83c01480 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -762,8 +762,6 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: # Isolate per-preset failures: one preset that fails to register # must not abort registration of the remaining enabled presets. try: - updates: Dict[str, Any] = {} - registered_commands = self._register_commands(manifest, pack_dir) existing_commands = metadata.get("registered_commands", {}) if not isinstance(existing_commands, dict): @@ -771,8 +769,13 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: merged_commands = copy.deepcopy(existing_commands) if registered_commands.get(agent_name): merged_commands[agent_name] = registered_commands[agent_name] + # Persist the commands phase immediately, mirroring + # install_from_directory(): _register_skills is an + # independently fallible phase, and if it raises, the files + # the commands phase already wrote to disk must still be + # tracked so preset removal can clean them up (#2948). if merged_commands != existing_commands: - updates["registered_commands"] = merged_commands + self.registry.update(pack_id, {"registered_commands": merged_commands}) registered_skills = self._register_skills(manifest, pack_dir) existing_skills = self._normalize_registered_skills( @@ -782,10 +785,7 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: if registered_skills.get(agent_name): merged_skills[agent_name] = registered_skills[agent_name] if merged_skills != existing_skills: - updates["registered_skills"] = merged_skills - - if updates: - self.registry.update(pack_id, updates) + self.registry.update(pack_id, {"registered_skills": merged_skills}) for tmpl in manifest.templates: if tmpl.get("type") == "command": diff --git a/tests/test_presets.py b/tests/test_presets.py index 48ee39d8ea..92e91f4a6a 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4219,6 +4219,61 @@ def test_use_rescaffold_reconciles_project_override(self, project_dir, temp_dir) ) assert "Preset body" not in content + def test_rescaffold_persists_commands_before_fallible_skills_phase( + self, project_dir, temp_dir + ): + """A failure in the skills phase must not lose track of command + files the commands phase already wrote to disk. + + ``register_enabled_presets_for_agent`` computes both + ``registered_commands`` and ``registered_skills`` and persists them + together in a single ``registry.update()`` call after both phases + run. If ``_register_skills`` raises, the whole per-preset ``try`` + block is caught and ``registry.update()`` is never reached — even + though ``_register_commands`` already wrote a real command file to + disk. That file becomes untracked and preset removal can no longer + clean it up. ``install_from_directory`` avoids this by persisting + ``registered_commands`` immediately after the commands phase, + before starting the independently fallible skills phase; rescaffold + must do the same (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + preset_dir = self._create_command_preset( + temp_dir, "rescaffold-persist-preset", "speckit.specify", + "Rescaffold persist test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Switch to gemini (a plain command-file agent, so _register_commands + # writes a real file) and make the *skills* phase blow up. + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_commands_dir = project_dir / ".gemini" / "commands" + gemini_commands_dir.mkdir(parents=True) + + from unittest.mock import patch + + with patch.object( + PresetManager, "_register_skills", + side_effect=RuntimeError("simulated skills failure"), + ): + manager.register_enabled_presets_for_agent("gemini") + + cmd_file = gemini_commands_dir / "speckit.specify.toml" + assert cmd_file.exists(), ( + "sanity: the commands phase must have written the file before " + "the skills phase raised" + ) + + metadata = manager.registry.get("rescaffold-persist-preset") + assert metadata["registered_commands"].get("gemini"), ( + "registered_commands must be persisted immediately after the " + "commands phase, not only after the (fallible) skills phase " + "also succeeds — otherwise the file written above is untracked " + "and preset removal can't clean it up (#2948)" + ) + def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_dir): """``integration use copilot`` with skills mode enabled must only write the SKILL.md mirror, not also copilot's static command file. @@ -4617,8 +4672,7 @@ def test_composed_none_unregister_respects_active_agent( yaml.dump(manifest_data, f) manager.install_from_directory(wrap_dir, "0.1.5", priority=5) - claude_dir = project_dir / ".gemini" / "commands" - cmd_file = claude_dir / f"{cmd_name}.toml" + cmd_file = gemini_commands_dir / f"{cmd_name}.toml" assert cmd_file.exists(), ( "sanity: the composed command should register for the active agent" ) From b9d9053b03cb22c72409f5c27603e0c378c6b5f3 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:05:55 +0200 Subject: [PATCH 09/27] fix: unregister stale opposite-mode preset artifact on same-agent skills toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix an Important gap in register_enabled_presets_for_agent() surfaced by quality review (#2948): toggling ai_skills for the *same already-active* command-backed agent (e.g. `integration upgrade copilot` after flipping ai_skills, with copilot staying active throughout) left a stale artifact from the previous mode behind, violating the command/skill mutual- exclusion invariant this PR otherwise enforces. - command -> skills: _register_commands()'s ai_skills guard makes the commands phase a no-op, but the previously-written command file (e.g. .agent.md) and its registered_commands[agent] entry were never cleaned up, so it lingered alongside the newly written SKILL.md. - skills -> command: _get_skills_dir() stops resolving a skills directory once ai_skills is off, making the skills phase a no-op, but the previously-written SKILL.md and its registered_skills[agent] entry were never cleaned up, so it lingered alongside the newly (re)written command file. register_enabled_presets_for_agent() now resolves once per call whether agent_name is a command-backed integration (extension != "/SKILL.md") and the current ai_skills state, then narrowly unregisters the stale opposite- mode entry for that agent via the existing _unregister_commands / _unregister_skills helpers before persisting updated tracking — mirroring the same per-agent, per-preset isolation already used elsewhere in this method. Native skill-only agents (claude, codex, ...) are unaffected: they have no command/skill toggle, so registered_commands and registered_skills legitimately co-exist for them by design. The trailing reconciliation pass, project-override precedence, and per-preset partial-failure isolation are all unchanged. Added red-first regression tests exercising the real install + register_enabled_presets_for_agent rescaffold path in both toggle directions: - test_rescaffold_toggle_command_to_skills_removes_stale_command_file - test_rescaffold_toggle_skills_to_command_removes_stale_skill_file Both failed against the prior code (stale artifact persisted / registry still tracked it) and pass after the fix. Targeted (tests/test_presets.py, tests/test_extensions.py: 691 passed) and full (3937 passed, 109 skipped) suites and ruff check on changed files pass clean. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 37 ++++++++ tests/test_presets.py | 127 ++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 1e83c01480..fec237932b 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -751,6 +751,24 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: if not agent_name: return + # Resolve once: whether agent_name is a command-backed integration + # (extension != "/SKILL.md") currently running in skills mode, or + # vice versa. Native skill-only agents (extension == "/SKILL.md", + # e.g. claude/codex) have no command/skill toggle at all — both + # registered_commands and registered_skills legitimately co-exist + # for them by design, so this restriction only applies to + # command-backed integrations. + try: + from ..agents import CommandRegistrar + + agent_config = CommandRegistrar().AGENT_CONFIGS.get(agent_name) + except ImportError: + agent_config = None + is_command_backed = bool(agent_config) and agent_config.get("extension") != "/SKILL.md" + ai_skills_now = is_command_backed and is_ai_skills_enabled( + load_init_options(self.project_root) + ) + resolver = PresetResolver(self.project_root) affected_cmd_names: set = set() for pack_id, metadata in reversed(self.registry.list_by_priority()): @@ -769,6 +787,15 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: merged_commands = copy.deepcopy(existing_commands) if registered_commands.get(agent_name): merged_commands[agent_name] = registered_commands[agent_name] + elif ai_skills_now and merged_commands.get(agent_name): + # Toggled command -> skills for this same agent: + # _register_commands's ai_skills guard just made this a + # no-op, but the command file this preset wrote while + # command mode was active is still on disk and still + # tracked. Unregister it narrowly for this agent so + # command-mode and skills-mode artifacts stay mutually + # exclusive (#2948). + self._unregister_commands({agent_name: merged_commands.pop(agent_name)}) # Persist the commands phase immediately, mirroring # install_from_directory(): _register_skills is an # independently fallible phase, and if it raises, the files @@ -784,6 +811,16 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: merged_skills = copy.deepcopy(existing_skills) if registered_skills.get(agent_name): merged_skills[agent_name] = registered_skills[agent_name] + elif is_command_backed and not ai_skills_now and merged_skills.get(agent_name): + # Mirror image: toggled skills -> command for this same + # agent. _get_skills_dir() no longer resolves a skills + # directory once ai_skills is off, so _register_skills + # is a no-op — but the SKILL.md this preset wrote while + # skills mode was active is still tracked and still on + # disk. Restore/remove it narrowly for this agent (#2948). + self._unregister_skills( + {agent_name: merged_skills.pop(agent_name)}, pack_dir + ) if merged_skills != existing_skills: self.registry.update(pack_id, {"registered_skills": merged_skills}) diff --git a/tests/test_presets.py b/tests/test_presets.py index 92e91f4a6a..d50c84715e 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4307,6 +4307,133 @@ def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_ skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "preset:copilot-skills-preset" in skill_file.read_text() + def test_rescaffold_toggle_command_to_skills_removes_stale_command_file( + self, project_dir, temp_dir + ): + """Toggling the *same* agent from command mode to skills mode must + remove the stale command-mode artifact, not just add the new one. + + Copilot stays the active agent throughout (``integration upgrade + copilot`` after flipping ``ai_skills``, not a switch to a different + agent). Before the fix, ``_register_commands``'s ``ai_skills`` guard + made rescaffold a no-op for the commands phase once skills mode was + on, leaving the previously written ``.agent.md`` file and its + ``registered_commands`` entry behind even though ``_register_skills`` + went on to also write the ``SKILL.md`` mirror — violating the + command/skill mutual-exclusion invariant this PR otherwise enforces + (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "toggle-cmd-to-skill-preset", "speckit.specify", + "Toggle test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + assert cmd_file.exists(), ( + "sanity: command mode should have written copilot's command file" + ) + metadata = manager.registry.get("toggle-cmd-to-skill-preset") + assert metadata["registered_commands"].get("copilot"), ( + "sanity: the command-mode write should be tracked for copilot" + ) + + # Flip ai_skills on for the *same* active agent and rescaffold, as + # `integration upgrade copilot` would after the mode toggle. + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert not cmd_file.exists(), ( + "the stale command-mode file must be removed once copilot has " + "toggled to skills mode for the same agent (#2948)" + ) + metadata = manager.registry.get("toggle-cmd-to-skill-preset") + assert not metadata["registered_commands"].get("copilot"), ( + "registered_commands must stop tracking copilot once its " + "artifact has been unregistered, or removal will try to clean " + "up a file that no longer exists (#2948)" + ) + skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + assert "preset:toggle-cmd-to-skill-preset" in skill_file.read_text(), ( + "sanity: the new skills-mode artifact should still be written" + ) + + def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file( + self, project_dir, temp_dir + ): + """Toggling the *same* agent from skills mode to command mode must + remove the stale skills-mode artifact, not just add the new one. + + Mirror image of the command-to-skills toggle: once ``ai_skills`` is + turned off for copilot (still the active agent), ``_get_skills_dir`` + stops resolving a skills directory for it, so ``_register_skills`` + becomes a no-op — but the ``SKILL.md`` written while skills mode was + on, and its ``registered_skills`` entry, were left behind even + though ``_register_commands`` went on to (re)write the ``.agent.md`` + command file, again breaking mutual exclusion (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # A core template lets the stale skill restore to core content + # (instead of being removed entirely, since it has nothing to fall + # back to), matching how `_unregister_skills` behaves elsewhere. + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_dir = self._create_command_preset( + temp_dir, "toggle-skill-to-cmd-preset", "speckit.specify", + "Toggle test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:toggle-skill-to-cmd-preset" in skill_file.read_text(), ( + "sanity: skills mode should have written the SKILL.md mirror" + ) + metadata = manager.registry.get("toggle-skill-to-cmd-preset") + assert metadata["registered_skills"].get("copilot"), ( + "sanity: the skills-mode write should be tracked for copilot" + ) + + # Flip ai_skills off for the *same* active agent and rescaffold, as + # `integration upgrade copilot` would after the mode toggle. + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_presets_for_agent("copilot") + + restored_content = skill_file.read_text() + assert "preset:toggle-skill-to-cmd-preset" not in restored_content, ( + "the stale skills-mode artifact must be reverted once copilot " + "has toggled to command mode for the same agent (#2948)" + ) + assert "Core specify body" in restored_content, ( + "sanity: the skill should fall back to core content, not just " + "lose the preset's override" + ) + metadata = manager.registry.get("toggle-skill-to-cmd-preset") + assert not metadata["registered_skills"].get("copilot"), ( + "registered_skills must stop tracking copilot once its " + "artifact has been unregistered/restored (#2948)" + ) + cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + assert cmd_file.exists(), ( + "sanity: the new command-mode artifact should still be written" + ) + assert "preset body" in cmd_file.read_text() + def test_skill_switch_then_remove_restores_every_skill_agent_dir( self, project_dir, temp_dir ): From 3a1e74931d6ef3d14428195eb2a3e4771edea6c1 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:28:40 +0200 Subject: [PATCH 10/27] fix: migrate legacy flat-list registered_skills on rescaffold even when unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix a valid finding from GitHub Copilot's review of HEAD b9d9053 (#2948): register_enabled_presets_for_agent() normalizes a legacy flat-list registered_skills value (predating per-agent provenance) to the {agent_name: [...]} dict shape in memory via _normalize_registered_skills, but the persistence check only compared the two *normalized* forms. When the freshly rescaffolded skill names are identical to what the legacy list already held — the common case, since nothing about the preset or skill actually changed — that comparison is a no-op and registry.update() is skipped, leaving the *raw* on-disk value as the un-migrated flat list. A later switch to a different skill-mode agent and removal then follows _unregister_skills's legacy best-effort path (restore only the currently active agent's directory) instead of the per-agent provenance path, permanently orphaning the first agent's override. Fix: track the raw (pre-normalization) existing value and force persistence whenever it's a non-empty list, independent of whether the normalized content changed. Traced registered_commands for the same class of bug: its registry value has always been Dict[str, List[str]] (no legacy flat-list format ever existed for it — the existing `if not isinstance(existing_commands, dict): existing_commands = {}` guard is not a lossy migration path), so this fix stays scoped to registered_skills only. Added red-first regression test test_rescaffold_migrates_legacy_flat_list_registered_skills: installs a preset, overwrites its registry entry with a raw legacy flat list, rescaffolds the *same* active agent with unchanged skill names, and asserts the raw registry is migrated to per-agent dict form. Extends the scenario with a switch to a second skill-mode agent and preset removal to prove both agents' directories restore cleanly instead of orphaning the first. Failed against the prior code (raw value stayed a list) and passes after the fix. Targeted (tests/test_presets.py, tests/test_extensions.py: 692 passed) and full (3938 passed, 109 skipped) suites and ruff check on changed files pass clean. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 17 ++++- tests/test_presets.py | 98 +++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index fec237932b..1941da3d4d 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -805,8 +805,9 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: self.registry.update(pack_id, {"registered_commands": merged_commands}) registered_skills = self._register_skills(manifest, pack_dir) + raw_existing_skills = metadata.get("registered_skills") existing_skills = self._normalize_registered_skills( - metadata.get("registered_skills"), fallback_agent=agent_name + raw_existing_skills, fallback_agent=agent_name ) merged_skills = copy.deepcopy(existing_skills) if registered_skills.get(agent_name): @@ -821,7 +822,19 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: self._unregister_skills( {agent_name: merged_skills.pop(agent_name)}, pack_dir ) - if merged_skills != existing_skills: + # A legacy flat-list registered_skills value (predating + # per-agent provenance) must migrate to the dict format on + # disk even when the rescaffolded names are unchanged from + # what the list already held — comparing only the + # *normalized* forms would otherwise treat that as a no-op + # and leave the raw un-migrated list in the registry, which + # later removal/switch handling treats as legacy + # best-effort (restoring only the currently active agent's + # directory) instead of per-agent provenance (#2948). + needs_migration = ( + isinstance(raw_existing_skills, list) and raw_existing_skills + ) + if merged_skills != existing_skills or needs_migration: self.registry.update(pack_id, {"registered_skills": merged_skills}) for tmpl in manifest.templates: diff --git a/tests/test_presets.py b/tests/test_presets.py index d50c84715e..b50023cbf8 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4512,6 +4512,104 @@ def test_skill_switch_then_remove_restores_every_skill_agent_dir( ) assert "Core specify body" in content + def test_rescaffold_migrates_legacy_flat_list_registered_skills( + self, project_dir, temp_dir + ): + """Rescaffolding a preset with a legacy flat-list ``registered_skills`` + entry must persist the migrated per-agent dict even when the + rescaffolded skill names are unchanged from before. + + ``_normalize_registered_skills`` converts a legacy flat ``List[str]`` + (predating per-agent provenance) into ``{agent_name: [...]}`` in + memory, but the persistence check compared only the *normalized* + ``merged_skills`` against the *normalized* ``existing_skills`` — + both derived from the same raw legacy list. When the freshly + registered names are identical to what the legacy list already + held (the common case: nothing about the preset or skill actually + changed), that comparison is a no-op and ``registry.update()`` is + skipped, leaving the *raw* on-disk value as the un-migrated flat + list. A later switch to a different skill-mode agent and removal + then follows the legacy best-effort restore path (only the + currently active agent's directory) instead of the per-agent + provenance path, orphaning the first agent's override (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "legacy-skills-preset", "speckit.specify", + "Legacy skills test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Simulate a registry entry written by a pre-#2948 spec-kit version: + # registered_skills stored as a flat list with no per-agent + # provenance, rather than the dict shape install_from_directory + # writes today. + manager.registry.update( + "legacy-skills-preset", {"registered_skills": ["speckit-specify"]}, + ) + metadata = manager.registry.get("legacy-skills-preset") + assert isinstance(metadata["registered_skills"], list), ( + "sanity: the injected legacy format is a flat list" + ) + + # Rescaffold for the *same* active agent (claude) with no actual + # change to the registered skill names, mirroring `integration + # upgrade claude` re-running registration for the active + # integration. + manager.register_enabled_presets_for_agent("claude") + + metadata = manager.registry.get("legacy-skills-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict), ( + "rescaffold must migrate a legacy flat-list registered_skills " + "entry to the per-agent dict format even when the " + "rescaffolded names are unchanged, or the raw registry stays " + "un-migrated and later removal loses per-agent provenance " + "(#2948)" + ) + assert registered_skills.get("claude") == ["speckit-specify"] + + # Switch to a different skill-mode agent and rescaffold again — + # with the dict format now in place, both directories should be + # tracked and therefore restorable on removal. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + metadata = manager.registry.get("legacy-skills-preset") + assert set(metadata.get("registered_skills", {})) == {"claude", "codex"}, ( + "the migrated dict must keep recording every agent directory " + "the preset actually wrote to, exactly like a preset that was " + "always in dict format (#2948)" + ) + + assert manager.remove("legacy-skills-preset") is True + + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" + for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:legacy-skills-preset" not in content, ( + f"{label}'s preset override must be restored on removal, " + "not orphaned because the registry stayed in legacy " + "flat-list format (#2948)" + ) + assert "Core specify body" in content + def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir): """Removal must validate a recorded skill directory before touching it. From 1bdb5b34739f5ecc4f3f20658b5a7d2d54b1f6b7 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:46:15 +0200 Subject: [PATCH 11/27] fix: reconcile before fallible skills phase, infer legacy skill provenance, and unregister stale extension artifacts on toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the Copilot review on HEAD b9d9053/3a1e749: 1. `register_enabled_presets_for_agent()` only recorded a preset's command names into `affected_cmd_names` (the set later passed to `_reconcile_composed_commands`/`_reconcile_skills`) in the loop that ran *after* `_register_skills()`, inside the same per-preset `try` block. If `_register_skills` raised, the `except` caught it and `continue`d before that loop ever ran — so a preset whose commands phase already wrote real content to disk never got reconciled against the full priority stack, leaving its raw content in place instead of a project override or higher-precedence preset's content. Fix: record the manifest's command names immediately after the commands phase succeeds and persists, before calling the independently fallible `_register_skills()`. 2. The legacy flat-list `registered_skills` migration (added for the previous review round) attributed every name in the list to whichever agent was currently being (re)activated. If the first operation after upgrading from a pre-#2948 registry was a direct switch to a *different* skill-mode agent (e.g. a legacy Claude override, then `integration use codex` with no intervening Claude rescaffold), the migrated dict only recorded `{"codex": [...]}`, permanently losing Claude's actual provenance and orphaning its override on later removal. Fix: added `_infer_legacy_skill_provenance()`, which probes every configured skill-mode agent's directory (via the same safe, symlink-validated helpers already used for restore/removal) for a `SKILL.md` whose frontmatter records this exact preset as the owner (`metadata.source == "preset:"`). A name found under more than one directory is attributed to every matching agent (the preset may have been active while the user switched between several skill-mode agents before provenance tracking existed); names that can't be matched to any directory still fall back to the previously-active best-effort behaviour. Directory grouping for shared-path aliases (e.g. agy/codex/zed all resolving to `.agents/skills`) intentionally does not call `.resolve()` on the path, since doing so diverges from `project_root`'s own resolution state on platforms where a path component is itself a symlink (e.g. macOS's `/var` -> `/private/var`) and made every subsequent containment check spuriously fail. 3. `register_enabled_extensions_for_agent()` has the same command/skill mutual-exclusion gap the preset path had (fixed in a previous round): toggling `ai_skills` for the *same active* agent left the opposite mode's artifact behind. Command -> skills left the extension's `.agent.md` file and its `registered_commands[agent]` entry in place once `skills_mode_active` made the commands phase a no-op. Skills -> command left the extension's `SKILL.md` file in place, since an empty `_register_extension_skills()` result (because this agent's skills directory no longer resolves once `ai_skills` is off) was treated as "nothing to register" rather than "this was rendered here before and is now stale". This diverges from the preset path in one respect: `registered_skills` for extensions has always been a flat list with no per-agent provenance (extension skills are only ever rendered for the active agent, never per-preset-per-agent tracked), so the fix resolves ownership by checking which of the extension's tracked skill names still exist as directories under this specific agent's directory before removing them — mirroring the same technique `unregister_agent_artifacts` already uses for full agent deactivation, but scoped narrowly to firing only when a toggle is actually detected (`skills_mode_active` / `command_mode_active`), so a same-mode re-run never disturbs already-correct artifacts or a user's manual customizations. Regression tests (all confirmed red before their respective fix, green after): - tests/test_presets.py::TestPresetSkills::test_rescaffold_reconciles_override_even_when_skills_phase_fails - tests/test_presets.py::TestPresetSkills::test_rescaffold_legacy_flat_list_direct_switch_preserves_original_agent - tests/test_extension_skills.py::TestExtensionSkillRegistration::test_rescaffold_toggle_command_to_skills_removes_stale_extension_command_file - tests/test_extension_skills.py::TestExtensionSkillRegistration::test_rescaffold_toggle_skills_to_command_removes_stale_extension_skill_file Verification: tests/test_presets.py + tests/test_extensions.py + tests/test_extension_skills.py (753 passed), tests/integrations/ (1768 passed, 1 skipped), full suite `pytest tests -q` (3942 passed, 109 skipped), `ruff check` on changed files clean. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 68 ++++++++++++ src/specify_cli/presets/__init__.py | 131 ++++++++++++++++++++-- tests/test_extension_skills.py | 97 ++++++++++++++++ tests/test_presets.py | 146 +++++++++++++++++++++++++ 4 files changed, 435 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 2891ad6947..669de120c2 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1805,6 +1805,22 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: and bool(agent_config) and agent_config.get("extension") != "/SKILL.md" ) + # Mirror image of skills_mode_active: this agent is command-backed, + # active, and currently in command mode. Used to detect a + # skills -> command toggle for this same agent, where the skills + # phase below returns empty (its directory no longer resolves) but + # a previously-written extension SKILL.md is now stale (#2948). + command_mode_active = ( + active_agent == agent_name + and not ai_skills_enabled + and bool(agent_config) + and agent_config.get("extension") != "/SKILL.md" + ) + agent_skills_dir = None + if agent_config and agent_config.get("extension") != "/SKILL.md": + from .. import _get_skills_dir as _resolve_agent_skills_dir + + agent_skills_dir = _resolve_agent_skills_dir(self.project_root, agent_name) for ext_id, metadata in self.registry.list().items(): if not metadata.get("enabled", True): @@ -1840,6 +1856,30 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: new_registered.pop(agent_name, None) if new_registered != registered_commands: updates["registered_commands"] = new_registered + elif agent_config and skills_mode_active: + # Toggled command -> skills for this same agent: the + # commands phase above is skipped, but a command file + # this extension previously wrote for this agent while + # command mode was active is still on disk and still + # tracked. Remove it narrowly for this agent so + # command-mode and skills-mode artifacts stay mutually + # exclusive, matching unregister_agent_artifacts's + # per-agent command cleanup (#2948). + registered_commands = metadata.get("registered_commands", {}) + if isinstance(registered_commands, dict) and registered_commands.get( + agent_name + ): + stale_commands = self._valid_name_list( + registered_commands.get(agent_name) + ) + if stale_commands: + registrar.unregister_commands( + {agent_name: stale_commands}, self.project_root + ) + new_registered = copy.deepcopy(registered_commands) + new_registered.pop(agent_name, None) + if new_registered != registered_commands: + updates["registered_commands"] = new_registered # Extension *skills* are only ever rendered for the active agent: # `_register_extension_skills` resolves the skills dir and @@ -1879,6 +1919,34 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: dict.fromkeys(existing_skills + registered_skills) ) updates["registered_skills"] = merged_skills + elif command_mode_active and agent_skills_dir is not None: + # Mirror image: toggled skills -> command for + # this same agent. _register_extension_skills + # returned empty because this agent's skills + # directory no longer resolves once ai_skills is + # off, but a SKILL.md this extension wrote while + # skills mode was active may still be tracked + # and still on disk. Remove it narrowly for this + # agent's directory only (#2948). + existing_skills = self._valid_name_list( + metadata.get("registered_skills", []) + ) + owned_here = [ + name + for name in existing_skills + if (agent_skills_dir / name).is_dir() + ] + if owned_here: + self._unregister_extension_skills( + owned_here, ext_id, skills_dir=agent_skills_dir + ) + remaining = [ + name + for name in existing_skills + if (agent_skills_dir / name).is_dir() + ] + if remaining != existing_skills: + updates["registered_skills"] = remaining if updates: self.registry.update(ext_id, updates) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 1941da3d4d..b67100b272 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -804,11 +804,36 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: if merged_commands != existing_commands: self.registry.update(pack_id, {"registered_commands": merged_commands}) + # Record this preset's command names for reconciliation now, + # right after the commands phase succeeds and before calling + # the independently fallible _register_skills(). Both used to + # be recorded together after skills also succeeded; if skills + # raised, the exception (caught below) skipped this entirely, + # so a preset whose commands phase wrote real content never + # got reconciled against the full priority stack and its raw + # content could be left in place instead of a project + # override or higher-precedence preset (#2948). + for tmpl in manifest.templates: + if tmpl.get("type") == "command": + affected_cmd_names.add(tmpl["name"]) + registered_skills = self._register_skills(manifest, pack_dir) raw_existing_skills = metadata.get("registered_skills") - existing_skills = self._normalize_registered_skills( - raw_existing_skills, fallback_agent=agent_name - ) + if isinstance(raw_existing_skills, list) and raw_existing_skills: + # Legacy flat-list value: don't assume agent_name wrote + # every name (the first post-upgrade operation may be a + # direct switch to a different skill-mode agent) — + # infer real ownership from on-disk provenance instead + # (#2948). + existing_skills = self._infer_legacy_skill_provenance( + [n for n in raw_existing_skills if isinstance(n, str)], + pack_id, + fallback_agent=agent_name, + ) + else: + existing_skills = self._normalize_registered_skills( + raw_existing_skills, fallback_agent=agent_name + ) merged_skills = copy.deepcopy(existing_skills) if registered_skills.get(agent_name): merged_skills[agent_name] = registered_skills[agent_name] @@ -836,10 +861,6 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: ) if merged_skills != existing_skills or needs_migration: self.registry.update(pack_id, {"registered_skills": merged_skills}) - - for tmpl in manifest.templates: - if tmpl.get("type") == "command": - affected_cmd_names.add(tmpl["name"]) except Exception as pack_err: from .. import _print_cli_warning @@ -1636,6 +1657,97 @@ def _register_skills( return {selected_ai: written} if written else {} + def _infer_legacy_skill_provenance( + self, skill_names: List[str], pack_id: str, fallback_agent: str + ) -> Dict[str, List[str]]: + """Infer per-agent ownership of a legacy flat-list ``registered_skills`` value. + + Pre-#2948 registries recorded ``registered_skills`` as a flat list + with no record of which agent directory each name was actually + written under. Blindly attributing every name to ``fallback_agent`` + (the agent currently being processed) loses the real writer whenever + the *first* operation after upgrading is a direct switch to a + *different* skill-mode agent — e.g. a legacy Claude override + followed directly by ``integration use codex``, with no + intervening rescaffold for Claude — permanently orphaning Claude's + override on later removal. + + Instead, every configured skill-mode agent's directory is probed + (via the same safe, symlink-validated helpers used for + restore/removal) for a ``SKILL.md`` whose frontmatter records this + exact preset as the owner (``metadata.source == "preset:"``, + the same marker :meth:`_register_skills` writes). A name can + legitimately be found under more than one agent's directory — the + preset may have been active while the user switched between several + skill-mode agents before provenance tracking existed — so every + matching agent is recorded, not just the first. Names that can't be + matched to any directory (e.g. the file was deleted out of band) + fall back to ``fallback_agent``, preserving the previous + best-effort behaviour for the unrecoverable case. + """ + from ..agents import CommandRegistrar + + registrar = CommandRegistrar() + skill_mode_agents = sorted( + name + for name, cfg in registrar.AGENT_CONFIGS.items() + if cfg.get("extension") == "/SKILL.md" + ) + + # Multiple agent names can resolve to the same physical directory + # (e.g. agy/codex/zed all use .agents/skills); group by directory so + # each is probed once and attributed to a single deterministic + # canonical agent name, matching the tie-break already used by + # _unregister_skills's directory grouping. Deliberately keep the + # unresolved path (matching what _safe_skills_dir_for_agent already + # validated) rather than calling .resolve() here: on macOS /var is + # itself a symlink to /private/var, so resolving would make this + # path diverge from self.project_root's own resolution state and + # make every subsequent containment check in + # _validate_skill_subdir() spuriously fail. + dir_to_agents: Dict[Path, List[str]] = {} + for agent_name in skill_mode_agents: + skills_dir = self._safe_skills_dir_for_agent(agent_name) + if skills_dir is None: + continue + dir_to_agents.setdefault(skills_dir, []).append(agent_name) + + marker = f"preset:{pack_id}" + inferred: Dict[str, List[str]] = {} + matched_names: set = set() + for resolved_dir, agents in dir_to_agents.items(): + canonical_agent = fallback_agent if fallback_agent in agents else sorted(agents)[0] + for name in skill_names: + skill_subdir = resolved_dir / name + if not self._validate_skill_subdir(skill_subdir, create=False): + continue + skill_file = skill_subdir / "SKILL.md" + if not skill_file.is_file(): + continue + try: + content = skill_file.read_text(encoding="utf-8") + except OSError: + continue + frontmatter, _ = registrar.parse_frontmatter(content) + skill_metadata = frontmatter.get("metadata") + source = ( + skill_metadata.get("source") + if isinstance(skill_metadata, dict) + else None + ) + if source == marker: + inferred.setdefault(canonical_agent, []).append(name) + matched_names.add(name) + + unmatched = [name for name in skill_names if name not in matched_names] + if unmatched and fallback_agent: + fallback_names = inferred.setdefault(fallback_agent, []) + for name in unmatched: + if name not in fallback_names: + fallback_names.append(name) + + return inferred + @staticmethod def _normalize_registered_skills( value: Any, fallback_agent: Optional[str] = None @@ -1651,6 +1763,11 @@ def _normalize_registered_skills( list to the agent currently being processed so the format self-migrates on the next write. Without a fallback agent, legacy lists are dropped rather than guessed at. + + Callers that can identify the owning preset (i.e. have a + ``pack_id``) should prefer :meth:`_infer_legacy_skill_provenance` + for a legacy flat-list value instead, which probes on-disk + provenance rather than assuming ``fallback_agent`` wrote every name. """ if isinstance(value, dict): return { diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index c2d9bb439e..8a7af933f0 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1262,6 +1262,103 @@ def fail_skills(self, manifest, ext_dir, link_outputs=False): assert "register extension skills for extension 'skill-fail'" in captured.out assert "Continuing with available registration results" in captured.out + def test_rescaffold_toggle_command_to_skills_removes_stale_extension_command_file( + self, project_dir, temp_dir + ): + """Toggling the *same* active agent from command mode to skills mode + must remove the stale extension command-mode artifact, not just add + the skills-mode one. + + Copilot stays the active agent throughout (mirroring ``integration + upgrade copilot --skills``, not a switch to a different agent). + ``register_enabled_extensions_for_agent`` skips the commands phase + once ``skills_mode_active`` is true, but before this fix it never + removed the ``.agent.md`` file (and ``registered_commands["copilot"]`` + entry) the commands phase previously wrote while command mode was + active — leaving both artifacts on disk at once and violating the + command/skill mutual-exclusion the PR description claims (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="toggle-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + agents_dir = project_dir / ".github" / "agents" + cmd_file = agents_dir / "speckit.toggle-ext.hello.agent.md" + assert cmd_file.exists(), "sanity: command mode should write .agent.md" + + # Toggle ai_skills on for the same active agent (copilot) and + # rescaffold, mirroring `integration upgrade copilot --skills`. + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + assert not cmd_file.exists(), ( + "the stale command-mode .agent.md file must be removed once " + "this agent toggles to skills mode, not left alongside the " + "new SKILL.md (#2948)" + ) + metadata = manager.registry.get("toggle-ext") + registered_commands = metadata.get("registered_commands", {}) + assert not registered_commands.get("copilot"), ( + "registered_commands tracking for copilot must be cleared " + "once its command file is removed on toggle (#2948)" + ) + skills_dir = project_dir / ".github" / "skills" + skill_file = skills_dir / "speckit-toggle-ext-hello" / "SKILL.md" + assert skill_file.exists(), "sanity: skills mode should write SKILL.md" + + def test_rescaffold_toggle_skills_to_command_removes_stale_extension_skill_file( + self, project_dir, temp_dir + ): + """Toggling the *same* active agent from skills mode to command mode + must remove the stale extension skills-mode artifact, not just add + the command-mode one. + + Mirror image of the command->skills toggle: ``_register_extension_skills`` + returns an empty list once skills mode is off for this agent (its + skills directory no longer resolves), but before this fix an empty + result was silently treated as "nothing to register" rather than + "this agent's skill was rendered here previously and is now stale", + so the ``SKILL.md`` this extension wrote while skills mode was + active stayed on disk even though a fresh ``.agent.md`` was written + right alongside it (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="toggle-ext2"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + skills_dir = project_dir / ".github" / "skills" + skill_file = skills_dir / "speckit-toggle-ext2-hello" / "SKILL.md" + assert skill_file.exists(), "sanity: skills mode should write SKILL.md" + + # Toggle ai_skills off for the same active agent (copilot) and + # rescaffold, mirroring `integration upgrade copilot` (no --skills). + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_extensions_for_agent("copilot") + + agents_dir = project_dir / ".github" / "agents" + cmd_file = agents_dir / "speckit.toggle-ext2.hello.agent.md" + assert cmd_file.exists(), "sanity: command mode should write .agent.md" + + assert not skill_file.exists(), ( + "the stale skills-mode SKILL.md file must be removed once this " + "agent toggles to command mode, not left alongside the new " + ".agent.md (#2948)" + ) + metadata = manager.registry.get("toggle-ext2") + registered_skills = metadata.get("registered_skills", []) + assert "speckit-toggle-ext2-hello" not in registered_skills, ( + "registered_skills tracking must be cleared for the removed " + "skill file, not left dangling once it's orphaned (#2948)" + ) + def test_existing_agent_command_path_file_is_not_detected( self, project_dir, temp_dir ): diff --git a/tests/test_presets.py b/tests/test_presets.py index b50023cbf8..a876068296 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4274,6 +4274,68 @@ def test_rescaffold_persists_commands_before_fallible_skills_phase( "and preset removal can't clean it up (#2948)" ) + def test_rescaffold_reconciles_override_even_when_skills_phase_fails( + self, project_dir, temp_dir + ): + """A project override must still win after rescaffold even if the + independently-fallible skills phase raises for that preset. + + ``register_enabled_presets_for_agent`` only records a preset's + command names into ``affected_cmd_names`` — the set later passed to + ``_reconcile_composed_commands``/``_reconcile_skills`` — in the + ``for tmpl in manifest.templates`` loop that runs *after* + ``_register_skills`` inside the per-preset ``try`` block. If + ``_register_skills`` raises, the per-preset ``except`` catches it + and ``continue``s before that loop ever runs, so this preset's + command names never make it into ``affected_cmd_names`` even though + ``_register_commands`` already wrote its raw content to disk. The + final reconciliation call is skipped for this preset entirely, + leaving the raw preset content in place instead of the project + override that should win (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override specify\n---\n\nOverride body\n", + encoding="utf-8", + ) + + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "reconcile-despite-skills-failure", "speckit.specify", + "Preset specify", "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Simulate `integration use gemini` with the skills phase failing + # for this preset (e.g. a symlink/permission error unrelated to the + # commands phase, which already succeeded). + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + + from unittest.mock import patch + + with patch.object( + PresetManager, "_register_skills", + side_effect=RuntimeError("simulated skills failure"), + ): + manager.register_enabled_presets_for_agent("gemini") + + cmd_file = gemini_dir / "speckit.specify.toml" + assert cmd_file.exists(), "sanity: gemini should get a command file at all" + content = cmd_file.read_text() + assert "Override body" in content, ( + "the project override must still win after rescaffold, even " + "though this preset's skills phase raised — a fallible skills " + "phase must not skip reconciliation for command writes that " + "already succeeded (#2948)" + ) + assert "Preset body" not in content + def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_dir): """``integration use copilot`` with skills mode enabled must only write the SKILL.md mirror, not also copilot's static command file. @@ -4610,6 +4672,90 @@ def test_rescaffold_migrates_legacy_flat_list_registered_skills( ) assert "Core specify body" in content + def test_rescaffold_legacy_flat_list_direct_switch_preserves_original_agent( + self, project_dir, temp_dir + ): + """A legacy flat-list ``registered_skills`` entry must not be + misattributed to the wrong agent when the *first* post-upgrade + operation is a direct switch to a different skill-mode agent. + + Blindly attributing every legacy flat-list name to ``agent_name`` — + the agent currently being (re)activated — loses the actual writer + whenever that first operation is ``integration use codex`` (or + ``switch``) run directly against a legacy Claude override, without + an intervening same-agent rescaffold for Claude first. The + migrated dict then only records ``{"codex": [...]}``, so a later + ``remove()`` restores Codex but permanently orphans the Claude + override that was never in the registry to begin with (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "legacy-direct-switch-preset", "speckit.specify", + "Legacy direct switch test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # install_from_directory wrote the preset's override to Claude's + # skill directory (the active agent at install time) — sanity-check + # that the marker is actually there before simulating the legacy + # registry format. + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:legacy-direct-switch-preset" in claude_skill.read_text(), ( + "sanity: install should have written the override under claude" + ) + + # Simulate a pre-#2948 registry: a flat list with no per-agent + # provenance, even though the file on disk was actually written + # under claude's directory. + manager.registry.update( + "legacy-direct-switch-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + # Directly switch to codex — no intervening rescaffold for claude — + # mirroring `integration use codex` / `switch codex` run right after + # upgrading spec-kit versions. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + metadata = manager.registry.get("legacy-direct-switch-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict) + assert set(registered_skills) == {"claude", "codex"}, ( + "migrating a legacy flat-list entry on a direct switch must " + "infer the actual writer (claude) from the existing on-disk " + "SKILL.md provenance, not attribute every name to whichever " + "agent happens to be activated first after the upgrade " + "(#2948)" + ) + + assert manager.remove("legacy-direct-switch-preset") is True + + codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" + for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:legacy-direct-switch-preset" not in content, ( + f"{label}'s preset override must be restored on removal, " + "not permanently orphaned by a misattributed legacy " + "migration (#2948)" + ) + assert "Core specify body" in content + def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir): """Removal must validate a recorded skill directory before touching it. From ee03a5055e5d3fbaa53160aa04c4ce959ba67e1d Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:03:53 +0200 Subject: [PATCH 12/27] fix: broaden legacy skill provenance inference to command-backed agents _infer_legacy_skill_provenance() only probed agents whose registrar config statically declares extension == "/SKILL.md", excluding command-backed agents (e.g. Copilot) that can also render preset overrides as SKILL.md files when ai_skills is enabled. A real preset-owned .github/skills/.../SKILL.md written while Copilot was the active skills-mode agent was therefore never probed and got misattributed entirely to whichever agent activated first after the upgrade, permanently orphaning Copilot's override on later removal. Broaden the candidate set to every configured integration (CommandRegistrar.AGENT_CONFIGS), reusing the existing safe-path helper (_safe_skills_dir_for_agent, itself built on the shared _get_skills_dir resolver) rather than inventing new path-construction logic. The existing preset-marker match (metadata.source == "preset:") continues to gate every attribution, so command-mode agents that never rendered this preset's skill are not falsely attributed. Add red-first regression tests: a legacy flat-list entry owned by Copilot in skills mode, switched directly to Claude with no intervening Copilot rescaffold, now migrates to a per-agent dict covering both agents, and removal restores both agents' files instead of orphaning Copilot's override; plus a negative-case test confirming a command-mode Copilot with no preset-owned skill marker is not falsely attributed during the same migration. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 73 ++++++++------ tests/test_presets.py | 151 ++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 32 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index b67100b272..9728bc909b 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1667,46 +1667,55 @@ def _infer_legacy_skill_provenance( written under. Blindly attributing every name to ``fallback_agent`` (the agent currently being processed) loses the real writer whenever the *first* operation after upgrading is a direct switch to a - *different* skill-mode agent — e.g. a legacy Claude override - followed directly by ``integration use codex``, with no - intervening rescaffold for Claude — permanently orphaning Claude's - override on later removal. - - Instead, every configured skill-mode agent's directory is probed - (via the same safe, symlink-validated helpers used for - restore/removal) for a ``SKILL.md`` whose frontmatter records this - exact preset as the owner (``metadata.source == "preset:"``, - the same marker :meth:`_register_skills` writes). A name can - legitimately be found under more than one agent's directory — the - preset may have been active while the user switched between several - skill-mode agents before provenance tracking existed — so every - matching agent is recorded, not just the first. Names that can't be - matched to any directory (e.g. the file was deleted out of band) - fall back to ``fallback_agent``, preserving the previous - best-effort behaviour for the unrecoverable case. + *different* agent — e.g. a legacy Copilot override (written while + Copilot was active with ``ai_skills`` enabled) followed directly by + ``integration use claude``, with no intervening rescaffold for + Copilot — permanently orphaning Copilot's override on later + removal. + + Every *configured* integration's skills directory is probed (via + the same safe, symlink-validated helpers used for + restore/removal), not only agents whose registrar config is + statically ``/SKILL.md``-only: a command-backed agent (e.g. + Copilot, whose command extension is ``.agent.md``) renders its + preset overrides as ``SKILL.md`` files exactly like a native + skill-only agent whenever it was the active agent with + ``ai_skills`` enabled, so excluding it would miss real, + preset-owned provenance and misattribute it to whichever agent + happens to be processed first. Each directory is probed for a + ``SKILL.md`` whose frontmatter records this exact preset as the + owner (``metadata.source == "preset:"``, the same marker + :meth:`_register_skills` writes) — this marker check is what keeps + the broadened probe from falsely attributing ownership to an + agent's directory that never actually held this preset's override + (e.g. a command-mode agent that never rendered skills, or an + unrelated skill of the same name). A name can legitimately be + found under more than one agent's directory — the preset may have + been active while the user switched between several agents before + provenance tracking existed — so every matching agent is recorded, + not just the first. Names that can't be matched to any directory + (e.g. the file was deleted out of band) fall back to + ``fallback_agent``, preserving the previous best-effort behaviour + for the unrecoverable case. """ from ..agents import CommandRegistrar registrar = CommandRegistrar() - skill_mode_agents = sorted( - name - for name, cfg in registrar.AGENT_CONFIGS.items() - if cfg.get("extension") == "/SKILL.md" - ) + candidate_agents = sorted(registrar.AGENT_CONFIGS) # Multiple agent names can resolve to the same physical directory - # (e.g. agy/codex/zed all use .agents/skills); group by directory so - # each is probed once and attributed to a single deterministic - # canonical agent name, matching the tie-break already used by - # _unregister_skills's directory grouping. Deliberately keep the - # unresolved path (matching what _safe_skills_dir_for_agent already - # validated) rather than calling .resolve() here: on macOS /var is - # itself a symlink to /private/var, so resolving would make this - # path diverge from self.project_root's own resolution state and - # make every subsequent containment check in + # (e.g. agy/amp/codex/zed all use .agents/skills); group by + # directory so each is probed once and attributed to a single + # deterministic canonical agent name, matching the tie-break + # already used by _unregister_skills's directory grouping. Deliberately + # keep the unresolved path (matching what _safe_skills_dir_for_agent + # already validated) rather than calling .resolve() here: on macOS + # /var is itself a symlink to /private/var, so resolving would make + # this path diverge from self.project_root's own resolution state + # and make every subsequent containment check in # _validate_skill_subdir() spuriously fail. dir_to_agents: Dict[Path, List[str]] = {} - for agent_name in skill_mode_agents: + for agent_name in candidate_agents: skills_dir = self._safe_skills_dir_for_agent(agent_name) if skills_dir is None: continue diff --git a/tests/test_presets.py b/tests/test_presets.py index a876068296..18a1b7318f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4756,6 +4756,157 @@ def test_rescaffold_legacy_flat_list_direct_switch_preserves_original_agent( ) assert "Core specify body" in content + def test_rescaffold_legacy_flat_list_infers_command_backed_skills_owner( + self, project_dir, temp_dir + ): + """Legacy provenance inference must also probe command-backed agents + that were running in skills mode, not only agents whose command + registrar config is statically ``/SKILL.md``-only. + + Copilot is command-backed (``extension: ".agent.md"``), but with + ``ai_skills`` enabled its preset overrides render as ``SKILL.md`` + files under ``.github/skills`` exactly like a native skill-only + agent (claude, codex, ...). Before the fix, + ``_infer_legacy_skill_provenance`` only probed agents whose + registrar config has a static ``extension == "/SKILL.md"``, so a + real preset-owned ``.github/skills/.../SKILL.md`` written while + Copilot was the active, skills-mode agent was never found — the + legacy flat list was misattributed entirely to whichever agent the + first post-upgrade switch happened to activate, permanently + orphaning Copilot's override on later removal (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + copilot_skills_dir = project_dir / ".github" / "skills" + self._create_skill(copilot_skills_dir, "speckit-specify") + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "legacy-copilot-skills-preset", "speckit.specify", + "Legacy copilot skills test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:legacy-copilot-skills-preset" in copilot_skill.read_text(), ( + "sanity: install should have written the override under " + "copilot's skills directory while copilot was active in " + "skills mode" + ) + # Sanity: no command-mode artifact was written either — copilot's + # command file and skills file are mutually exclusive. + assert not list((project_dir / ".github" / "agents").glob("*specify*")), ( + "sanity: copilot in skills mode must not also write a command " + "file that could be falsely attributed instead" + ) + + # Simulate a pre-#2948 registry: a flat list with no per-agent + # provenance, even though the file on disk was actually written + # under copilot's skills directory. + manager.registry.update( + "legacy-copilot-skills-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + # Directly switch to claude — no intervening rescaffold for + # copilot — mirroring `integration use claude` run right after + # upgrading spec-kit versions. + self._write_init_options(project_dir, ai="claude", ai_skills=True) + manager.register_enabled_presets_for_agent("claude") + + metadata = manager.registry.get("legacy-copilot-skills-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict) + assert set(registered_skills) == {"copilot", "claude"}, ( + "migrating a legacy flat-list entry on a direct switch must " + "infer the actual writer (copilot, running in skills mode) " + "even though copilot's registrar config is command-backed, " + "not just agents with a static /SKILL.md extension (#2948)" + ) + + assert manager.remove("legacy-copilot-skills-preset") is True + + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + for skill_file, label in ((copilot_skill, "copilot"), (claude_skill, "claude")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:legacy-copilot-skills-preset" not in content, ( + f"{label}'s preset override must be restored on removal, " + "not permanently orphaned by a legacy migration that " + "failed to probe command-backed skills-mode agents (#2948)" + ) + assert "Core specify body" in content + + def test_infer_legacy_skill_provenance_does_not_falsely_attribute_command_mode_copilot( + self, project_dir, temp_dir + ): + """Broadening provenance inference to command-backed agents must not + falsely attribute ownership to an agent's directory that has no + preset-owned marker. + + Copilot stays in plain command mode throughout (no skills ever + rendered there), so ``.github/skills`` never receives this + preset's ``SKILL.md``. Probing copilot's skills directory anyway + (now that inference isn't restricted to static ``/SKILL.md`` + agents) must find nothing there and must not invent a false + ``"copilot"`` entry (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + # Copilot has never been active; its command directory holds an + # unrelated file so the directory exists, but no skills directory + # or SKILL.md was ever written for it. + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "no-false-attribution-preset", "speckit.specify", + "No false attribution test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + manager.registry.update( + "no-false-attribution-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + # Rescaffold again for the same agent (claude) with unchanged + # names, triggering the legacy migration path. + manager.register_enabled_presets_for_agent("claude") + + metadata = manager.registry.get("no-false-attribution-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict) + assert set(registered_skills) == {"claude"}, ( + "copilot must not appear in the migrated registry when it has " + "never actually rendered this preset's skill — probing its " + "directory for a marker match must not create a false " + "attribution (#2948)" + ) + assert not (project_dir / ".github" / "skills").exists(), ( + "no .github/skills directory should have been created as a " + "side effect of probing for provenance (#2948)" + ) + def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir): """Removal must validate a recorded skill directory before touching it. From 37cceb7bc1f5583523ca2e9d92425bdf79283c81 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:51:21 +0200 Subject: [PATCH 13/27] fix: preserve extension skill tracking for mirrors in other agent dirs The skills -> command toggle cleanup in register_enabled_extensions_for_agent() recomputed the remaining tracked registered_skills names by checking only the toggling agent's own skills directory. Since registered_skills is a single flat list shared across every agent an extension has ever been activated under (skills are only ever rendered for the active agent, so there is no per-agent registry key), a name whose mirror still existed under a *different*, previously-active agent's directory was incorrectly dropped from tracking as soon as the current agent's own copy was removed. A later full removal only iterates registered_skills, so the orphaned mirror under the other agent's directory was never found or cleaned up. Add _extension_owned_skill_names(), which re-verifies ownership across every configured agent's skills directory (deduped by shared path) the same way the existing _unregister_extension_skills() fallback scan already does, keeping a name only when a SKILL.md with a matching metadata.source == "extension:" marker is found somewhere - read-only, no directory creation, no symlink escape. Use it instead of re-checking only the toggling agent's own directory when recomputing what remains tracked after narrow stale-mirror cleanup. Add a red-first regression test: Auggie is activated in skills mode first (writing a mirror), then Copilot is activated in skills mode (writing its own mirror for the same names), then Copilot toggles to command mode. Before the fix, registered_skills lost both names entirely even though Auggie's mirrors were untouched on disk; after the fix tracking is preserved and a subsequent full removal correctly cleans up Auggie's remaining mirrors too. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 107 +++++++++++++++++++++++-- tests/test_extension_skills.py | 89 ++++++++++++++++++++ 2 files changed, 191 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 669de120c2..2d023fc4ba 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1366,6 +1366,93 @@ def _unregister_extension_skills( continue shutil.rmtree(skill_subdir) + def _extension_owned_skill_names( + self, skill_names: List[str], extension_id: str + ) -> List[str]: + """Return the subset of *skill_names* still marker-verified anywhere. + + ``registered_skills`` is a single flat list shared across every + agent this extension has ever been activated under (skills are + only ever rendered for the currently active agent, so there is no + per-agent registry key to consult). A name can therefore still be + globally owned by this extension even after it's removed from one + particular agent's directory, if an earlier activation under a + *different* agent left its own marker-verified mirror behind. + + This scans the same candidate directories (every configured + agent's skills folder, deduped by shared path, plus the default + skills directory) as the fallback branch of + :meth:`_unregister_extension_skills`, but read-only: no directory + is created and a name is only kept if at least one candidate + directory contains a ``SKILL.md`` whose ``metadata.source`` field + matches this exact extension (the same ownership marker + :meth:`_register_extension_skills` writes), so an unrelated + directory or user-created skill of the same name can't cause a + false positive. Symlink/containment safety mirrors the existing + fallback scan: each candidate path is resolved and the resulting + skill subdirectory is required to stay within it before any file + is read. + """ + if not skill_names: + return [] + + from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR + + candidate_dirs: set[Path] = set() + for cfg in AGENT_CONFIG.values(): + folder = cfg.get("folder", "") + if folder: + candidate_dirs.add(self.project_root / folder.rstrip("/") / "skills") + candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR) + + marker = f"extension:{extension_id}" + owned: set = set() + for skills_candidate in candidate_dirs: + if len(owned) == len(skill_names): + break # every name already confirmed owned somewhere + if not skills_candidate.is_dir(): + continue + try: + resolved_candidate = skills_candidate.resolve() + except OSError: + continue + for skill_name in skill_names: + if skill_name in owned: + continue + sn_path = Path(skill_name) + if sn_path.is_absolute() or len(sn_path.parts) != 1: + continue + try: + skill_subdir = (skills_candidate / skill_name).resolve() + skill_subdir.relative_to(resolved_candidate) # raises if outside + except (OSError, ValueError): + continue + if not skill_subdir.is_dir(): + continue + skill_md = skill_subdir / "SKILL.md" + if not skill_md.is_file(): + continue + try: + import yaml as _yaml + + raw = skill_md.read_text(encoding="utf-8") + source = "" + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + fm = _yaml.safe_load(parts[1]) or {} + source = ( + fm.get("metadata", {}).get("source", "") + if isinstance(fm, dict) + else "" + ) + except (OSError, UnicodeDecodeError, Exception): + continue + if source == marker: + owned.add(skill_name) + + return [name for name in skill_names if name in owned] + def check_compatibility( self, manifest: ExtensionManifest, speckit_version: str ) -> bool: @@ -1940,11 +2027,21 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: self._unregister_extension_skills( owned_here, ext_id, skills_dir=agent_skills_dir ) - remaining = [ - name - for name in existing_skills - if (agent_skills_dir / name).is_dir() - ] + # registered_skills is a single flat list + # shared across every agent this extension + # was ever activated under (unlike presets' + # per-agent dict), so a name removed from + # *this* agent's directory may still have a + # marker-verified mirror under a different, + # previously-active agent's directory. + # Recompute across every safe, supported + # skills directory rather than just this + # one, or a still-existing mirror elsewhere + # would be silently dropped from tracking + # and orphaned on later removal (#2948). + remaining = self._extension_owned_skill_names( + existing_skills, ext_id + ) if remaining != existing_skills: updates["registered_skills"] = remaining diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 8a7af933f0..eee68b8f34 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1359,6 +1359,95 @@ def test_rescaffold_toggle_skills_to_command_removes_stale_extension_skill_file( "skill file, not left dangling once it's orphaned (#2948)" ) + def test_toggle_to_command_preserves_tracking_for_mirror_in_other_agent_dir( + self, project_dir, temp_dir + ): + """Skills->command toggle cleanup must not drop global tracking for a + skill name that still has a mirror under a *different* agent's + skills directory from an earlier activation. + + ``registered_skills`` is a flat, agent-agnostic list for extensions + (skills are only ever rendered for the currently active agent, by + design). Auggie is activated first (skills mode), writing a mirror + under ``.augment/skills``. Copilot is then activated (also skills + mode), writing its own mirror under ``.github/skills`` for the same + skill names — the flat list already contains those names, so + nothing new is added. Copilot is then toggled to command mode: its + own ``.github/skills`` mirror becomes stale and must be removed, + but the still-existing Auggie mirror means the extension still + globally owns these skill names. Before this fix, the recompute + after toggle-cleanup only checked *copilot's* directory, so it + dropped the names from ``registered_skills`` entirely — losing + track of Auggie's still-existing mirror, which a later `remove()` + would then never find and clean up (or restore during override + reconciliation), permanently orphaning it (#2948). + """ + _create_init_options(project_dir, ai="auggie", ai_skills=True) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="multi-agent-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("auggie") + + auggie_skills_dir = project_dir / ".augment" / "skills" + auggie_hello = auggie_skills_dir / "speckit-multi-agent-ext-hello" / "SKILL.md" + auggie_world = auggie_skills_dir / "speckit-multi-agent-ext-world" / "SKILL.md" + assert auggie_hello.exists() and auggie_world.exists(), ( + "sanity: auggie's skills-mode activation should mirror both " + "extension commands as SKILL.md files" + ) + + # Activate copilot in skills mode too (no intervening removal of + # auggie's mirrors) — the same extension's skills get mirrored a + # second time, under a different agent's directory. + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + copilot_skills_dir = project_dir / ".github" / "skills" + copilot_hello = copilot_skills_dir / "speckit-multi-agent-ext-hello" / "SKILL.md" + copilot_world = copilot_skills_dir / "speckit-multi-agent-ext-world" / "SKILL.md" + assert copilot_hello.exists() and copilot_world.exists(), ( + "sanity: copilot's skills-mode activation should also mirror " + "both extension commands" + ) + + # Toggle copilot to command mode (mirroring `integration upgrade + # copilot` with no --skills) — copilot's mirror is now stale. + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_extensions_for_agent("copilot") + + assert not copilot_hello.exists() and not copilot_world.exists(), ( + "copilot's own stale skills-mode mirrors must be removed once " + "it toggles to command mode" + ) + assert auggie_hello.exists() and auggie_world.exists(), ( + "auggie's mirrors from an earlier activation must be left " + "untouched by copilot's own toggle cleanup" + ) + + metadata = manager.registry.get("multi-agent-ext") + registered_skills = metadata.get("registered_skills", []) + assert set(registered_skills) == { + "speckit-multi-agent-ext-hello", + "speckit-multi-agent-ext-world", + }, ( + "registered_skills must retain both names: auggie's mirrors " + "still exist on disk, so the extension still globally owns " + "these skill names even though copilot's own copy is now gone " + "(#2948)" + ) + + # Full removal must still find and clean up Auggie's remaining + # mirrors via the preserved tracking. + assert manager.remove("multi-agent-ext") is True + assert not auggie_hello.exists() and not auggie_world.exists(), ( + "removal must clean up every remaining extension-owned mirror, " + "not just the ones under the last-active agent's directory — " + "this only works if registered_skills tracking wasn't " + "prematurely dropped during the earlier toggle (#2948)" + ) + def test_existing_agent_command_path_file_is_not_detected( self, project_dir, temp_dir ): From 28561e6fc1e0431fdea272908ca8c89b56f92cf7 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:15:10 +0200 Subject: [PATCH 14/27] fix: reject symlinked skills-directory escape in extension skill scans _extension_owned_skill_names() and the fast/fallback paths of its sibling _unregister_extension_skills() called skills_candidate.resolve() and then checked children relative to that already-resolved candidate. If the candidate directory itself (e.g. .gemini/skills) was a symlink pointing outside the project root, both the resolve() call and the subsequent containment check silently passed through the symlink instead of rejecting it: - _extension_owned_skill_names() would falsely attribute ownership to a marker-matching SKILL.md living outside the project. - _unregister_extension_skills()'s fast path (an explicit skills_dir, as passed by the toggle-cleanup call site) and its fallback scan (used during full extension removal) would both shutil.rmtree() the external directory, deleting unrelated content outside the project. Fix by validating the candidate directory itself with the existing _validate_safe_shared_directory() shared-infra helper before any probe or delete: it rejects a symlink at any path component (walking down from the project root, including the final component) without ever resolving through it, and is already used elsewhere in the codebase for the same class of shared-directory containment check. Unsafe candidates are skipped/refused rather than followed. Add red-first security regression tests reproducing each of the three call sites with a `.gemini/skills` symlink pointing at an external directory containing a marker-matching SKILL.md and an unrelated precious_file.txt: provenance inference must not attribute the name, and both the explicit-skills_dir fast path and the None-skills_dir fallback scan must leave the external directory and file untouched. Existing valid shared/deduped directory tests (e.g. agy/amp/codex/zed sharing .agents/skills) continue to pass, confirming legitimate shared directories still clean up correctly. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 38 ++++++ tests/test_extension_skills.py | 158 +++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 2d023fc4ba..37d8e2dc3e 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1262,10 +1262,26 @@ def _unregister_extension_skills( if not skill_names: return + from ..shared_infra import _validate_safe_shared_directory + if skills_dir is None: skills_dir = self._get_skills_dir() if skills_dir: + # Reject the candidate directory itself (any path component, + # including the final one) if it's a symlink escaping the + # project root, before probing or deleting anything inside it. + # A caller-supplied skills_dir (e.g. a specific agent's + # directory resolved without side effects) could have been + # replaced with a symlink between registration and removal; + # resolving it and only checking children relative to the + # already-resolved candidate (the previous approach) would + # silently follow the symlink instead of rejecting it. + try: + _validate_safe_shared_directory(self.project_root, skills_dir) + except (ValueError, OSError): + return + # Fast path: we know the exact skills directory for skill_name in skill_names: # Guard against path traversal from a corrupted registry entry: @@ -1323,6 +1339,16 @@ def _unregister_extension_skills( for skills_candidate in candidate_dirs: if not skills_candidate.is_dir(): continue + # Reject the candidate directory itself (any path + # component) if it's a symlink escaping the project + # root, before probing or deleting anything inside it — + # same guard as the fast path above. + try: + _validate_safe_shared_directory( + self.project_root, skills_candidate + ) + except (ValueError, OSError): + continue for skill_name in skill_names: # Same path-traversal guard as the fast path above sn_path = Path(skill_name) @@ -1397,6 +1423,7 @@ def _extension_owned_skill_names( return [] from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR + from ..shared_infra import _validate_safe_shared_directory candidate_dirs: set[Path] = set() for cfg in AGENT_CONFIG.values(): @@ -1412,6 +1439,17 @@ def _extension_owned_skill_names( break # every name already confirmed owned somewhere if not skills_candidate.is_dir(): continue + # Reject the candidate directory itself (any path component) + # if it's a symlink escaping the project root, before probing + # anything inside it. Resolving it and only checking children + # relative to the already-resolved candidate (the previous + # approach) would silently follow the symlink instead of + # rejecting it, letting a marker-matching SKILL.md outside the + # project be falsely attributed. + try: + _validate_safe_shared_directory(self.project_root, skills_candidate) + except (ValueError, OSError): + continue try: resolved_candidate = skills_candidate.resolve() except OSError: diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index eee68b8f34..17f9d8f1ac 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1448,6 +1448,164 @@ def test_toggle_to_command_preserves_tracking_for_mirror_in_other_agent_dir( "prematurely dropped during the earlier toggle (#2948)" ) + def test_extension_owned_skill_names_rejects_symlinked_candidate_directory( + self, project_dir, temp_dir + ): + """Provenance probing must not follow a symlinked candidate skills + directory that escapes the project root, even when a marker- + matching SKILL.md exists at the symlink target. + + Both ``_extension_owned_skill_names`` and its sibling + ``_unregister_extension_skills`` previously called + ``skills_candidate.resolve()`` and then checked children relative + to that *already-resolved* candidate — so if the candidate + directory itself (e.g. ``.gemini/skills``) was a symlink pointing + outside the project root, both the resolve and the subsequent + containment check silently passed *through* the symlink instead + of rejecting it. A marker-matching ``SKILL.md`` at the symlink + target would therefore be falsely attributed to the extension. + """ + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + external_dir = temp_dir / "external-skills-root" + external_dir.mkdir() + (external_dir / "precious_file.txt").write_text( + "do not touch", encoding="utf-8" + ) + external_skill_subdir = external_dir / "speckit-sym-escape-ext-hello" + external_skill_subdir.mkdir() + (external_skill_subdir / "SKILL.md").write_text( + "---\n" + "name: speckit-sym-escape-ext-hello\n" + "description: external marker-matching skill\n" + "metadata:\n" + " source: extension:sym-escape-ext\n" + "---\n\n" + "external body\n", + encoding="utf-8", + ) + + gemini_dir = project_dir / ".gemini" + gemini_dir.mkdir() + os.symlink(str(external_dir), str(gemini_dir / "skills")) + + manager = ExtensionManager(project_dir) + owned = manager._extension_owned_skill_names( + ["speckit-sym-escape-ext-hello"], "sym-escape-ext" + ) + + assert owned == [], ( + "a symlinked candidate skills directory escaping the project " + "root must never be followed for provenance attribution, " + "even when a marker-matching SKILL.md exists at its target" + ) + + def test_unregister_extension_skills_fallback_does_not_follow_symlinked_dir( + self, project_dir, temp_dir + ): + """Fallback removal scanning must not delete through a symlinked + candidate skills directory escaping the project root. + + Mirrors the previous test but exercises the actual deletion path: + before the fix, a symlinked ``.gemini/skills`` pointing outside + the project root would be resolved and scanned, and the + marker-matching external ``SKILL.md`` directory would be deleted + via ``shutil.rmtree`` — collateral damage to unrelated external + content (here, ``precious_file.txt`` sitting alongside it). + """ + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + external_dir = temp_dir / "external-skills-root2" + external_dir.mkdir() + precious_file = external_dir / "precious_file.txt" + precious_file.write_text("do not touch", encoding="utf-8") + external_skill_subdir = external_dir / "speckit-sym-escape-ext2-hello" + external_skill_subdir.mkdir() + external_skill_md = external_skill_subdir / "SKILL.md" + external_skill_md.write_text( + "---\n" + "name: speckit-sym-escape-ext2-hello\n" + "description: external marker-matching skill\n" + "metadata:\n" + " source: extension:sym-escape-ext2\n" + "---\n\n" + "external body\n", + encoding="utf-8", + ) + + gemini_dir = project_dir / ".gemini" + gemini_dir.mkdir() + os.symlink(str(external_dir), str(gemini_dir / "skills")) + + manager = ExtensionManager(project_dir) + # Exercise the fallback scan (skills_dir=None) exactly as a full + # `remove()` would invoke it. + manager._unregister_extension_skills( + ["speckit-sym-escape-ext2-hello"], "sym-escape-ext2" + ) + + assert precious_file.exists(), ( + "unrelated external content must survive: the fallback scan " + "must never delete through a symlinked candidate directory " + "escaping the project root" + ) + assert external_skill_md.exists(), ( + "the external marker-matching skill directory itself must " + "not be removed via a symlinked candidate path" + ) + + def test_unregister_extension_skills_fast_path_rejects_symlinked_explicit_dir( + self, project_dir, temp_dir + ): + """Explicit-skills_dir fast path must reject a symlinked directory + escaping the project root, mirroring the register-time call site + where a caller resolves a specific agent's directory without + side effects and passes it straight through. + """ + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + external_dir = temp_dir / "external-skills-root3" + external_dir.mkdir() + precious_file = external_dir / "precious_file.txt" + precious_file.write_text("do not touch", encoding="utf-8") + external_skill_subdir = external_dir / "speckit-sym-escape-ext3-hello" + external_skill_subdir.mkdir() + (external_skill_subdir / "SKILL.md").write_text( + "---\n" + "name: speckit-sym-escape-ext3-hello\n" + "description: external marker-matching skill\n" + "metadata:\n" + " source: extension:sym-escape-ext3\n" + "---\n\n" + "external body\n", + encoding="utf-8", + ) + + gemini_dir = project_dir / ".gemini" + gemini_dir.mkdir() + symlinked_skills_dir = gemini_dir / "skills" + os.symlink(str(external_dir), str(symlinked_skills_dir)) + + manager = ExtensionManager(project_dir) + manager._unregister_extension_skills( + ["speckit-sym-escape-ext3-hello"], + "sym-escape-ext3", + skills_dir=symlinked_skills_dir, + ) + + assert precious_file.exists(), ( + "unrelated external content must survive: the fast path must " + "refuse to delete through an explicit but symlinked skills_dir " + "escaping the project root" + ) + assert external_skill_subdir.exists(), ( + "the external marker-matching skill directory must not be " + "removed via an explicit symlinked directory argument" + ) + def test_existing_agent_command_path_file_is_not_detected( self, project_dir, temp_dir ): From 1d8f9e3989aef1b87eb7618bb6b05e1e43be19ba Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:56:17 +0200 Subject: [PATCH 15/27] Fix unscoped extension-skill removal and legacy preset provenance on direct remove - _unregister_extension_skills(): omitting skills_dir now always triggers the full multi-directory fallback scan instead of narrowing to the currently active agent's directory. Previously, remove() (the only caller that omits skills_dir) would resolve the active agent's dir and take the scoped fast path, orphaning a previously-active second agent's extension skill mirror during full removal. - PresetManager.remove(): infer legacy flat-list registered_skills provenance (reusing _infer_legacy_skill_provenance from the prior rescaffold fix) before invoking _unregister_skills, so a direct `preset remove` with no intervening rescaffold/switch also restores every previously-active agent's directory instead of only the currently active one. Added regression tests: - test_remove_while_second_agent_still_in_skills_mode_cleans_up_first_agent_mirror - test_remove_infers_legacy_flat_list_provenance_without_prior_rescaffold Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 30 +++++----- src/specify_cli/presets/__init__.py | 24 ++++++++ tests/test_extension_skills.py | 62 +++++++++++++++++++++ tests/test_presets.py | 77 ++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 15 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 37d8e2dc3e..d4e9bb69ac 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1241,32 +1241,32 @@ def _unregister_extension_skills( Called during extension removal to clean up skill files that were created by ``_register_extension_skills()``. - If *skills_dir* is not provided and ``_get_skills_dir()`` returns - ``None`` (e.g. the user removed init-options.json or toggled - ai_skills after installation), we fall back to scanning all known - agent skills directories so that orphaned skill directories are - still cleaned up. In that case each candidate directory is - verified against the SKILL.md ``metadata.source`` field before - removal to avoid accidentally deleting user-created skills with - the same name. + When *skills_dir* is omitted, this is a genuinely unscoped removal + (e.g. full extension removal): ``registered_skills`` is a single + flat list covering mirrors created under *every* agent this + extension was ever activated under, not just the currently active + one, so we always scan all known agent skills directories rather + than narrowing to whichever agent happens to be active right now. + Each candidate directory is verified against the SKILL.md + ``metadata.source`` field before removal to avoid accidentally + deleting user-created skills with the same name. Args: skill_names: List of skill names to remove. extension_id: Extension ID used to verify ownership during fallback candidate scanning. - skills_dir: Optional explicit skills directory to use instead - of resolving via ``_get_skills_dir()``. Useful when the - caller needs to target a specific agent's skills directory - regardless of the currently-active agent in init-options. + skills_dir: Optional explicit skills directory to scope + cleanup to. Useful when the caller needs to target a + specific agent's skills directory regardless of the + currently-active agent in init-options. When omitted, + every configured agent's skills directory is scanned + instead of resolving just the currently active one. """ if not skill_names: return from ..shared_infra import _validate_safe_shared_directory - if skills_dir is None: - skills_dir = self._get_skills_dir() - if skills_dir: # Reject the candidate directory itself (any path component, # including the final one) if it's a symlink escaping the diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 9728bc909b..7852eb5d11 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -2247,6 +2247,30 @@ def remove(self, pack_id: str) -> bool: metadata = self.registry.get(pack_id) # Restore original skills when preset is removed registered_skills = metadata.get("registered_skills", []) if metadata else [] + if isinstance(registered_skills, list) and registered_skills: + # Legacy flat-list registries predate per-agent provenance + # tracking. Migration to the per-agent dict form previously + # only happened during a rescaffold (register_enabled_presets_ + # for_agent); if the *first* post-upgrade operation is instead + # `preset remove` (no intervening use/upgrade), the legacy + # branch of _unregister_skills restores only the currently + # active agent's directory, leaving this preset's overrides in + # every previously active agent's directory orphaned. Infer + # real per-agent ownership from the on-disk preset marker now, + # while pack_id is still known, and hand the resulting mapping + # through the same dict-based cleanup path already used for + # non-legacy registries (#2948). + from .. import load_init_options + + init_opts = load_init_options(self.project_root) + fallback_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None + if not isinstance(fallback_agent, str): + fallback_agent = "" + registered_skills = self._infer_legacy_skill_provenance( + [name for name in registered_skills if isinstance(name, str)], + pack_id, + fallback_agent=fallback_agent, + ) registered_commands = metadata.get("registered_commands", {}) if metadata else {} pack_dir = self.presets_dir / pack_id diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 17f9d8f1ac..2cd89aeeb9 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1448,6 +1448,68 @@ def test_toggle_to_command_preserves_tracking_for_mirror_in_other_agent_dir( "prematurely dropped during the earlier toggle (#2948)" ) + def test_remove_while_second_agent_still_in_skills_mode_cleans_up_first_agent_mirror( + self, project_dir, temp_dir + ): + """Full extension removal must clean up every previously-active + agent's mirror, not just the currently active one, even with no + intervening toggle. + + Auggie is activated first (skills mode), writing a mirror. + Copilot is then activated (also skills mode, still active at + removal time) and writes its own mirror for the same names. + ``remove()`` calls ``_unregister_extension_skills(registered_skills, + extension_id)`` with no explicit ``skills_dir`` — genuinely + unscoped, "clean up everywhere this extension owns something". + Before this fix, omitting ``skills_dir`` caused the method to + resolve the *currently active* agent's directory via + ``_get_skills_dir()`` and take the scoped fast path instead of the + all-directory fallback scan, so only Copilot's mirror was removed + and Auggie's was silently left orphaned (#2948). + """ + _create_init_options(project_dir, ai="auggie", ai_skills=True) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="remove-multi-agent-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("auggie") + + auggie_skills_dir = project_dir / ".augment" / "skills" + auggie_hello = auggie_skills_dir / "speckit-remove-multi-agent-ext-hello" / "SKILL.md" + auggie_world = auggie_skills_dir / "speckit-remove-multi-agent-ext-world" / "SKILL.md" + assert auggie_hello.exists() and auggie_world.exists(), ( + "sanity: auggie's skills-mode activation should mirror both " + "extension commands as SKILL.md files" + ) + + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + copilot_skills_dir = project_dir / ".github" / "skills" + copilot_hello = copilot_skills_dir / "speckit-remove-multi-agent-ext-hello" / "SKILL.md" + copilot_world = copilot_skills_dir / "speckit-remove-multi-agent-ext-world" / "SKILL.md" + assert copilot_hello.exists() and copilot_world.exists(), ( + "sanity: copilot's skills-mode activation should also mirror " + "both extension commands" + ) + + # Remove the extension while copilot (the second agent) is still + # the active, skills-mode agent — no toggle, no intervening + # rescaffold for auggie. + assert manager.remove("remove-multi-agent-ext") is True + + assert not copilot_hello.exists() and not copilot_world.exists(), ( + "sanity: the currently active agent's mirrors must be removed" + ) + assert not auggie_hello.exists() and not auggie_world.exists(), ( + "removal must also clean up the first agent's (auggie) " + "mirrors even though it is no longer the active agent — " + "omitting skills_dir must trigger the all-directory fallback " + "scan, not silently narrow to the currently active agent's " + "directory (#2948)" + ) + def test_extension_owned_skill_names_rejects_symlinked_candidate_directory( self, project_dir, temp_dir ): diff --git a/tests/test_presets.py b/tests/test_presets.py index 18a1b7318f..fcbcf3171d 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4907,6 +4907,83 @@ def test_infer_legacy_skill_provenance_does_not_falsely_attribute_command_mode_c "side effect of probing for provenance (#2948)" ) + def test_remove_infers_legacy_flat_list_provenance_without_prior_rescaffold( + self, project_dir, temp_dir + ): + """``preset remove`` on a legacy flat-list registry must restore + every previously active agent's directory, not just the currently + active one, even when it is the *very first* post-upgrade + operation (no intervening ``use``/``upgrade``/rescaffold). + + Pre-#2948 registries recorded a flat ``registered_skills`` list + because presets were rendered for every detected skill-mode agent + at once, not just the active one. Migrating that legacy format to + the per-agent dict form previously only happened as a side effect + of ``register_enabled_presets_for_agent`` (i.e. a rescaffold or + ``integration use``/``switch``). If the user's first action after + upgrading is instead directly running ``preset remove``, the + legacy branch of ``_unregister_skills`` restored only the + currently active agent's directory (via ``_get_skills_dir()``), + permanently leaving this preset's override in every other, + previously active agent's directory (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "remove-legacy-no-rescaffold-preset", "speckit.specify", + "Remove legacy no rescaffold test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:remove-legacy-no-rescaffold-preset" in claude_skill.read_text(), ( + "sanity: install should have written the override under " + "claude's skill directory" + ) + # Simulate the pre-#2948 "register for every detected agent" + # install behaviour by also placing the marker under codex's + # directory directly (mirroring the old, non-active-only + # rendering that predates this PR). + codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" + codex_skill.write_text(claude_skill.read_text(), encoding="utf-8") + + # Simulate a pre-#2948 registry: a flat list with no per-agent + # provenance, even though both directories actually hold this + # preset's marker on disk. + manager.registry.update( + "remove-legacy-no-rescaffold-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + # No intervening use/upgrade/rescaffold: remove() is the very + # first operation run after the legacy registry was written. + assert manager.remove("remove-legacy-no-rescaffold-preset") is True + + for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:remove-legacy-no-rescaffold-preset" not in content, ( + f"{label}'s preset override must be restored on removal " + "even with no prior rescaffold to migrate the legacy " + "flat-list format first — remove() must infer real " + "per-agent ownership from on-disk provenance itself " + "(#2948)" + ) + assert "Core specify body" in content + def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir): """Removal must validate a recorded skill directory before touching it. From 0ab9a5f3f4fe2729ff096ac7eafdbcd190a1a95a Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:08:11 +0200 Subject: [PATCH 16/27] Keep unregister_agent_artifacts scoped to its agent when directory is absent ExtensionManager.unregister_agent_artifacts() converted its resolved agent_skills_dir to None whenever that directory didn't exist, before calling _unregister_extension_skills(). After 1d8f9e3, omitting skills_dir means "genuinely unscoped removal": scan every configured agent's directory, reserved for ExtensionManager.remove()'s full project cleanup. Since unregister_agent_artifacts is agent-scoped (used by switch to clean up the previous integration's artifacts), this caused it to delete every other agent's live extension skill mirrors whenever the target agent's own directory happened to be absent, e.g. unregistering an agent that was never activated. Fix: always pass the explicit, agent-scoped skills_dir, even when it doesn't exist on disk, so the fast path is a safe no-op for an absent directory instead of falling back to the all-agents scan. Registry reconciliation (dropping removed names from the flat registered_skills list) now only runs when the agent's directory actually exists, so an absent directory can't be misread as "these names were removed everywhere" and wipe tracking for mirrors that still legitimately live under other agents' directories. Added regression test: - test_unregister_agent_artifacts_stays_scoped_when_agent_dir_absent Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 50 ++++++++++++------- tests/test_extension_skills.py | 69 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index d4e9bb69ac..8288a9adb4 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1871,30 +1871,44 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: metadata.get("registered_skills", []) ) if registered_skills: - # Only pass the resolved skills_dir when it actually exists. - # Otherwise let _unregister_extension_skills fall back to - # scanning all known agent skills directories, which is useful - # for cleaning up stale entries created by earlier installs. - skills_dir = agent_skills_dir if agent_skills_dir.is_dir() else None + # Always pass the explicit, agent-scoped skills_dir — even + # when it doesn't currently exist on disk. This method must + # stay scoped to *this* agent only; omitting skills_dir (a + # bare ``None``) tells _unregister_extension_skills "this is + # a genuinely unscoped removal", which triggers its + # all-configured-agents fallback scan — reserved for + # ExtensionManager.remove()'s full project cleanup. If this + # agent's directory doesn't exist, there is nothing under it + # to clean up; the fast path below is a safe no-op in that + # case (every candidate skill_subdir.is_dir() check fails). self._unregister_extension_skills( - registered_skills, ext_id, skills_dir=skills_dir + registered_skills, ext_id, skills_dir=agent_skills_dir ) - # Only reconcile registry state when cleanup was scoped to a - # specific existing directory. When skills_dir is None, - # _unregister_extension_skills falls back to scanning multiple - # candidate directories, so agent_skills_dir cannot be used to - # infer what was removed. When skills_dir is set, - # _unregister_extension_skills may intentionally skip deletion - # when ownership cannot be verified (e.g., corrupted/missing - # SKILL.md or mismatching metadata.source). Only drop registry - # entries for skill directories that were actually removed so - # future cleanup attempts can still find skipped ones. - if skills_dir is not None: + # Only reconcile registry state when this agent's directory + # actually exists. When it's absent, this agent never had + # any of these skills mirrored under its own directory in + # the first place, so there is nothing to conclude about + # global ``registered_skills`` tracking from that absence — + # other agents' directories may still legitimately hold + # live mirrors for these same names (the flat list is + # agent-agnostic). Recomputing "remaining" against an + # absent directory would incorrectly conclude every name + # was removed and drop them all from the registry, silently + # orphaning any still-live mirrors under other agents' + # directories from future cleanup/removal. + # + # When the directory does exist, _unregister_extension_skills + # may intentionally skip deletion when ownership cannot be + # verified (e.g., corrupted/missing SKILL.md or mismatching + # metadata.source). Only drop registry entries for skill + # directories that were actually removed so future cleanup + # attempts can still find skipped ones. + if agent_skills_dir.is_dir(): remaining_skills = [ skill_name for skill_name in registered_skills - if (skills_dir / skill_name).is_dir() + if (agent_skills_dir / skill_name).is_dir() ] if remaining_skills != registered_skills: updates["registered_skills"] = remaining_skills diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 2cd89aeeb9..0cbad8007c 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1510,6 +1510,75 @@ def test_remove_while_second_agent_still_in_skills_mode_cleans_up_first_agent_mi "directory (#2948)" ) + def test_unregister_agent_artifacts_stays_scoped_when_agent_dir_absent( + self, project_dir, temp_dir + ): + """``unregister_agent_artifacts`` must remain scoped to the given + agent even when that agent's own skills directory does not exist — + it must never fall through to the genuinely-unscoped, all-directory + removal semantics reserved for ``remove()``. + + Auggie and Copilot are both activated in skills mode, each writing + its own mirror. ``unregister_agent_artifacts("cursor-agent")`` is + then called for a supported agent that was *never* activated, so + its skills directory does not exist on disk. Before this fix, the + caller converted the resolved-but-absent directory to ``None`` + before calling ``_unregister_extension_skills`` — and after the + prior fix (1d8f9e3), omitting ``skills_dir`` means "scan and clean + up every configured agent's directory", not "this specific agent + has nothing to clean up". That silently deleted Auggie's and + Copilot's still-live mirrors while "cleaning up" an agent that was + never even active. + """ + _create_init_options(project_dir, ai="auggie", ai_skills=True) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="scoped-unregister-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("auggie") + + auggie_skills_dir = project_dir / ".augment" / "skills" + auggie_hello = auggie_skills_dir / "speckit-scoped-unregister-ext-hello" / "SKILL.md" + auggie_world = auggie_skills_dir / "speckit-scoped-unregister-ext-world" / "SKILL.md" + assert auggie_hello.exists() and auggie_world.exists(), ( + "sanity: auggie's skills-mode activation should mirror both " + "extension commands as SKILL.md files" + ) + + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + copilot_skills_dir = project_dir / ".github" / "skills" + copilot_hello = copilot_skills_dir / "speckit-scoped-unregister-ext-hello" / "SKILL.md" + copilot_world = copilot_skills_dir / "speckit-scoped-unregister-ext-world" / "SKILL.md" + assert copilot_hello.exists() and copilot_world.exists(), ( + "sanity: copilot's skills-mode activation should also mirror " + "both extension commands" + ) + + cursor_skills_dir = project_dir / ".cursor" / "skills" + assert not cursor_skills_dir.exists(), ( + "sanity: cursor-agent was never activated so it has no " + "skills directory on disk" + ) + + # Unregister artifacts for an agent that was never activated (its + # skills directory is absent). This must be a no-op with respect + # to other agents' live mirrors. + manager.unregister_agent_artifacts("cursor-agent") + + assert auggie_hello.exists() and auggie_world.exists(), ( + "unregistering a never-activated agent's artifacts must not " + "delete auggie's live mirror — an absent target directory " + "must not widen cleanup to every configured agent directory" + ) + assert copilot_hello.exists() and copilot_world.exists(), ( + "unregistering a never-activated agent's artifacts must not " + "delete copilot's live mirror — an absent target directory " + "must not widen cleanup to every configured agent directory" + ) + def test_extension_owned_skill_names_rejects_symlinked_candidate_directory( self, project_dir, temp_dir ): From 31c9b97cde19fecc08e929fad09955eefb3dd1da Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:17:26 +0200 Subject: [PATCH 17/27] Preserve global skill tracking across agents in unregister_agent_artifacts The present-directory branch of ExtensionManager.unregister_agent_artifacts() recomputed "remaining" registered_skills only by checking whether each name still existed under the just-cleaned agent's own directory. registered_skills is a single flat list shared across every agent an extension was ever activated under (skills are only ever rendered for the currently active agent, so there's no per-agent registry key). Repro: auggie and copilot both have mirrors for the same extension; unregister_agent_artifacts("auggie") correctly removes auggie's own mirror, sees the names absent from auggie's (now empty) directory, and stores an empty registered_skills list - even though copilot's mirror is still live on disk and now untracked. A later full remove() then reads an empty registry and leaves copilot's mirror orphaned. Fix: after the agent-scoped cleanup, recompute remaining names with _extension_owned_skill_names(), which scans every safe, configured agent skills directory (not just the one just cleaned) and keeps a name only if a marker-verified SKILL.md for this extension still exists somewhere. This is the same helper already used for the analogous same-agent toggle-cleanup case, so no new abstraction was introduced. Explicit per-agent cleanup, marker ownership verification, and symlink/containment safety are unchanged. Added regression test: - test_unregister_agent_artifacts_preserves_tracking_for_other_agent_mirror Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 22 ++++--- tests/test_extension_skills.py | 87 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 8288a9adb4..76afb57ee2 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1901,15 +1901,21 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: # When the directory does exist, _unregister_extension_skills # may intentionally skip deletion when ownership cannot be # verified (e.g., corrupted/missing SKILL.md or mismatching - # metadata.source). Only drop registry entries for skill - # directories that were actually removed so future cleanup - # attempts can still find skipped ones. + # metadata.source). A name no longer present under *this* + # agent's directory isn't necessarily gone everywhere either + # — registered_skills is a single flat list shared across + # every agent this extension was ever activated under, so + # an earlier activation under a different, still-active + # agent may have left its own marker-verified mirror behind. + # Recompute across every safe, supported skills directory + # (the same helper used for the analogous toggle-cleanup + # case) rather than just this one, or a still-existing + # mirror elsewhere would be silently dropped from tracking + # and orphaned on later removal (#2948). if agent_skills_dir.is_dir(): - remaining_skills = [ - skill_name - for skill_name in registered_skills - if (agent_skills_dir / skill_name).is_dir() - ] + remaining_skills = self._extension_owned_skill_names( + registered_skills, ext_id + ) if remaining_skills != registered_skills: updates["registered_skills"] = remaining_skills diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 0cbad8007c..36fdad6575 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1579,6 +1579,93 @@ def test_unregister_agent_artifacts_stays_scoped_when_agent_dir_absent( "must not widen cleanup to every configured agent directory" ) + def test_unregister_agent_artifacts_preserves_tracking_for_other_agent_mirror( + self, project_dir, temp_dir + ): + """Present-directory reconciliation in ``unregister_agent_artifacts`` + must not drop global ``registered_skills`` tracking for a name that + still has a marker-verified mirror under a *different* agent's + directory. + + Auggie and Copilot are both activated in skills mode, each writing + its own mirror for the same extension skill names into the single, + agent-agnostic flat ``registered_skills`` list. + ``unregister_agent_artifacts("auggie")`` removes auggie's own + mirror (its directory exists, so the fast path finds and deletes + it) — but before this fix, the registry reconciliation afterward + only checked whether each name still existed under *auggie's* own + (now-empty) directory, concluding every name was gone and wiping + ``registered_skills`` to ``[]`` even though Copilot's mirror was + still live and now untracked. A later full ``remove()`` would then + read an empty registry and leave Copilot's mirror permanently + orphaned (#2948). + """ + _create_init_options(project_dir, ai="auggie", ai_skills=True) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="dual-agent-unregister-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("auggie") + + auggie_skills_dir = project_dir / ".augment" / "skills" + auggie_hello = auggie_skills_dir / "speckit-dual-agent-unregister-ext-hello" / "SKILL.md" + auggie_world = auggie_skills_dir / "speckit-dual-agent-unregister-ext-world" / "SKILL.md" + assert auggie_hello.exists() and auggie_world.exists(), ( + "sanity: auggie's skills-mode activation should mirror both " + "extension commands as SKILL.md files" + ) + + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + copilot_skills_dir = project_dir / ".github" / "skills" + copilot_hello = copilot_skills_dir / "speckit-dual-agent-unregister-ext-hello" / "SKILL.md" + copilot_world = copilot_skills_dir / "speckit-dual-agent-unregister-ext-world" / "SKILL.md" + assert copilot_hello.exists() and copilot_world.exists(), ( + "sanity: copilot's skills-mode activation should also mirror " + "both extension commands" + ) + + # Unregister artifacts for auggie only (its directory exists and + # is cleaned up), while copilot's mirror is untouched and remains + # live on disk. + manager.unregister_agent_artifacts("auggie") + + assert not auggie_hello.exists() and not auggie_world.exists(), ( + "sanity: auggie's own mirror must be removed" + ) + assert copilot_hello.exists() and copilot_world.exists(), ( + "sanity: copilot's mirror must be untouched by an auggie-scoped " + "unregister call" + ) + + registry_metadata = manager.registry.get("dual-agent-unregister-ext") + tracked = registry_metadata.get("registered_skills", []) + assert "speckit-dual-agent-unregister-ext-hello" in tracked, ( + "registered_skills tracking must be preserved for names still " + "owned by copilot's live mirror, even though they were removed " + "from auggie's own (now nonexistent) directory — reconciling " + "against only the just-cleaned agent's directory incorrectly " + "concludes the name is gone everywhere (#2948)" + ) + assert "speckit-dual-agent-unregister-ext-world" in tracked, ( + "registered_skills tracking must be preserved for names still " + "owned by copilot's live mirror, even though they were removed " + "from auggie's own (now nonexistent) directory — reconciling " + "against only the just-cleaned agent's directory incorrectly " + "concludes the name is gone everywhere (#2948)" + ) + + # A subsequent full extension removal must still find and clean up + # copilot's remaining mirror via the preserved tracking. + assert manager.remove("dual-agent-unregister-ext") is True + assert not copilot_hello.exists() and not copilot_world.exists(), ( + "full removal must clean up copilot's remaining mirror — this " + "only works if registered_skills tracking wasn't prematurely " + "dropped by the earlier auggie-scoped unregister call (#2948)" + ) + def test_extension_owned_skill_names_rejects_symlinked_candidate_directory( self, project_dir, temp_dir ): From ab6c28cd81a88c924914ad2773b16849ea90661b Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:16:50 +0200 Subject: [PATCH 18/27] Reconcile every historical agent on preset removal; validate child skill dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 3 findings from the Copilot review on HEAD 31c9b97 (#2948): 1. presets/__init__.py: remove()'s command reconciliation only recreated the surviving preset's content for the currently active agent, even though the removed preset's registered_commands could span multiple historical (now-inactive) agents recorded via prior rescaffolds. Now remove() captures every historical agent registered_commands actually targeted (before mutation) and passes it as extra_agents through _reconcile_composed_commands -> _register_for_non_skill_agents / _register_command_from_path -> registrar.register_commands_for_non_ skill_agents, so the active-only restriction for install/use is preserved while post-removal reconciliation restores every touched directory. 2. presets/__init__.py: the analogous gap existed for skills. _unregister_ skills() now returns {skills_dir: renderer_agent} for every directory it actually restored, and _reconcile_skills() accepts extra_skills_dirs to reconcile each of those directories (via a new apply_to_dir() helper), not only the currently active skills directory. _register_skills() gained optional target_dir/target_agent overrides (forcing create_missing_skills off for non-active directories) so a historical directory is only ever restored, never seeded with brand-new skills. 3. extensions/__init__.py: _extension_owned_skill_names() and both the fast and fallback paths of _unregister_extension_skills() validated only the parent skills_dir for symlink escape, then resolved skills_dir / skill_name and checked containment relative to that already-resolved parent. A per-skill child that is itself a symlink to a different, legitimate skill directory within the same (safe) root passed that containment check, so deleting/attributing through the symlink name could destroy or misattribute an unrelated skill reached only via the alias. All three call sites now run the shared _validate_safe_shared_directory() component-wise check against the full skills_dir / skill_name path (not just the parent) before any read or delete, rejecting a symlinked child outright rather than following it, even when its resolved target remains in-bounds. Regression tests added (all confirmed red against pre-fix code, green after): - test_remove_reconciles_command_for_every_historical_agent - test_remove_reconciles_skill_for_every_historical_agent - test_extension_owned_skill_names_rejects_symlinked_child_skill_dir - test_unregister_extension_skills_explicit_dir_rejects_symlinked_child - test_unregister_extension_skills_fallback_rejects_symlinked_child Tests: tests/test_presets.py (361), tests/test_extension_skills.py (69), tests/test_extensions.py (338) all pass; tests/integrations (1768 passed, 1 skipped) pass; full suite 3902 passed / 74 skipped (90 pre-existing, environment-only git-signing tests deselected — confirmed failing identically on the pre-change baseline due to local 1Password SSH-agent signing, unrelated to this change). ruff check clean on all changed files. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/agents.py | 16 +- src/specify_cli/extensions/__init__.py | 49 ++-- src/specify_cli/presets/__init__.py | 299 +++++++++++++++++-------- tests/test_extension_skills.py | 143 ++++++++++++ tests/test_presets.py | 151 +++++++++++++ 5 files changed, 552 insertions(+), 106 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 735032039b..b97677a9ab 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -11,7 +11,7 @@ import re from copy import deepcopy from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Iterable, List, Optional import yaml @@ -1083,6 +1083,7 @@ def register_commands_for_non_skill_agents( link_outputs: bool = False, extension_id: Optional[str] = None, only_agent: Optional[str] = None, + extra_agents: Optional[Iterable[str]] = None, ) -> Dict[str, List[str]]: """Register commands for all non-skill agents in the project. @@ -1102,14 +1103,25 @@ def register_commands_for_non_skill_agents( only_agent: If set, restrict registration to this single agent (#2948). An agent name that matches no configured agent (e.g. an empty string) yields no registrations at all. + extra_agents: Additional agent names to register for besides + ``only_agent``. Used by post-removal reconciliation to also + restore surviving content into historical agent directories + a just-removed preset actually wrote to, not only the + currently active agent (#2948). Ignored when ``only_agent`` + is ``None`` (already unrestricted). Returns: Dictionary mapping agent names to list of registered commands """ results = {} self._ensure_configs() + extra_agents_set = frozenset(extra_agents) if extra_agents else frozenset() for agent_name, agent_config in self.AGENT_CONFIGS.items(): - if only_agent is not None and agent_name != only_agent: + if ( + only_agent is not None + and agent_name != only_agent + and agent_name not in extra_agents_set + ): continue if agent_config.get("extension") == "/SKILL.md": continue diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 76afb57ee2..96f96f6c48 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1290,10 +1290,19 @@ def _unregister_extension_skills( sn_path = Path(skill_name) if sn_path.is_absolute() or len(sn_path.parts) != 1: continue + skill_subdir = skills_dir / skill_name + # Validate every path component down to the skill's own + # subdirectory, not just the already-validated parent + # skills_dir: a per-skill child can itself be a symlink to + # another directory whose *resolved* target still lands + # inside this same (safe) skills root, which the previous + # resolve()+relative_to() containment check alone would + # not catch. Reject the symlink outright rather than + # following it, even when the target is otherwise + # in-bounds (#2948). try: - skill_subdir = (skills_dir / skill_name).resolve() - skill_subdir.relative_to(skills_dir.resolve()) # raises if outside - except (OSError, ValueError): + _validate_safe_shared_directory(self.project_root, skill_subdir) + except (ValueError, OSError): continue if not skill_subdir.is_dir(): continue @@ -1354,12 +1363,19 @@ def _unregister_extension_skills( sn_path = Path(skill_name) if sn_path.is_absolute() or len(sn_path.parts) != 1: continue + skill_subdir = skills_candidate / skill_name + # Validate every path component down to the skill's + # own subdirectory, not just the already-validated + # candidate parent: a per-skill child can itself be a + # symlink to another directory whose resolved target + # still lands inside this same candidate, which the + # previous resolve()+relative_to() containment check + # alone would not catch (#2948). try: - skill_subdir = (skills_candidate / skill_name).resolve() - skill_subdir.relative_to( - skills_candidate.resolve() - ) # raises if outside - except (OSError, ValueError): + _validate_safe_shared_directory( + self.project_root, skill_subdir + ) + except (ValueError, OSError): continue if not skill_subdir.is_dir(): continue @@ -1450,20 +1466,23 @@ def _extension_owned_skill_names( _validate_safe_shared_directory(self.project_root, skills_candidate) except (ValueError, OSError): continue - try: - resolved_candidate = skills_candidate.resolve() - except OSError: - continue for skill_name in skill_names: if skill_name in owned: continue sn_path = Path(skill_name) if sn_path.is_absolute() or len(sn_path.parts) != 1: continue + skill_subdir = skills_candidate / skill_name + # Validate every path component down to the skill's own + # subdirectory, not just the already-validated candidate + # parent: a per-skill child can itself be a symlink to + # another directory whose resolved target still lands + # inside this same candidate, which the previous + # resolve()+relative_to() containment check alone would + # not catch (#2948). try: - skill_subdir = (skills_candidate / skill_name).resolve() - skill_subdir.relative_to(resolved_candidate) # raises if outside - except (OSError, ValueError): + _validate_safe_shared_directory(self.project_root, skill_subdir) + except (ValueError, OSError): continue if not skill_subdir.is_dir(): continue diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 7852eb5d11..69021dcd32 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -16,7 +16,7 @@ import shutil from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union +from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union, Set if TYPE_CHECKING: from ..agents import CommandRegistrar @@ -910,7 +910,9 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) - def _reconcile_composed_commands(self, command_names: List[str]) -> None: + def _reconcile_composed_commands( + self, command_names: List[str], extra_agents: Optional[Set[str]] = None + ) -> None: """Re-resolve and re-register composed commands from the full stack. After install or remove, recompute the effective content for each @@ -930,6 +932,13 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: Args: command_names: List of command names to reconcile + extra_agents: Additional agent names to also reconcile besides + the currently active one. Populated by ``remove()`` with the + historical agents a just-removed preset's + ``registered_commands`` actually targeted, so a surviving + lower-priority preset's content is restored there too — not + only for the currently active agent (#2948). Install/use + callers omit this, preserving pure active-only behavior. """ if not command_names: return @@ -996,7 +1005,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": self._register_for_non_skill_agents( registrar, [tmpl], manifest.id, pack_dir, - only_agent=only_agent, + only_agent=only_agent, extra_agents=extra_agents, ) registered = True break @@ -1024,7 +1033,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: matching_cmds, ext_id, ext_dir, self.project_root, context_note=f"\n\n\n", - only_agent=only_agent, + only_agent=only_agent, extra_agents=extra_agents, ) registered = True except Exception: @@ -1036,7 +1045,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: self._register_command_from_path( registrar, cmd_name, top_path, source_id=source_id, - only_agent=only_agent, + only_agent=only_agent, extra_agents=extra_agents, ) else: # Composed command — resolve from full stack @@ -1073,7 +1082,11 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: agent: cmd_names_to_unregister for agent in registrar.AGENT_CONFIGS if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md" - and (only_agent is None or agent == only_agent) + and ( + only_agent is None + or agent == only_agent + or agent in (extra_agents or ()) + ) }, self.project_root, ) @@ -1096,7 +1109,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: registrar, [{**tmpl, "file": f".composed/{cmd_name}.md"}], manifest.id, pack_dir, - only_agent=only_agent, + only_agent=only_agent, extra_agents=extra_agents, ) registered = True break @@ -1118,7 +1131,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: self._register_command_from_path( registrar, cmd_name, composed_file, source_id=source_id, - only_agent=only_agent, + only_agent=only_agent, extra_agents=extra_agents, ) def _register_command_from_path( @@ -1128,6 +1141,7 @@ def _register_command_from_path( cmd_path: Path, source_id: str = "reconciled", only_agent: Optional[str] = None, + extra_agents: Optional[Set[str]] = None, ) -> None: """Register a single command from a file path (non-preset source). @@ -1140,6 +1154,8 @@ def _register_command_from_path( cmd_path: Path to the command file source_id: Source attribution for rendered output only_agent: If set, restrict registration to this single agent (#2948). + extra_agents: Additional agent names to register for besides + ``only_agent`` (post-removal reconciliation only, #2948). """ if not cmd_path.exists(): return @@ -1170,7 +1186,7 @@ def _register_command_from_path( pass # best-effort alias loading self._register_for_non_skill_agents( registrar, [cmd_tmpl], source_id, cmd_path.parent, - only_agent=only_agent, + only_agent=only_agent, extra_agents=extra_agents, ) def _register_for_non_skill_agents( @@ -1180,6 +1196,7 @@ def _register_for_non_skill_agents( source_id: str, source_dir: Path, only_agent: Optional[str] = None, + extra_agents: Optional[Set[str]] = None, ) -> None: """Register commands for non-skill agents during reconciliation. @@ -1198,10 +1215,14 @@ def _register_for_non_skill_agents( only_agent: If set, restrict registration to this single agent, matching the active-only rule applied by ``_register_commands`` (#2948). + extra_agents: Additional agent names to register for besides + ``only_agent``. Used by post-removal reconciliation to also + restore surviving content into historical agent directories + a just-removed preset actually wrote to (#2948). """ registrar.register_commands_for_non_skill_agents( commands, source_id, source_dir, self.project_root, - only_agent=only_agent, + only_agent=only_agent, extra_agents=extra_agents, ) class _FilteredManifest: @@ -1225,7 +1246,11 @@ def templates(self) -> List[Dict[str, Any]]: if t.get("name") in self._cmd_names ] - def _reconcile_skills(self, command_names: List[str]) -> None: + def _reconcile_skills( + self, + command_names: List[str], + extra_skills_dirs: Optional[Dict[Path, Optional[str]]] = None, + ) -> None: """Re-register skills for commands whose winning layer changed. After a preset is removed, finds the next preset in the priority @@ -1234,52 +1259,66 @@ def _reconcile_skills(self, command_names: List[str]) -> None: Args: command_names: List of command names to reconcile skills for + extra_skills_dirs: Additional ``{skills_dir: renderer_agent}`` + pairs to reconcile besides the currently active skills + directory. Populated by ``remove()`` from the directories + ``_unregister_skills`` just restored to core/extension + content: a surviving lower-priority preset's override must + be re-applied to every one of those directories too, not + only the currently active agent's directory, or an + inactive integration is left with stale/missing content + even though the removed preset no longer wins there + either (#2948). """ if not command_names: return resolver = PresetResolver(self.project_root) - skills_dir = self._get_skills_dir() + active_skills_dir = self._get_skills_dir() + + from .. import load_init_options + + init_opts = load_init_options(self.project_root) + active_ai = init_opts.get("ai") if isinstance(init_opts, dict) else None + if not isinstance(active_ai, str) or not active_ai: + active_ai = None # Cache registry once to avoid repeated filesystem reads presets_by_priority = list(self.registry.list_by_priority()) # Group command names by winning preset to batch _register_skills calls - # while only registering skills for the specific commands being reconciled. + # while only registering skills for the specific commands being + # reconciled. This resolution (which preset/content wins) is + # directory-independent, so it's computed once and then applied to + # every affected directory below. preset_cmds: Dict[str, List[str]] = {} non_preset_skills: List[tuple] = [] + managed_skill_names: set = set() for cmd_name in command_names: layers = resolver.collect_all_layers(cmd_name, "command") if not layers: continue - # Re-create the skill directory only if it was previously managed - # (i.e., listed in some preset's registered_skills). This avoids - # creating new skill dirs that _register_skills would normally skip. - if skills_dir: - skill_name, _ = self._skill_names_for_command(cmd_name) - skill_subdir = skills_dir / skill_name - if not skill_subdir.exists(): - # Check if any preset previously registered this skill - was_managed = False - for _pid, meta in presets_by_priority: - if not isinstance(meta, dict): - continue - recorded = meta.get("registered_skills", []) - if isinstance(recorded, dict): - in_any_agent = any( - skill_name in names - for names in recorded.values() - if isinstance(names, list) - ) - else: - in_any_agent = skill_name in recorded - if in_any_agent: - was_managed = True - break - if was_managed: - skill_subdir.mkdir(parents=True, exist_ok=True) + skill_name, _ = self._skill_names_for_command(cmd_name) + # Track whether any preset previously registered this skill + # (i.e., it was actively managed), so a not-yet-existing skill + # dir can be re-created per affected directory below. + for _pid, meta in presets_by_priority: + if not isinstance(meta, dict): + continue + recorded = meta.get("registered_skills", []) + if isinstance(recorded, dict): + in_any_agent = any( + skill_name in names + for names in recorded.values() + if isinstance(names, list) + ) + else: + in_any_agent = skill_name in recorded + if in_any_agent: + managed_skill_names.add(skill_name) + break top_path = layers[0]["path"] # Find the preset that owns the winning layer @@ -1293,39 +1332,44 @@ def _reconcile_skills(self, command_names: List[str]) -> None: if not found_preset: # Winner is a non-preset source (core/extension/override). # Track the winning layer path for skill restoration. - skill_name, _ = self._skill_names_for_command(cmd_name) non_preset_skills.append((skill_name, cmd_name, layers[0])) - # Restore skills for commands whose winner is non-preset. - if non_preset_skills and skills_dir: - # Separate override-backed skills from core/extension-backed ones. - # _unregister_skills can rmtree the skill dir, so overrides must - # be handled directly (create dir + write) without that call. - core_ext_skills = [] - override_skills = [] - for item in non_preset_skills: - if item[2]["source"] == "project override": - override_skills.append(item) - else: - core_ext_skills.append(item) + core_ext_skills = [s for s in non_preset_skills if s[2]["source"] != "project override"] + override_skills = [s for s in non_preset_skills if s[2]["source"] == "project override"] + + def apply_to_dir( + skills_dir: Path, dir_agent: Optional[str], *, is_active: bool + ) -> None: + # Re-create the skill directory only if it was previously + # managed (i.e., listed in some preset's registered_skills). + # This avoids creating new skill dirs that _register_skills + # would normally skip. + for skill_name in managed_skill_names: + skill_subdir = skills_dir / skill_name + if not skill_subdir.exists(): + skill_subdir.mkdir(parents=True, exist_ok=True) + # Restore skills for commands whose winner is non-preset. + # _unregister_skills_in_dir can rmtree the skill dir, so + # overrides must be handled directly (create dir + write) + # without that call. if core_ext_skills: - self._unregister_skills( - [s[0] for s in core_ext_skills], self.presets_dir + self._unregister_skills_in_dir( + [s[0] for s in core_ext_skills], skills_dir, dir_agent ) for skill_name, cmd_name, top_layer in override_skills: skill_subdir = skills_dir / skill_name # Same symlink guard as _register_skills's registration path - # (#2948): mkdir(exist_ok=True) alone would silently follow an - # existing symlinked subdirectory before writing SKILL.md + # (#2948): mkdir(exist_ok=True) alone would silently follow + # an existing symlinked subdirectory before writing SKILL.md # through it. if not self._validate_skill_subdir(skill_subdir, create=True): continue skill_file = skill_subdir / "SKILL.md" try: from ..agents import CommandRegistrar - from .. import SKILL_DESCRIPTIONS, load_init_options + from .. import SKILL_DESCRIPTIONS registrar = CommandRegistrar() content = top_layer["path"].read_text(encoding="utf-8") fm, body = registrar.parse_frontmatter(content) @@ -1336,9 +1380,8 @@ def _reconcile_skills(self, command_names: List[str]) -> None: short_name.replace(".", "-"), f"Command: {short_name}", ) - init_opts = load_init_options(self.project_root) - selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else "" - if isinstance(selected_ai, str): + selected_ai = dir_agent if isinstance(dir_agent, str) else "" + if selected_ai: body = registrar.resolve_skill_placeholders( selected_ai, fm, body, self.project_root ) @@ -1346,10 +1389,9 @@ def _reconcile_skills(self, command_names: List[str]) -> None: body, registrar, selected_ai ) from ..integrations import get_integration - integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None + integration = get_integration(selected_ai) if selected_ai else None fm_data = registrar.build_skill_frontmatter( - selected_ai if isinstance(selected_ai, str) else "", - skill_name, desc, + selected_ai, skill_name, desc, f"override:{cmd_name}", ) registrar.apply_argument_hint(fm, fm_data, integration) @@ -1366,21 +1408,38 @@ def _reconcile_skills(self, command_names: List[str]) -> None: except Exception: pass # best-effort override skill restoration - # Register skills only for the specific commands being reconciled, - # not all commands in each winning preset's manifest. - for pack_id, cmds in preset_cmds.items(): - pack_dir = self.presets_dir / pack_id - manifest_path = pack_dir / "preset.yml" - if not manifest_path.exists(): - continue - try: - manifest = PresetManifest(manifest_path) - except PresetValidationError: - continue - # Filter manifest to only the commands being reconciled - cmds_set = set(cmds) - filtered_manifest = self._FilteredManifest(manifest, cmds_set) - self._register_skills(filtered_manifest, pack_dir) + # Register skills only for the specific commands being + # reconciled, not all commands in each winning preset's + # manifest. + for pack_id, cmds in preset_cmds.items(): + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + if not manifest_path.exists(): + continue + try: + manifest = PresetManifest(manifest_path) + except PresetValidationError: + continue + cmds_set = set(cmds) + filtered_manifest = self._FilteredManifest(manifest, cmds_set) + if is_active: + # Preserve exact prior behaviour for the currently + # active directory (including the ability to create + # brand-new skill subdirectories when ai_skills is on). + self._register_skills(filtered_manifest, pack_dir) + else: + self._register_skills( + filtered_manifest, pack_dir, + target_dir=skills_dir, target_agent=dir_agent or "", + ) + + if active_skills_dir: + apply_to_dir(active_skills_dir, active_ai, is_active=True) + + for extra_dir, extra_agent in (extra_skills_dirs or {}).items(): + if extra_dir == active_skills_dir: + continue # already reconciled above as the active directory + apply_to_dir(extra_dir, extra_agent, is_active=False) def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. @@ -1494,6 +1553,9 @@ def _register_skills( self, manifest: "PresetManifest", preset_dir: Path, + *, + target_dir: Optional[Path] = None, + target_agent: Optional[str] = None, ) -> Dict[str, List[str]]: """Generate SKILL.md files for preset command overrides. @@ -1507,6 +1569,18 @@ def _register_skills( Args: manifest: Preset manifest. preset_dir: Installed preset directory. + target_dir: Explicit skills directory to render into, instead + of resolving the currently active one. Used by + ``_reconcile_skills`` to restore a surviving preset's + override into a historical (currently inactive) agent's + directory that removal of a higher-priority preset just + reverted (#2948). + target_agent: Explicit agent name to render for, paired with + ``target_dir``. When set, skills are only ever restored + into already-tracked directories/names — brand-new skill + subdirectories are never created for a non-active, + explicitly targeted directory (that creation path is only + meaningful for the currently active agent). Returns: ``{agent_name: [skill_name, ...]}`` for the single active @@ -1535,7 +1609,7 @@ def _register_skills( if not filtered: return {} - skills_dir = self._get_skills_dir() + skills_dir = target_dir if target_dir is not None else self._get_skills_dir() if not skills_dir: return {} @@ -1546,10 +1620,16 @@ def _register_skills( init_opts = load_init_options(self.project_root) if not isinstance(init_opts, dict): init_opts = {} - selected_ai = init_opts.get("ai") + selected_ai = target_agent if target_agent is not None else init_opts.get("ai") if not isinstance(selected_ai, str) or not selected_ai: return {} - ai_skills_enabled = is_ai_skills_enabled(init_opts) + # A target_dir/target_agent call reconciles an explicitly-known, + # already-tracked directory (see _reconcile_skills) rather than the + # currently active agent, so ai_skills_enabled must not be derived + # from the *current* project-wide toggle for that other agent — it + # only controls whether brand-new skill subdirectories may be + # created below, which is only meaningful for the active agent. + ai_skills_enabled = target_agent is None and is_ai_skills_enabled(init_opts) registrar = CommandRegistrar() integration = get_integration(selected_ai) agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) @@ -1846,7 +1926,7 @@ def _unregister_skills( self, registered_skills: Union[Dict[str, List[str]], List[str]], preset_dir: Path, - ) -> None: + ) -> Dict[Path, Optional[str]]: """Restore original SKILL.md files after a preset is removed. For each skill that was overridden by the preset, attempts to @@ -1867,9 +1947,17 @@ def _unregister_skills( ``List[str]`` from a registry written before this provenance tracking existed. preset_dir: The preset's installed directory (may already be deleted). + + Returns: + ``{skills_dir: renderer_agent}`` for every directory actually + restored, so callers (e.g. ``remove()``) can hand these same + directories to :meth:`_reconcile_skills` — a surviving + lower-priority preset's override must be re-applied to every + directory this removal touched, not only the currently active + agent's directory (#2948). """ if not registered_skills: - return + return {} if isinstance(registered_skills, dict): from .. import load_init_options @@ -1909,25 +1997,32 @@ def _unregister_skills( active_agent if active_agent in agents else sorted(agents)[0] ) self._unregister_skills_in_dir(group["names"], skills_dir, renderer_agent) - return + return { + skills_dir: ( + active_agent if active_agent in group["agents"] else sorted(group["agents"])[0] + ) + for skills_dir, group in groups.items() + } # Legacy flat-list format: no record of which agent directory these # names were written under, so best-effort restore is limited to the # currently active agent's directory (the pre-provenance behaviour). skills_dir = self._get_skills_dir() if not skills_dir: - return + return {} from .. import load_init_options init_opts = load_init_options(self.project_root) if not isinstance(init_opts, dict): init_opts = {} selected_ai = init_opts.get("ai") + selected_ai = selected_ai if isinstance(selected_ai, str) else None self._unregister_skills_in_dir( registered_skills, skills_dir, - selected_ai if isinstance(selected_ai, str) else None, + selected_ai, ) + return {skills_dir: selected_ai} def _unregister_skills_in_dir( self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str] @@ -2274,6 +2369,27 @@ def remove(self, pack_id: str) -> bool: registered_commands = metadata.get("registered_commands", {}) if metadata else {} pack_dir = self.presets_dir / pack_id + # Record which historical agents this preset's registered_commands + # actually targeted, *before* any filtering below, so post-removal + # reconciliation can restore a surviving lower-priority preset's + # override into every one of those directories too — not only the + # currently active agent's. Without this, removing a preset that + # was rendered under a previously-active (now inactive) agent + # deletes that agent's command file via _unregister_commands below, + # but active-only reconciliation would only recreate the surviving + # winner for the current agent, leaving the inactive integration + # with a missing/stale file (#2948). + try: + from ..agents import CommandRegistrar as _CommandRegistrarForScope + except ImportError: + _CommandRegistrarForScope = None + affected_command_agents = { + agent_name + for agent_name in registered_commands + if _CommandRegistrarForScope is None + or _CommandRegistrarForScope.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" + } + # Collect ALL command names before filtering for reconciliation, # so commands registered only for skill-based agents are also # reconciled. Every command-type template's primary name is added @@ -2304,8 +2420,9 @@ def remove(self, pack_id: str) -> bool: # names from registered_commands are still unregistered. pass + affected_skill_dirs: Dict[Path, Optional[str]] = {} if registered_skills: - self._unregister_skills(registered_skills, pack_dir) + affected_skill_dirs = self._unregister_skills(registered_skills, pack_dir) try: from ..agents import CommandRegistrar except ImportError: @@ -2330,8 +2447,12 @@ def remove(self, pack_id: str) -> bool: # re-resolve from the remaining stack so the next layer takes effect. if removed_cmd_names: try: - self._reconcile_composed_commands(list(removed_cmd_names)) - self._reconcile_skills(list(removed_cmd_names)) + self._reconcile_composed_commands( + list(removed_cmd_names), extra_agents=affected_command_agents + ) + self._reconcile_skills( + list(removed_cmd_names), extra_skills_dirs=affected_skill_dirs + ) except Exception as exc: import warnings warnings.warn( diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 36fdad6575..33e8b175e3 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1824,6 +1824,149 @@ def test_unregister_extension_skills_fast_path_rejects_symlinked_explicit_dir( "removed via an explicit symlinked directory argument" ) + def test_extension_owned_skill_names_rejects_symlinked_child_skill_dir( + self, project_dir, temp_dir + ): + """Provenance probing must reject a per-skill child directory that + is itself a symlink, even when its resolved target stays inside + the (real, non-symlinked) skills root. + + Both ``_extension_owned_skill_names`` and + ``_unregister_extension_skills`` previously only validated the + *parent* ``skills_dir`` for symlink escape, then resolved + ``skills_dir / skill_name`` and checked containment relative to + the already-resolved parent. A child symlink whose target + resolves inside that same root passes that containment check, so + a corrupted or attacker-controlled registry entry naming a + symlink alias could cause a legitimate, unrelated skill directory + to be falsely attributed as extension-owned via the alias. + """ + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True) + + # A real, legitimately marker-matching skill directory under its + # own name — this represents genuine extension-owned content. + real_skill_dir = skills_dir / "speckit-child-sym-real" + real_skill_dir.mkdir() + (real_skill_dir / "SKILL.md").write_text( + "---\n" + "name: speckit-child-sym-real\n" + "description: real skill\n" + "metadata:\n" + " source: extension:child-sym-ext\n" + "---\n\n" + "real body\n", + encoding="utf-8", + ) + + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + # A *different* registered name that is merely a symlink alias + # pointing at the real skill directory above — both still live + # inside the same, non-symlinked skills root. + alias_name = "speckit-child-sym-alias" + os.symlink(str(real_skill_dir), str(skills_dir / alias_name)) + + manager = ExtensionManager(project_dir) + owned = manager._extension_owned_skill_names( + [alias_name], "child-sym-ext" + ) + + assert owned == [], ( + "a per-skill child directory that is itself a symlink must " + "never be followed for provenance attribution, even when its " + "resolved target remains inside the skills root" + ) + + def test_unregister_extension_skills_explicit_dir_rejects_symlinked_child( + self, project_dir, temp_dir + ): + """Fast (explicit ``skills_dir``) removal path must refuse to + delete through a per-skill child directory that is itself a + symlink, even when the resolved target stays inside the skills + root — deleting the resolved target would destroy a legitimate, + differently-named skill directory via the alias. + """ + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True) + + precious_skill_dir = skills_dir / "speckit-child-sym-precious" + precious_skill_dir.mkdir() + precious_skill_md = precious_skill_dir / "SKILL.md" + precious_skill_md.write_text( + "---\n" + "name: speckit-child-sym-precious\n" + "description: precious skill\n" + "metadata:\n" + " source: extension:child-sym-ext2\n" + "---\n\n" + "precious body\n", + encoding="utf-8", + ) + + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + alias_name = "speckit-child-sym-alias2" + os.symlink(str(precious_skill_dir), str(skills_dir / alias_name)) + + manager = ExtensionManager(project_dir) + manager._unregister_extension_skills( + [alias_name], "child-sym-ext2", skills_dir=skills_dir, + ) + + assert precious_skill_dir.exists(), ( + "the real skill directory reached only through a symlink " + "alias must survive removal of the alias name (#2948)" + ) + assert precious_skill_md.exists(), ( + "the real skill directory's SKILL.md must not be deleted via " + "a differently-named symlink alias" + ) + + def test_unregister_extension_skills_fallback_rejects_symlinked_child( + self, project_dir, temp_dir + ): + """Fallback (unscoped, ``skills_dir=None``) removal scan must also + refuse to delete through a per-skill child symlink, mirroring the + explicit-dir fast path. + """ + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True) + + precious_skill_dir = skills_dir / "speckit-child-sym-precious3" + precious_skill_dir.mkdir() + precious_skill_md = precious_skill_dir / "SKILL.md" + precious_skill_md.write_text( + "---\n" + "name: speckit-child-sym-precious3\n" + "description: precious skill\n" + "metadata:\n" + " source: extension:child-sym-ext3\n" + "---\n\n" + "precious body\n", + encoding="utf-8", + ) + + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + alias_name = "speckit-child-sym-alias3" + os.symlink(str(precious_skill_dir), str(skills_dir / alias_name)) + + manager = ExtensionManager(project_dir) + manager._unregister_extension_skills([alias_name], "child-sym-ext3") + + assert precious_skill_dir.exists(), ( + "the real skill directory reached only through a symlink " + "alias must survive the unscoped fallback removal scan (#2948)" + ) + assert precious_skill_md.exists(), ( + "the real skill directory's SKILL.md must not be deleted via " + "a differently-named symlink alias during fallback removal" + ) + def test_existing_agent_command_path_file_is_not_detected( self, project_dir, temp_dir ): diff --git a/tests/test_presets.py b/tests/test_presets.py index fcbcf3171d..4a7bc8b160 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -5295,6 +5295,157 @@ def test_composed_none_unregister_respects_active_agent( "inactive agent's directory (#2948)" ) + def test_remove_reconciles_command_for_every_historical_agent( + self, project_dir, temp_dir + ): + """Removing a preset must reconcile every historical agent its + ``registered_commands`` actually targeted, not only the currently + active one. + + Preset B (lower precedence, survives) is installed while gemini is + active, then preset A (higher precedence) overrides the same + command while gemini is still active. Switching the active + integration to opencode and rescaffolding re-registers both + presets under opencode too, so preset A's ``registered_commands`` + now spans two agents: gemini (now inactive) and opencode (active). + Removing A deletes its command file from *both* directories via + ``_unregister_commands``, but active-only reconciliation used to + recreate the surviving preset B's content only for the active + agent (opencode), leaving gemini's directory with a stale/missing + file (#2948). + """ + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_b_dir = self._create_command_preset( + temp_dir, "hist-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + preset_a_dir = self._create_command_preset( + temp_dir, "hist-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + + gemini_cmd_files = list(gemini_dir.glob("*specify*")) + assert gemini_cmd_files, "sanity: gemini should have the command file" + assert "preset A body" in gemini_cmd_files[0].read_text(), ( + "sanity: preset A (higher precedence) should win initially" + ) + + # Switch the active integration to opencode and rescaffold, mirroring + # `integration use opencode`. This merges opencode into both + # presets' registered_commands alongside the pre-existing gemini + # entry recorded while gemini was active. + self._write_init_options(project_dir, ai="opencode", ai_skills=False) + opencode_dir = project_dir / ".opencode" / "commands" + opencode_dir.mkdir(parents=True, exist_ok=True) + manager.register_enabled_presets_for_agent("opencode") + + metadata_a = manager.registry.get("hist-preset-a") + assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, ( + "sanity: preset A's registered_commands must span both the " + "historical (gemini) and currently active (opencode) agents" + ) + + assert manager.remove("hist-preset-a") is True + + gemini_cmd_files = list(gemini_dir.glob("*specify*")) + opencode_cmd_files = list(opencode_dir.glob("*specify*")) + assert gemini_cmd_files, "gemini's command file must still exist after removal" + assert opencode_cmd_files, "opencode's command file must still exist after removal" + assert "preset B body" in gemini_cmd_files[0].read_text(), ( + "removing the higher-precedence preset must restore the " + "surviving preset's content in the historical (inactive) " + "agent's directory too, not only the active agent's (#2948)" + ) + assert "preset B body" in opencode_cmd_files[0].read_text(), ( + "the surviving preset's content must also be restored for the " + "currently active agent" + ) + + def test_remove_reconciles_skill_for_every_historical_agent( + self, project_dir, temp_dir + ): + """Removing a preset must reconcile every historical skills + directory its ``registered_skills`` actually targeted, not only + the currently active one. + + Preset B (survives) is installed while claude is active, then + preset A (higher precedence) overrides the same command while + claude is still active. Switching to codex and rescaffolding + records codex too, so preset A's ``registered_skills`` spans both + claude (now inactive) and codex (active) directories. Removing A + restores both directories to core/extension via + ``_unregister_skills``, but ``_reconcile_skills`` used to only + resolve/apply the surviving winner for the currently active + skills directory, leaving claude's directory reverted to + core/extension content instead of preset B's override (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + + # A core template fallback is required so unregistering the + # top-priority preset's SKILL.md restores core content rather than + # deleting the skill directory outright when no preset remains to + # apply on top of it (mirrors the pre-existing skills-reconciliation + # fixtures elsewhere in this file). + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_b_dir = self._create_command_preset( + temp_dir, "hist-skill-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + preset_a_dir = self._create_command_preset( + temp_dir, "hist-skill-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + + claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:hist-skill-preset-a" in claude_skill_file.read_text(), ( + "sanity: preset A (higher precedence) should win initially" + ) + + # Switch the active integration to codex (a distinct skills + # directory) and rescaffold, mirroring `integration use codex`. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_skills_dir = project_dir / ".agents" / "skills" + manager.register_enabled_presets_for_agent("codex") + + metadata_a = manager.registry.get("hist-skill-preset-a") + assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, ( + "sanity: preset A's registered_skills must span both the " + "historical (claude) and currently active (codex) agents" + ) + + assert manager.remove("hist-skill-preset-a") is True + + codex_skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md" + assert claude_skill_file.exists(), "claude's skill file must still exist after removal" + assert codex_skill_file.exists(), "codex's skill file must still exist after removal" + assert "preset:hist-skill-preset-b" in claude_skill_file.read_text(), ( + "removing the higher-precedence preset must restore the " + "surviving preset's override in the historical (inactive) " + "agent's directory too, not only the active agent's (#2948)" + ) + assert "preset:hist-skill-preset-b" in codex_skill_file.read_text(), ( + "the surviving preset's override must also be restored for " + "the currently active agent" + ) + def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir): """Restore must validate each per-skill subdirectory, not just its parent. From f19225a5dddb2b3478289bc70e39fc00e83aaae2 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:47:53 +0200 Subject: [PATCH 19/27] Persist historical reconciliation ownership; defer destructive toggle cleanup; validate registry-provided skill names Round 11 review findings (5 comments on HEAD ab6c28c), three root causes: A) Historical-agent reconciliation wrote surviving content to disk but discarded the returned per-agent write map, so the preset's own registered_commands/registered_skills never learned about directories reconciliation restored on its behalf. A later removal of that same preset then orphaned those directories. Added _merge_pack_registered_commands/_merge_pack_registered_skills and wired them into _reconcile_composed_commands and _reconcile_skills's apply_to_dir so every actual write is merged back into the winning preset's registry metadata. B) Command<->skills toggle on an already-active agent deleted the old artifact before the replacement registration ran, in both presets/__init__.py's register_enabled_presets_for_agent and extensions/__init__.py's register_enabled_extensions_for_agent. If the replacement step raised, both artifacts were lost. Deferred the destructive cleanup until after the replacement phase completes without raising (register-new-then-remove-old ordering); the mirror skills->command direction was already safe since the new command file is always registered unconditionally before any cleanup runs. C) _unregister_skills_in_dir and _infer_legacy_skill_provenance joined a registry-provided (untrusted) skill name directly onto a directory before any name-shape validation. An absolute in-project name discards the intended parent directory entirely (Path's "/" operator drops the left side for an absolute right side), letting a corrupted registry entry escape the intended skills subtree while still resolving inside the project root - passing the existing containment/symlink check. Added a centralized _is_safe_registry_skill_name guard (rejecting non-strings, empty strings, absolute paths, multi-component paths, and "."/".." ) and applied it before every path join derived from registry-provided skill names in both functions. Also fixed _infer_legacy_skill_provenance's unmatched-name fallback, which previously still attributed rejected names to fallback_agent even after the loop skipped them. Added red-first regressions for all three root causes, covering: a two-preset historical-command-agent survivor scenario, an analogous skill-agent survivor scenario, injected skills-phase failure during a preset command->skills toggle and the extension equivalent, a direct unit test of the new name-safety guard, an absolute-path escape attempt against _unregister_skills_in_dir, and a false-attribution attempt against _infer_legacy_skill_provenance. Tests: tests/test_presets.py (367 passed), tests/test_extension_skills.py + tests/test_extensions.py (408 passed), tests/integrations (1768 passed, 1 skipped), full suite tests -q deselecting the pre-existing 1Password-signing-affected tests/extensions/git/test_git_extension.py (3909 passed, 74 skipped, 90 deselected). ruff check clean on all changed files. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 52 ++-- src/specify_cli/presets/__init__.py | 232 ++++++++++++++-- tests/test_extension_skills.py | 56 ++++ tests/test_presets.py | 352 +++++++++++++++++++++++++ 4 files changed, 657 insertions(+), 35 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 96f96f6c48..b8ec4f00f9 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2001,6 +2001,10 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: # registration of the remaining enabled extensions for this agent. try: updates: Dict[str, Any] = {} + # Set when a command -> skills toggle for this same agent + # defers stale command-mode cleanup until the skills + # replacement below confirms success (#2948). + deferred_stale_commands: Optional[List[str]] = None if agent_config and not skills_mode_active: registered = registrar.register_commands_for_agent( @@ -2022,28 +2026,27 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: updates["registered_commands"] = new_registered elif agent_config and skills_mode_active: # Toggled command -> skills for this same agent: the - # commands phase above is skipped, but a command file - # this extension previously wrote for this agent while + # commands phase above is skipped. A command file this + # extension previously wrote for this agent while # command mode was active is still on disk and still - # tracked. Remove it narrowly for this agent so - # command-mode and skills-mode artifacts stay mutually - # exclusive, matching unregister_agent_artifacts's - # per-agent command cleanup (#2948). + # tracked, but it must NOT be removed yet — the skills + # phase below is an independently fallible replacement + # step, and deleting the old artifact before it + # succeeds would leave neither the old command file nor + # a new skill file if skills registration raises. The + # actual removal is deferred until after the skills + # phase below completes without raising (#2948). registered_commands = metadata.get("registered_commands", {}) if isinstance(registered_commands, dict) and registered_commands.get( agent_name ): - stale_commands = self._valid_name_list( + deferred_stale_commands = self._valid_name_list( registered_commands.get(agent_name) ) - if stale_commands: - registrar.unregister_commands( - {agent_name: stale_commands}, self.project_root - ) - new_registered = copy.deepcopy(registered_commands) - new_registered.pop(agent_name, None) - if new_registered != registered_commands: - updates["registered_commands"] = new_registered + else: + deferred_stale_commands = None + else: + deferred_stale_commands = None # Extension *skills* are only ever rendered for the active agent: # `_register_extension_skills` resolves the skills dir and @@ -2122,6 +2125,25 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: if remaining != existing_skills: updates["registered_skills"] = remaining + # The skills phase above completed without raising + # (this ``else:`` is only reached on success), so a + # deferred command -> skills toggle cleanup queued + # above is now safe to apply: the replacement skill + # registration is confirmed, so the stale + # command-mode artifact can finally be removed + # without risking a transient state where neither + # artifact exists (#2948). + if deferred_stale_commands: + registrar.unregister_commands( + {agent_name: deferred_stale_commands}, self.project_root + ) + registered_commands = metadata.get("registered_commands", {}) + if isinstance(registered_commands, dict): + new_registered = copy.deepcopy(registered_commands) + new_registered.pop(agent_name, None) + if new_registered != registered_commands: + updates["registered_commands"] = new_registered + if updates: self.registry.update(ext_id, updates) except Exception as ext_err: diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 69021dcd32..dc260935c3 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -785,17 +785,24 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: if not isinstance(existing_commands, dict): existing_commands = {} merged_commands = copy.deepcopy(existing_commands) + # Toggled command -> skills for this same agent: + # _register_commands's ai_skills guard just made this a + # no-op, but the command file this preset wrote while + # command mode was active is still on disk and still + # tracked. Do NOT unregister it yet — _register_skills() + # below is an independently fallible replacement step, and + # deleting the old artifact before it succeeds would leave + # neither the old command file nor a new skill file if + # skills registration raises. The old artifact is only + # removed after the skills phase below completes without + # raising, preserving command/skill mutual exclusion while + # never leaving a transient failure with nothing in place + # (#2948). + stale_command_names: Optional[List[str]] = None if registered_commands.get(agent_name): merged_commands[agent_name] = registered_commands[agent_name] elif ai_skills_now and merged_commands.get(agent_name): - # Toggled command -> skills for this same agent: - # _register_commands's ai_skills guard just made this a - # no-op, but the command file this preset wrote while - # command mode was active is still on disk and still - # tracked. Unregister it narrowly for this agent so - # command-mode and skills-mode artifacts stay mutually - # exclusive (#2948). - self._unregister_commands({agent_name: merged_commands.pop(agent_name)}) + stale_command_names = merged_commands[agent_name] # Persist the commands phase immediately, mirroring # install_from_directory(): _register_skills is an # independently fallible phase, and if it raises, the files @@ -843,7 +850,12 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: # directory once ai_skills is off, so _register_skills # is a no-op — but the SKILL.md this preset wrote while # skills mode was active is still tracked and still on - # disk. Restore/remove it narrowly for this agent (#2948). + # disk. Restore/remove it narrowly for this agent. This + # direction is already register-new-then-remove-old: + # _register_commands (the replacement) ran unconditionally + # above and only reaches here once it has already + # succeeded, so this cleanup happens only after the new + # command artifact is confirmed in place (#2948). self._unregister_skills( {agent_name: merged_skills.pop(agent_name)}, pack_dir ) @@ -861,6 +873,15 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: ) if merged_skills != existing_skills or needs_migration: self.registry.update(pack_id, {"registered_skills": merged_skills}) + + # The skills phase above completed without raising, so the + # replacement artifact is confirmed — now it's safe to + # remove the stale command-mode artifact deferred earlier + # (#2948). + if stale_command_names: + self._unregister_commands({agent_name: stale_command_names}) + merged_commands.pop(agent_name, None) + self.registry.update(pack_id, {"registered_commands": merged_commands}) except Exception as pack_err: from .. import _print_cli_warning @@ -910,6 +931,50 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) + def _merge_pack_registered_commands( + self, pack_id: str, written: Optional[Dict[str, List[str]]] + ) -> None: + """Merge actually-written agent command registrations into a preset's metadata. + + Reconciliation (``_reconcile_composed_commands``) can write a + preset's content into an agent directory the preset never wrote to + before — most notably a historical (currently inactive) agent + supplied via ``extra_agents`` when a higher-priority preset is + removed. If that write isn't reflected back into the winning + preset's own ``registered_commands``, the registry silently lies + about which directories the preset owns: a later removal of this + same preset only cleans up the agents it already knew about, + orphaning the directory reconciliation just wrote to on its behalf + (#2948). + + Args: + pack_id: The preset whose metadata should be updated. + written: ``{agent_name: [cmd_name, ...]}`` actually written by + the reconciliation call just made, exactly mirroring + ``CommandRegistrar.register_commands_for_non_skill_agents``'s + return value. A falsy value is a no-op. + """ + if not written: + return + metadata = self.registry.get(pack_id) + if metadata is None: + return # pack_id no longer installed (e.g. removed mid-loop) + existing_commands = metadata.get("registered_commands", {}) + if not isinstance(existing_commands, dict): + existing_commands = {} + merged_commands = copy.deepcopy(existing_commands) + changed = False + for agent_name, cmd_names in written.items(): + if not cmd_names: + continue + existing_names = merged_commands.get(agent_name, []) + new_names = [n for n in cmd_names if n not in existing_names] + if new_names: + merged_commands[agent_name] = existing_names + new_names + changed = True + if changed: + self.registry.update(pack_id, {"registered_commands": merged_commands}) + def _reconcile_composed_commands( self, command_names: List[str], extra_agents: Optional[Set[str]] = None ) -> None: @@ -1003,10 +1068,11 @@ def _reconcile_composed_commands( if manifest: for tmpl in manifest.templates: if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": - self._register_for_non_skill_agents( + written = self._register_for_non_skill_agents( registrar, [tmpl], manifest.id, pack_dir, only_agent=only_agent, extra_agents=extra_agents, ) + self._merge_pack_registered_commands(manifest.id, written) registered = True break break @@ -1105,12 +1171,13 @@ def _reconcile_composed_commands( composed_dir.mkdir(parents=True, exist_ok=True) composed_file = composed_dir / f"{cmd_name}.md" composed_file.write_text(composed, encoding="utf-8") - self._register_for_non_skill_agents( + written = self._register_for_non_skill_agents( registrar, [{**tmpl, "file": f".composed/{cmd_name}.md"}], manifest.id, pack_dir, only_agent=only_agent, extra_agents=extra_agents, ) + self._merge_pack_registered_commands(manifest.id, written) registered = True break else: @@ -1142,7 +1209,7 @@ def _register_command_from_path( source_id: str = "reconciled", only_agent: Optional[str] = None, extra_agents: Optional[Set[str]] = None, - ) -> None: + ) -> Dict[str, List[str]]: """Register a single command from a file path (non-preset source). Used by reconciliation when the winning layer is an extension, @@ -1156,9 +1223,14 @@ def _register_command_from_path( only_agent: If set, restrict registration to this single agent (#2948). extra_agents: Additional agent names to register for besides ``only_agent`` (post-removal reconciliation only, #2948). + + Returns: + ``{agent_name: [cmd_name, ...]}`` for every agent this call + actually registered the command for (empty if the source path + doesn't exist or nothing was written). """ if not cmd_path.exists(): - return + return {} cmd_tmpl: Dict[str, Any] = { "name": cmd_name, "type": "command", @@ -1184,7 +1256,7 @@ def _register_command_from_path( break except Exception: pass # best-effort alias loading - self._register_for_non_skill_agents( + return self._register_for_non_skill_agents( registrar, [cmd_tmpl], source_id, cmd_path.parent, only_agent=only_agent, extra_agents=extra_agents, ) @@ -1197,7 +1269,7 @@ def _register_for_non_skill_agents( source_dir: Path, only_agent: Optional[str] = None, extra_agents: Optional[Set[str]] = None, - ) -> None: + ) -> Dict[str, List[str]]: """Register commands for non-skill agents during reconciliation. Skill-based agents (``/SKILL.md`` layout) are handled separately: @@ -1219,8 +1291,15 @@ def _register_for_non_skill_agents( ``only_agent``. Used by post-removal reconciliation to also restore surviving content into historical agent directories a just-removed preset actually wrote to (#2948). + + Returns: + ``{agent_name: [cmd_name, ...]}`` for every agent this call + actually registered a command for, mirroring + ``CommandRegistrar.register_commands_for_non_skill_agents``'s + return value so callers can merge it into a preset's own + ``registered_commands`` tracking (#2948). """ - registrar.register_commands_for_non_skill_agents( + return registrar.register_commands_for_non_skill_agents( commands, source_id, source_dir, self.project_root, only_agent=only_agent, extra_agents=extra_agents, ) @@ -1246,6 +1325,59 @@ def templates(self) -> List[Dict[str, Any]]: if t.get("name") in self._cmd_names ] + def _merge_pack_registered_skills( + self, pack_id: str, written: Optional[Dict[str, List[str]]] + ) -> None: + """Merge actually-written agent skill registrations into a preset's metadata. + + Mirrors :meth:`_merge_pack_registered_commands` for the skills + side: ``_reconcile_skills`` can render a preset's SKILL.md content + into an agent directory the preset never wrote to before — most + notably a historical (currently inactive) agent restored via + ``extra_skills_dirs`` when a higher-priority preset is removed. If + that write isn't reflected back into the winning preset's own + ``registered_skills``, a later removal of this same preset only + cleans up the agents it already knew about, orphaning the skill + directory reconciliation just wrote to on its behalf (#2948). + + Args: + pack_id: The preset whose metadata should be updated. + written: ``{agent_name: [skill_name, ...]}`` actually written + by the ``_register_skills`` call just made. A falsy value + is a no-op. + """ + if not written: + return + metadata = self.registry.get(pack_id) + if metadata is None: + return # pack_id no longer installed (e.g. removed mid-loop) + raw_existing_skills = metadata.get("registered_skills") + if isinstance(raw_existing_skills, list) and raw_existing_skills: + # Legacy flat-list value: infer real per-agent ownership from + # on-disk provenance rather than guessing (#2948). + fallback_agent = next(iter(written)) if written else None + existing_skills = self._infer_legacy_skill_provenance( + [n for n in raw_existing_skills if isinstance(n, str)], + pack_id, + fallback_agent=fallback_agent, + ) + else: + existing_skills = self._normalize_registered_skills(raw_existing_skills) + merged_skills = copy.deepcopy(existing_skills) + changed = ( + isinstance(raw_existing_skills, list) and bool(raw_existing_skills) + ) + for agent_name, skill_names in written.items(): + if not skill_names: + continue + existing_names = merged_skills.get(agent_name, []) + new_names = [n for n in skill_names if n not in existing_names] + if new_names: + merged_skills[agent_name] = existing_names + new_names + changed = True + if changed: + self.registry.update(pack_id, {"registered_skills": merged_skills}) + def _reconcile_skills( self, command_names: List[str], @@ -1426,12 +1558,21 @@ def apply_to_dir( # Preserve exact prior behaviour for the currently # active directory (including the ability to create # brand-new skill subdirectories when ai_skills is on). - self._register_skills(filtered_manifest, pack_dir) + written = self._register_skills(filtered_manifest, pack_dir) else: - self._register_skills( + written = self._register_skills( filtered_manifest, pack_dir, target_dir=skills_dir, target_agent=dir_agent or "", ) + # The winning preset may not have previously written to + # this directory's agent (most notably a historical agent + # reconciliation just restored content into via + # extra_skills_dirs). If that write isn't merged back into + # the preset's own registered_skills, its registry entry + # silently lies about which directories it owns and a + # later removal of this same preset orphans the directory + # reconciliation just wrote to on its behalf (#2948). + self._merge_pack_registered_skills(pack_id, written) if active_skills_dir: apply_to_dir(active_skills_dir, active_ai, is_active=True) @@ -1802,11 +1943,18 @@ def _infer_legacy_skill_provenance( dir_to_agents.setdefault(skills_dir, []).append(agent_name) marker = f"preset:{pack_id}" + # Filter unsafe names once, up front, rather than only inside the + # matching loop: any name skipped there would otherwise still + # land in "unmatched" below and get blindly attributed to + # fallback_agent anyway, defeating the guard entirely (#2948). + safe_skill_names = [ + name for name in skill_names if self._is_safe_registry_skill_name(name) + ] inferred: Dict[str, List[str]] = {} matched_names: set = set() for resolved_dir, agents in dir_to_agents.items(): canonical_agent = fallback_agent if fallback_agent in agents else sorted(agents)[0] - for name in skill_names: + for name in safe_skill_names: skill_subdir = resolved_dir / name if not self._validate_skill_subdir(skill_subdir, create=False): continue @@ -1828,7 +1976,7 @@ def _infer_legacy_skill_provenance( inferred.setdefault(canonical_agent, []).append(name) matched_names.add(name) - unmatched = [name for name in skill_names if name not in matched_names] + unmatched = [name for name in safe_skill_names if name not in matched_names] if unmatched and fallback_agent: fallback_names = inferred.setdefault(fallback_agent, []) for name in unmatched: @@ -1893,6 +2041,36 @@ def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]: return None return skills_dir + @staticmethod + def _is_safe_registry_skill_name(name: Any) -> bool: + """Validate a registry-provided skill name is a single safe path component. + + ``registered_skills`` entries are persisted registry data, not + derived from the current preset manifest, so a corrupted or + maliciously edited registry could contain an absolute path, a + multi-segment path (containing ``/`` or ``\\``), or a traversal + component (``"."``/``".."``) instead of a plain skill directory + name. Any of these — if joined directly onto a skills directory — + can escape the intended skill subtree while still resolving to a + location inside the project root, which is enough to pass the + parent-directory containment/symlink check alone (#2948). This + centralizes the single boundary check every preset cleanup and + provenance loop that consumes registry-provided skill names must + apply before ever constructing a path from one. + """ + if not isinstance(name, str) or not name: + return False + if name in (".", ".."): + return False + candidate = Path(name) + if candidate.is_absolute(): + return False + if len(candidate.parts) != 1: + return False + if candidate.name != name: + return False + return True + def _validate_skill_subdir(self, skill_subdir: Path, *, create: bool) -> bool: """Validate a single skill's subdirectory is symlink-free. @@ -2046,6 +2224,20 @@ def _unregister_skills_in_dir( extension_restore_index = self._build_extension_skill_restore_index() for skill_name in skill_names: + # Guard against a corrupted/malicious registry entry: a + # registered_skills name is persisted data, not derived from + # the current manifest, so it must be validated as a single, + # relative, non-"."/".." path component before ever being + # joined onto skills_dir. Without this, an absolute name + # discards skills_dir entirely (Path's "/" operator drops the + # left side for an absolute right side) or a multi-component + # name containing ".." can resolve to a different, unrelated + # directory that still happens to be inside the project root + # — passing the containment-only symlink guard below and + # letting removal overwrite/delete it (#2948). + if not self._is_safe_registry_skill_name(skill_name): + continue + # Derive command name from skill name (speckit-specify -> specify) short_name = skill_name if short_name.startswith("speckit-"): diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 33e8b175e3..c4cee68261 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1310,6 +1310,62 @@ def test_rescaffold_toggle_command_to_skills_removes_stale_extension_command_fil skill_file = skills_dir / "speckit-toggle-ext-hello" / "SKILL.md" assert skill_file.exists(), "sanity: skills mode should write SKILL.md" + def test_toggle_command_to_skills_preserves_old_extension_command_on_skills_failure( + self, project_dir, temp_dir, monkeypatch + ): + """An extension command->skills toggle must not destroy the old + command artifact before the new skill registration has actually + succeeded. + + Mirrors the analogous preset-side fix + (``test_toggle_command_to_skills_preserves_old_command_on_skills_failure`` + in ``tests/test_presets.py``): before the fix, the stale + command-mode file/tracking was unregistered unconditionally as soon + as the commands phase was skipped for ``skills_mode_active``, + regardless of whether the subsequent, independently-fallible + ``_register_extension_skills()`` call actually succeeded. If skills + raises, the exception handler just warns and continues, leaving + neither the old command file nor a new skill file (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="toggle-fail-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + agents_dir = project_dir / ".github" / "agents" + cmd_file = agents_dir / "speckit.toggle-fail-ext.hello.agent.md" + assert cmd_file.exists(), "sanity: command mode should write .agent.md" + metadata = manager.registry.get("toggle-fail-ext") + assert metadata.get("registered_commands", {}).get("copilot"), ( + "sanity: the command-mode write should be tracked for copilot" + ) + + # Toggle ai_skills on for the same active agent (copilot), but with + # skills registration injected to fail. + _create_init_options(project_dir, ai="copilot", ai_skills=True) + + def _raise_register_extension_skills(*args, **kwargs): + raise OSError("simulated extension skills-phase failure") + + monkeypatch.setattr( + manager, "_register_extension_skills", _raise_register_extension_skills + ) + manager.register_enabled_extensions_for_agent("copilot") + + assert cmd_file.exists(), ( + "the old command-mode artifact must survive when the " + "replacement skills registration fails — deleting it before " + "the new artifact is confirmed leaves neither in place (#2948)" + ) + metadata = manager.registry.get("toggle-fail-ext") + assert metadata.get("registered_commands", {}).get("copilot"), ( + "registered_commands must keep tracking copilot's still-live " + "command file when the skills replacement failed (#2948)" + ) + def test_rescaffold_toggle_skills_to_command_removes_stale_extension_skill_file( self, project_dir, temp_dir ): diff --git a/tests/test_presets.py b/tests/test_presets.py index 4a7bc8b160..0410f0a479 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4425,6 +4425,67 @@ def test_rescaffold_toggle_command_to_skills_removes_stale_command_file( "sanity: the new skills-mode artifact should still be written" ) + def test_toggle_command_to_skills_preserves_old_command_on_skills_failure( + self, project_dir, temp_dir, monkeypatch + ): + """A command->skills toggle must not destroy the old command + artifact before the new skill registration has actually succeeded. + + Before the fix, the stale command-mode file/tracking was + unregistered unconditionally as soon as ``_register_commands``'s + ``ai_skills`` guard made the commands phase a no-op — regardless + of whether the subsequent, independently-fallible + ``_register_skills()`` call actually succeeded. If skills raises + (e.g. a transient I/O error), the per-preset exception handler + just logs and continues, leaving neither the old command file + nor a new skill file — the preset's command override vanishes + entirely from copilot until the next successful rescaffold + (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "toggle-failure-preset", "speckit.specify", + "Toggle failure test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + assert cmd_file.exists(), ( + "sanity: command mode should have written copilot's command file" + ) + metadata = manager.registry.get("toggle-failure-preset") + assert metadata["registered_commands"].get("copilot"), ( + "sanity: the command-mode write should be tracked for copilot" + ) + + # Flip ai_skills on for the *same* active agent and rescaffold, as + # `integration upgrade copilot` would, but with skills registration + # injected to fail. + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + def _raise_register_skills(*args, **kwargs): + raise OSError("simulated skills-phase failure") + + monkeypatch.setattr(manager, "_register_skills", _raise_register_skills) + manager.register_enabled_presets_for_agent("copilot") + + assert cmd_file.exists(), ( + "the old command-mode artifact must survive when the " + "replacement skills registration fails — deleting it before " + "the new artifact is confirmed leaves neither in place (#2948)" + ) + metadata = manager.registry.get("toggle-failure-preset") + assert metadata["registered_commands"].get("copilot"), ( + "registered_commands must keep tracking copilot's still-live " + "command file when the skills replacement failed, or a later " + "removal/rescaffold will believe there is nothing to clean up " + "even though the file is still on disk (#2948)" + ) + def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file( self, project_dir, temp_dir ): @@ -5368,6 +5429,95 @@ def test_remove_reconciles_command_for_every_historical_agent( "currently active agent" ) + def test_remove_reconciliation_tracks_new_historical_agent_for_survivor( + self, project_dir, temp_dir + ): + """Historical-agent reconciliation writes must be recorded in the + surviving preset's own ``registered_commands``, not just written + to disk and forgotten. + + Preset A is installed while gemini is active, then survives to be + active under opencode too (so A's ``registered_commands`` spans + both gemini and opencode). Preset B is installed *only* while + opencode is active — B's ``registered_commands`` is + ``{"opencode": [...]}`` and never mentions gemini. Removing A + triggers reconciliation that writes B's content into gemini's + directory (an agent B never wrote to before) via ``extra_agents``, + but if that write isn't merged back into B's own + ``registered_commands``, B's registry entry still only says + ``{"opencode": [...]}`` even though B's content now lives in + gemini's directory too. A later ``remove('b')`` then only cleans + up opencode, leaving the gemini file — reconciled there entirely + by side effect of removing A — as a permanent orphan with no + preset tracking it (#2948). + """ + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_a_dir = self._create_command_preset( + temp_dir, "orphan-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + + self._write_init_options(project_dir, ai="opencode", ai_skills=False) + opencode_dir = project_dir / ".opencode" / "commands" + opencode_dir.mkdir(parents=True, exist_ok=True) + manager.register_enabled_presets_for_agent("opencode") + + metadata_a = manager.registry.get("orphan-preset-a") + assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, ( + "sanity: preset A must be tracked under both agents" + ) + + # Preset B is installed only now, while opencode is the sole + # active agent — it never writes to or tracks gemini. + preset_b_dir = self._create_command_preset( + temp_dir, "orphan-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + + metadata_b = manager.registry.get("orphan-preset-b") + assert set(metadata_b.get("registered_commands", {})) == {"opencode"}, ( + "sanity: preset B must only be tracked for opencode before " + "preset A is removed" + ) + + assert manager.remove("orphan-preset-a") is True + + # B is now written into gemini's directory as a side effect of + # reconciling A's removal, via the historical-agent extra_agents + # pass. + gemini_cmd_files = list(gemini_dir.glob("*specify*")) + assert gemini_cmd_files, "sanity: gemini's directory must have B's restored content" + assert "preset B body" in gemini_cmd_files[0].read_text() + + metadata_b = manager.registry.get("orphan-preset-b") + assert set(metadata_b.get("registered_commands", {})) == {"gemini", "opencode"}, ( + "preset B's own registered_commands must be updated to " + "include gemini once reconciliation actually writes content " + "there on its behalf — otherwise B's registry entry silently " + "lies about which directories it owns (#2948)" + ) + + assert manager.remove("orphan-preset-b") is True + + # No preset is installed any more, so gemini's file must have been + # reconciled down to the core bundled template (or removed + # entirely) — but it must NOT still contain B's stale content, + # which would mean B's write there was never tracked for cleanup. + remaining_gemini_files = list(gemini_dir.glob("*specify*")) + for f in remaining_gemini_files: + assert "preset B body" not in f.read_text(), ( + "removing preset B must clean up gemini's directory too, " + "since B's registered_commands was updated to include it " + "— otherwise B's stale content is orphaned there forever " + "with no preset left to track or clean it up (#2948)" + ) + def test_remove_reconciles_skill_for_every_historical_agent( self, project_dir, temp_dir ): @@ -5446,6 +5596,107 @@ def test_remove_reconciles_skill_for_every_historical_agent( "the currently active agent" ) + def test_remove_reconciliation_tracks_new_historical_skill_agent_for_survivor( + self, project_dir, temp_dir + ): + """Historical-agent skill reconciliation writes must be recorded in + the surviving preset's own ``registered_skills``, mirroring + ``test_remove_reconciliation_tracks_new_historical_agent_for_survivor`` + for the command side. + + Preset A is installed while claude is active, then survives to be + active under codex too (so A's ``registered_skills`` spans both + claude and codex). Preset B is installed *only* while codex is + active — B's ``registered_skills`` is ``{"codex": [...]}`` and + never mentions claude. Removing A triggers reconciliation that + renders B's SKILL.md into claude's directory (an agent B never + wrote to before) via ``extra_skills_dirs``, but if that write + isn't merged back into B's own ``registered_skills``, a later + ``remove('b')`` only cleans up codex, leaving claude's SKILL.md — + rendered there entirely by side effect of removing A — untracked + by any preset (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + # Pre-create the skill so _register_commands/_register_skills find + # an existing skill to overwrite (mirrors every other skill test + # in this class — native skill agents only overwrite already + # existing skill directories, they don't materialize brand-new + # ones outside of active-agent creation). + self._create_skill(claude_skills_dir, "speckit-specify") + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_a_dir = self._create_command_preset( + temp_dir, "orphan-skill-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + + self._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + manager.register_enabled_presets_for_agent("codex") + + metadata_a = manager.registry.get("orphan-skill-preset-a") + assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, ( + "sanity: preset A must be tracked under both agents" + ) + + # Preset B is installed only now, while codex is the sole active + # agent — it never writes to or tracks claude. + preset_b_dir = self._create_command_preset( + temp_dir, "orphan-skill-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + + metadata_b = manager.registry.get("orphan-skill-preset-b") + assert set(metadata_b.get("registered_skills", {})) == {"codex"}, ( + "sanity: preset B must only be tracked for codex before " + "preset A is removed" + ) + + assert manager.remove("orphan-skill-preset-a") is True + + claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert claude_skill_file.exists(), ( + "sanity: claude's skill file must have been restored by " + "reconciliation" + ) + assert "preset:orphan-skill-preset-b" in claude_skill_file.read_text(), ( + "sanity: claude's SKILL.md must reflect preset B's content " + "after preset A is removed" + ) + + metadata_b = manager.registry.get("orphan-skill-preset-b") + assert set(metadata_b.get("registered_skills", {})) == {"claude", "codex"}, ( + "preset B's own registered_skills must be updated to include " + "claude once reconciliation actually renders content there " + "on its behalf — otherwise B's registry entry silently lies " + "about which directories it owns (#2948)" + ) + + assert manager.remove("orphan-skill-preset-b") is True + + # No preset is installed any more, so claude's SKILL.md must have + # been reconciled down to the core bundled template (or removed + # entirely) — but it must NOT still contain B's stale content, + # which would mean B's write there was never tracked for cleanup. + if claude_skill_file.exists(): + assert "preset:orphan-skill-preset-b" not in claude_skill_file.read_text(), ( + "removing preset B must clean up claude's directory too, " + "since B's registered_skills was updated to include it — " + "otherwise B's stale content is orphaned there forever " + "with no preset left to track or clean it up (#2948)" + ) + def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir): """Restore must validate each per-skill subdirectory, not just its parent. @@ -5518,6 +5769,107 @@ def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir): "the symlink itself should be left alone" ) + def test_is_safe_registry_skill_name_rejects_unsafe_values(self, project_dir): + """Unit-test the centralized registry skill-name boundary guard. + + ``registered_skills`` entries are persisted registry data, not + manifest-derived, so every preset cleanup/provenance loop that + joins one onto a directory must first reject: non-strings, empty + strings, absolute paths, multi-component paths (containing ``/``), + and the literal traversal components ``"."``/``".."`` — the last + of which is *not* caught by a naive ``is_absolute() or + len(parts) != 1`` check alone, since ``Path("..").parts`` is a + single-element tuple (#2948). + """ + manager = PresetManager(project_dir) + is_safe = manager._is_safe_registry_skill_name + + assert is_safe("speckit-specify") is True + assert is_safe("") is False + assert is_safe(None) is False + assert is_safe(123) is False + assert is_safe(["speckit-specify"]) is False + assert is_safe(".") is False + assert is_safe("..") is False + assert is_safe("/etc/passwd") is False + assert is_safe(str(project_dir / "important-data")) is False + assert is_safe("foo/bar") is False + assert is_safe("foo/..") is False + assert is_safe("../foo") is False + + def test_unregister_skills_in_dir_rejects_absolute_registry_name( + self, project_dir + ): + """A corrupted ``registered_skills`` entry with an absolute path must not escape. + + ``Path`` join with an absolute right-hand operand discards the + left side entirely (``skills_dir / "/abs/path"`` == ``"/abs/path"``), + so an absolute in-project path stored in the registry would bypass + ``skills_dir`` altogether if not rejected before the join (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + claude_skills_dir.mkdir(parents=True) + + precious_dir = project_dir / "important-data" + precious_dir.mkdir() + precious_file = precious_dir / "SKILL.md" + precious_file.write_text("precious-absolute-target-marker") + + manager = PresetManager(project_dir) + manager._unregister_skills_in_dir( + [str(precious_dir)], claude_skills_dir, "claude" + ) + + assert precious_dir.is_dir(), ( + "an absolute registry entry must not let cleanup escape " + "skills_dir to an unrelated project directory (#2948)" + ) + assert precious_file.read_text() == "precious-absolute-target-marker" + + def test_infer_legacy_skill_provenance_rejects_absolute_registry_name( + self, project_dir + ): + """Legacy provenance inference must reject an absolute registry name. + + ``_infer_legacy_skill_provenance`` receives its ``skill_names`` + directly from a legacy flat-list ``registered_skills`` value — + registry data, not manifest-derived — and joins each name onto a + candidate agent's resolved skills directory the same way + ``_unregister_skills_in_dir`` does. An absolute in-project name + discards the candidate directory entirely (Python's ``/`` operator + drops the left side for an absolute right side), so it can read + an unrelated project directory's ``SKILL.md`` and, if its + frontmatter happens to carry a matching preset source marker, + falsely attribute an unrelated directory as this preset's own + skill override under whichever agent is being probed (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + claude_skills_dir.mkdir(parents=True) + + precious_dir = project_dir / "important-data" + precious_dir.mkdir() + (precious_dir / "SKILL.md").write_text( + "---\n" + "metadata:\n" + " source: preset:some-pack\n" + "---\n\n" + "# Unrelated directory, not a real preset skill\n" + ) + + manager = PresetManager(project_dir) + inferred = manager._infer_legacy_skill_provenance( + [str(precious_dir)], "some-pack", "claude" + ) + + for names in inferred.values(): + assert str(precious_dir) not in names, ( + "an absolute registry entry must not be falsely attributed " + "as preset-owned provenance by probing outside the " + "intended skills subtree (#2948)" + ) + def test_copilot_skills_registration_restored_after_process_restart( self, project_dir, temp_dir ): From d0d152ef6cf55997fa9f358644f6a255991f9cf7 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:18:55 +0200 Subject: [PATCH 20/27] Verify replacement actually landed before retiring stale toggle artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command<->skills toggle cleanup added for #2948 deferred destructive removal of the old-mode artifact until after the replacement registration call completed without raising. That was necessary but not sufficient: none of _register_skills(), _register_commands(), register_commands_for_agent(), or _register_extension_skills() raise on a missing source template, a safety-validation skip, or a corrupted manifest entry — they simply return an empty or partial result. Treating "did not raise" as "fully replaced" meant a stale artifact could still be deleted (or its tracking dropped) even though its specific replacement never actually landed, leaving neither artifact in place for that logical command/skill. Fix all four affected toggle directions by checking the replacement call's actual return value before allowing any destructive step: - presets command->skills (register_enabled_presets_for_agent): only unregister a stale command name once its corresponding skill name (via the existing _skill_names_for_command() helper) is confirmed present in the skills call's returned names for that agent; the remainder stays tracked and on disk. - presets skills->command (register_enabled_presets_for_agent): only unregister a stale skill name once its corresponding command name is confirmed present in the commands call's returned names for that agent, using the same helper. - extensions skills->command (register_enabled_extensions_for_agent): only remove a skill mirror once the matching command (mapped via the existing HookExecutor._skill_name_from_command() helper) is confirmed present in register_commands_for_agent's returned names. - extensions command->skills (register_enabled_extensions_for_agent): only remove a deferred stale command once its matching skill name is confirmed present in _register_extension_skills()'s returned names. All four reuse the existing command<->skill name-derivation helpers rather than inventing new mapping logic. Registry tracking is updated to retain exactly the unreplaced subset rather than being popped wholesale, so partially-successful toggles leave correct, minimal tracking behind. Added 8 new regression tests (4 presets, 4 extensions) covering both the fully-empty and genuinely-partial result cases for each of the four toggle directions, using real missing-source-file scenarios (not mocked return values) to exercise the actual code paths. Confirmed red before the fix and green after for all 8. Focused (test_presets.py, test_extension_skills.py, test_extensions.py, tests/integrations): 2551 passed, 1 skipped. Full suite (tests, excluding the pre-existing environment-local 1Password-signing git-extension failures): 3917 passed, 74 skipped, 90 deselected. ruff check: clean. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 65 +++++- src/specify_cli/presets/__init__.py | 59 ++++- tests/test_extension_skills.py | 215 ++++++++++++++++++ tests/test_presets.py | 292 +++++++++++++++++++++++++ 4 files changed, 607 insertions(+), 24 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index b8ec4f00f9..2415b7f172 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2103,9 +2103,26 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: for name in existing_skills if (agent_skills_dir / name).is_dir() ] - if owned_here: + # Only retire a skill mirror when the + # replacement command for the same logical + # command was actually written this call — + # `registered` (from register_commands_for_agent + # above) may be empty or a partial subset + # (missing source file, safety rejection, + # corrupted manifest), and removing a skill + # mirror whose command replacement never + # landed would leave neither artifact (#2948). + replaced_skill_names = { + HookExecutor._skill_name_from_command(cmd_name) + for cmd_name in (registered or []) + } + to_remove = [ + name for name in owned_here + if name in replaced_skill_names + ] + if to_remove: self._unregister_extension_skills( - owned_here, ext_id, skills_dir=agent_skills_dir + to_remove, ext_id, skills_dir=agent_skills_dir ) # registered_skills is a single flat list # shared across every agent this extension @@ -2132,17 +2149,41 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: # registration is confirmed, so the stale # command-mode artifact can finally be removed # without risking a transient state where neither - # artifact exists (#2948). + # artifact exists. Only retire a stale command + # whose corresponding skill was actually returned + # this call — `registered_skills` may be empty or + # a partial subset (missing source file, safety + # rejection, corrupted manifest), and unregistering + # a command whose skill replacement never landed + # would leave neither artifact (#2948). if deferred_stale_commands: - registrar.unregister_commands( - {agent_name: deferred_stale_commands}, self.project_root - ) - registered_commands = metadata.get("registered_commands", {}) - if isinstance(registered_commands, dict): - new_registered = copy.deepcopy(registered_commands) - new_registered.pop(agent_name, None) - if new_registered != registered_commands: - updates["registered_commands"] = new_registered + replaced_skill_names = set(registered_skills or []) + fully_replaced = [ + cmd_name for cmd_name in deferred_stale_commands + if HookExecutor._skill_name_from_command(cmd_name) + in replaced_skill_names + ] + if fully_replaced: + registrar.unregister_commands( + {agent_name: fully_replaced}, self.project_root + ) + registered_commands = metadata.get( + "registered_commands", {} + ) + if isinstance(registered_commands, dict) and ( + registered_commands.get(agent_name) + ): + new_registered = copy.deepcopy(registered_commands) + remaining_commands = [ + c for c in new_registered[agent_name] + if c not in fully_replaced + ] + if remaining_commands: + new_registered[agent_name] = remaining_commands + else: + new_registered.pop(agent_name, None) + if new_registered != registered_commands: + updates["registered_commands"] = new_registered if updates: self.registry.update(ext_id, updates) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index dc260935c3..07b90708b7 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -854,11 +854,28 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: # direction is already register-new-then-remove-old: # _register_commands (the replacement) ran unconditionally # above and only reaches here once it has already - # succeeded, so this cleanup happens only after the new - # command artifact is confirmed in place (#2948). - self._unregister_skills( - {agent_name: merged_skills.pop(agent_name)}, pack_dir - ) + # succeeded — but that call can still have returned + # empty or partial results (missing source template, + # safety-validation skip, corrupted manifest), so only + # retire the subset of stale skills whose corresponding + # command name was actually returned for this agent; + # anything unreplaced stays tracked and on disk (#2948). + stale_skill_names = merged_skills[agent_name] + replaced_skill_names: set = set() + for cmd_name in registered_commands.get(agent_name) or []: + modern_name, legacy_name = self._skill_names_for_command(cmd_name) + replaced_skill_names.add(modern_name) + replaced_skill_names.add(legacy_name) + to_remove = [n for n in stale_skill_names if n in replaced_skill_names] + remaining_stale = [ + n for n in stale_skill_names if n not in replaced_skill_names + ] + if to_remove: + self._unregister_skills({agent_name: to_remove}, pack_dir) + if remaining_stale: + merged_skills[agent_name] = remaining_stale + else: + merged_skills.pop(agent_name, None) # A legacy flat-list registered_skills value (predating # per-agent provenance) must migrate to the dict format on # disk even when the rescaffolded names are unchanged from @@ -874,14 +891,32 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: if merged_skills != existing_skills or needs_migration: self.registry.update(pack_id, {"registered_skills": merged_skills}) - # The skills phase above completed without raising, so the - # replacement artifact is confirmed — now it's safe to - # remove the stale command-mode artifact deferred earlier - # (#2948). + # The skills phase above completed without raising, but a + # non-raising result can still be empty or partial (missing + # source template, safety-validation skip, corrupted + # manifest) — retiring every stale command purely on "did + # not raise" would delete a command whose replacement skill + # never actually landed, leaving neither artifact. Only + # retire the subset of stale commands whose corresponding + # skill name was actually returned for this agent; anything + # unreplaced stays tracked and on disk (#2948). if stale_command_names: - self._unregister_commands({agent_name: stale_command_names}) - merged_commands.pop(agent_name, None) - self.registry.update(pack_id, {"registered_commands": merged_commands}) + replaced_skill_names = set(registered_skills.get(agent_name) or []) + fully_replaced = [] + remaining_stale = [] + for cmd_name in stale_command_names: + modern_name, legacy_name = self._skill_names_for_command(cmd_name) + if modern_name in replaced_skill_names or legacy_name in replaced_skill_names: + fully_replaced.append(cmd_name) + else: + remaining_stale.append(cmd_name) + if fully_replaced: + self._unregister_commands({agent_name: fully_replaced}) + if remaining_stale: + merged_commands[agent_name] = remaining_stale + else: + merged_commands.pop(agent_name, None) + self.registry.update(pack_id, {"registered_commands": merged_commands}) except Exception as pack_err: from .. import _print_cli_warning diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index c4cee68261..9ebef14b95 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1366,6 +1366,221 @@ def _raise_register_extension_skills(*args, **kwargs): "command file when the skills replacement failed (#2948)" ) + def test_toggle_command_to_skills_empty_result_preserves_old_extension_command( + self, project_dir, temp_dir + ): + """An empty (non-raising) skills result must not delete any old + extension command artifact. + + Deleting both of the extension's own installed command source + files makes ``_register_extension_skills`` genuinely return ``[]`` + for copilot without raising — a real "missing source" case, not an + injected exception — which must leave both old command-mode + artifacts and their tracking untouched (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="toggle-empty-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + agents_dir = project_dir / ".github" / "agents" + hello_cmd_file = agents_dir / "speckit.toggle-empty-ext.hello.agent.md" + world_cmd_file = agents_dir / "speckit.toggle-empty-ext.world.agent.md" + assert hello_cmd_file.exists() and world_cmd_file.exists(), ( + "sanity: command mode should have written both command files" + ) + + # Remove both installed command sources so _register_extension_skills + # can find nothing to render for either command. + ext_commands_dir = manager.extensions_dir / "toggle-empty-ext" / "commands" + (ext_commands_dir / "hello.md").unlink() + (ext_commands_dir / "world.md").unlink() + + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + assert hello_cmd_file.exists() and world_cmd_file.exists(), ( + "an empty (non-raising) skills registration result must not " + "cause the old command-mode artifacts to be deleted (#2948)" + ) + metadata = manager.registry.get("toggle-empty-ext") + registered_commands = metadata.get("registered_commands", {}).get("copilot", []) + assert set(registered_commands) == { + "speckit.toggle-empty-ext.hello", "speckit.toggle-empty-ext.world", + }, ( + "registered_commands must keep tracking copilot's still-live " + "command files when nothing was actually replaced (#2948)" + ) + skills_dir = project_dir / ".github" / "skills" + assert not (skills_dir / "speckit-toggle-empty-ext-hello").exists(), ( + "sanity: no skill should have been written when both sources " + "were missing" + ) + assert not (skills_dir / "speckit-toggle-empty-ext-world").exists() + + def test_toggle_command_to_skills_partial_result_only_removes_replaced_extension_command( + self, project_dir, temp_dir + ): + """Only the extension command whose skill replacement actually + landed is retired. + + A two-command extension (hello, world) where only ``world``'s + installed source goes missing right before the toggle, so + ``_register_extension_skills`` genuinely returns a partial result + (only ``hello``'s skill). ``hello``'s old command artifact must be + retired; ``world``'s must survive with its tracking intact, since + no replacement for it landed (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="toggle-partial-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + agents_dir = project_dir / ".github" / "agents" + hello_cmd_file = agents_dir / "speckit.toggle-partial-ext.hello.agent.md" + world_cmd_file = agents_dir / "speckit.toggle-partial-ext.world.agent.md" + assert hello_cmd_file.exists() and world_cmd_file.exists(), ( + "sanity: command mode should have written both command files" + ) + + # Remove only world's installed source so its skill replacement is + # silently skipped (missing source), while hello's succeeds. + ext_commands_dir = manager.extensions_dir / "toggle-partial-ext" / "commands" + (ext_commands_dir / "world.md").unlink() + + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + assert not hello_cmd_file.exists(), ( + "hello's old command artifact must be retired since its skill " + "replacement actually landed (#2948)" + ) + assert world_cmd_file.exists(), ( + "world's old command artifact must survive since its skill " + "replacement never landed (missing source) (#2948)" + ) + metadata = manager.registry.get("toggle-partial-ext") + registered_commands = metadata.get("registered_commands", {}).get("copilot", []) + assert registered_commands == ["speckit.toggle-partial-ext.world"], ( + "registered_commands must stop tracking hello (retired) but " + "keep tracking world (still live) (#2948)" + ) + skills_dir = project_dir / ".github" / "skills" + assert (skills_dir / "speckit-toggle-partial-ext-hello" / "SKILL.md").exists() + assert not (skills_dir / "speckit-toggle-partial-ext-world").exists() + + def test_toggle_skills_to_command_empty_result_preserves_old_extension_skill( + self, project_dir, temp_dir + ): + """An empty (non-raising) command result must not delete any old + extension skill artifact. + + Mirror image of the empty-result command->skills case: deleting + both of the extension's own installed command source files makes + ``register_commands_for_agent`` genuinely return an empty/falsy + result for copilot without raising, which must leave both old + skill-mode artifacts and their tracking untouched (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="toggle-skill-empty-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + skills_dir = project_dir / ".github" / "skills" + hello_skill_file = skills_dir / "speckit-toggle-skill-empty-ext-hello" / "SKILL.md" + world_skill_file = skills_dir / "speckit-toggle-skill-empty-ext-world" / "SKILL.md" + assert hello_skill_file.exists() and world_skill_file.exists(), ( + "sanity: skills mode should have written both SKILL.md mirrors" + ) + + # Remove both installed command sources so register_commands_for_agent + # can find nothing to render for either command. + ext_commands_dir = manager.extensions_dir / "toggle-skill-empty-ext" / "commands" + (ext_commands_dir / "hello.md").unlink() + (ext_commands_dir / "world.md").unlink() + + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_extensions_for_agent("copilot") + + assert hello_skill_file.exists() and world_skill_file.exists(), ( + "an empty (non-raising) command registration result must not " + "cause the old skills-mode artifacts to be deleted (#2948)" + ) + metadata = manager.registry.get("toggle-skill-empty-ext") + registered_skills = metadata.get("registered_skills", []) + assert { + "speckit-toggle-skill-empty-ext-hello", + "speckit-toggle-skill-empty-ext-world", + } <= set(registered_skills), ( + "registered_skills must keep tracking copilot's still-live " + "skill files when nothing was actually replaced (#2948)" + ) + + def test_toggle_skills_to_command_partial_result_only_removes_replaced_extension_skill( + self, project_dir, temp_dir + ): + """Only the extension skill whose command replacement actually + landed is retired. + + Mirror image of the partial-result command->skills case: a + two-command extension where only ``world``'s installed source goes + missing right before the toggle, so ``register_commands_for_agent`` + genuinely returns a partial result (only ``hello``'s command). + ``hello``'s old skill artifact must be retired; ``world``'s must + survive with its tracking intact, since no replacement for it + landed (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="toggle-skill-partial-ext"), "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + skills_dir = project_dir / ".github" / "skills" + hello_skill_file = skills_dir / "speckit-toggle-skill-partial-ext-hello" / "SKILL.md" + world_skill_file = skills_dir / "speckit-toggle-skill-partial-ext-world" / "SKILL.md" + assert hello_skill_file.exists() and world_skill_file.exists(), ( + "sanity: skills mode should have written both SKILL.md mirrors" + ) + + # Remove only world's installed source so its command replacement + # is silently skipped (missing source), while hello's succeeds. + ext_commands_dir = manager.extensions_dir / "toggle-skill-partial-ext" / "commands" + (ext_commands_dir / "world.md").unlink() + + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_extensions_for_agent("copilot") + + assert not hello_skill_file.exists(), ( + "hello's old skill artifact must be retired since its command " + "replacement actually landed (#2948)" + ) + assert world_skill_file.exists(), ( + "world's old skill artifact must survive since its command " + "replacement never landed (missing source) (#2948)" + ) + metadata = manager.registry.get("toggle-skill-partial-ext") + registered_skills = metadata.get("registered_skills", []) + assert "speckit-toggle-skill-partial-ext-hello" not in registered_skills, ( + "hello must stop being tracked as a skill once its artifact " + "has been unregistered (#2948)" + ) + assert "speckit-toggle-skill-partial-ext-world" in registered_skills, ( + "world must keep being tracked as a skill since its old " + "artifact is still on disk (#2948)" + ) + def test_rescaffold_toggle_skills_to_command_removes_stale_extension_skill_file( self, project_dir, temp_dir ): diff --git a/tests/test_presets.py b/tests/test_presets.py index 0410f0a479..0a4c8aeb8d 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3186,6 +3186,44 @@ def _create_command_preset(self, temp_dir, preset_id, command_name, description, yaml.dump(manifest_data, f) return preset_dir + def _create_multi_command_preset(self, temp_dir, preset_id, command_names): + """Install-directory helper for a preset with more than one command. + + Used to prove partial-result handling: a command's own template + entry can genuinely be skipped by registration (missing source + file, safety-validation rejection) while sibling commands in the + same preset still succeed. + """ + preset_dir = temp_dir / preset_id + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + templates = [] + for command_name in command_names: + command_file = f"{command_name}.md" + (preset_dir / "commands" / command_file).write_text( + f"---\ndescription: {command_name} test command\n---\n\n" + f"{command_name} body\n" + ) + templates.append({ + "type": "command", + "name": command_name, + "file": f"commands/{command_file}", + }) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": templates}, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + return preset_dir + def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): """When skills mode was used, a preset command override should update the skill.""" # Simulate skills mode having been used: write init-options + create skill @@ -4486,6 +4524,260 @@ def _raise_register_skills(*args, **kwargs): "even though the file is still on disk (#2948)" ) + def test_toggle_command_to_skills_empty_result_preserves_old_command( + self, project_dir, temp_dir + ): + """A non-raising but empty skills result must not delete the old command. + + Before the fix, the stale command-mode artifact was retired as + soon as ``_register_skills()`` completed without raising — + regardless of whether it actually wrote anything for this agent. + Deleting the preset's own command source file (simulating a + missing/corrupted override) makes ``_register_skills`` return + ``{}`` for copilot without raising at all, which must leave the + old command file and its tracking untouched (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "toggle-empty-result-preset", "speckit.specify", + "Toggle empty-result test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + assert cmd_file.exists(), ( + "sanity: command mode should have written copilot's command file" + ) + + # Remove the *installed* copy of the preset's source file (not the + # original temp source) so _register_skills can find nothing to + # render — a real "missing source" case, not an exception — leaving + # registered_skills empty for copilot. + (manager.presets_dir / "toggle-empty-result-preset" / "commands" / "speckit.specify.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert cmd_file.exists(), ( + "an empty (non-raising) skills registration result must not " + "cause the old command-mode artifact to be deleted (#2948)" + ) + metadata = manager.registry.get("toggle-empty-result-preset") + assert metadata["registered_commands"].get("copilot"), ( + "registered_commands must keep tracking copilot's still-live " + "command file when nothing was actually replaced (#2948)" + ) + skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + assert not skill_file.exists(), ( + "sanity: no skill should have been written when the source " + "was missing" + ) + + def test_toggle_command_to_skills_partial_result_only_removes_replaced_command( + self, project_dir, temp_dir + ): + """Only the command whose skill replacement actually landed is retired. + + A two-command preset where one command's source file goes missing + right before the toggle: ``_register_skills`` genuinely returns a + partial result (one name present, one silently skipped) without + raising. The command whose skill was written must be retired; the + other must keep both its old command file and its registry + tracking, since no replacement for it actually landed (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset( + temp_dir, "toggle-partial-result-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + specify_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + plan_cmd_file = copilot_commands_dir / "speckit.plan.agent.md" + assert specify_cmd_file.exists() and plan_cmd_file.exists(), ( + "sanity: command mode should have written both command files" + ) + metadata = manager.registry.get("toggle-partial-result-preset") + assert set(metadata["registered_commands"].get("copilot", [])) == { + "speckit.specify", "speckit.plan", + }, "sanity: both commands should be tracked for copilot" + + # Remove only the plan command's *installed* source so its skill + # replacement is silently skipped (missing source), while + # specify's succeeds — a genuine partial result, not an injected + # exception. + (manager.presets_dir / "toggle-partial-result-preset" / "commands" / "speckit.plan.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert not specify_cmd_file.exists(), ( + "the specify command's old artifact must be retired since its " + "skill replacement actually landed (#2948)" + ) + assert plan_cmd_file.exists(), ( + "the plan command's old artifact must survive since its skill " + "replacement never landed (missing source) (#2948)" + ) + metadata = manager.registry.get("toggle-partial-result-preset") + tracked_commands = metadata["registered_commands"].get("copilot", []) + assert "speckit.specify" not in tracked_commands, ( + "specify must stop being tracked as a command once its " + "artifact has been unregistered (#2948)" + ) + assert "speckit.plan" in tracked_commands, ( + "plan must keep being tracked as a command since its old " + "artifact is still on disk (#2948)" + ) + specify_skill_file = ( + project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + ) + assert "preset:toggle-partial-result-preset" in specify_skill_file.read_text(), ( + "sanity: specify's new skill artifact should exist" + ) + plan_skill_file = project_dir / ".github" / "skills" / "speckit-plan" + assert not plan_skill_file.exists(), ( + "sanity: no skill should have been written for plan since its " + "source was missing" + ) + + def test_toggle_skills_to_command_empty_result_preserves_old_skill( + self, project_dir, temp_dir + ): + """A non-raising but empty command result must not delete the old skill. + + Mirror image of the empty-result command->skills case: deleting the + preset's own source file makes ``_register_commands`` return ``{}`` + for copilot without raising, which must leave the old SKILL.md and + its tracking untouched (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "toggle-skill-empty-result-preset", "speckit.specify", + "Toggle empty-result test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), ( + "sanity: skills mode should have written the SKILL.md mirror" + ) + + # Remove the preset's own *installed* source file so + # _register_commands can find nothing to render — a real "missing + # source" case, not an exception — leaving registered_commands + # empty for copilot. + (manager.presets_dir / "toggle-skill-empty-result-preset" / "commands" / "speckit.specify.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_presets_for_agent("copilot") + + assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), ( + "an empty (non-raising) command registration result must not " + "cause the old skills-mode artifact to be deleted/reverted " + "(#2948)" + ) + metadata = manager.registry.get("toggle-skill-empty-result-preset") + assert "speckit-specify" in metadata["registered_skills"].get("copilot", []), ( + "registered_skills must keep tracking copilot's still-live " + "skill file when nothing was actually replaced (#2948)" + ) + # Note: a command file may still exist here — general command + # reconciliation independently restores the next-best (e.g. core) + # layer for the *command name*, regardless of this preset's own + # missing source. That's an orthogonal, existing behaviour; the + # invariant under test is specifically that the *skill* mirror and + # its tracking survive the empty registration result. + + def test_toggle_skills_to_command_partial_result_only_removes_replaced_skill( + self, project_dir, temp_dir + ): + """Only the skill whose command replacement actually landed is retired. + + Mirror image of the partial-result command->skills case: a + two-command preset where one command's source file goes missing + right before the toggle, so ``_register_commands`` genuinely + returns a partial result. The skill whose command was written + must be retired; the other must keep both its old SKILL.md and + its registry tracking, since no replacement for it landed (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + self._create_skill(skills_dir, "speckit-plan") + + # A core template lets the retired skill restore to core content + # instead of being removed entirely (it has nothing else to fall + # back to), matching _unregister_skills's behaviour elsewhere. + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_dir = self._create_multi_command_preset( + temp_dir, "toggle-skill-partial-result-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + specify_skill_file = skills_dir / "speckit-specify" / "SKILL.md" + plan_skill_file = skills_dir / "speckit-plan" / "SKILL.md" + assert "preset:toggle-skill-partial-result-preset" in specify_skill_file.read_text() + assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text() + metadata = manager.registry.get("toggle-skill-partial-result-preset") + assert set(metadata["registered_skills"].get("copilot", [])) == { + "speckit-specify", "speckit-plan", + }, "sanity: both skills should be tracked for copilot" + + # Remove only the plan command's *installed* source so its command + # replacement is silently skipped (missing source), while + # specify's succeeds. + (manager.presets_dir / "toggle-skill-partial-result-preset" / "commands" / "speckit.plan.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_presets_for_agent("copilot") + + assert "preset:toggle-skill-partial-result-preset" not in specify_skill_file.read_text(), ( + "the specify skill's old artifact must be retired/reverted " + "since its command replacement actually landed (#2948)" + ) + assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text(), ( + "the plan skill's old artifact must survive since its command " + "replacement never landed (missing source) (#2948)" + ) + metadata = manager.registry.get("toggle-skill-partial-result-preset") + tracked_skills = metadata["registered_skills"].get("copilot", []) + assert "speckit-specify" not in tracked_skills, ( + "specify must stop being tracked as a skill once its artifact " + "has been unregistered/reverted (#2948)" + ) + assert "speckit-plan" in tracked_skills, ( + "plan must keep being tracked as a skill since its old " + "artifact is still on disk (#2948)" + ) + assert (copilot_commands_dir / "speckit.specify.agent.md").exists(), ( + "sanity: specify's new command artifact should exist" + ) + def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file( self, project_dir, temp_dir ): From 664dfbe0bdd3d93778079fd00dfa40bdc012af27 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:33:52 +0200 Subject: [PATCH 21/27] Retire alias command groups on toggle; scope preset cleanup to switched-away agent (#2948) Fixes three current Copilot review findings on HEAD d0d152e: 1. Command->skills toggle cleanup only matched a stale command's own name against the returned replacement skill name. Aliases (CommandRegistrar tracks and returns primary + alias names flattened into one list) never have their own skill rendered -- only the primary command's skill is rendered -- so an alias's name could never match, leaving its command artifact and tracking behind forever even after the primary's replacement landed. Fixed identically in both presets (register_enabled_presets_for_agent) and extensions (register_enabled_extensions_for_agent): build a primary->alias mapping from the manifest, group stale names by primary, and retire/keep the whole group together based solely on whether the primary's skill replacement actually landed. 2. `integration switch` to a not-yet-installed target unregistered the old agent's extension artifacts but had no preset equivalent, so a preset's command overrides (including custom preset commands) and skill mirrors for the deactivated agent lingered as orphans. Added `PresetManager.unregister_agent_artifacts()`, mirroring `ExtensionManager.unregister_agent_artifacts()`: scoped strictly to the given agent, migrates a legacy flat-list `registered_skills` entry via existing on-disk provenance inference before removing anything (so other agents' real ownership is preserved rather than guessed or dropped), and guards against double-processing an artifact through both the commands and skills paths for native SKILL.md agents. Wired via a new `_unregister_presets_for_agent()` helper into the integration switch command's existing old-agent cleanup phase. Added red-first regression tests: - tests/test_presets.py: alias-group retire/keep/partial-multi-group tests for the command->skills toggle; unregister_agent_artifacts scoping tests for commands and legacy-list skill provenance. - tests/test_extension_skills.py: alias-group retire/keep tests for the extension command->skills toggle. - tests/integrations/test_integration_subcommand.py: end-to-end switch test proving a preset's custom command override is cleaned up when switching to a not-yet-installed integration, with tracking updated correctly and the new agent's registration unaffected. All new tests confirmed red (AttributeError / orphaned file assertions) before the fix and green after. Full suite: 3980 passed, 109 skipped. ruff check clean on all changed files. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 40 +- src/specify_cli/integrations/_helpers.py | 33 ++ .../integrations/_migrate_commands.py | 14 + src/specify_cli/presets/__init__.py | 138 ++++++- .../test_integration_subcommand.py | 77 ++++ tests/test_extension_skills.py | 183 +++++++++ tests/test_presets.py | 360 ++++++++++++++++++ 7 files changed, 841 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 2415b7f172..012e819749 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2158,10 +2158,46 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: # would leave neither artifact (#2948). if deferred_stale_commands: replaced_skill_names = set(registered_skills or []) + # Commands may carry aliases (CommandRegistrar. + # register_commands_for_agent() tracks and + # returns primary + alias names flattened + # together into one list), but + # _register_extension_skills() only ever + # renders/returns the *primary* command name's + # skill — running an alias's own name through + # _skill_name_from_command() never matches + # anything real, so an alias would stay + # tracked/on-disk forever even after its + # primary's skill replacement landed. Map each + # stale name back to its manifest command's + # primary so the whole primary+alias group is + # retired or kept together, based solely on + # whether the *primary*'s skill replacement + # actually landed (#2948). + alias_to_primary: Dict[str, str] = {} + for cmd_info in manifest.commands: + primary_name = cmd_info.get("name") + if not isinstance(primary_name, str): + continue + for alias in cmd_info.get("aliases", []) or []: + if isinstance(alias, str): + alias_to_primary[alias] = primary_name + + group_fully_replaced: Dict[str, bool] = {} + for cmd_name in deferred_stale_commands: + primary_name = alias_to_primary.get(cmd_name, cmd_name) + if primary_name in group_fully_replaced: + continue + group_fully_replaced[primary_name] = ( + HookExecutor._skill_name_from_command(primary_name) + in replaced_skill_names + ) + fully_replaced = [ cmd_name for cmd_name in deferred_stale_commands - if HookExecutor._skill_name_from_command(cmd_name) - in replaced_skill_names + if group_fully_replaced.get( + alias_to_primary.get(cmd_name, cmd_name), False + ) ] if fully_replaced: registrar.unregister_commands( diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 91d53e01a0..7001ca84d1 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -451,6 +451,39 @@ def _register_presets_for_agent( ) +def _unregister_presets_for_agent( + project_root: Path, + agent_key: str, + *, + continuing: str, +) -> None: + """Best-effort removal of ``agent_key``'s preset command/skill artifacts. + + Mirrors ``_unregister_extensions_for_agent``: used by ``switch`` when + uninstalling the previous integration so its preset command overrides + and skill mirrors don't linger as orphans in the old agent's directory + once a different (possibly not-yet-installed) integration becomes + active (#2948). + + Best-effort: never aborts the surrounding integration operation. + """ + try: + from ..presets import PresetManager + + preset_mgr = PresetManager(project_root) + preset_mgr.unregister_agent_artifacts(agent_key) + except Exception as preset_err: + from .. import _print_cli_warning + + _print_cli_warning( + "clean up preset artifacts for", + "integration", + agent_key, + preset_err, + continuing=continuing, + ) + + # --------------------------------------------------------------------------- # CLI formatting helpers (re-exported from _commands.py) # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 9ae2af3bc4..40bb7fbe66 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -36,6 +36,7 @@ _set_default_integration, _set_default_integration_or_exit, _unregister_extensions_for_agent, + _unregister_presets_for_agent, _update_init_options_for_integration, _write_integration_json, ) @@ -196,6 +197,19 @@ def integration_switch( continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.", ) + # Unregister preset commands/skills for the old agent for the same + # reason: without this, a preset's command overrides (including + # custom preset commands) and skill mirrors rendered for + # installed_key would remain orphaned in its directory once a + # different, possibly not-yet-installed integration becomes active + # (#2948). Scoped strictly to installed_key; other agents' files, + # tracking, and the preset packs themselves are untouched. + _unregister_presets_for_agent( + project_root, + installed_key, + continuing="Continuing with integration switch; old preset artifacts may need manual cleanup.", + ) + # Clear metadata so a failed Phase 2 doesn't leave stale references installed_keys = [installed for installed in installed_keys if installed != installed_key] _clear_init_options_for_integration(project_root, installed_key) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 07b90708b7..8c9b208866 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -902,11 +902,46 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: # unreplaced stays tracked and on disk (#2948). if stale_command_names: replaced_skill_names = set(registered_skills.get(agent_name) or []) + # Commands may carry aliases (CommandRegistrar.register_ + # commands() tracks and returns primary + alias names + # flattened together into one list), but _register_ + # skills() only ever renders/returns the *primary* + # command name's skill — running an alias's own name + # through _skill_names_for_command() never matches + # anything real, so an alias would stay tracked/on-disk + # forever even after its primary's skill replacement + # landed. Map each stale name back to its template's + # primary via the manifest so the whole primary+alias + # group is retired or kept together, based solely on + # whether the *primary*'s skill replacement actually + # landed (#2948). + alias_to_primary: Dict[str, str] = {} + for tmpl in manifest.templates: + if tmpl.get("type") != "command": + continue + primary_name = tmpl.get("name") + if not isinstance(primary_name, str): + continue + for alias in tmpl.get("aliases", []): + if isinstance(alias, str): + alias_to_primary[alias] = primary_name + + group_fully_replaced: Dict[str, bool] = {} + for cmd_name in stale_command_names: + primary_name = alias_to_primary.get(cmd_name, cmd_name) + if primary_name in group_fully_replaced: + continue + modern_name, legacy_name = self._skill_names_for_command(primary_name) + group_fully_replaced[primary_name] = ( + modern_name in replaced_skill_names + or legacy_name in replaced_skill_names + ) + fully_replaced = [] remaining_stale = [] for cmd_name in stale_command_names: - modern_name, legacy_name = self._skill_names_for_command(cmd_name) - if modern_name in replaced_skill_names or legacy_name in replaced_skill_names: + primary_name = alias_to_primary.get(cmd_name, cmd_name) + if group_fully_replaced.get(primary_name, False): fully_replaced.append(cmd_name) else: remaining_stale.append(cmd_name) @@ -952,6 +987,105 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: stacklevel=2, ) + def unregister_agent_artifacts(self, agent_name: str) -> None: + """Remove ``agent_name``'s tracked preset command/skill artifacts. + + Mirrors ``ExtensionManager.unregister_agent_artifacts()`` (#2948): + used by ``integration switch`` when deactivating the previous + integration, so a preset's command overrides and skill mirrors + written for that agent don't linger as orphans in its directory + once a different (possibly not-yet-installed) integration becomes + active — including custom preset commands and files the registrar + would otherwise skip as user-modified. + + Scoped strictly to ``agent_name``: only that agent's own tracked + artifacts and registry entries are touched. Other agents' files, + tracking, and preset packs themselves are left untouched, and no + priority-stack reconciliation runs — this is agent-scoped cleanup + only, not preset removal. + """ + if not agent_name: + return + + try: + from ..agents import CommandRegistrar + + agent_config = CommandRegistrar().AGENT_CONFIGS.get(agent_name) + except ImportError: + agent_config = None + if agent_config is None: + return + + for pack_id, metadata in list(self.registry.list().items()): + pack_dir = self.presets_dir / pack_id + updates: Dict[str, Any] = {} + + raw_skills = metadata.get("registered_skills", []) + if isinstance(raw_skills, list) and raw_skills: + # Legacy flat-list value predating per-agent provenance: + # infer real ownership from on-disk markers before removing + # anything, so only agent_name's actual share is unregistered + # and the rest migrates to per-agent form instead of either + # guessing every name belongs to agent_name or blindly + # leaving other agents' shares unrecoverable (#2948). + registered_skills_all = self._infer_legacy_skill_provenance( + [n for n in raw_skills if isinstance(n, str)], + pack_id, + fallback_agent=agent_name, + ) + skills_migrated = True + elif isinstance(raw_skills, dict): + registered_skills_all = copy.deepcopy(raw_skills) + skills_migrated = False + else: + registered_skills_all = {} + skills_migrated = False + + registered_commands = metadata.get("registered_commands", {}) + if not isinstance(registered_commands, dict): + registered_commands = {} + + agent_command_names = [ + n for n in registered_commands.get(agent_name, []) if isinstance(n, str) + ] + + # Native SKILL.md agents (claude/codex/agy/…) materialize their + # preset override in _register_commands(), tracked under + # registered_commands, not registered_skills — see + # _register_skills()'s own docstring ("Native skill agents … + # materialize brand-new preset skills in _register_commands()"). + # A legacy flat-list registered_skills value predating that + # split can still attribute the very same on-disk file to this + # agent via provenance inference; unregistering through both + # paths would double-process the identical directory (delete + # via the commands path, then no-op "restore" via the skills + # path since the directory is already gone). Mirror remove()'s + # own coordination: whenever this agent's artifact is already + # handled via registered_commands, never additionally treat it + # as a registered_skills entry for the same agent. + if agent_command_names and agent_config.get("extension") == "/SKILL.md": + registered_skills_all.pop(agent_name, None) + + if agent_command_names: + self._unregister_commands({agent_name: agent_command_names}) + new_registered_commands = copy.deepcopy(registered_commands) + new_registered_commands.pop(agent_name, None) + updates["registered_commands"] = new_registered_commands + + agent_skill_names = registered_skills_all.get(agent_name) or [] + if agent_skill_names or skills_migrated: + if agent_skill_names: + self._unregister_skills({agent_name: agent_skill_names}, pack_dir) + remaining = { + other_agent: names + for other_agent, names in registered_skills_all.items() + if other_agent != agent_name + } + updates["registered_skills"] = remaining + + if updates: + self.registry.update(pack_id, updates) + def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: """Remove previously registered command files from agent directories. diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 92b0212d11..ce2fc4f342 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2281,6 +2281,83 @@ def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path): assert "opencode" in git_meta["registered_commands"] assert "copilot" not in git_meta["registered_commands"] + def test_switch_to_not_yet_installed_unregisters_old_preset_artifacts(self, tmp_path): + """Switching to a not-yet-installed integration must also clean up + the old agent's preset command overrides, mirroring the existing + extension cleanup on the same code path (#2948). + + Without this, a preset's command override -- including a custom + preset command -- rendered for the previous agent lingers as an + orphan once a different, not-yet-installed integration becomes the + new active agent. + """ + project = _init_project(tmp_path, "auggie") + + preset_src = tmp_path / "switch-cleanup-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Custom preset command\n---\nOverridden content\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "switch-cleanup-preset", + "name": "Switch Cleanup Preset", + "version": "1.0.0", + "description": "Test preset with a custom command override", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + import yaml + + (preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") + + result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) + assert result.exit_code == 0, f"preset add failed: {result.output}" + + auggie_cmd = project / ".augment" / "commands" / "speckit.specify.md" + assert auggie_cmd.exists(), "sanity: preset command registered for auggie" + + registry_path = project / ".specify" / "presets" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "presets" + ]["switch-cleanup-preset"]["registered_commands"] + assert "auggie" in registered, "sanity: auggie tracked before switch" + + # opencode is not yet installed in this project. + result = _run_in_project(project, [ + "integration", "switch", "opencode", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + assert not auggie_cmd.exists(), ( + "old agent's preset command override must be removed on switch " + "to a not-yet-installed integration, mirroring the existing " + "extension cleanup on this same code path (#2948)" + ) + + opencode_cmd = project / ".opencode" / "commands" / "speckit.specify.md" + assert opencode_cmd.exists(), "preset command should be registered for the new agent" + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "presets" + ]["switch-cleanup-preset"]["registered_commands"] + assert "auggie" not in registered, ( + "old agent's tracking must be dropped after switch cleanup" + ) + assert "opencode" in registered + def test_switch_does_not_register_disabled_extensions(self, tmp_path): """Disabled extensions should stay disabled and should not migrate commands.""" project = _init_project(tmp_path, "opencode") diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 9ebef14b95..485a69262c 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -119,6 +119,62 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path: return ext_dir +def _create_extension_dir_with_aliases( + temp_dir: Path, ext_id: str, command_specs: list +) -> Path: + """Create an extension directory whose commands carry aliases. + + ``command_specs`` is a list of ``(short_name, [alias, ...])`` tuples; + the primary command name is ``speckit..`` and each + alias is used verbatim as its own tracked/rendered command name, + mirroring how ``CommandRegistrar.register_commands()`` tracks and + returns primary + alias names flattened together (#2948). + """ + ext_dir = temp_dir / ext_id + ext_dir.mkdir() + + commands = [] + for short_name, aliases in command_specs: + commands.append({ + "name": f"speckit.{ext_id}.{short_name}", + "file": f"commands/{short_name}.md", + "description": f"Test {short_name} command", + "aliases": list(aliases), + }) + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Test Extension", + "version": "1.0.0", + "description": "A test extension for alias skill registration", + }, + "requires": { + "speckit_version": ">=0.1.0", + }, + "provides": {"commands": commands}, + } + + with open(ext_dir / "extension.yml", "w") as f: + yaml.safe_dump(manifest_data, f) + + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + for short_name, _aliases in command_specs: + (commands_dir / f"{short_name}.md").write_text( + "---\n" + f"description: \"Test {short_name} command\"\n" + "---\n" + "\n" + f"# {short_name.title()} Command\n" + "\n" + f"Run this to {short_name}.\n" + ) + + return ext_dir + + def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Path: """Create an extension whose command description contains non-ASCII characters.""" ext_dir = temp_dir / ext_id @@ -1581,6 +1637,133 @@ def test_toggle_skills_to_command_partial_result_only_removes_replaced_extension "artifact is still on disk (#2948)" ) + def test_toggle_command_to_skills_retires_alias_group_when_primary_skill_lands( + self, project_dir, temp_dir + ): + """An extension command's aliases must be retired together with + its primary once the primary's skill replacement lands. + + ``CommandRegistrar.register_commands_for_agent()`` tracks and + returns primary + alias names flattened together, but + ``_register_extension_skills()`` only ever renders/returns the + *primary* command name's skill. Without grouping by primary, the + alias command artifact and its tracking entry would survive + forever even after mutual exclusion is otherwise enforced for the + primary (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir_with_aliases( + temp_dir, "alias-group-success-ext", + [("hello", ["speckit.hello-alias"])], + ), + "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + agents_dir = project_dir / ".github" / "agents" + primary_cmd_file = agents_dir / "speckit.alias-group-success-ext.hello.agent.md" + alias_cmd_file = agents_dir / "speckit.hello-alias.agent.md" + assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( + "sanity: command mode should have written both the primary " + "and alias command files" + ) + metadata = manager.registry.get("alias-group-success-ext") + assert set(metadata["registered_commands"].get("copilot", [])) == { + "speckit.alias-group-success-ext.hello", "speckit.hello-alias", + }, "sanity: both primary and alias should be tracked for copilot" + + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + assert not primary_cmd_file.exists(), ( + "the primary's old command artifact must be retired once its " + "skill replacement lands (#2948)" + ) + assert not alias_cmd_file.exists(), ( + "the alias's old command artifact must be retired together " + "with its primary once the primary's skill replacement lands " + "(#2948)" + ) + metadata = manager.registry.get("alias-group-success-ext") + registered_commands = metadata.get("registered_commands", {}) + assert not registered_commands.get("copilot"), ( + "neither the primary nor the alias should remain tracked as " + "commands once both artifacts are retired (#2948)" + ) + skill_file = ( + project_dir / ".github" / "skills" + / "speckit-alias-group-success-ext-hello" / "SKILL.md" + ) + assert skill_file.exists(), "sanity: the primary's skill should have been written" + + def test_toggle_command_to_skills_keeps_alias_group_when_primary_skill_missing( + self, project_dir, temp_dir + ): + """An extension command's aliases must survive together with its + primary when the primary's skill replacement never lands. + + Mirror image of the group-retirement case: deleting the + extension's own installed command source makes + ``_register_extension_skills`` genuinely return nothing for the + primary, so neither the primary nor its alias have a real + replacement — both old command artifacts and their tracking must + survive (#2948). + """ + _create_init_options(project_dir, ai="copilot", ai_skills=False) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir_with_aliases( + temp_dir, "alias-group-failure-ext", + [("hello", ["speckit.hello-alias-2"])], + ), + "0.1.0", + register_commands=False, + ) + manager.register_enabled_extensions_for_agent("copilot") + + agents_dir = project_dir / ".github" / "agents" + primary_cmd_file = agents_dir / "speckit.alias-group-failure-ext.hello.agent.md" + alias_cmd_file = agents_dir / "speckit.hello-alias-2.agent.md" + assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( + "sanity: command mode should have written both the primary " + "and alias command files" + ) + + # Remove the installed source so _register_extension_skills can + # find nothing to render for the primary command. + ext_commands_dir = manager.extensions_dir / "alias-group-failure-ext" / "commands" + (ext_commands_dir / "hello.md").unlink() + + _create_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_extensions_for_agent("copilot") + + assert primary_cmd_file.exists(), ( + "the primary's old command artifact must survive since its " + "skill replacement never landed (#2948)" + ) + assert alias_cmd_file.exists(), ( + "the alias's old command artifact must survive together with " + "its primary since neither has a real replacement (#2948)" + ) + metadata = manager.registry.get("alias-group-failure-ext") + assert set(metadata["registered_commands"].get("copilot", [])) == { + "speckit.alias-group-failure-ext.hello", "speckit.hello-alias-2", + }, ( + "both the primary and alias must keep being tracked since " + "nothing was actually replaced (#2948)" + ) + skill_file = ( + project_dir / ".github" / "skills" + / "speckit-alias-group-failure-ext-hello" / "SKILL.md" + ) + assert not skill_file.exists(), ( + "sanity: no skill should have been written when the source " + "was missing" + ) + def test_rescaffold_toggle_skills_to_command_removes_stale_extension_skill_file( self, project_dir, temp_dir ): diff --git a/tests/test_presets.py b/tests/test_presets.py index 0a4c8aeb8d..e4cfd84999 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3224,6 +3224,46 @@ def _create_multi_command_preset(self, temp_dir, preset_id, command_names): yaml.dump(manifest_data, f) return preset_dir + def _create_multi_command_preset_with_aliases(self, temp_dir, preset_id, command_specs): + """Install-directory helper for a preset whose commands carry aliases. + + ``command_specs`` is a list of ``(primary_name, [alias, ...])`` + tuples. Each command gets its own source file (aliases share the + same source/content as their primary — CommandRegistrar renders + them from the same command file, just under a different output + name (#2948)). + """ + preset_dir = temp_dir / preset_id + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + templates = [] + for primary_name, aliases in command_specs: + command_file = f"{primary_name}.md" + (preset_dir / "commands" / command_file).write_text( + f"---\ndescription: {primary_name} test command\n---\n\n" + f"{primary_name} body\n" + ) + templates.append({ + "type": "command", + "name": primary_name, + "file": f"commands/{command_file}", + "aliases": list(aliases), + }) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": templates}, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + return preset_dir + def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): """When skills mode was used, a preset command override should update the skill.""" # Simulate skills mode having been used: write init-options + create skill @@ -4778,6 +4818,179 @@ def test_toggle_skills_to_command_partial_result_only_removes_replaced_skill( "sanity: specify's new command artifact should exist" ) + def test_toggle_command_to_skills_retires_alias_group_when_primary_skill_lands( + self, project_dir, temp_dir + ): + """A command's aliases must be retired together with its primary + once the primary's skill replacement lands. + + ``CommandRegistrar.register_commands()`` tracks and returns + primary + alias names flattened together, but ``_register_skills()`` + only ever renders/returns the *primary* command name's skill. The + alias's own name run through ``_skill_names_for_command()`` never + matches anything real, so without grouping by primary, the alias + command artifact and its tracking entry would survive forever even + after mutual exclusion is otherwise enforced for the primary (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset_with_aliases( + temp_dir, "alias-group-success-preset", + [("speckit.specify", ["speckit.spec"])], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md" + assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( + "sanity: command mode should have written both the primary " + "and alias command files" + ) + metadata = manager.registry.get("alias-group-success-preset") + assert set(metadata["registered_commands"].get("copilot", [])) == { + "speckit.specify", "speckit.spec", + }, "sanity: both primary and alias should be tracked for copilot" + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert not primary_cmd_file.exists(), ( + "the primary's old command artifact must be retired once its " + "skill replacement lands (#2948)" + ) + assert not alias_cmd_file.exists(), ( + "the alias's old command artifact must be retired together " + "with its primary once the primary's skill replacement lands " + "(#2948)" + ) + metadata = manager.registry.get("alias-group-success-preset") + registered_commands = metadata.get("registered_commands", {}) + assert not registered_commands.get("copilot"), ( + "neither the primary nor the alias should remain tracked as " + "commands once both artifacts are retired (#2948)" + ) + skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + assert skill_file.exists(), "sanity: the primary's skill should have been written" + + def test_toggle_command_to_skills_keeps_alias_group_when_primary_skill_missing( + self, project_dir, temp_dir + ): + """A command's aliases must survive together with its primary when + the primary's skill replacement never lands. + + Mirror image of the group-retirement case: deleting the preset's + own installed command source makes ``_register_skills`` genuinely + return nothing for ``speckit.specify``, so neither the primary nor + its alias have a real replacement — both old command artifacts and + their tracking must survive (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset_with_aliases( + temp_dir, "alias-group-failure-preset", + [("speckit.specify", ["speckit.spec"])], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md" + assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( + "sanity: command mode should have written both the primary " + "and alias command files" + ) + + # Remove the installed source so _register_skills can find nothing + # to render for the primary — a real "missing source" case. + (manager.presets_dir / "alias-group-failure-preset" / "commands" / "speckit.specify.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert primary_cmd_file.exists(), ( + "the primary's old command artifact must survive since its " + "skill replacement never landed (#2948)" + ) + assert alias_cmd_file.exists(), ( + "the alias's old command artifact must survive together with " + "its primary since neither has a real replacement (#2948)" + ) + metadata = manager.registry.get("alias-group-failure-preset") + assert set(metadata["registered_commands"].get("copilot", [])) == { + "speckit.specify", "speckit.spec", + }, ( + "both the primary and alias must keep being tracked since " + "nothing was actually replaced (#2948)" + ) + skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + assert not skill_file.exists(), ( + "sanity: no skill should have been written when the source " + "was missing" + ) + + def test_toggle_command_to_skills_partial_multi_group_only_retires_successful_group( + self, project_dir, temp_dir + ): + """With two independent alias groups, only the group whose primary + skill actually lands is retired; the other survives intact. + + A preset with two commands (``speckit.specify`` with alias + ``speckit.spec``, and ``speckit.plan`` with alias + ``speckit.plan-alt``) where only ``speckit.plan``'s installed + source goes missing: ``speckit.specify``'s group (primary + alias) + must be fully retired, while ``speckit.plan``'s entire group + (primary + alias) must survive together, since grouping is + per-primary, not per-individual-name (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset_with_aliases( + temp_dir, "alias-group-partial-preset", + [ + ("speckit.specify", ["speckit.spec"]), + ("speckit.plan", ["speckit.plan-alt"]), + ], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + specify_cmd = copilot_commands_dir / "speckit.specify.agent.md" + spec_alias_cmd = copilot_commands_dir / "speckit.spec.agent.md" + plan_cmd = copilot_commands_dir / "speckit.plan.agent.md" + plan_alias_cmd = copilot_commands_dir / "speckit.plan-alt.agent.md" + assert all( + f.exists() for f in (specify_cmd, spec_alias_cmd, plan_cmd, plan_alias_cmd) + ), "sanity: command mode should have written all four command files" + + # Remove only plan's installed source so its group's skill + # replacement is silently skipped, while specify's group succeeds. + (manager.presets_dir / "alias-group-partial-preset" / "commands" / "speckit.plan.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert not specify_cmd.exists() and not spec_alias_cmd.exists(), ( + "specify's whole group (primary + alias) must be retired " + "since its skill replacement landed (#2948)" + ) + assert plan_cmd.exists() and plan_alias_cmd.exists(), ( + "plan's whole group (primary + alias) must survive together " + "since its skill replacement never landed (#2948)" + ) + metadata = manager.registry.get("alias-group-partial-preset") + tracked_commands = set(metadata["registered_commands"].get("copilot", [])) + assert tracked_commands == {"speckit.plan", "speckit.plan-alt"}, ( + "only plan's group should remain tracked as commands; " + "specify's group must be fully untracked (#2948)" + ) + def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file( self, project_dir, temp_dir ): @@ -6217,6 +6430,153 @@ def test_copilot_skills_registration_restored_after_process_restart( ) assert "Core specify body" in skill_file.read_text() + def test_unregister_agent_artifacts_scoped_to_target_agent_only( + self, project_dir, temp_dir + ): + """``unregister_agent_artifacts`` must remove only the target + agent's own tracked command/skill artifacts. + + Used by ``integration switch`` when deactivating the previous + integration for a not-yet-installed target (#2948): without this, + a preset's command override -- including a custom preset command -- + and skill mirror rendered for the old agent remain orphaned once a + different integration becomes active. Another agent's own + registrations (files and registry tracking) must survive + untouched, and no priority-stack reconciliation should run as a + side effect. + """ + self._write_init_options(project_dir, ai="auggie", ai_skills=False) + # Registration only writes to an agent's directory once it's + # "detected" on disk (mirroring a real `integration install` + # having already created it), so pre-create both agents' + # directories before installing the preset. + (project_dir / ".augment" / "commands").mkdir(parents=True) + (project_dir / ".opencode" / "commands").mkdir(parents=True) + preset_dir = self._create_command_preset( + temp_dir, "switch-cleanup-preset", "speckit.specify", + "Custom preset command", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + auggie_cmd = project_dir / ".augment" / "commands" / "speckit.specify.md" + assert auggie_cmd.exists(), "sanity: preset command registered for auggie" + + # Simulate a later `integration use opencode` rescaffold that also + # registered the preset for opencode, while auggie's own + # registration (from before the switch) is still present in the + # registry. + self._write_init_options(project_dir, ai="opencode", ai_skills=False) + manager.register_enabled_presets_for_agent("opencode") + + opencode_cmd = project_dir / ".opencode" / "commands" / "speckit.specify.md" + assert opencode_cmd.exists(), "sanity: preset command registered for opencode" + + metadata = manager.registry.get("switch-cleanup-preset") + registered_commands = metadata.get("registered_commands", {}) + assert "auggie" in registered_commands and "opencode" in registered_commands + + manager.unregister_agent_artifacts("auggie") + + assert not auggie_cmd.exists(), ( + "auggie's own preset command must be removed when switching " + "away from auggie to a not-yet-installed integration (#2948)" + ) + assert opencode_cmd.exists(), ( + "opencode's preset command must survive unregistering auggie's " + "artifacts -- cleanup must stay scoped to the target agent" + ) + + metadata = manager.registry.get("switch-cleanup-preset") + registered_commands = metadata.get("registered_commands", {}) + assert "auggie" not in registered_commands, ( + "auggie's tracking must be dropped after unregistering its artifacts" + ) + assert "opencode" in registered_commands, ( + "opencode's tracking must be preserved untouched" + ) + + def test_unregister_agent_artifacts_migrates_legacy_skill_list_scoped( + self, project_dir, temp_dir + ): + """Unregistering an agent's artifacts from a legacy flat-list + ``registered_skills`` entry must infer real per-agent ownership + before removing anything, so only the target agent's own share is + cleaned up and any other agent's still-live mirror survives, + rather than either guessing every name belongs to the target agent + or dropping all tracking wholesale (#2948). + + Uses Copilot (command-backed, rendering skills via ``ai_skills``) + as the agent being switched away from, and Claude (a native + SKILL.md agent) as the separate, still-live owner — mirroring the + established legacy-provenance test pattern used elsewhere for + this exact registry shape. + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + copilot_skills_dir = project_dir / ".github" / "skills" + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(copilot_skills_dir, "speckit-specify") + self._create_skill(claude_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "switch-legacy-skill-preset", "speckit.specify", + "Legacy skill switch test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:switch-legacy-skill-preset" in copilot_skill.read_text(), ( + "sanity: install wrote the override under copilot" + ) + + # A separate activation under claude (before provenance tracking + # existed) also left a live, marker-verified mirror there. + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + claude_skill.write_text( + "---\nname: speckit-specify\nmetadata:\n source: preset:switch-legacy-skill-preset\n" + "---\n\npreset body\n", + encoding="utf-8", + ) + + # Simulate a pre-#2948 registry: a flat list with no per-agent + # provenance for either writer. + manager.registry.update( + "switch-legacy-skill-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + manager.unregister_agent_artifacts("copilot") + + assert "preset:switch-legacy-skill-preset" not in copilot_skill.read_text(), ( + "copilot's own skill override must be restored when switching " + "away from copilot" + ) + assert "Core specify body" in copilot_skill.read_text() + + assert "preset:switch-legacy-skill-preset" in claude_skill.read_text(), ( + "claude's own, separately-written mirror must survive " + "unregistering copilot's artifacts -- legacy-list inference " + "must not misattribute or drop claude's real ownership (#2948)" + ) + + metadata = manager.registry.get("switch-legacy-skill-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict), ( + "legacy flat-list value must migrate to per-agent form" + ) + assert "copilot" not in registered_skills + assert "claude" in registered_skills and "speckit-specify" in registered_skills["claude"], ( + "claude's real ownership must be preserved in the migrated tracking" + ) + class TestPresetSetPriority: """Test preset set-priority CLI command.""" From b64937ac8d7d6adff3d5c7c8667b652426c47096 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:13:06 +0200 Subject: [PATCH 22/27] fix: track reconciled extension artifacts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 170 ++++++++++++++++++++-------- tests/test_presets.py | 89 +++++++++++++++ 2 files changed, 212 insertions(+), 47 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 8c9b208866..fe8f54650b 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1144,6 +1144,30 @@ def _merge_pack_registered_commands( if changed: self.registry.update(pack_id, {"registered_commands": merged_commands}) + def _merge_extension_registered_commands( + self, extension_id: str, written: Optional[Dict[str, List[str]]] + ) -> None: + """Merge reconciliation writes into an extension's registry entry.""" + if not written: + return + registry = ExtensionRegistry(self.project_root / ".specify" / "extensions") + metadata = registry.get(extension_id) + if metadata is None: + return + existing_commands = metadata.get("registered_commands", {}) + if not isinstance(existing_commands, dict): + existing_commands = {} + merged_commands = copy.deepcopy(existing_commands) + changed = False + for agent_name, cmd_names in written.items(): + existing_names = merged_commands.get(agent_name, []) + new_names = [name for name in cmd_names if name not in existing_names] + if new_names: + merged_commands[agent_name] = existing_names + new_names + changed = True + if changed: + registry.update(extension_id, {"registered_commands": merged_commands}) + def _reconcile_composed_commands( self, command_names: List[str], extra_agents: Optional[Set[str]] = None ) -> None: @@ -1249,10 +1273,14 @@ def _reconcile_composed_commands( # Top layer is a non-preset source (extension, core, or # project override). Register directly from the layer path. source = layers[0]["source"] + extension_id = None + written: Dict[str, List[str]] = {} if source.startswith("extension:"): # Use extension's own registration to preserve context formatting - ext_id = source.split(":", 1)[1].split(" ", 1)[0] - ext_dir = self.project_root / ".specify" / "extensions" / ext_id + extension_id = source.split(":", 1)[1].split(" ", 1)[0] + ext_dir = ( + self.project_root / ".specify" / "extensions" / extension_id + ) ext_manifest_path = ext_dir / "extension.yml" if ext_manifest_path.exists(): try: @@ -1264,10 +1292,10 @@ def _reconcile_composed_commands( if c.get("name") == cmd_name ] if matching_cmds: - registrar.register_commands_for_non_skill_agents( - matching_cmds, ext_id, ext_dir, + written = registrar.register_commands_for_non_skill_agents( + matching_cmds, extension_id, ext_dir, self.project_root, - context_note=f"\n\n\n", + context_note=f"\n\n\n", only_agent=only_agent, extra_agents=extra_agents, ) registered = True @@ -1276,12 +1304,16 @@ def _reconcile_composed_commands( # generic path-based registration below. pass if not registered: - source_id = source.split(":", 1)[1].split(" ", 1)[0] if source.startswith("extension:") else source - self._register_command_from_path( + source_id = extension_id or source + written = self._register_command_from_path( registrar, cmd_name, top_path, source_id=source_id, only_agent=only_agent, extra_agents=extra_agents, ) + if extension_id: + self._merge_extension_registered_commands( + extension_id, written + ) else: # Composed command — resolve from full stack composed = resolver.resolve_content(cmd_name, "command") @@ -1364,11 +1396,15 @@ def _reconcile_composed_commands( source_id = source.split(":", 1)[1].split(" ", 1)[0] else: source_id = source - self._register_command_from_path( + written = self._register_command_from_path( registrar, cmd_name, composed_file, source_id=source_id, only_agent=only_agent, extra_agents=extra_agents, ) + if source.startswith("extension:"): + self._merge_extension_registered_commands( + source_id, written + ) def _register_command_from_path( self, @@ -1550,7 +1586,9 @@ def _merge_pack_registered_skills( def _reconcile_skills( self, command_names: List[str], - extra_skills_dirs: Optional[Dict[Path, Optional[str]]] = None, + extra_skills_dirs: Optional[ + Dict[Path, tuple[Optional[str], List[str]]] + ] = None, ) -> None: """Re-register skills for commands whose winning layer changed. @@ -1560,16 +1598,10 @@ def _reconcile_skills( Args: command_names: List of command names to reconcile skills for - extra_skills_dirs: Additional ``{skills_dir: renderer_agent}`` - pairs to reconcile besides the currently active skills - directory. Populated by ``remove()`` from the directories - ``_unregister_skills`` just restored to core/extension - content: a surviving lower-priority preset's override must - be re-applied to every one of those directories too, not - only the currently active agent's directory, or an - inactive integration is left with stale/missing content - even though the removed preset no longer wins there - either (#2948). + extra_skills_dirs: Additional + ``{skills_dir: (renderer_agent, managed_skill_names)}`` + entries restored by ``_unregister_skills``. Reconciliation + is limited to the names actually managed in each directory. """ if not command_names: return @@ -1639,13 +1671,20 @@ def _reconcile_skills( override_skills = [s for s in non_preset_skills if s[2]["source"] == "project override"] def apply_to_dir( - skills_dir: Path, dir_agent: Optional[str], *, is_active: bool + skills_dir: Path, + dir_agent: Optional[str], + *, + is_active: bool, + managed_names: Optional[Set[str]] = None, ) -> None: + dir_managed_names = ( + managed_skill_names if managed_names is None else managed_names + ) # Re-create the skill directory only if it was previously # managed (i.e., listed in some preset's registered_skills). # This avoids creating new skill dirs that _register_skills # would normally skip. - for skill_name in managed_skill_names: + for skill_name in dir_managed_names: skill_subdir = skills_dir / skill_name if not skill_subdir.exists(): skill_subdir.mkdir(parents=True, exist_ok=True) @@ -1654,12 +1693,23 @@ def apply_to_dir( # _unregister_skills_in_dir can rmtree the skill dir, so # overrides must be handled directly (create dir + write) # without that call. - if core_ext_skills: + dir_core_ext_names = [ + candidate + for _skill_name, cmd_name, _top_layer in core_ext_skills + for candidate in self._skill_names_for_command(cmd_name) + if candidate in dir_managed_names + ] + if dir_core_ext_names: self._unregister_skills_in_dir( - [s[0] for s in core_ext_skills], skills_dir, dir_agent + dir_core_ext_names, skills_dir, dir_agent ) for skill_name, cmd_name, top_layer in override_skills: + if not any( + name in dir_managed_names + for name in self._skill_names_for_command(cmd_name) + ): + continue skill_subdir = skills_dir / skill_name # Same symlink guard as _register_skills's registration path # (#2948): mkdir(exist_ok=True) alone would silently follow @@ -1713,6 +1763,16 @@ def apply_to_dir( # reconciled, not all commands in each winning preset's # manifest. for pack_id, cmds in preset_cmds.items(): + dir_cmds = [ + cmd + for cmd in cmds + if any( + name in dir_managed_names + for name in self._skill_names_for_command(cmd) + ) + ] + if not dir_cmds: + continue pack_dir = self.presets_dir / pack_id manifest_path = pack_dir / "preset.yml" if not manifest_path.exists(): @@ -1721,7 +1781,7 @@ def apply_to_dir( manifest = PresetManifest(manifest_path) except PresetValidationError: continue - cmds_set = set(cmds) + cmds_set = set(dir_cmds) filtered_manifest = self._FilteredManifest(manifest, cmds_set) if is_active: # Preserve exact prior behaviour for the currently @@ -1743,13 +1803,27 @@ def apply_to_dir( # reconciliation just wrote to on its behalf (#2948). self._merge_pack_registered_skills(pack_id, written) + extra_dirs = extra_skills_dirs or {} if active_skills_dir: - apply_to_dir(active_skills_dir, active_ai, is_active=True) + active_provenance = extra_dirs.get(active_skills_dir) + apply_to_dir( + active_skills_dir, + active_ai, + is_active=True, + managed_names=( + set(active_provenance[1]) if active_provenance else None + ), + ) - for extra_dir, extra_agent in (extra_skills_dirs or {}).items(): + for extra_dir, (extra_agent, extra_names) in extra_dirs.items(): if extra_dir == active_skills_dir: continue # already reconciled above as the active directory - apply_to_dir(extra_dir, extra_agent, is_active=False) + apply_to_dir( + extra_dir, + extra_agent, + is_active=False, + managed_names=set(extra_names), + ) def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. @@ -2273,7 +2347,7 @@ def _unregister_skills( self, registered_skills: Union[Dict[str, List[str]], List[str]], preset_dir: Path, - ) -> Dict[Path, Optional[str]]: + ) -> Dict[Path, tuple[Optional[str], List[str]]]: """Restore original SKILL.md files after a preset is removed. For each skill that was overridden by the preset, attempts to @@ -2296,12 +2370,8 @@ def _unregister_skills( preset_dir: The preset's installed directory (may already be deleted). Returns: - ``{skills_dir: renderer_agent}`` for every directory actually - restored, so callers (e.g. ``remove()``) can hand these same - directories to :meth:`_reconcile_skills` — a surviving - lower-priority preset's override must be re-applied to every - directory this removal touched, not only the currently active - agent's directory (#2948). + ``{skills_dir: (renderer_agent, managed_skill_names)}`` for + every directory actually restored. """ if not registered_skills: return {} @@ -2335,21 +2405,24 @@ def _unregister_skills( group = groups.setdefault(skills_dir, {"agents": [], "names": []}) group["agents"].append(agent_name) for name in skill_names: - if name not in group["names"]: + if ( + self._is_safe_registry_skill_name(name) + and name not in group["names"] + ): group["names"].append(name) + restored: Dict[Path, tuple[Optional[str], List[str]]] = {} for skills_dir, group in groups.items(): agents = group["agents"] renderer_agent = ( active_agent if active_agent in agents else sorted(agents)[0] ) self._unregister_skills_in_dir(group["names"], skills_dir, renderer_agent) - return { - skills_dir: ( - active_agent if active_agent in group["agents"] else sorted(group["agents"])[0] + restored[skills_dir] = ( + renderer_agent, + list(group["names"]), ) - for skills_dir, group in groups.items() - } + return restored # Legacy flat-list format: no record of which agent directory these # names were written under, so best-effort restore is limited to the @@ -2364,12 +2437,13 @@ def _unregister_skills( init_opts = {} selected_ai = init_opts.get("ai") selected_ai = selected_ai if isinstance(selected_ai, str) else None - self._unregister_skills_in_dir( - registered_skills, - skills_dir, - selected_ai, - ) - return {skills_dir: selected_ai} + safe_names = [ + name + for name in registered_skills + if self._is_safe_registry_skill_name(name) + ] + self._unregister_skills_in_dir(safe_names, skills_dir, selected_ai) + return {skills_dir: (selected_ai, safe_names)} def _unregister_skills_in_dir( self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str] @@ -2781,7 +2855,9 @@ def remove(self, pack_id: str) -> bool: # names from registered_commands are still unregistered. pass - affected_skill_dirs: Dict[Path, Optional[str]] = {} + affected_skill_dirs: Dict[ + Path, tuple[Optional[str], List[str]] + ] = {} if registered_skills: affected_skill_dirs = self._unregister_skills(registered_skills, pack_dir) try: diff --git a/tests/test_presets.py b/tests/test_presets.py index e4cfd84999..0f82e005f4 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -6023,6 +6023,55 @@ def test_remove_reconciliation_tracks_new_historical_agent_for_survivor( "with no preset left to track or clean it up (#2948)" ) + def test_extension_reconciliation_tracks_new_historical_agent( + self, project_dir + ): + from specify_cli.extensions import ExtensionRegistry + + self._write_init_options(project_dir, ai="opencode", ai_skills=False) + (project_dir / ".opencode" / "commands").mkdir(parents=True) + (project_dir / ".gemini" / "commands").mkdir(parents=True) + + ext_dir = project_dir / ".specify" / "extensions" / "tracked-ext" + (ext_dir / "commands").mkdir(parents=True) + (ext_dir / "commands" / "tracked.md").write_text( + "---\ndescription: tracked\n---\n\nExtension body\n", + encoding="utf-8", + ) + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: tracked-ext\n name: Tracked\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.tracked\n" + " file: commands/tracked.md\n" + " description: Tracked command\n", + encoding="utf-8", + ) + ExtensionRegistry(ext_dir.parent).add( + "tracked-ext", + { + "version": "1.0.0", + "source": "dev", + "enabled": True, + "registered_commands": { + "opencode": ["speckit.tracked-ext.tracked"] + }, + }, + ) + + manager = PresetManager(project_dir) + manager._reconcile_composed_commands( + ["speckit.tracked-ext.tracked"], extra_agents={"gemini"} + ) + + assert list((project_dir / ".gemini" / "commands").glob("*tracked*")) + metadata = ExtensionRegistry(ext_dir.parent).get("tracked-ext") + assert set(metadata["registered_commands"]) == {"gemini", "opencode"} + def test_remove_reconciles_skill_for_every_historical_agent( self, project_dir, temp_dir ): @@ -6101,6 +6150,46 @@ def test_remove_reconciles_skill_for_every_historical_agent( "the currently active agent" ) + def test_skill_reconciliation_preserves_per_directory_names( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_dir, "speckit-alpha") + alpha_dir = self._create_command_preset( + temp_dir, "partial-alpha", "speckit.alpha", + "Alpha", "alpha body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(alpha_dir, "0.1.5") + + self._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_dir, "speckit-beta") + beta_dir = self._create_command_preset( + temp_dir, "partial-beta", "speckit.beta", + "Beta", "beta body", + ) + manager.install_from_directory(beta_dir, "0.1.5") + + affected = manager._unregister_skills( + { + "claude": ["speckit-alpha"], + "codex": ["speckit-beta"], + }, + manager.presets_dir / "partial-alpha", + ) + manager._reconcile_skills( + ["speckit.alpha", "speckit.beta"], + extra_skills_dirs=affected, + ) + + assert (claude_dir / "speckit-alpha" / "SKILL.md").exists() + assert (codex_dir / "speckit-beta" / "SKILL.md").exists() + assert not (claude_dir / "speckit-beta").exists() + assert not (codex_dir / "speckit-alpha").exists() + def test_remove_reconciliation_tracks_new_historical_skill_agent_for_survivor( self, project_dir, temp_dir ): From 170ca149b22632c46dbb2ef7b588abb3991442e2 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:36:06 +0200 Subject: [PATCH 23/27] Fix native skill preset reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 116 +++++++++++++++--- .../test_integration_subcommand.py | 53 ++++++++ tests/test_presets.py | 43 +++++++ 3 files changed, 196 insertions(+), 16 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index fe8f54650b..5bea8b9c33 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1715,7 +1715,9 @@ def apply_to_dir( # (#2948): mkdir(exist_ok=True) alone would silently follow # an existing symlinked subdirectory before writing SKILL.md # through it. - if not self._validate_skill_subdir(skill_subdir, create=True): + if not self._validate_skill_subdir( + skill_subdir, create=True, skills_root=skills_dir + ): continue skill_file = skill_subdir / "SKILL.md" try: @@ -1825,26 +1827,87 @@ def apply_to_dir( managed_names=set(extra_names), ) + def _resolve_agent_skills_dir(self, agent_name: str) -> Path: + """Resolve the real skill output directory for an integration.""" + from .. import _get_skills_dir as _project_skills_dir + from ..agents import CommandRegistrar + + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config and agent_config.get("extension") == "/SKILL.md": + return registrar._resolve_agent_dir( + agent_name, agent_config, self.project_root + ) + return _project_skills_dir(self.project_root, agent_name) + + def _skills_validation_root(self, skills_dir: Path) -> Optional[Path]: + """Return the trusted root containing a project or user skill dir.""" + for root in (self.project_root, Path.home()): + if skills_dir.is_relative_to(root): + return root + return None + def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. - Delegates to :func:`resolve_active_skills_dir` which reads - init-options, applies the Kimi native-skills fallback, and - safely creates the directory when ``ai_skills`` is enabled. + Uses :func:`resolve_active_skills_dir` for activation/detection, + then resolves native skill agents through the registrar's output + directory so integrations such as Hermes write to their global + skills path rather than their project-local detection marker. Returns ``None`` (instead of raising) when the directory cannot be created due to symlink, containment, or permission issues so that callers can fall back gracefully. """ - from .. import resolve_active_skills_dir, _print_cli_warning + from .. import ( + _print_cli_warning, + load_init_options, + resolve_active_skills_dir, + ) + from ..shared_infra import _ensure_safe_shared_directory try: - return resolve_active_skills_dir(self.project_root) + skills_dir = resolve_active_skills_dir(self.project_root) except (ValueError, OSError) as exc: _print_cli_warning( "resolve", "skills directory", None, exc, continuing="Continuing without skill registration.", ) return None + if skills_dir is None: + return None + + opts = load_init_options(self.project_root) + selected_ai = opts.get("ai") if isinstance(opts, dict) else None + if not isinstance(selected_ai, str) or not selected_ai: + return skills_dir + + agent_skills_dir = self._resolve_agent_skills_dir(selected_ai) + if agent_skills_dir == skills_dir: + return skills_dir + + validation_root = self._skills_validation_root(agent_skills_dir) + if validation_root is None: + _print_cli_warning( + "resolve", + "skills directory", + str(agent_skills_dir), + ValueError("skills directory is outside trusted roots"), + continuing="Continuing without skill registration.", + ) + return None + try: + _ensure_safe_shared_directory( + validation_root, + agent_skills_dir, + context="preset skills directory", + ) + except (ValueError, OSError) as exc: + _print_cli_warning( + "resolve", "skills directory", str(agent_skills_dir), exc, + continuing="Continuing without skill registration.", + ) + return None + return agent_skills_dir @staticmethod def _skill_names_for_command(cmd_name: str) -> tuple[str, str]: @@ -2093,7 +2156,9 @@ def _register_skills( # is_dir() above follows symlinks, so a symlinked subdir # with a real parent would otherwise slip through and have # SKILL.md written through it to an arbitrary location (#2948). - if not self._validate_skill_subdir(skill_subdir, create=True): + if not self._validate_skill_subdir( + skill_subdir, create=True, skills_root=skills_dir + ): continue frontmatter_data = registrar.build_skill_frontmatter( selected_ai, @@ -2199,7 +2264,9 @@ def _infer_legacy_skill_provenance( canonical_agent = fallback_agent if fallback_agent in agents else sorted(agents)[0] for name in safe_skill_names: skill_subdir = resolved_dir / name - if not self._validate_skill_subdir(skill_subdir, create=False): + if not self._validate_skill_subdir( + skill_subdir, create=False, skills_root=resolved_dir + ): continue skill_file = skill_subdir / "SKILL.md" if not skill_file.is_file(): @@ -2271,13 +2338,15 @@ def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]: touched; directories that don't exist or fail validation are skipped rather than raising. """ - from .. import _get_skills_dir as _resolve_skills_dir from ..shared_infra import _ensure_safe_shared_directory - skills_dir = _resolve_skills_dir(self.project_root, agent_name) + skills_dir = self._resolve_agent_skills_dir(agent_name) + validation_root = self._skills_validation_root(skills_dir) + if validation_root is None: + return None try: _ensure_safe_shared_directory( - self.project_root, skills_dir, + validation_root, skills_dir, create=False, context="preset skills directory", ) except (ValueError, OSError): @@ -2314,7 +2383,13 @@ def _is_safe_registry_skill_name(name: Any) -> bool: return False return True - def _validate_skill_subdir(self, skill_subdir: Path, *, create: bool) -> bool: + def _validate_skill_subdir( + self, + skill_subdir: Path, + *, + create: bool, + skills_root: Optional[Path] = None, + ) -> bool: """Validate a single skill's subdirectory is symlink-free. Unlike :meth:`_safe_skills_dir_for_agent` (which only validates the @@ -2327,18 +2402,25 @@ def _validate_skill_subdir(self, skill_subdir: Path, *, create: bool) -> bool: restore/removal path (``create=False``, so a missing directory is left for the caller's own existence check to skip). Returns ``False`` rather than raising when the path escapes the project - root or crosses a symlink. + root or crosses a symlink. ``skills_root`` supplies the trusted + agent output boundary for native global skill integrations such as + Hermes; project-local callers default to ``self.project_root``. """ from ..shared_infra import _ensure_safe_shared_directory, _validate_safe_shared_directory + validation_root = skills_root or self.project_root + if validation_root.is_symlink(): + return False try: if create: _ensure_safe_shared_directory( - self.project_root, skill_subdir, + validation_root, skill_subdir, create=True, context="preset skill directory", ) else: - _validate_safe_shared_directory(self.project_root, skill_subdir) + _validate_safe_shared_directory( + validation_root, skill_subdir + ) except (ValueError, OSError): return False return True @@ -2496,7 +2578,9 @@ def _unregister_skills_in_dir( # (with a safe, non-symlinked parent) would otherwise slip past # _safe_skills_dir_for_agent's parent-only check and have # write_text/rmtree operate through it (#2948). - if not self._validate_skill_subdir(skill_subdir, create=False): + if not self._validate_skill_subdir( + skill_subdir, create=False, skills_root=skills_dir + ): continue if not skill_file.is_file(): # Only manage directories that contain the expected skill entrypoint. diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index ce2fc4f342..7ca09c3e73 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2974,6 +2974,59 @@ def test_upgrade_active_integration_reregisters_extensions(self, tmp_path): "upgrade of the active integration re-registers extension commands" ) + def test_upgrade_active_integration_reregisters_presets(self, tmp_path): + """Upgrading the active integration restores missing preset artifacts.""" + import yaml + + project = _init_project(tmp_path, "claude") + preset_src = tmp_path / "upgrade-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.upgrade-check.md").write_text( + "---\ndescription: Upgrade check\n---\nPreset upgrade body\n", + encoding="utf-8", + ) + manifest = { + "schema_version": "1.0", + "preset": { + "id": "upgrade-preset", + "name": "Upgrade Preset", + "version": "1.0.0", + "description": "Upgrade preset test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.upgrade-check", + "file": "commands/speckit.upgrade-check.md", + } + ] + }, + } + (preset_src / "preset.yml").write_text( + yaml.dump(manifest), encoding="utf-8" + ) + + result = _run_in_project( + project, ["preset", "add", "--dev", str(preset_src)] + ) + assert result.exit_code == 0, result.output + + skill_dir = ( + project / ".claude" / "skills" / "speckit-upgrade-check" + ) + skill_file = skill_dir / "SKILL.md" + assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8") + shutil.rmtree(skill_dir) + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8") + def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path): """Upgrading a non-active agent must not touch the active agent's skills. diff --git a/tests/test_presets.py b/tests/test_presets.py index 0f82e005f4..26e3aff8a3 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4297,6 +4297,49 @@ def test_use_rescaffold_reconciles_project_override(self, project_dir, temp_dir) ) assert "Preset body" not in content + def test_hermes_rescaffold_reconciles_global_skill_output( + self, project_dir, temp_dir, monkeypatch + ): + home = temp_dir / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: home) + (home / ".hermes" / "skills").mkdir(parents=True) + self._write_init_options(project_dir, ai="hermes", ai_skills=True) + (project_dir / ".hermes" / "skills").mkdir(parents=True) + + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override specify\n---\n\nOverride body\n", + encoding="utf-8", + ) + + preset_dir = self._create_command_preset( + temp_dir, "hermes-reconcile-preset", "speckit.specify", + "Preset specify", "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.register_enabled_presets_for_agent("hermes") + + skill_file = ( + home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md" + ) + assert skill_file.exists() + content = skill_file.read_text(encoding="utf-8") + assert "Override body" in content + assert "Preset body" not in content + assert not list( + (project_dir / ".hermes" / "skills").glob("speckit-*/SKILL.md") + ) + + (overrides_dir / "speckit.specify.md").unlink() + assert manager.remove("hermes-reconcile-preset") is True + if skill_file.exists(): + restored = skill_file.read_text(encoding="utf-8") + assert "Override body" not in restored + assert "Preset body" not in restored + def test_rescaffold_persists_commands_before_fallible_skills_phase( self, project_dir, temp_dir ): From bee8c77904e906654b1de04509256c6dba635e99 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:55:05 +0200 Subject: [PATCH 24/27] Fix shared native skill cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 51 ++++++++++++++++++++-- tests/test_presets.py | 68 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 5bea8b9c33..0b6ac73a57 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1010,10 +1010,12 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: try: from ..agents import CommandRegistrar - agent_config = CommandRegistrar().AGENT_CONFIGS.get(agent_name) + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) except ImportError: + registrar = None agent_config = None - if agent_config is None: + if agent_config is None or registrar is None: return for pack_id, metadata in list(self.registry.list().items()): @@ -1063,17 +1065,58 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: # own coordination: whenever this agent's artifact is already # handled via registered_commands, never additionally treat it # as a registered_skills entry for the same agent. + native_skills_entry_removed = False if agent_command_names and agent_config.get("extension") == "/SKILL.md": + native_skills_entry_removed = agent_name in registered_skills_all registered_skills_all.pop(agent_name, None) if agent_command_names: - self._unregister_commands({agent_name: agent_command_names}) + command_names_to_unregister = agent_command_names + if agent_config.get("extension") == "/SKILL.md": + agent_output = registrar._resolve_agent_dir( + agent_name, agent_config, self.project_root + ) + shared_names: set[str] = set() + for other_agent, other_names in registered_commands.items(): + if ( + other_agent == agent_name + or not isinstance(other_names, list) + ): + continue + other_config = registrar.AGENT_CONFIGS.get(other_agent) + if ( + not other_config + or other_config.get("extension") != "/SKILL.md" + ): + continue + other_output = registrar._resolve_agent_dir( + other_agent, other_config, self.project_root + ) + if other_output == agent_output: + shared_names.update( + name + for name in other_names + if isinstance(name, str) + ) + command_names_to_unregister = [ + name + for name in agent_command_names + if name not in shared_names + ] + if command_names_to_unregister: + self._unregister_commands( + {agent_name: command_names_to_unregister} + ) new_registered_commands = copy.deepcopy(registered_commands) new_registered_commands.pop(agent_name, None) updates["registered_commands"] = new_registered_commands agent_skill_names = registered_skills_all.get(agent_name) or [] - if agent_skill_names or skills_migrated: + if ( + agent_skill_names + or skills_migrated + or native_skills_entry_removed + ): if agent_skill_names: self._unregister_skills({agent_name: agent_skill_names}, pack_dir) remaining = { diff --git a/tests/test_presets.py b/tests/test_presets.py index 26e3aff8a3..0e7e326a01 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -6628,6 +6628,74 @@ def test_unregister_agent_artifacts_scoped_to_target_agent_only( "opencode's tracking must be preserved untouched" ) + def test_unregister_native_agent_persists_skills_metadata_pop( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="agy", ai_skills=True) + (project_dir / ".agents" / "skills").mkdir(parents=True) + preset_dir = self._create_command_preset( + temp_dir, + "native-metadata-cleanup-preset", + "speckit.shared-cleanup", + "Shared cleanup", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + metadata = manager.registry.get("native-metadata-cleanup-preset") + assert "agy" in metadata.get("registered_commands", {}) + manager.registry.update( + "native-metadata-cleanup-preset", + {"registered_skills": {"agy": ["speckit-shared-cleanup"]}}, + ) + + manager.unregister_agent_artifacts("agy") + + metadata = manager.registry.get("native-metadata-cleanup-preset") + assert "agy" not in metadata.get("registered_commands", {}) + assert "agy" not in metadata.get("registered_skills", {}) + + def test_unregister_native_agent_preserves_shared_output_owner( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="agy", ai_skills=True) + (project_dir / ".agents" / "skills").mkdir(parents=True) + preset_dir = self._create_command_preset( + temp_dir, + "shared-native-output-preset", + "speckit.shared-owner", + "Shared owner", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-shared-owner" + / "SKILL.md" + ) + assert skill_file.exists() + metadata = manager.registry.get("shared-native-output-preset") + registered_commands = metadata.get("registered_commands", {}) + assert "agy" in registered_commands and "codex" in registered_commands + + manager.unregister_agent_artifacts("agy") + + assert skill_file.exists(), ( + "shared SKILL.md must survive while codex still owns the same " + "physical output" + ) + metadata = manager.registry.get("shared-native-output-preset") + registered_commands = metadata.get("registered_commands", {}) + assert "agy" not in registered_commands + assert "codex" in registered_commands + def test_unregister_agent_artifacts_migrates_legacy_skill_list_scoped( self, project_dir, temp_dir ): From b50065350ea97da24f5c89a8da02d73088b656f6 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:13:24 +0200 Subject: [PATCH 25/27] Fix partial preset rescaffold tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 14 ++++++-- tests/test_presets.py | 52 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 0b6ac73a57..f11e128067 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -800,7 +800,12 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: # (#2948). stale_command_names: Optional[List[str]] = None if registered_commands.get(agent_name): - merged_commands[agent_name] = registered_commands[agent_name] + existing_names = merged_commands.get(agent_name, []) + merged_commands[agent_name] = existing_names + [ + name + for name in registered_commands[agent_name] + if name not in existing_names + ] elif ai_skills_now and merged_commands.get(agent_name): stale_command_names = merged_commands[agent_name] # Persist the commands phase immediately, mirroring @@ -843,7 +848,12 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: ) merged_skills = copy.deepcopy(existing_skills) if registered_skills.get(agent_name): - merged_skills[agent_name] = registered_skills[agent_name] + existing_names = merged_skills.get(agent_name, []) + merged_skills[agent_name] = existing_names + [ + name + for name in registered_skills[agent_name] + if name not in existing_names + ] elif is_command_backed and not ai_skills_now and merged_skills.get(agent_name): # Mirror image: toggled skills -> command for this same # agent. _get_skills_dir() no longer resolves a skills diff --git a/tests/test_presets.py b/tests/test_presets.py index 0e7e326a01..c61c239b3e 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4546,6 +4546,58 @@ def test_rescaffold_toggle_command_to_skills_removes_stale_command_file( "sanity: the new skills-mode artifact should still be written" ) + def test_same_mode_partial_command_rescaffold_keeps_skipped_tracking( + self, project_dir, temp_dir + ): + """A partial command refresh must keep still-live skipped artifacts tracked.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + preset_dir = self._create_multi_command_preset( + temp_dir, + "same-mode-partial-command-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + installed_dir = manager.presets_dir / "same-mode-partial-command-preset" + (installed_dir / "commands" / "speckit.plan.md").unlink() + manager.register_enabled_presets_for_agent("copilot") + + metadata = manager.registry.get("same-mode-partial-command-preset") + assert set(metadata["registered_commands"]["copilot"]) == { + "speckit.specify", + "speckit.plan", + } + assert (commands_dir / "speckit.plan.agent.md").exists() + + def test_same_mode_partial_skill_rescaffold_keeps_skipped_tracking( + self, project_dir, temp_dir + ): + """A partial skill refresh must keep still-live skipped artifacts tracked.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + self._create_skill(skills_dir, "speckit-plan") + preset_dir = self._create_multi_command_preset( + temp_dir, + "same-mode-partial-skill-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + installed_dir = manager.presets_dir / "same-mode-partial-skill-preset" + (installed_dir / "commands" / "speckit.plan.md").unlink() + manager.register_enabled_presets_for_agent("copilot") + + metadata = manager.registry.get("same-mode-partial-skill-preset") + assert set(metadata["registered_skills"]["copilot"]) == { + "speckit-specify", + "speckit-plan", + } + def test_toggle_command_to_skills_preserves_old_command_on_skills_failure( self, project_dir, temp_dir, monkeypatch ): From 1c9974310d3b78ce9aa9ac214a8c010553552461 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:33:05 +0200 Subject: [PATCH 26/27] Fix preset agent skill lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 43 ++++++++++++++++- tests/test_presets.py | 71 +++++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index f11e128067..bd25c8f5cc 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -726,6 +726,7 @@ def _register_commands( manifest.id, preset_dir, self.project_root, + create_missing_active_skills_dir=True, only_agent=active_agent, ) @@ -1029,7 +1030,6 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: return for pack_id, metadata in list(self.registry.list().items()): - pack_dir = self.presets_dir / pack_id updates: Dict[str, Any] = {} raw_skills = metadata.get("registered_skills", []) @@ -1128,7 +1128,9 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: or native_skills_entry_removed ): if agent_skill_names: - self._unregister_skills({agent_name: agent_skill_names}, pack_dir) + self._delete_agent_preset_skills( + agent_name, agent_skill_names, pack_id + ) remaining = { other_agent: names for other_agent, names in registered_skills_all.items() @@ -2580,6 +2582,43 @@ def _unregister_skills( self._unregister_skills_in_dir(safe_names, skills_dir, selected_ai) return {skills_dir: (selected_ai, safe_names)} + def _delete_agent_preset_skills( + self, agent_name: str, skill_names: List[str], pack_id: str + ) -> None: + """Delete still-preset-owned skills when an agent is deactivated.""" + skills_dir = self._safe_skills_dir_for_agent(agent_name) + if skills_dir is None: + return + + from ..agents import CommandRegistrar + + registrar = CommandRegistrar() + marker = f"preset:{pack_id}" + for skill_name in skill_names: + if not self._is_safe_registry_skill_name(skill_name): + continue + skill_subdir = skills_dir / skill_name + if not self._validate_skill_subdir( + skill_subdir, create=False, skills_root=skills_dir + ): + continue + skill_file = skill_subdir / "SKILL.md" + if not skill_file.is_file(): + continue + try: + content = skill_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + frontmatter, _ = registrar.parse_frontmatter(content) + metadata = frontmatter.get("metadata") + source = ( + metadata.get("source") + if isinstance(metadata, dict) + else None + ) + if source == marker: + shutil.rmtree(skill_subdir) + def _unregister_skills_in_dir( self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str] ) -> None: diff --git a/tests/test_presets.py b/tests/test_presets.py index c61c239b3e..7c1c3170f9 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3745,10 +3745,9 @@ def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): metadata = manager.registry.get("self-test") assert "speckit-specify" not in metadata.get("registered_skills", {}).get("qwen", []) - def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir): - """Skills should not be created when no existing skill dir is found.""" - self._write_init_options(project_dir, ai="claude") - # Don't create skills dir — simulate skills mode never created them + def test_no_skills_registered_when_skills_mode_disabled(self, project_dir, temp_dir): + """Skills should not be created when skills mode is disabled.""" + self._write_init_options(project_dir, ai="claude", ai_skills=False) manager = PresetManager(project_dir) install_self_test_preset(manager) @@ -5235,6 +5234,32 @@ def test_skill_switch_then_remove_restores_every_skill_agent_dir( ) assert "Core specify body" in content + def test_native_skill_activation_recreates_deleted_skills_root( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + preset_dir = self._create_command_preset( + temp_dir, + "native-root-recovery-preset", + "speckit.specify", + "Native root recovery", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + codex_skills_dir = project_dir / ".agents" / "skills" + assert not codex_skills_dir.exists() + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:native-root-recovery-preset" in skill_file.read_text() + metadata = manager.registry.get("native-root-recovery-preset") + assert "speckit.specify" in metadata["registered_commands"]["codex"] + def test_rescaffold_migrates_legacy_flat_list_registered_skills( self, project_dir, temp_dir ): @@ -6680,6 +6705,37 @@ def test_unregister_agent_artifacts_scoped_to_target_agent_only( "opencode's tracking must be preserved untouched" ) + def test_unregister_agent_artifacts_deletes_marker_owned_skill( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify\n---\n\nCore body\n", + encoding="utf-8", + ) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + preset_dir = self._create_command_preset( + temp_dir, + "deactivated-skill-preset", + "speckit.specify", + "Deactivation cleanup", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_dir = skills_dir / "speckit-specify" + assert "preset:deactivated-skill-preset" in ( + skill_dir / "SKILL.md" + ).read_text() + + manager.unregister_agent_artifacts("copilot") + + assert not skill_dir.exists() + def test_unregister_native_agent_persists_skills_metadata_pop( self, project_dir, temp_dir ): @@ -6807,11 +6863,10 @@ def test_unregister_agent_artifacts_migrates_legacy_skill_list_scoped( manager.unregister_agent_artifacts("copilot") - assert "preset:switch-legacy-skill-preset" not in copilot_skill.read_text(), ( - "copilot's own skill override must be restored when switching " - "away from copilot" + assert not copilot_skill.parent.exists(), ( + "copilot's marker-owned preset skill must be deleted when " + "switching away from copilot" ) - assert "Core specify body" in copilot_skill.read_text() assert "preset:switch-legacy-skill-preset" in claude_skill.read_text(), ( "claude's own, separately-written mirror must survive " From bd31afba894b1b7a878adcacc06f8efa5e6d6b73 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:50:38 +0200 Subject: [PATCH 27/27] Clarify preset removal reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/presets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/presets.md b/docs/reference/presets.md index a92adee906..b8f318ac9e 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -139,7 +139,7 @@ catalogs: Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers. -Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into the active integration's directory only, instead of being re-resolved by agents or written to every detected agent directory (#2948). During preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. A non-active installed integration does not receive these command files until it becomes the default — `specify integration use ` (or `switch `) rescaffolds enabled presets for the newly active integration. Agents do not re-resolve the stack each time they run a command. +Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into the active integration's directory only, instead of being re-resolved by agents or written to every detected agent directory (#2948). During preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Install and rescaffold remain active-only, but removal may also update previously targeted inactive directories recorded by the removed preset to restore the surviving command or skill layer. A non-active installed integration does not otherwise receive these command files until it becomes the default — `specify integration use ` (or `switch `) rescaffolds enabled presets for the newly active integration. Agents do not re-resolve the stack each time they run a command. By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.