Skip to content

Commit 1049e17

Browse files
authored
feat: add catalog discovery CLI commands (#2360)
* feat: add catalog discovery CLI commands * fix: address second Copilot review * fix: address third Copilot review * fix: align catalog remove with displayed order * fix: route local catalog config errors to local guidance * fix: address integration catalog review feedback * fix: accept numeric string catalog priorities * fix: align catalog remove with visible entries * fix: preserve invalid catalog root validation * fix: include invalid catalog priority value * fix: preserve falsy catalog root validation * fix: clarify integration catalog guidance * fix: align integration catalog list and remove * fix: align integration catalog edge cases * fix: clarify catalog error guidance tests * fix: clarify integration catalog edge cases * fix: harden integration catalog removal * fix: validate integration state before catalog search * fix: reject empty integration catalog URL * fix: allow catalog remove to clean non-string URLs * fix: address catalog env and priority review * fix: align catalog source display names * fix: align catalog fallback names
1 parent 9cf3151 commit 1049e17

4 files changed

Lines changed: 2070 additions & 20 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1886,6 +1886,13 @@ def get_speckit_version() -> str:
18861886
)
18871887
app.add_typer(integration_app, name="integration")
18881888

1889+
integration_catalog_app = typer.Typer(
1890+
name="catalog",
1891+
help="Manage integration catalog sources",
1892+
add_completion=False,
1893+
)
1894+
integration_app.add_typer(integration_catalog_app, name="catalog")
1895+
18891896

18901897
INTEGRATION_JSON = ".specify/integration.json"
18911898

