Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
87 changes: 86 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,91 @@ def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
raise typer.Exit(1)


# ---------------------------------------------------------------------------
# Extension (un)registration helpers (shared by switch / install / 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``.

``switch`` has always re-registered enabled extensions for the agent it
activates; ``install`` and ``upgrade`` call this so a newly added (or
refreshed) agent reaches command-registration parity. 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`` installed as a secondary integration) therefore
receives command files rather than skills here — matching ``extension
add``'s multi-agent behavior. ``switch`` avoids this only because it makes
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 install/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
13 changes: 13 additions & 0 deletions src/specify_cli/integrations/_install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
_get_speckit_version,
_read_integration_json,
_refresh_init_options_speckit_version,
_register_extensions_for_agent,
_remove_integration_json,
_resolve_integration_options,
_resolve_script_type,
Expand Down Expand Up @@ -188,6 +189,18 @@ def integration_install(
)
raise typer.Exit(1)

# Register enabled extensions for the newly installed agent so it gets the
# same extension commands the existing agents already have for command
# registration parity with switch; otherwise a second integration silently
# lacks the first agent's extension commands. See #2886. Done after the
# try/except (the install has committed) so this best-effort step can never
# trigger the rollback above.
_register_extensions_for_agent(
project_root,
integration.key,
continuing="The integration was installed, but installed extensions may need re-registration.",
)

name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully")
if default_key:
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")
Loading