mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 14:01:01 +08:00
* refactor(integrations): co-locate integration commands in integrations/ domain dir
- Remove commands/ stubs (handlers will live in domain dirs)
- Move all integration CLI handlers out of __init__.py into integrations/
- Split into focused modules under integrations/:
_helpers.py (340 lines) — domain helpers
_install_commands.py (306 lines) — install / uninstall
_migrate_commands.py (487 lines) — switch / upgrade
_query_commands.py (442 lines) — list / use / search / info / catalog
_commands.py (34 lines) — app objects + register()
- __init__.py reduced by ~1400 lines; integration block replaced with register() call
- Fix patch paths in tests to new module locations
* fix(integrations): restore original integration list output in refactor
Preserve the CLI Required column, post-table default/installed summary,
and no-installed guidance that were dropped during the no-behavior-change
refactor of integration list into _query_commands.py.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(integrations): restore _clear/_update_init_options public imports
The refactor that split integration commands moved
_clear_init_options_for_integration and _update_init_options_for_integration
into integrations/_helpers.py, but tests still import them from the top-level
specify_cli package, causing ImportError. Re-export them with explicit aliases
at the end of __init__.py to preserve the public import surface.
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
465 lines
18 KiB
Python
465 lines
18 KiB
Python
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Optional
|
|
|
|
import typer
|
|
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,
|
|
_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]")
|
|
|
|
|
|
@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]."
|
|
),
|
|
)
|
|
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")
|