@@ -1880,6 +1880,13 @@ def get_speckit_version() -> str:
18801880)
18811881app .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
18841891INTEGRATION_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 ("\n Tip: 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 ("\n Try:" )
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 ("\n Try 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 ("\n Try: 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