Skip to content

Commit af9205f

Browse files
marcelsafinCopilot
andcommitted
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>
1 parent 1736f07 commit af9205f

6 files changed

Lines changed: 186 additions & 67 deletions

File tree

src/specify_cli/agents.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,7 @@ def register_commands_for_all_agents(
932932
link_outputs: bool = False,
933933
create_missing_active_skills_dir: bool = False,
934934
extension_id: Optional[str] = None,
935+
only_agent: Optional[str] = None,
935936
) -> Dict[str, List[str]]:
936937
"""Register commands for all detected agents in the project.
937938
@@ -949,6 +950,8 @@ def register_commands_for_all_agents(
949950
skills directory) and is skipped when safe resolution or
950951
creation fails.
951952
extension_id: Extension id when rendering extension-owned commands.
953+
only_agent: If set, restrict registration to this single agent
954+
while keeping all detection and recovery safeguards (#2948).
952955
953956
Returns:
954957
Dictionary mapping agent names to list of registered commands
@@ -972,6 +975,8 @@ def register_commands_for_all_agents(
972975
)
973976
active_created_skills_dir: Optional[Path] = None
974977
for agent_name, agent_config in self.AGENT_CONFIGS.items():
978+
if only_agent is not None and agent_name != only_agent:
979+
continue
975980
active_skills_output = (
976981
agent_name == active_skills_agent
977982
and agent_config.get("extension") == "/SKILL.md"

src/specify_cli/extensions/__init__.py

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,64 @@ def _ensure_usable(skills_dir: Path) -> Optional[Path]:
975975
return _ensure_usable(agent_skills_dir)
976976
return _ensure_usable(skills_dir)
977977

978+
def _register_commands_for_active_agent(
979+
self,
980+
manifest: ExtensionManifest,
981+
extension_dir: Path,
982+
link_outputs: bool = False,
983+
) -> Dict[str, List[str]]:
984+
"""Register extension commands for the active integration only.
985+
986+
Maintainer-requested behavior for #2948: ``extension add`` treats the
987+
project as single-active — only the integration recorded in
988+
init-options gets command files. Non-active integrations receive them
989+
when selected via ``integration use`` / ``switch`` (rescaffold).
990+
991+
Projects without a recorded active integration (pre-init-options
992+
layouts or direct library use) fall back to detection-based
993+
registration for all agents.
994+
995+
Returns:
996+
Mapping of agent name to registered command names, matching the
997+
``registered_commands`` registry shape.
998+
"""
999+
from .. import load_init_options
1000+
1001+
registrar = CommandRegistrar()
1002+
init_options = load_init_options(self.project_root)
1003+
if not isinstance(init_options, dict):
1004+
init_options = {}
1005+
active_agent = init_options.get("ai")
1006+
1007+
if not active_agent or active_agent not in registrar.AGENT_CONFIGS:
1008+
return registrar.register_commands_for_all_agents(
1009+
manifest,
1010+
extension_dir,
1011+
self.project_root,
1012+
link_outputs=link_outputs,
1013+
create_missing_active_skills_dir=True,
1014+
)
1015+
1016+
agent_config = registrar.AGENT_CONFIGS[active_agent]
1017+
if (
1018+
is_ai_skills_enabled(init_options)
1019+
and agent_config.get("extension") != "/SKILL.md"
1020+
):
1021+
# Active agent runs skills mode: extension artifacts render as
1022+
# skills via _register_extension_skills, not as command files.
1023+
return {}
1024+
1025+
# Route through the all-agents pass restricted to the active agent so
1026+
# detection and missing-skills-dir recovery safeguards still apply.
1027+
return registrar.register_commands_for_all_agents(
1028+
manifest,
1029+
extension_dir,
1030+
self.project_root,
1031+
link_outputs=link_outputs,
1032+
create_missing_active_skills_dir=True,
1033+
only_agent=active_agent,
1034+
)
1035+
9781036
def _register_extension_skills(
9791037
self,
9801038
manifest: ExtensionManifest,
@@ -1404,17 +1462,11 @@ def install_from_directory(
14041462
ignore_fn = self._load_extensionignore(source_dir)
14051463
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
14061464

1407-
# Register commands with AI agents
1465+
# Register commands with AI agents (active integration only, #2948)
14081466
registered_commands = {}
14091467
if register_commands:
1410-
registrar = CommandRegistrar()
1411-
# Register for all detected agents
1412-
registered_commands = registrar.register_commands_for_all_agents(
1413-
manifest,
1414-
dest_dir,
1415-
self.project_root,
1416-
link_outputs=link_commands,
1417-
create_missing_active_skills_dir=True,
1468+
registered_commands = self._register_commands_for_active_agent(
1469+
manifest, dest_dir, link_outputs=link_commands
14181470
)
14191471

14201472
# 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:
17011753
"""Register installed, enabled extensions for ``agent_name``.
17021754
17031755
Command-file registration is scoped to the explicit ``agent_name``
1704-
argument, so this method can be used after install, upgrade, or switch.
1705-
Extension skill rendering is still scoped to the active ``ai`` /
1706-
``ai_skills`` settings in init-options, so non-active skills-mode
1707-
targets receive command files here. Per-agent skills parity is tracked
1708-
separately in #2948.
1756+
argument. Since #2948, callers pass the active agent only (``use`` /
1757+
``switch`` activate the target first; ``upgrade`` calls it only for
1758+
the active integration), so extension skill rendering — scoped to the
1759+
active ``ai`` / ``ai_skills`` init-options — matches ``agent_name``.
17091760
"""
17101761
if not agent_name:
17111762
return
@@ -1765,13 +1816,11 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
17651816
# Extension *skills* are only ever rendered for the active agent:
17661817
# `_register_extension_skills` resolves the skills dir and
17671818
# frontmatter from init-options["ai"], ignoring ``agent_name``.
1768-
# When this method runs for a non-active agent — as install/upgrade
1769-
# now do for a secondary integration (#2886) — the skills pass would
1770-
# re-render the *active* agent's extension skills as a side effect,
1819+
# Running the skills pass for a non-active agent would re-render
1820+
# the *active* agent's extension skills as a side effect,
17711821
# resurrecting skill files the user deliberately deleted. Skip it
1772-
# unless the target is the active agent; `switch` is unaffected
1773-
# because it activates the target before registering. (Rendering
1774-
# skills for a non-active target is tracked separately in #2948.)
1822+
# unless the target is the active agent (defense in depth: since
1823+
# #2948 callers only pass the active agent anyway).
17751824
if agent_name == active_agent:
17761825
try:
17771826
registered_skills = self._register_extension_skills(
@@ -1970,6 +2019,7 @@ def register_commands_for_all_agents(
19702019
project_root: Path,
19712020
link_outputs: bool = False,
19722021
create_missing_active_skills_dir: bool = False,
2022+
only_agent: Optional[str] = None,
19732023
) -> Dict[str, List[str]]:
19742024
"""Register extension commands for all detected agents."""
19752025
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
@@ -1981,6 +2031,7 @@ def register_commands_for_all_agents(
19812031
context_note=context_note,
19822032
link_outputs=link_outputs,
19832033
create_missing_active_skills_dir=create_missing_active_skills_dir,
2034+
only_agent=only_agent,
19842035
extension_id=manifest.id,
19852036
)
19862037

src/specify_cli/integrations/_helpers.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -376,19 +376,14 @@ def _register_extensions_for_agent(
376376
"""Register all enabled extensions' commands/skills for ``agent_key``.
377377
378378
``use`` / ``switch`` re-register enabled extensions for the agent they
379-
activate; ``upgrade`` backfills them for the refreshed agent. Plain
380-
``install`` deliberately does not call this helper so adding a secondary
381-
integration has no extension side effects until it is selected or upgraded.
382-
See issue #2886.
383-
384-
Known limitation: extension *skill* rendering is scoped to the active
385-
agent (init-options track a single ``ai`` / ``ai_skills`` pair). A
386-
skills-mode agent registered while it is *not* the active agent (e.g.
387-
Copilot ``--skills`` registered while non-active) therefore
388-
receives command files rather than skills here — matching ``extension
389-
add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they
390-
make the target the active agent first. Per-agent skills parity is tracked in
391-
#2948.
379+
activate (rescaffold); ``upgrade`` does so only for the *active*
380+
integration. Plain ``install`` and upgrade of a non-active integration
381+
deliberately skip this helper so a secondary integration has no extension
382+
side effects until it is selected. See issues #2886 and #2948.
383+
384+
Callers always pass the active agent (use/switch activate the target
385+
before registering), so extension *skill* rendering — which is scoped to
386+
the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``.
392387
393388
Best-effort: never aborts the surrounding integration operation. Callers
394389
invoke it *after* the use/upgrade/switch transaction has committed so a

