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>
This commit is contained in:
marcelsafin
2026-07-10 23:44:15 +02:00
co-authored by Copilot
parent 2486c08c57
commit 12a3d67731
6 changed files with 501 additions and 36 deletions
+43 -1
View File
@@ -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
+6
View File
@@ -1082,6 +1082,7 @@ class CommandRegistrar:
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 @@ class CommandRegistrar:
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 @@ class CommandRegistrar:
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")
+26 -19
View File
@@ -989,29 +989,34 @@ class ExtensionManager:
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.
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.
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.
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 @@ class ExtensionManager:
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
+148 -16
View File
@@ -28,7 +28,12 @@ from packaging import version as pkg_version
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 @@ class PresetManager:
# 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 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
matching_cmds, ext_id, ext_dir,
self.project_root,
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
only_agent=only_agent,
)
registered = True
except Exception:
@@ -869,6 +938,7 @@ class PresetManager:
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 @@ class PresetManager:
registrar,
[{**tmpl, "file": f".composed/{cmd_name}.md"}],
manifest.id, pack_dir,
only_agent=only_agent,
)
registered = True
break
@@ -940,6 +1011,7 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
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 @@ class PresetManager:
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()