@@ -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,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+ "\n Tip: 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+ "\n Tip: 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 ("\n Tip: 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 ("\n Try:" )
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+ "\n Check 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+ "\n Check 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 ("\n Try 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 ("\n Try: 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