@@ -2535,6 +2542,314 @@ def integration_upgrade(
25352542
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
25362543

25372544

2545+
# ===== Integration catalog discovery commands =====
2546+
#
2547+
# These commands mirror the workflow catalog CLI shape:
2548+
# - `search` / `info` for discovery over the active catalog stack
2549+
# - `catalog list/add/remove` for managing catalog sources
2550+
#
2551+
# They deliberately do NOT add `integration add/remove/enable/disable/
2552+
# set-priority`: integrations are single-active (install / uninstall / switch),
2553+
# not additive like extensions and presets.
2554+
2555+
2556+
def _require_specify_project() -> Path:
2557+
"""Return the current project root if it is a spec-kit project, else exit."""
2558+
project_root = Path.cwd()
2559+
if not (project_root / ".specify").exists():
2560+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
2561+
console.print("Run this command from a spec-kit project root")
2562+
raise typer.Exit(1)
2563+
return project_root
2564+
2565+
2566+
@integration_app.command("search")
2567+
def integration_search(
2568+
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
2569+
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
2570+
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
2571+
):
2572+
"""Search for integrations in the active catalog stack."""
2573+
from .integrations import INTEGRATION_REGISTRY
2574+
from .integrations.catalog import (
2575+
IntegrationCatalog,
2576+
IntegrationCatalogError,
2577+
IntegrationValidationError,
2578+
)
2579+
2580+
project_root = _require_specify_project()
2581+
integration_config = _read_integration_json(project_root)
2582+
installed_key = integration_config.get("integration")
2583+
catalog = IntegrationCatalog(project_root)
2584+
2585+
try:
2586+
results = catalog.search(query=query, tag=tag, author=author)
2587+
except IntegrationValidationError as exc:
2588+
console.print(f"[red]Error:[/red] {exc}")
2589+
console.print(
2590+
"\nTip: Check the configuration file path shown above for invalid catalog configuration "
2591+
"(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
2592+
)
2593+
raise typer.Exit(1)
2594+
except IntegrationCatalogError as exc:
2595+
console.print(f"[red]Error:[/red] {exc}")
2596+
if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
2597+
console.print(
2598+
"\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid "
2599+
"catalog URL, or unset it to use the configured catalog files "
2600+
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
2601+
)
2602+
else:
2603+
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
2604+
raise typer.Exit(1)
2605+
2606+
if not results:
2607+
console.print("\n[yellow]No integrations found matching criteria[/yellow]")
2608+
if query or tag or author:
2609+
console.print("\nTry:")
2610+
console.print(" • Broader search terms")
2611+
console.print(" • Remove filters")
2612+
console.print(" • specify integration search (show all)")
2613+
return
2614+
2615+
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
2616+
for integ in sorted(results, key=lambda e: e.get("id", "")):
2617+
iid = integ.get("id", "?")
2618+
name = integ.get("name", iid)
2619+
version = integ.get("version", "?")
2620+
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
2621+
desc = integ.get("description", "")
2622+
if desc:
2623+
console.print(f" {desc}")
2624+
2625+
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
2626+
tags = integ.get("tags", [])
2627+
if isinstance(tags, list) and tags:
2628+
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
2629+
2630+
cat_name = integ.get("_catalog_name", "")
2631+
install_allowed = integ.get("_install_allowed", True)
2632+
if cat_name:
2633+
if install_allowed:
2634+
console.print(f" [dim]Catalog:[/dim] {cat_name}")
2635+
else:
2636+
console.print(
2637+
f" [dim]Catalog:[/dim] {cat_name} "
2638+
"[yellow](discovery only — not installable)[/yellow]"
2639+
)
2640+
2641+
if iid == installed_key:
2642+
console.print("\n [green]✓ Installed[/green] (currently active)")
2643+
elif iid in INTEGRATION_REGISTRY:
2644+
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
2645+
elif install_allowed:
2646+
console.print(
2647+
"\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs "
2648+
"can be installed with 'specify integration install'."
2649+
)
2650+
else:
2651+
console.print(
2652+
f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'."
2653+
)
2654+
console.print()
2655+
2656+
2657+
@integration_app.command("info")
2658+
def integration_info(
2659+
integration_id: str = typer.Argument(..., help="Integration ID"),
2660+
):
2661+
"""Show catalog details for a single integration."""
2662+
from .integrations import INTEGRATION_REGISTRY
2663+
from .integrations.catalog import (
2664+
IntegrationCatalog,
2665+
IntegrationCatalogError,
2666+
IntegrationValidationError,
2667+
)
2668+
2669+
project_root = _require_specify_project()
2670+
catalog = IntegrationCatalog(project_root)
2671+
installed_key = _read_integration_json(project_root).get("integration")
2672+
2673+
try:
2674+
info = catalog.get_integration_info(integration_id)
2675+
except IntegrationCatalogError as exc:
2676+
info = None
2677+
# Keep the live exception so the fallback branch below can give
2678+
# different guidance for local-config vs. network failures.
2679+
catalog_error: Optional[IntegrationCatalogError] = exc
2680+
else:
2681+
catalog_error = None
2682+
2683+
if info:
2684+
name = info.get("name", integration_id)
2685+
version = info.get("version", "?")
2686+
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
2687+
if info.get("description"):
2688+
console.print(f" {info['description']}")
2689+
console.print()
2690+
2691+
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
2692+
if info.get("license"):
2693+
console.print(f" [dim]License:[/dim] {info['license']}")
2694+
2695+
tags = info.get("tags", [])
2696+
if isinstance(tags, list) and tags:
2697+
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
2698+
2699+
cat_name = info.get("_catalog_name", "")
2700+
install_allowed = info.get("_install_allowed", True)
2701+
if cat_name:
2702+
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
2703+
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
2704+
2705+
if info.get("repository"):
2706+
console.print(f" [dim]Repository:[/dim] {info['repository']}")
2707+
2708+
if integration_id == installed_key:
2709+
console.print("\n [green]✓ Installed[/green] (currently active)")
2710+
elif integration_id in INTEGRATION_REGISTRY:
2711+
console.print("\n [dim]Built-in integration (not currently active)[/dim]")
2712+
return
2713+
2714+
if integration_id in INTEGRATION_REGISTRY:
2715+
integration = INTEGRATION_REGISTRY[integration_id]
2716+
cfg = integration.config or {}
2717+
name = cfg.get("name", integration_id)
2718+
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})")
2719+
console.print(" [dim]Built-in integration (not listed in catalog)[/dim]")
2720+
if integration_id == installed_key:
2721+
console.print("\n [green]✓ Installed[/green] (currently active)")
2722+
if catalog_error:
2723+
console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}")
2724+
return
2725+
2726+
if catalog_error:
2727+
console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}")
2728+
if isinstance(catalog_error, IntegrationValidationError):
2729+
console.print(
2730+
"\nCheck the configuration file path shown above "
2731+
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), "
2732+
"or use a built-in integration ID directly."
2733+
)
2734+
elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
2735+
console.print(
2736+
"\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, "
2737+
"or unset it to use the configured catalog files, or use a built-in integration ID directly."
2738+
)
2739+
else:
2740+
console.print("\nTry again when online, or use a built-in integration ID directly.")
2741+
else:
2742+
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
2743+
console.print("\nTry: specify integration search")
2744+
raise typer.Exit(1)
2745+
2746+
2747+
@integration_catalog_app.command("list")
2748+
def integration_catalog_list():
2749+
"""List configured integration catalog sources."""
2750+
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
2751+
2752+
project_root = _require_specify_project()
2753+
catalog = IntegrationCatalog(project_root)
2754+
env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip()
2755+
2756+
try:
2757+
if env_override:
2758+
project_configs = None
2759+
configs = catalog.get_catalog_configs()
2760+
else:
2761+
project_configs = catalog.get_project_catalog_configs()
2762+
configs = project_configs if project_configs is not None else catalog.get_catalog_configs()
2763+
except IntegrationCatalogError as exc:
2764+
console.print(f"[red]Error:[/red] {exc}")
2765+
raise typer.Exit(1)
2766+
2767+
console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n")
2768+
if env_override:
2769+
console.print(
2770+
" SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files."
2771+
)
2772+
console.print(
2773+
" Project/user catalog sources are not active while the env override is set.\n"
2774+
)
2775+
console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n")
2776+
elif project_configs is None:
2777+
console.print(" No project-level catalog sources configured.\n")
2778+
console.print("[bold]Active catalog sources (non-removable here):[/bold]\n")
2779+
else:
2780+
console.print("[bold]Project catalog sources (removable):[/bold]\n")
2781+
2782+
for i, cfg in enumerate(configs):
2783+
install_status = (
2784+
"[green]install allowed[/green]"
2785+
if cfg.get("install_allowed")
2786+
else "[yellow]discovery only[/yellow]"
2787+
)
2788+
raw_name = cfg.get("name")
2789+
display_name = str(raw_name).strip() if raw_name is not None else ""
2790+
if not display_name:
2791+
display_name = f"catalog-{i + 1}"
2792+
if env_override or project_configs is None:
2793+
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
2794+
else:
2795+
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
2796+
console.print(f" {cfg.get('url', '')}")
2797+
if cfg.get("description"):
2798+
console.print(f" [dim]{cfg['description']}[/dim]")
2799+
console.print()
2800+
2801+
2802+
@integration_catalog_app.command("add")
2803+
def integration_catalog_add(
2804+
url: str = typer.Argument(
2805+
...,
2806+
help=(
2807+
"Catalog URL to add (HTTPS required, except http://localhost, "
2808+
"http://127.0.0.1, or http://[::1] for local testing)"
2809+
),
2810+
),
2811+
name: Optional[str] = typer.Option(None, "--name", help="Catalog name"),
2812+
):
2813+
"""Add an integration catalog source to the project config."""
2814+
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
2815+
2816+
project_root = _require_specify_project()
2817+
catalog = IntegrationCatalog(project_root)
2818+
2819+
# Normalize once here so the success message reflects what was actually
2820+
# stored. ``IntegrationCatalog.add_catalog`` strips again defensively.
2821+
normalized_url = url.strip()
2822+
2823+
try:
2824+
catalog.add_catalog(normalized_url, name)
2825+
except IntegrationCatalogError as exc:
2826+
# Covers both URL validation (base class) and config-file validation
2827+
# (IntegrationValidationError subclass).
2828+
console.print(f"[red]Error:[/red] {exc}")
2829+
raise typer.Exit(1)
2830+
2831+
console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")
2832+
2833+
2834+
@integration_catalog_app.command("remove")
2835+
def integration_catalog_remove(
2836+
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
2837+
):
2838+
"""Remove an integration catalog source by 0-based index."""
2839+
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
2840+
2841+
project_root = _require_specify_project()
2842+
catalog = IntegrationCatalog(project_root)
2843+
2844+
try:
2845+
removed_name = catalog.remove_catalog(index)
2846+
except IntegrationCatalogError as exc:
2847+
console.print(f"[red]Error:[/red] {exc}")
2848+
raise typer.Exit(1)
2849+
2850+
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
2851+
2852+
25382853
# ===== Preset Commands =====
25392854

25402855

0 commit comments

Comments
 (0)