Skip to content

Commit 12a3d67

Browse files
marcelsafinCopilot
andcommitted
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>
1 parent 2486c08 commit 12a3d67

6 files changed

Lines changed: 502 additions & 37 deletions

File tree

src/specify_cli/_init_options.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@
33
import json
44
from collections.abc import Mapping
55
from pathlib import Path
6-
from typing import Any
6+
from typing import Any, Union
77

88

99
INIT_OPTIONS_FILE = ".specify/init-options.json"
1010

1111

12+
class _MissingInitOptionsFile:
13+
"""Sentinel: init-options.json does not exist at all (legacy layout)."""
14+
15+
def __repr__(self) -> str: # pragma: no cover - debug aid only
16+
return "MISSING_INIT_OPTIONS_FILE"
17+
18+
19+
MISSING_INIT_OPTIONS_FILE = _MissingInitOptionsFile()
20+
21+
1222
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
1323
"""Persist the CLI options used during ``specify init``."""
1424
dest = project_path / INIT_OPTIONS_FILE
@@ -34,3 +44,35 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
3444
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
3545
"""Return True only when init options explicitly enable AI skills."""
3646
return isinstance(opts, Mapping) and opts.get("ai_skills") is True
47+
48+
49+
def resolve_active_agent_for_registration(
50+
project_path: Path,
51+
) -> Union[str, None, _MissingInitOptionsFile]:
52+
"""Resolve the active integration key for active-only registration (#2948).
53+
54+
``load_init_options`` collapses "no file", "unreadable/malformed file",
55+
and "valid file with no recorded active agent" into the same ``{}``
56+
result, which previously made corrupted-but-present init-options behave
57+
like a legacy pre-init-options project and fall back to registering
58+
every detected agent. This helper distinguishes those cases explicitly:
59+
60+
- Returns :data:`MISSING_INIT_OPTIONS_FILE` when init-options.json does
61+
not exist at all (pre-init-options layout or direct library use).
62+
Callers should fall back to detection-based registration for all
63+
agents, matching the original pre-#2948 behavior for such projects.
64+
- Returns ``None`` when init-options.json exists but could not provide a
65+
valid non-empty string active agent (malformed/unreadable JSON,
66+
non-object payload, or a non-string/empty ``ai`` value). Callers must
67+
fail closed (register nothing) rather than treat this like "no file"
68+
or pass a non-string key into agent-config lookups.
69+
- Returns the active agent key (a non-empty string) otherwise.
70+
"""
71+
path = project_path / INIT_OPTIONS_FILE
72+
if not path.exists():
73+
return MISSING_INIT_OPTIONS_FILE
74+
75+
active_agent = load_init_options(project_path).get("ai")
76+
if isinstance(active_agent, str) and active_agent:
77+
return active_agent
78+
return None

src/specify_cli/agents.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,7 @@ def register_commands_for_non_skill_agents(
10821082
context_note: Optional[str] = None,
10831083
link_outputs: bool = False,
10841084
extension_id: Optional[str] = None,
1085+
only_agent: Optional[str] = None,
10851086
) -> Dict[str, List[str]]:
10861087
"""Register commands for all non-skill agents in the project.
10871088
@@ -1098,13 +1099,18 @@ def register_commands_for_non_skill_agents(
10981099
link_outputs: If True, create dev-mode symlinks for rendered
10991100
command files when supported by the OS.
11001101
extension_id: Extension id when rendering extension-owned commands.
1102+
only_agent: If set, restrict registration to this single agent
1103+
(#2948). An agent name that matches no configured agent
1104+
(e.g. an empty string) yields no registrations at all.
11011105
11021106
Returns:
11031107
Dictionary mapping agent names to list of registered commands
11041108
"""
11051109
results = {}
11061110
self._ensure_configs()
11071111
for agent_name, agent_config in self.AGENT_CONFIGS.items():
1112+
if only_agent is not None and agent_name != only_agent:
1113+
continue
11081114
if agent_config.get("extension") == "/SKILL.md":
11091115
continue
11101116
detect_dir_str = agent_config.get("detect_dir")

src/specify_cli/extensions/__init__.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -989,29 +989,34 @@ def _register_commands_for_active_agent(
989989
when selected via ``integration use`` / ``switch`` (rescaffold).
990990
991991
Projects without a recorded active integration at all (pre-init-options
992-
layouts or direct library use) fall back to detection-based
993-
registration for all agents. A *recorded* active key that has no
994-
registrar config (e.g. ``generic``, which is deliberately excluded
995-
from ``AGENT_CONFIGS``) is not treated as "no active integration" —
996-
it must not cause registration to target other detected agents.
997-
998-
A recorded but malformed ``ai`` value (non-string, e.g. ``[]`` or
999-
``null``) is also not "no active integration" — corrupted
1000-
init-options must fail closed (register nothing) rather than
1001-
fall back to registering every detected agent.
992+
layouts or direct library use, i.e. init-options.json does not
993+
exist) fall back to detection-based registration for all agents. A
994+
*recorded* active key that has no registrar config (e.g. ``generic``,
995+
which is deliberately excluded from ``AGENT_CONFIGS``) is not treated
996+
as "no active integration" — it must not cause registration to
997+
target other detected agents.
998+
999+
An init-options.json that exists but is corrupted, unreadable, or
1000+
has a malformed/empty ``ai`` value (e.g. ``[]`` or ``null``) is also
1001+
not "no active integration" — fail closed (register nothing) rather
1002+
than fall back to registering every detected agent, which would
1003+
otherwise happen because a corrupted file loads the same as an
1004+
absent one.
10021005
10031006
Returns:
10041007
Mapping of agent name to registered command names, matching the
10051008
``registered_commands`` registry shape.
10061009
"""
10071010
from .. import load_init_options
1011+
from .._init_options import (
1012+
MISSING_INIT_OPTIONS_FILE,
1013+
resolve_active_agent_for_registration,
1014+
)
10081015

10091016
registrar = CommandRegistrar()
1010-
init_options = load_init_options(self.project_root)
1011-
if not isinstance(init_options, dict):
1012-
init_options = {}
1017+
active_agent = resolve_active_agent_for_registration(self.project_root)
10131018

1014-
if "ai" not in init_options:
1019+
if active_agent is MISSING_INIT_OPTIONS_FILE:
10151020
return registrar.register_commands_for_all_agents(
10161021
manifest,
10171022
extension_dir,
@@ -1020,14 +1025,16 @@ def _register_commands_for_active_agent(
10201025
create_missing_active_skills_dir=True,
10211026
)
10221027

1023-
active_agent = init_options.get("ai")
1024-
if not isinstance(active_agent, str) or not active_agent:
1025-
# A recorded key was found but it is malformed (not a non-empty
1026-
# string). Fail closed instead of falling back to all agents or
1027-
# passing a non-string key into AGENT_CONFIGS.get() below, which
1028-
# would raise TypeError for unhashable values like a list.
1028+
if active_agent is None:
1029+
# init-options.json exists but could not provide a valid active
1030+
# agent (corrupted/unreadable/non-object JSON, or a malformed
1031+
# "ai" value). Fail closed instead of falling back to all agents
1032+
# or passing a non-string key into AGENT_CONFIGS.get() below,
1033+
# which would raise TypeError for unhashable values like a list.
10291034
return {}
10301035

1036+
init_options = load_init_options(self.project_root)
1037+
10311038
# A recorded active key with no registrar config (e.g. "generic",
10321039
# deliberately excluded from AGENT_CONFIGS) has nothing to register
10331040
# through this path, but it is still an active integration. Passing

0 commit comments

Comments
 (0)