src/specify_cli/integrations/_migrate_commands.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -491,17 +491,19 @@ def integration_upgrade(
491491
if stale_removed:
492492
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
493493

494-
# Re-register enabled extensions for the upgraded agent so its extension
495-
# commands are (re)created — including agents installed before this
496-
# back-fill existed. Mirrors switch for command registration; see #2886.
497-
# Done after the upgrade has fully settled (Phase 2 included) and outside
498-
# the try/except above so this best-effort step cannot affect upgrade
499-
# success.
500-
_register_extensions_for_agent(
501-
project_root,
502-
key,
503-
continuing="The integration was upgraded, but installed extensions may need re-registration.",
504-
)
494+
# Re-register enabled extensions only when upgrading the *active*
495+
# integration, so its extension commands are (re)created after the
496+
# upgrade settled (Phase 2 included). Done outside the try/except above
497+
# so this best-effort step cannot affect upgrade success. Non-active
498+
# integrations are rescaffolded by `use` / `switch` instead — the #2886
499+
# back-fill for non-active agents was removed at maintainer request
500+
# (#2948).
501+
if key == installed_key:
502+
_register_extensions_for_agent(
503+
project_root,
504+
key,
505+
continuing="The integration was upgraded, but installed extensions may need re-registration.",
506+
)
505507

506508
name = (integration.config or {}).get("name", key)
507509
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")

tests/integrations/test_integration_subcommand.py

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,53 @@ def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tm
13711371
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
13721372
).exists()
13731373

