mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
refactor(integrations): co-locate integration commands in integrations/ domain dir (PR-5/8) (#2720)
* 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>
This commit is contained in:
464
src/specify_cli/integrations/_query_commands.py
Normal file
464
src/specify_cli/integrations/_query_commands.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user