Skip to content
37 changes: 22 additions & 15 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1637,16 +1637,12 @@ def unregister_agent_artifacts(self, agent_name: str) -> None:
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
"""Register installed, enabled extensions for ``agent_name``.

This is intended to be called after switching integrations. Command
registration is scoped to the explicit ``agent_name`` argument, but some
behavior still depends on the current init-options state (for example,
skills-mode handling uses the active ``ai`` / ``ai_skills`` settings).

Callers should therefore pass the agent that has just been made active
in init-options; in normal use, ``agent_name`` is expected to match the
current ``ai`` value. This mirrors extension install behavior while
avoiding stale default-mode command directories when that active agent
is running in skills mode (notably Copilot ``--skills``).
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.
"""
if not agent_name:
return
Expand Down Expand Up @@ -1698,11 +1694,22 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
if new_registered != registered_commands:
updates["registered_commands"] = new_registered

registered_skills = self._register_extension_skills(manifest, ext_dir)
if registered_skills:
existing_skills = self._valid_name_list(metadata.get("registered_skills", []))
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
updates["registered_skills"] = merged_skills
# 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,
# 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.)
if agent_name == active_agent:
registered_skills = self._register_extension_skills(manifest, ext_dir)
if registered_skills:
existing_skills = self._valid_name_list(metadata.get("registered_skills", []))
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
updates["registered_skills"] = merged_skills

if updates:
self.registry.update(ext_id, updates)
Expand Down
89 changes: 88 additions & 1 deletion src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import os
from pathlib import Path
from typing import Any
from typing import Any, Callable

import typer

Expand Down Expand Up @@ -385,6 +385,93 @@ def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
raise typer.Exit(1)


# ---------------------------------------------------------------------------
# Extension (un)registration helpers (shared by use / switch / upgrade)
# ---------------------------------------------------------------------------

def _best_effort_extension_op(
project_root: Path,
agent_key: str,
op: Callable[[Any, str], None],
*,
phase: str,
continuing: str,
) -> None:
"""Run a best-effort ``ExtensionManager`` operation for ``agent_key``.

``op`` receives the ``ExtensionManager`` and ``agent_key``. Any failure is
surfaced as a warning via ``_print_cli_warning`` and never aborts the
surrounding integration operation. ``continuing`` describes what already
succeeded so the warning makes the partial outcome clear.
"""
try:
from ..extensions import ExtensionManager

ext_mgr = ExtensionManager(project_root)
op(ext_mgr, agent_key)
except Exception as ext_err:
from .. import _print_cli_warning

_print_cli_warning(phase, "integration", agent_key, ext_err, continuing=continuing)


def _register_extensions_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""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.

Best-effort: never aborts the surrounding integration operation. Callers
invoke it *after* the use/upgrade/switch transaction has committed so a
failure here cannot trigger a rollback.
"""
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key),
phase="register extension artifacts for",
continuing=continuing,
)


def _unregister_extensions_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Best-effort removal of ``agent_key``'s extension artifacts.

Used by ``switch`` when uninstalling the previous integration so its
extension command/skill files don't linger as orphans in the old agent's
directory.
"""
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.unregister_agent_artifacts(key),
phase="clean up extension artifacts for",
continuing=continuing,
)


# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------
Expand Down
57 changes: 28 additions & 29 deletions src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@
_get_speckit_version,
_read_integration_json,
_refresh_init_options_speckit_version,
_register_extensions_for_agent,
_remove_integration_json,
_resolve_integration_options,
_resolve_integration_script_type,
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_unregister_extensions_for_agent,
_update_init_options_for_integration,
_write_integration_json,
)
Expand Down Expand Up @@ -170,19 +172,11 @@ def integration_switch(

# Unregister extension commands for the old agent so they don't
# remain as orphans in the old agent's directory.
try:
from ..extensions import ExtensionManager

ext_mgr = ExtensionManager(project_root)
ext_mgr.unregister_agent_artifacts(installed_key)
except Exception as ext_err:
_print_cli_warning(
"clean up extension artifacts for",
"integration",
installed_key,
ext_err,
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
)
_unregister_extensions_for_agent(
project_root,
installed_key,
continuing="Continuing with integration switch; old extension 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]
Expand Down Expand Up @@ -269,22 +263,6 @@ def integration_switch(
parsed_options=parsed_options,
)

# Re-register extension commands for the new agent so that
# previously-installed extensions are available in the new integration.
try:
from ..extensions import ExtensionManager

ext_mgr = ExtensionManager(project_root)
ext_mgr.register_enabled_extensions_for_agent(target)
except Exception as ext_err:
_print_cli_warning(
"register extension artifacts for",
"integration",
target,
ext_err,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)

except Exception as exc:
# Attempt rollback of any files written by setup
try:
Expand Down Expand Up @@ -332,6 +310,15 @@ def integration_switch(
)
raise typer.Exit(1)

# Re-register extension commands for the new agent so previously-installed
# extensions are available in it. Done after the try/except (the switch has
# committed) so this best-effort step can never trigger the rollback above.
_register_extensions_for_agent(
project_root,
target,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)

name = (target_integration.config or {}).get("name", target)
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")

Expand Down Expand Up @@ -486,5 +473,17 @@ 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.",
)

name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
6 changes: 6 additions & 0 deletions src/specify_cli/integrations/_query_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from ._commands import integration_app, integration_catalog_app
from ._helpers import (
_read_integration_json,
_register_extensions_for_agent,
_resolve_integration_options,
_set_default_integration_or_exit,
)
Expand Down Expand Up @@ -242,6 +243,11 @@ def integration_use(
f"[cyan]specify integration use {key} --force[/cyan]."
),
)
_register_extensions_for_agent(
project_root,
key,
continuing="The integration was selected, but installed extensions may need re-registration.",
)
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")


Expand Down
Loading