1374+
def test_extension_add_registers_active_integration_only(self, tmp_path):
1375+
"""``extension add`` registers commands for the active integration only.
1376+
1377+
Maintainer-requested behavior for #2948: with multiple integrations
1378+
installed, ``extension add`` must treat the project as single-active —
1379+
only the current integration gets the new extension's commands.
1380+
Non-active integrations receive them when selected via
1381+
``integration use`` / ``switch`` (rescaffold).
1382+
"""
1383+
project = _init_project(tmp_path, "claude")
1384+
1385+
result = _run_in_project(project, [
1386+
"integration", "install", "codex",
1387+
"--script", "sh",
1388+
])
1389+
assert result.exit_code == 0, result.output
1390+
1391+
result = _run_in_project(project, ["extension", "add", "git"])
1392+
assert result.exit_code == 0, f"extension add failed: {result.output}"
1393+
1394+
registry_path = project / ".specify" / "extensions" / ".registry"
1395+
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
1396+
"extensions"
1397+
]["git"]["registered_commands"]
1398+
assert "claude" in registered, "active integration gets the extension"
1399+
assert "codex" not in registered, (
1400+
"non-active integration must not be registered on add (#2948)"
1401+
)
1402+
assert (
1403+
project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
1404+
).exists()
1405+
assert not (
1406+
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
1407+
).exists()
1408+
1409+
# Selecting the other integration rescaffolds it with the extension.
1410+
result = _run_in_project(project, ["integration", "use", "codex"])
1411+
assert result.exit_code == 0, result.output
1412+
1413+
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
1414+
"extensions"
1415+
]["git"]["registered_commands"]
1416+
assert "codex" in registered, "use registers extensions for the new active agent"
1417+
assert (
1418+
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
1419+
).exists()
1420+
13741421

13751422
# ── uninstall ────────────────────────────────────────────────────────
13761423

@@ -2492,13 +2539,13 @@ def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path):
24922539
"shared .sh scripts must be executable after upgrade"
24932540
)
24942541

