@@ -1886,6 +1886,13 @@ def get_speckit_version() -> str:
18861886)
18871887app .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
18901897INTEGRATION_JSON = ".specify/integration.json"
18911898
@@ -2535,6 +2542,238 @@ 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 IntegrationCatalog , IntegrationCatalogError
2575+
2576+ project_root = _require_specify_project ()
2577+ catalog = IntegrationCatalog (project_root )
2578+
2579+ try :
2580+ results = catalog .search (query = query , tag = tag , author = author )
2581+ except IntegrationCatalogError as exc :
2582+ console .print (f"[red]Error:[/red] { exc } " )
2583+ console .print ("\n Tip: The catalog may be temporarily unavailable. Try again later." )
2584+ raise typer .Exit (1 )
2585+
2586+ if not results :
2587+ console .print ("\n [yellow]No integrations found matching criteria[/yellow]" )
2588+ if query or tag or author :
2589+ console .print ("\n Try:" )
2590+ console .print (" • Broader search terms" )
2591+ console .print (" • Remove filters" )
2592+ console .print (" • specify integration search (show all)" )
2593+ return
2594+
2595+ installed_key = _read_integration_json (project_root ).get ("integration" )
2596+
2597+ console .print (f"\n [green]Found { len (results )} integration(s):[/green]\n " )
2598+ for integ in sorted (results , key = lambda e : e .get ("id" , "" )):
2599+ iid = integ .get ("id" , "?" )
2600+ name = integ .get ("name" , iid )
2601+ version = integ .get ("version" , "?" )
2602+ console .print (f"[bold]{ name } [/bold] ({ iid } ) v{ version } " )
2603+ desc = integ .get ("description" , "" )
2604+ if desc :
2605+ console .print (f" { desc } " )
2606+
2607+ console .print (f"\n [dim]Author:[/dim] { integ .get ('author' , 'Unknown' )} " )
2608+ tags = integ .get ("tags" , [])
2609+ if isinstance (tags , list ) and tags :
2610+ console .print (f" [dim]Tags:[/dim] { ', ' .join (str (t ) for t in tags )} " )
2611+
2612+ cat_name = integ .get ("_catalog_name" , "" )
2613+ install_allowed = integ .get ("_install_allowed" , True )
2614+ if cat_name :
2615+ if install_allowed :
2616+ console .print (f" [dim]Catalog:[/dim] { cat_name } " )
2617+ else :
2618+ console .print (
2619+ f" [dim]Catalog:[/dim] { cat_name } "
2620+ "[yellow](discovery only — not installable)[/yellow]"
2621+ )
2622+
2623+ if iid == installed_key :
2624+ console .print ("\n [green]✓ Installed[/green] (currently active)" )
2625+ elif iid in INTEGRATION_REGISTRY :
2626+ console .print (f"\n [cyan]Install:[/cyan] specify integration install { iid } " )
2627+ elif install_allowed :
2628+ console .print (f"\n [cyan]Install:[/cyan] specify integration install { iid } " )
2629+ else :
2630+ console .print (
2631+ f"\n [yellow]⚠[/yellow] Not directly installable from '{ cat_name } '."
2632+ )
2633+ console .print ()
2634+
2635+
2636+ @integration_app .command ("info" )
2637+ def integration_info (
2638+ integration_id : str = typer .Argument (..., help = "Integration ID" ),
2639+ ):
2640+ """Show catalog details for a single integration."""
2641+ from .integrations import INTEGRATION_REGISTRY
2642+ from .integrations .catalog import IntegrationCatalog , IntegrationCatalogError
2643+
2644+ project_root = _require_specify_project ()
2645+ catalog = IntegrationCatalog (project_root )
2646+ installed_key = _read_integration_json (project_root ).get ("integration" )
2647+
2648+ try :
2649+ info = catalog .get_integration_info (integration_id )
2650+ except IntegrationCatalogError as exc :
2651+ info = None
2652+ catalog_error : Optional [str ] = str (exc )
2653+ else :
2654+ catalog_error = None
2655+
2656+ if info :
2657+ name = info .get ("name" , integration_id )
2658+ version = info .get ("version" , "?" )
2659+ console .print (f"\n [bold cyan]{ name } [/bold cyan] ({ integration_id } ) v{ version } " )
2660+ if info .get ("description" ):
2661+ console .print (f" { info ['description' ]} " )
2662+ console .print ()
2663+
2664+ console .print (f" [dim]Author:[/dim] { info .get ('author' , 'Unknown' )} " )
2665+ if info .get ("license" ):
2666+ console .print (f" [dim]License:[/dim] { info ['license' ]} " )
2667+
2668+ tags = info .get ("tags" , [])
2669+ if isinstance (tags , list ) and tags :
2670+ console .print (f" [dim]Tags:[/dim] { ', ' .join (str (t ) for t in tags )} " )
2671+
2672+ cat_name = info .get ("_catalog_name" , "" )
2673+ install_allowed = info .get ("_install_allowed" , True )
2674+ if cat_name :
2675+ install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
2676+ console .print (f" [dim]Source catalog:[/dim] { cat_name } { install_note } " )
2677+
2678+ if info .get ("repository" ):
2679+ console .print (f" [dim]Repository:[/dim] { info ['repository' ]} " )
2680+
2681+ if integration_id == installed_key :
2682+ console .print ("\n [green]✓ Installed[/green] (currently active)" )
2683+ elif integration_id in INTEGRATION_REGISTRY :
2684+ console .print ("\n [dim]Built-in integration (not currently active)[/dim]" )
2685+ return
2686+
2687+ if integration_id in INTEGRATION_REGISTRY :
2688+ integration = INTEGRATION_REGISTRY [integration_id ]
2689+ cfg = integration .config or {}
2690+ name = cfg .get ("name" , integration_id )
2691+ console .print (f"\n [bold cyan]{ name } [/bold cyan] ({ integration_id } )" )
2692+ console .print (" [dim]Built-in integration (not listed in catalog)[/dim]" )
2693+ if integration_id == installed_key :
2694+ console .print ("\n [green]✓ Installed[/green] (currently active)" )
2695+ if catalog_error :
2696+ console .print (f"\n [yellow]Catalog unavailable:[/yellow] { catalog_error } " )
2697+ return
2698+
2699+ if catalog_error :
2700+ console .print (f"[red]Error:[/red] Could not query integration catalog: { catalog_error } " )
2701+ console .print ("\n Try again when online, or use a built-in integration ID directly." )
2702+ else :
2703+ console .print (f"[red]Error:[/red] Integration '{ integration_id } ' not found" )
2704+ console .print ("\n Try: specify integration search" )
2705+ raise typer .Exit (1 )
2706+
2707+
2708+ @integration_catalog_app .command ("list" )
2709+ def integration_catalog_list ():
2710+ """List configured integration catalog sources."""
2711+ from .integrations .catalog import IntegrationCatalog , IntegrationCatalogError
2712+
2713+ project_root = _require_specify_project ()
2714+ catalog = IntegrationCatalog (project_root )
2715+
2716+ try :
2717+ configs = catalog .get_catalog_configs ()
2718+ except IntegrationCatalogError as exc :
2719+ console .print (f"[red]Error:[/red] { exc } " )
2720+ raise typer .Exit (1 )
2721+
2722+ console .print ("\n [bold cyan]Integration Catalog Sources:[/bold cyan]\n " )
2723+ for i , cfg in enumerate (configs ):
2724+ install_status = (
2725+ "[green]install allowed[/green]"
2726+ if cfg .get ("install_allowed" )
2727+ else "[yellow]discovery only[/yellow]"
2728+ )
2729+ console .print (f" [{ i } ] [bold]{ cfg .get ('name' , f'catalog-{ i + 1 } ' )} [/bold] — { install_status } " )
2730+ console .print (f" { cfg .get ('url' , '' )} " )
2731+ if cfg .get ("description" ):
2732+ console .print (f" [dim]{ cfg ['description' ]} [/dim]" )
2733+ console .print ()
2734+
2735+
2736+ @integration_catalog_app .command ("add" )
2737+ def integration_catalog_add (
2738+ url : str = typer .Argument (..., help = "Catalog URL to add (must use HTTPS)" ),
2739+ name : Optional [str ] = typer .Option (None , "--name" , help = "Catalog name" ),
2740+ ):
2741+ """Add an integration catalog source to the project config."""
2742+ from .integrations .catalog import IntegrationCatalog , IntegrationCatalogError
2743+
2744+ project_root = _require_specify_project ()
2745+ catalog = IntegrationCatalog (project_root )
2746+
2747+ try :
2748+ catalog .add_catalog (url , name )
2749+ except IntegrationCatalogError as exc :
2750+ # Covers both URL validation (base class) and config-file validation
2751+ # (IntegrationValidationError subclass).
2752+ console .print (f"[red]Error:[/red] { exc } " )
2753+ raise typer .Exit (1 )
2754+
2755+ console .print (f"[green]✓[/green] Catalog source added: { url } " )
2756+
2757+
2758+ @integration_catalog_app .command ("remove" )
2759+ def integration_catalog_remove (
2760+ index : int = typer .Argument (..., help = "Catalog index to remove (from 'catalog list')" ),
2761+ ):
2762+ """Remove an integration catalog source by 0-based index."""
2763+ from .integrations .catalog import IntegrationCatalog , IntegrationCatalogError
2764+
2765+ project_root = _require_specify_project ()
2766+ catalog = IntegrationCatalog (project_root )
2767+
2768+ try :
2769+ removed_name = catalog .remove_catalog (index )
2770+ except IntegrationCatalogError as exc :
2771+ console .print (f"[red]Error:[/red] { exc } " )
2772+ raise typer .Exit (1 )
2773+
2774+ console .print (f"[green]✓[/green] Catalog source '{ removed_name } ' removed" )
2775+
2776+
25382777# ===== Preset Commands =====
25392778
25402779
0 commit comments