Skip to content

Commit 049ab8e

Browse files
committed
feat: add catalog discovery CLI commands
1 parent aad7b16 commit 049ab8e

4 files changed

Lines changed: 810 additions & 0 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

src/specify_cli/integrations/catalog.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ class IntegrationCatalogError(Exception):
3030
"""Raised when a catalog operation fails."""
3131

3232

33+
class IntegrationValidationError(IntegrationCatalogError):
34+
"""Validation error for catalog config or catalog management operations."""
35+
36+
3337
class IntegrationDescriptorError(Exception):
3438
"""Raised when an integration.yml descriptor is invalid."""
3539

@@ -408,6 +412,133 @@ def clear_cache(self) -> None:
408412
for f in self.cache_dir.glob(pattern):
409413
f.unlink(missing_ok=True)
410414

415+
# -- Catalog-source management ----------------------------------------
416+
417+
CONFIG_FILENAME = "integration-catalogs.yml"
418+
419+
def get_catalog_configs(self) -> List[Dict[str, Any]]:
420+
"""Return the active catalog stack as a list of dicts.
421+
422+
Thin adapter over :meth:`get_active_catalogs` that yields plain dicts
423+
suitable for CLI rendering and JSON-like consumers.
424+
"""
425+
return [
426+
{
427+
"name": e.name,
428+
"url": e.url,
429+
"priority": e.priority,
430+
"install_allowed": e.install_allowed,
431+
"description": e.description,
432+
}
433+
for e in self.get_active_catalogs()
434+
]
435+
436+
def add_catalog(self, url: str, name: Optional[str] = None) -> None:
437+
"""Add a catalog source to the project-level config file.
438+
439+
The URL is validated before being written. Duplicate URLs are rejected.
440+
Priority is derived as ``max(existing) + 1`` so the new entry sorts last
441+
in the resolution order unless the user edits the file manually.
442+
"""
443+
self._validate_catalog_url(url)
444+
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
445+
446+
data: Dict[str, Any] = {"catalogs": []}
447+
if config_path.exists():
448+
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
449+
if not isinstance(raw, dict):
450+
raise IntegrationValidationError(
451+
"Catalog config file is corrupted (expected a mapping)."
452+
)
453+
data = raw
454+
455+
catalogs = data.get("catalogs", [])
456+
if not isinstance(catalogs, list):
457+
raise IntegrationValidationError(
458+
"Catalog config 'catalogs' must be a list."
459+
)
460+
461+
for cat in catalogs:
462+
if isinstance(cat, dict) and cat.get("url") == url:
463+
raise IntegrationValidationError(
464+
f"Catalog URL already configured: {url}"
465+
)
466+
467+
max_priority = max(
468+
(cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)),
469+
default=0,
470+
)
471+
catalogs.append(
472+
{
473+
"name": name or f"catalog-{len(catalogs) + 1}",
474+
"url": url,
475+
"priority": max_priority + 1,
476+
"install_allowed": True,
477+
"description": "",
478+
}
479+
)
480+
data["catalogs"] = catalogs
481+
482+
config_path.parent.mkdir(parents=True, exist_ok=True)
483+
with open(config_path, "w", encoding="utf-8") as f:
484+
yaml.dump(
485+
data,
486+
f,
487+
default_flow_style=False,
488+
sort_keys=False,
489+
allow_unicode=True,
490+
)
491+
492+
def remove_catalog(self, index: int) -> str:
493+
"""Remove a catalog source by 0-based index. Returns the removed name."""
494+
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
495+
if not config_path.exists():
496+
raise IntegrationValidationError("No catalog config file found.")
497+
498+
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
499+
if not isinstance(data, dict):
500+
raise IntegrationValidationError(
501+
"Catalog config file is corrupted (expected a mapping)."
502+
)
503+
504+
catalogs = data.get("catalogs", [])
505+
if not isinstance(catalogs, list):
506+
raise IntegrationValidationError(
507+
"Catalog config 'catalogs' must be a list."
508+
)
509+
510+
if index < 0 or index >= len(catalogs):
511+
raise IntegrationValidationError(
512+
f"Catalog index {index} out of range (0-{len(catalogs) - 1})."
513+
)
514+
515+
removed = catalogs.pop(index)
516+
517+
if catalogs:
518+
data["catalogs"] = catalogs
519+
with open(config_path, "w", encoding="utf-8") as f:
520+
yaml.dump(
521+
data,
522+
f,
523+
default_flow_style=False,
524+
sort_keys=False,
525+
allow_unicode=True,
526+
)
527+
else:
528+
# Removing the final entry: delete the config file rather than
529+
# leaving behind an empty `catalogs:` list. `_load_catalog_config`
530+
# treats an empty list as an error, so leaving the file would
531+
# break every subsequent `integration` command until the user
532+
# manually deletes `.specify/integration-catalogs.yml`.
533+
# Deleting the file lets the project fall back to built-in
534+
# defaults, which matches the behavior before any
535+
# `catalog add` was ever run.
536+
config_path.unlink()
537+
538+
if isinstance(removed, dict):
539+
return removed.get("name", f"catalog-{index + 1}")
540+
return f"catalog-{index + 1}"
541+
411542

412543
# ---------------------------------------------------------------------------
413544
# IntegrationDescriptor (integration.yml)

0 commit comments

Comments
 (0)