2495-
def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path):
2496-
"""Upgrade re-registers enabled extensions for the upgraded agent.
2542+
def test_upgrade_does_not_backfill_non_active_integration(self, tmp_path):
2543+
"""Upgrading a non-active integration must not register extensions for it.
24972544
2498-
Regression for #2886: agents installed before extension back-fill
2499-
existed (or whose extension artifacts went missing) should regain the
2500-
enabled extensions' commands on ``upgrade``, reaching parity with
2501-
``switch``.
2545+
Maintainer-requested behavior for #2948 (reverses the #2886 upgrade
2546+
back-fill): non-active integrations only receive extension artifacts
2547+
when selected via ``integration use`` / ``switch``. Upgrade of a
2548+
non-active integration refreshes its own files and nothing else.
25022549
"""
25032550
project = _init_project(tmp_path, "claude")
25042551

@@ -2511,34 +2558,52 @@ def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path):
25112558
])
25122559
assert result.exit_code == 0, result.output
25132560

2514-
# Simulate a project created before the install/upgrade back-fill: drop
2515-
# codex's extension registration and its rendered artifacts.
25162561
registry_path = project / ".specify" / "extensions" / ".registry"
2517-
registry = json.loads(registry_path.read_text(encoding="utf-8"))
2518-
registry["extensions"]["git"]["registered_commands"].pop("codex", None)
2519-
registry_path.write_text(json.dumps(registry), encoding="utf-8")
2520-
agents_skills = project / ".agents" / "skills"
2521-
for skill_dir in agents_skills.glob("speckit-git-*"):
2522-
shutil.rmtree(skill_dir)
2523-
2524-
# Precondition: codex is now missing the git extension.
25252562
assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[
25262563
"extensions"
25272564
]["git"]["registered_commands"]
2528-
assert not (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
25292565

25302566
result = _run_in_project(project, [
25312567
"integration", "upgrade", "codex",
25322568
"--script", "sh",
25332569
])
25342570
assert result.exit_code == 0, result.output
25352571

2536-
# Upgrade back-filled the git extension for codex.
25372572
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
25382573
"extensions"
25392574
]["git"]["registered_commands"]
2540-
assert "codex" in registered, "upgrade should re-register extension commands (#2886)"
2541-
assert (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
2575+
assert "codex" not in registered, (
2576+
"upgrade must not back-fill non-active integrations (#2948)"
2577+
)
2578+
assert not (
2579+
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
2580+
).exists()
2581+
2582+
def test_upgrade_active_integration_reregisters_extensions(self, tmp_path):
2583+
"""Upgrading the active integration restores its extension commands.
2584+
2585+
The active integration keeps the re-registration pass on upgrade so
2586+
missing or stale extension command files are recreated (#2948 scopes
2587+
the pass to the active integration; #2886 introduced it).
2588+
"""
2589+
project = _init_project(tmp_path, "claude")
2590+
2591+
result = _run_in_project(project, ["extension", "add", "git"])
2592+
assert result.exit_code == 0, f"extension add failed: {result.output}"
2593+
2594+
cmd_file = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
2595+
assert cmd_file.exists(), "precondition: extension command registered"
2596+
cmd_file.unlink()
2597+
2598+
result = _run_in_project(project, [
2599+
"integration", "upgrade", "claude",
2600+
"--script", "sh",
2601+
])
2602+
assert result.exit_code == 0, result.output
2603+
2604+
assert cmd_file.exists(), (
2605+
"upgrade of the active integration re-registers extension commands"
2606+
)
25422607

25432608
def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path):
25442609
"""Upgrading a non-active agent must not touch the active agent's skills.

tests/test_extensions.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,7 @@ def fake_register_all(
11481148
link_outputs=False,
11491149
create_missing_active_skills_dir=False,
11501150
extension_id=None,
1151+
only_agent=None,
11511152
):
11521153
captured["create_missing_active_skills_dir"] = (
11531154
create_missing_active_skills_dir
@@ -7448,7 +7449,7 @@ def test_non_cline_extension_no_hyphenation(self, tmp_path):
74487449
from specify_cli import app
74497450
from specify_cli.agents import CommandRegistrar
74507451

7451-
project_dir, ext_dir, claude_commands_dir = self._setup_mock_extension(tmp_path, "claude")
7452+
project_dir, ext_dir, agents_commands_dir = self._setup_mock_extension(tmp_path, "amp")
74527453

74537454
# 3. Run specify extension add
74547455
runner = CliRunner()
@@ -7465,8 +7466,8 @@ def test_non_cline_extension_no_hyphenation(self, tmp_path):
74657466
assert "speckit-mock-ext-hello" not in result.output
74667467

74677468
# Verify on-disk command names are dotted
7468-
hello_file = claude_commands_dir / "speckit.mock-ext.hello.md"
7469-
greet_file = claude_commands_dir / "speckit.mock-ext.greet.md"
7469+
hello_file = agents_commands_dir / "speckit.mock-ext.hello.md"
7470+
greet_file = agents_commands_dir / "speckit.mock-ext.greet.md"
74707471

74717472
assert hello_file.exists()
74727473
assert greet_file.exists()

0 commit comments

Comments
 (0)