Files
github-spec-kit/src/specify_cli/integrations/_query_commands.py
Pascal THUET ce01877610 fix: register enabled extensions for agent on integration use/upgrade (#2949)
* fix: register enabled extensions for agent on integration install/upgrade

install and upgrade only set up the integration's own core commands; only
switch re-registered the enabled extensions' commands for the target agent.
A second integration added via install (or refreshed via upgrade) was
therefore silently missing the extension commands the existing agents
already had (e.g. the bundled agent-context extension).

Extract switch's registration into a shared _register_extensions_for_agent
helper and call it from install and upgrade too, so every installed agent
ends up with every enabled extension's commands — full parity with switch.

Closes #2886

* test: pin skills-mode secondary-agent registration; document #2948 limitation

Extension skill rendering is scoped to the active agent (init-options track a
single ai / ai_skills pair), so a skills-mode agent registered while not active
(e.g. Copilot --skills installed as a secondary integration) gets command files
rather than skills. install/upgrade match extension add here; only switch
renders skills, because it activates the target first.

Add a regression test pinning this behavior and document the limitation on the
shared helper. Per-agent skills parity is tracked separately in #2948.

* fix: don't re-render the active agent's skills when registering a non-active agent

register_enabled_extensions_for_agent runs an active-agent-scoped skills pass
(_register_extension_skills resolves the skills dir from init-options["ai"],
ignoring the passed agent). Routing install/upgrade of a secondary integration
through it re-rendered the *active* skills-mode agent's extension skills as a
side effect — resurrecting skill files the user had deliberately deleted. Gate
the skills pass on the target being the active agent; switch is unaffected
because it activates the target first.

Also harden the skills-mode install test (assert a core skill so --skills is
load-bearing, drop a vacuous registered_skills assertion) and add a regression
test. Surfaced by review of the PR; skills parity for non-active agents stays
tracked in #2948.

* refactor: share the extension-op scaffold and run (un)registration post-commit

Review cleanups, no behavior change on the success path:

- Extract the best-effort ExtensionManager scaffold (lazy import, instantiate,
  except -> _print_cli_warning) into _best_effort_extension_op. Both
  _register_extensions_for_agent and a new _unregister_extensions_for_agent
  delegate to it, removing the duplicate block left inline in switch.
- Invoke the best-effort extension registration AFTER the install/switch/upgrade
  try/except has committed, so a failure in it can never trigger the rollback
  (install and switch teardown on except).

* docs: clarify extension registration parity scope

* fix(integrations): defer extension registration until use

* fix(tests): remove redundant shutil import

* fix(integrations): backfill extensions for installed switch targets
2026-06-22 17:48:55 -05:00

553 lines
22 KiB
Python

"""specify integration list/status/use/search/info + catalog list/add/remove command handlers."""
from __future__ import annotations
import json
import os
from typing import Any, Optional
import typer
from rich.markup import escape as _rich_escape
from rich.table import Table
from .._console import console
from ..integration_state import (
default_integration_key as _default_integration_key,
installed_integration_keys as _installed_integration_keys,
)
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,
)
@integration_app.command("list")
def integration_list(
catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
):
"""List available integrations and installed status."""
from . import INTEGRATION_REGISTRY
from .. import _require_specify_project
project_root = _require_specify_project()
current = _read_integration_json(project_root)
default_key = _default_integration_key(current)
installed_keys = set(_installed_integration_keys(current))
if catalog:
from .catalog import IntegrationCatalog, IntegrationCatalogError
ic = IntegrationCatalog(project_root)
try:
entries = ic.search()
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
if not entries:
console.print("[yellow]No integrations found in catalog.[/yellow]")
return
table = Table(title="Integration Catalog")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Version")
table.add_column("Source")
table.add_column("Status")
table.add_column("Multi-install Safe")
for entry in sorted(entries, key=lambda e: e["id"]):
eid = entry["id"]
cat_name = entry.get("_catalog_name", "")
install_allowed = entry.get("_install_allowed", True)
if eid == default_key:
status = "[green]installed (default)[/green]"
elif eid in installed_keys:
status = "[green]installed[/green]"
elif eid in INTEGRATION_REGISTRY:
status = "built-in"
elif install_allowed is False:
status = "discovery-only"
else:
status = ""
safe = ""
if eid in INTEGRATION_REGISTRY:
reg_integ = INTEGRATION_REGISTRY[eid]
safe = "yes" if getattr(reg_integ, "multi_install_safe", False) else "no"
table.add_row(
eid,
entry.get("name", eid),
entry.get("version", ""),
cat_name,
status,
safe,
)
console.print(table)
return
if not INTEGRATION_REGISTRY:
console.print("[yellow]No integrations available.[/yellow]")
return
table = Table(title="Coding Agent Integrations")
table.add_column("Key", style="cyan")
table.add_column("Name")
table.add_column("Status")
table.add_column("CLI Required")
table.add_column("Multi-install Safe")
for key in sorted(INTEGRATION_REGISTRY.keys()):
integration = INTEGRATION_REGISTRY[key]
cfg = integration.config or {}
name = cfg.get("name", key)
requires_cli = cfg.get("requires_cli", False)
if key == default_key:
status = "[green]installed (default)[/green]"
elif key in installed_keys:
status = "[green]installed[/green]"
else:
status = ""
cli_req = "yes" if requires_cli else "no (IDE)"
safe = "yes" if getattr(integration, "multi_install_safe", False) else "no"
table.add_row(key, name, status, cli_req, safe)
console.print(table)
if installed_keys:
console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]")
console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]")
else:
console.print("\n[yellow]No integration currently installed.[/yellow]")
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
def _print_integration_status_report(report: dict[str, Any]) -> None:
status = report["status"]
status_label = {
"ok": "[green]OK[/green]",
"warning": "[yellow]WARNING[/yellow]",
"error": "[red]ERROR[/red]",
}.get(str(status), str(status).upper())
installed = report.get("installed_integrations") or []
installed_display = ", ".join(_rich_escape(str(item)) for item in installed)
console.print(f"Integration status: {status_label}")
console.print(
f"Default integration: {_rich_escape(str(report.get('default_integration') or 'none'))}"
)
console.print(f"Installed integrations: {installed_display if installed else 'none'}")
multi_install_safe = report.get("multi_install_safe")
if multi_install_safe is None:
multi_install_safe_display = "unknown"
else:
multi_install_safe_display = "yes" if multi_install_safe else "no"
console.print(f"Multi-install safe: {multi_install_safe_display}")
console.print(
f"Shared templates target alignment: "
f"{_rich_escape(str(report.get('shared_templates_target_alignment') or 'none'))}"
)
console.print(f"Modified managed files: {report.get('modified_managed_files', 0)}")
console.print(f"Missing managed files: {report.get('missing_managed_files', 0)}")
console.print(f"Invalid manifest paths: {report.get('invalid_manifest_paths', 0)}")
console.print(f"Unchecked manifests: {report.get('unchecked_manifests', 0)}")
findings = report.get("findings") or []
if not findings:
return
console.print()
console.print("[bold]Findings:[/bold]")
for item in findings:
severity = item.get("severity", "")
severity_label = {
"error": "[red]error[/red]",
"warning": "[yellow]warning[/yellow]",
}.get(severity, severity)
prefix = f"- {severity_label} {_rich_escape(str(item.get('code', '')))}"
if item.get("integration"):
prefix += f" ({_rich_escape(str(item['integration']))})"
console.print(
f"{prefix}: {_rich_escape(str(item.get('message', '')))}",
soft_wrap=True,
)
if item.get("suggestion"):
console.print(
f" Suggestion: {_rich_escape(str(item['suggestion']))}",
soft_wrap=True,
)
@integration_app.command("status")
def integration_status(
json_output: bool = typer.Option(
False,
"--json",
help="Emit machine-readable integration status.",
),
):
"""Report the current project's integration status without changing files."""
from .. import _require_specify_project
from ..integration_status import build_integration_status_report
project_root = _require_specify_project()
report = build_integration_status_report(project_root)
if json_output:
typer.echo(json.dumps(report, indent=2))
else:
_print_integration_status_report(report)
if report["status"] == "error":
raise typer.Exit(1)
@integration_app.command("use")
def integration_use(
key: str = typer.Argument(help="Installed integration key to make the default"),
force: bool = typer.Option(False, "--force", help="Overwrite existing shared infrastructure files, including customizations, while changing the default"),
):
"""Set the default integration without uninstalling other integrations."""
from . import get_integration
from .. import _require_specify_project
project_root = _require_specify_project()
current = _read_integration_json(project_root)
installed_keys = _installed_integration_keys(current)
if key not in installed_keys:
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
if installed_keys:
console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}")
else:
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
raise typer.Exit(1)
integration = get_integration(key)
if integration is None:
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
raise typer.Exit(1)
raw_options, parsed_options = _resolve_integration_options(integration, current, key, None)
_set_default_integration_or_exit(
project_root,
current,
key,
integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
refresh_templates_force=force,
refresh_hint=(
"To overwrite customizations, re-run with "
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].")
# ===== Integration catalog discovery commands =====
#
# These commands mirror the workflow catalog CLI shape:
# - `search` / `info` for discovery over the active catalog stack
# - `catalog list/add/remove` for managing catalog sources
#
# They deliberately do NOT add `integration add/remove/enable/disable/
# set-priority`: integrations are single-active (install / uninstall / switch),
# not additive like extensions and presets.
@integration_app.command("search")
def integration_search(
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
):
"""Search for integrations in the active catalog stack."""
from . import INTEGRATION_REGISTRY
from .catalog import (
IntegrationCatalog,
IntegrationCatalogError,
IntegrationValidationError,
)
from .. import _require_specify_project
project_root = _require_specify_project()
integration_config = _read_integration_json(project_root)
installed_key = _default_integration_key(integration_config)
catalog = IntegrationCatalog(project_root)
try:
results = catalog.search(query=query, tag=tag, author=author)
except IntegrationValidationError as exc:
console.print(f"[red]Error:[/red] {exc}")
console.print(
"\nTip: Check the configuration file path shown above for invalid catalog configuration "
"(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
)
raise typer.Exit(1)
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
console.print(
"\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid "
"catalog URL, or unset it to use the configured catalog files "
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
)
else:
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
raise typer.Exit(1)
if not results:
console.print("\n[yellow]No integrations found matching criteria[/yellow]")
if query or tag or author:
console.print("\nTry:")
console.print(" • Broader search terms")
console.print(" • Remove filters")
console.print(" • specify integration search (show all)")
return
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
for integ in sorted(results, key=lambda e: e.get("id", "")):
iid = integ.get("id", "?")
name = integ.get("name", iid)
version = integ.get("version", "?")
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
desc = integ.get("description", "")
if desc:
console.print(f" {desc}")
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
tags = integ.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
cat_name = integ.get("_catalog_name", "")
install_allowed = integ.get("_install_allowed", True)
if cat_name:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {cat_name}")
else:
console.print(
f" [dim]Catalog:[/dim] {cat_name} "
"[yellow](discovery only — not installable)[/yellow]"
)
if iid == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
elif iid in INTEGRATION_REGISTRY:
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
elif install_allowed:
console.print(
"\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs "
"can be installed with 'specify integration install'."
)
else:
console.print(
f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'."
)
console.print()
@integration_app.command("info")
def integration_info(
integration_id: str = typer.Argument(..., help="Integration ID"),
):
"""Show catalog details for a single integration."""
from . import INTEGRATION_REGISTRY
from .catalog import (
IntegrationCatalog,
IntegrationCatalogError,
IntegrationValidationError,
)
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
installed_key = _default_integration_key(_read_integration_json(project_root))
try:
info = catalog.get_integration_info(integration_id)
except IntegrationCatalogError as exc:
info = None
# Keep the live exception so the fallback branch below can give
# different guidance for local-config vs. network failures.
catalog_error: Optional[IntegrationCatalogError] = exc
else:
catalog_error = None
if info:
name = info.get("name", integration_id)
version = info.get("version", "?")
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
if info.get("description"):
console.print(f" {info['description']}")
console.print()
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
if info.get("license"):
console.print(f" [dim]License:[/dim] {info['license']}")
tags = info.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
cat_name = info.get("_catalog_name", "")
install_allowed = info.get("_install_allowed", True)
if cat_name:
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
if info.get("repository"):
console.print(f" [dim]Repository:[/dim] {info['repository']}")
if integration_id == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
elif integration_id in INTEGRATION_REGISTRY:
console.print("\n [dim]Built-in integration (not currently active)[/dim]")
return
if integration_id in INTEGRATION_REGISTRY:
integration = INTEGRATION_REGISTRY[integration_id]
cfg = integration.config or {}
name = cfg.get("name", integration_id)
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})")
console.print(" [dim]Built-in integration (not listed in catalog)[/dim]")
if integration_id == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
if catalog_error:
console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}")
return
if catalog_error:
console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}")
if isinstance(catalog_error, IntegrationValidationError):
console.print(
"\nCheck the configuration file path shown above "
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), "
"or use a built-in integration ID directly."
)
elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
console.print(
"\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, "
"or unset it to use the configured catalog files, or use a built-in integration ID directly."
)
else:
console.print("\nTry again when online, or use a built-in integration ID directly.")
else:
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
console.print("\nTry: specify integration search")
raise typer.Exit(1)
@integration_catalog_app.command("list")
def integration_catalog_list():
"""List configured integration catalog sources."""
from .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip()
try:
if env_override:
project_configs = None
configs = catalog.get_catalog_configs()
else:
project_configs = catalog.get_project_catalog_configs()
configs = project_configs if project_configs is not None else catalog.get_catalog_configs()
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n")
if env_override:
console.print(
" SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files."
)
console.print(
" Project/user catalog sources are not active while the env override is set.\n"
)
console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n")
elif project_configs is None:
console.print(" No project-level catalog sources configured.\n")
console.print("[bold]Active catalog sources (non-removable here):[/bold]\n")
else:
console.print("[bold]Project catalog sources (removable):[/bold]\n")
for i, cfg in enumerate(configs):
install_status = (
"[green]install allowed[/green]"
if cfg.get("install_allowed")
else "[yellow]discovery only[/yellow]"
)
raw_name = cfg.get("name")
display_name = str(raw_name).strip() if raw_name is not None else ""
if not display_name:
display_name = f"catalog-{i + 1}"
if env_override or project_configs is None:
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
else:
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
console.print(f" {cfg.get('url', '')}")
if cfg.get("description"):
console.print(f" [dim]{cfg['description']}[/dim]")
console.print()
@integration_catalog_app.command("add")
def integration_catalog_add(
url: str = typer.Argument(
...,
help=(
"Catalog URL to add (HTTPS required, except http://localhost, "
"http://127.0.0.1, or http://[::1] for local testing)"
),
),
name: Optional[str] = typer.Option(None, "--name", help="Catalog name"),
):
"""Add an integration catalog source to the project config."""
from .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
# Normalize once here so the success message reflects what was actually
# stored. ``IntegrationCatalog.add_catalog`` strips again defensively.
normalized_url = url.strip()
try:
catalog.add_catalog(normalized_url, name)
except IntegrationCatalogError as exc:
# Covers both URL validation (base class) and config-file validation
# (IntegrationValidationError subclass).
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")
@integration_catalog_app.command("remove")
def integration_catalog_remove(
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
):
"""Remove an integration catalog source by 0-based index."""
from .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
try:
removed_name = catalog.remove_catalog(index)
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")