mirror of
https://github.com/github/spec-kit.git
synced 2026-07-05 21:49:47 +08:00
* 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
553 lines
22 KiB
Python
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")
|