Skip to content

Commit 522acb8

Browse files
committed
feat: add catalog discovery CLI commands
1 parent aad7b16 commit 522acb8

4 files changed

Lines changed: 912 additions & 2 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1880,6 +1880,13 @@ def get_speckit_version() -> str:
18801880
)
18811881
app.add_typer(integration_app, name="integration")
18821882

1883+
integration_catalog_app = typer.Typer(
1884+
name="catalog",
1885+
help="Manage integration catalog sources",
1886+
add_completion=False,
1887+
)
1888+
integration_app.add_typer(integration_catalog_app, name="catalog")
1889+
18831890

18841891
INTEGRATION_JSON = ".specify/integration.json"
18851892

@@ -2529,6 +2536,238 @@ def integration_upgrade(
25292536
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
25302537

25312538

2539+
# ===== Integration catalog discovery commands =====
2540+
#
2541+
# These commands mirror the workflow catalog CLI shape:
2542+
# - `search` / `info` for discovery over the active catalog stack
2543+
# - `catalog list/add/remove` for managing catalog sources
2544+
#
2545+
# They deliberately do NOT add `integration add/remove/enable/disable/
2546+
# set-priority`: integrations are single-active (install / uninstall / switch),
2547+
# not additive like extensions and presets.
2548+
2549+
2550+
def _require_specify_project() -> Path:
2551+
"""Return the current project root if it is a spec-kit project, else exit."""
2552+
project_root = Path.cwd()
2553+
if not (project_root / ".specify").exists():
2554+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
2555+
console.print("Run this command from a spec-kit project root")
2556+
raise typer.Exit(1)
2557+
return project_root
2558+
2559+
2560+
@integration_app.command("search")
2561+
def integration_search(
2562+
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
2563+
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
2564+
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
2565+
):
2566+
"""Search for integrations in the active catalog stack."""
2567+
from .integrations import INTEGRATION_REGISTRY
2568+
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
2569+
2570+
project_root = _require_specify_project()
2571+
catalog = IntegrationCatalog(project_root)
2572+
2573+
try:
2574+
results = catalog.search(query=query, tag=tag, author=author)
2575+
except IntegrationCatalogError as exc:
2576+
console.print(f"[red]Error:[/red] {exc}")
2577+
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
2578+
raise typer.Exit(1)
2579+
2580+
if not results:
2581+
console.print("\n[yellow]No integrations found matching criteria[/yellow]")
2582+
if query or tag or author:
2583+
console.print("\nTry:")
2584+
console.print(" • Broader search terms")
2585+
console.print(" • Remove filters")
2586+
console.print(" • specify integration search (show all)")
2587+
return
2588+
2589+
installed_key = _read_integration_json(project_root).get("integration")
2590+
2591+
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
2592+
for integ in sorted(results, key=lambda e: e.get("id", "")):
2593+
iid = integ.get("id", "?")
2594+
name = integ.get("name", iid)
2595+
version = integ.get("version", "?")
2596+
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
2597+
desc = integ.get("description", "")
2598+
if desc:
2599+
console.print(f" {desc}")
2600+
2601+
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
2602+
tags = integ.get("tags", [])
2603+
if isinstance(tags, list) and tags:
2604+
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
2605+
2606+
cat_name = integ.get("_catalog_name", "")
2607+
install_allowed = integ.get("_install_allowed", True)
2608+
if cat_name:
2609+
if install_allowed:
2610+
console.print(f" [dim]Catalog:[/dim] {cat_name}")
2611+
else:
2612+
console.print(
2613+
f" [dim]Catalog:[/dim] {cat_name} "
2614+
"[yellow](discovery only — not installable)[/yellow]"
2615+
)
2616+
2617+
if iid == installed_key:
2618+
console.print("\n [green]✓ Installed[/green] (currently active)")
2619+
elif iid in INTEGRATION_REGISTRY:
2620+
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
2621+
elif install_allowed:
2622+
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
2623+
else:
2624+
console.print(
2625+
f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'."
2626+
)
2627+
console.print()
2628+
2629+
2630+
@integration_app.command("info")
2631+
def integration_info(
2632+
integration_id: str = typer.Argument(..., help="Integration ID"),
2633+
):
2634+
"""Show catalog details for a single integration."""
2635+
from .integrations import INTEGRATION_REGISTRY
2636+
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
2637+
2638+
project_root = _require_specify_project()
2639+
catalog = IntegrationCatalog(project_root)
2640+
installed_key = _read_integration_json(project_root).get("integration")
2641+
2642+
try:
2643+
info = catalog.get_integration_info(integration_id)
2644+
except IntegrationCatalogError as exc:
2645+
info = None
2646+
catalog_error: Optional[str] = str(exc)
2647+
else:
2648+
catalog_error = None
2649+
2650+
if info:
2651+
name = info.get("name", integration_id)
2652+
version = info.get("version", "?")
2653+
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
2654+
if info.get("description"):
2655+
console.print(f" {info['description']}")
2656+
console.print()
2657+
2658+
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
2659+
if info.get("license"):
2660+
console.print(f" [dim]License:[/dim] {info['license']}")
2661+
2662+
tags = info.get("tags", [])
2663+
if isinstance(tags, list) and tags:
2664+
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
2665+
2666+
cat_name = info.get("_catalog_name", "")
2667+
install_allowed = info.get("_install_allowed", True)
2668+
if cat_name:
2669+
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
2670+
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
2671+
2672+
if info.get("repository"):
2673+
console.print(f" [dim]Repository:[/dim] {info['repository']}")
2674+
2675+
if integration_id == installed_key:
2676+
console.print("\n [green]✓ Installed[/green] (currently active)")
2677+
elif integration_id in INTEGRATION_REGISTRY:
2678+
console.print("\n [dim]Built-in integration (not currently active)[/dim]")
2679+
return
2680+
2681+
if integration_id in INTEGRATION_REGISTRY:
2682+
integration = INTEGRATION_REGISTRY[integration_id]
2683+
cfg = integration.config or {}
2684+
name = cfg.get("name", integration_id)
2685+
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})")
2686+
console.print(" [dim]Built-in integration (not listed in catalog)[/dim]")
2687+
if integration_id == installed_key:
2688+
console.print("\n [green]✓ Installed[/green] (currently active)")
2689+
if catalog_error:
2690+
console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}")
2691+
return
2692+
2693+
if catalog_error:
2694+
console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}")
2695+
console.print("\nTry again when online, or use a built-in integration ID directly.")
2696+
else:
2697+
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
2698+
console.print("\nTry: specify integration search")
2699+
raise typer.Exit(1)
2700+
2701+
2702+
@integration_catalog_app.command("list")
2703+
def integration_catalog_list():
2704+
"""List configured integration catalog sources."""
2705+
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
2706+
2707+
project_root = _require_specify_project()
2708+
catalog = IntegrationCatalog(project_root)
2709+
2710+
try:
2711+
configs = catalog.get_catalog_configs()
2712+
except IntegrationCatalogError as exc:
2713+
console.print(f"[red]Error:[/red] {exc}")
2714+
raise typer.Exit(1)
2715+
2716+
console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n")
2717+
for i, cfg in enumerate(configs):
2718+
install_status = (
2719+
"[green]install allowed[/green]"
2720+
if cfg.get("install_allowed")
2721+
else "[yellow]discovery only[/yellow]"
2722+
)
2723+
console.print(f" [{i}] [bold]{cfg.get('name', f'catalog-{i + 1}')}[/bold] — {install_status}")
2724+
console.print(f" {cfg.get('url', '')}")
2725+
if cfg.get("description"):
2726+
console.print(f" [dim]{cfg['description']}[/dim]")
2727+
console.print()
2728+
2729+
2730+
@integration_catalog_app.command("add")
2731+
def integration_catalog_add(
2732+
url: str = typer.Argument(..., help="Catalog URL to add (must use HTTPS)"),
2733+
name: Optional[str] = typer.Option(None, "--name", help="Catalog name"),
2734+
):
2735+
"""Add an integration catalog source to the project config."""
2736+
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
2737+
2738+
project_root = _require_specify_project()
2739+
catalog = IntegrationCatalog(project_root)
2740+
2741+
try:
2742+
catalog.add_catalog(url, name)
2743+
except IntegrationCatalogError as exc:
2744+
# Covers both URL validation (base class) and config-file validation
2745+
# (IntegrationValidationError subclass).
2746+
console.print(f"[red]Error:[/red] {exc}")
2747+
raise typer.Exit(1)
2748+
2749+
console.print(f"[green]✓[/green] Catalog source added: {url}")
2750+
2751+
2752+
@integration_catalog_app.command("remove")
2753+
def integration_catalog_remove(
2754+
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
2755+
):
2756+
"""Remove an integration catalog source by 0-based index."""
2757+
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
2758+
2759+
project_root = _require_specify_project()
2760+
catalog = IntegrationCatalog(project_root)
2761+
2762+
try:
2763+
removed_name = catalog.remove_catalog(index)
2764+
except IntegrationCatalogError as exc:
2765+
console.print(f"[red]Error:[/red] {exc}")
2766+
raise typer.Exit(1)
2767+
2768+
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
2769+
2770+
25322771
# ===== Preset Commands =====
25332772

25342773

0 commit comments

Comments
 (0)