Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 303 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1886,6 +1886,13 @@ def get_speckit_version() -> str:
)
app.add_typer(integration_app, name="integration")

integration_catalog_app = typer.Typer(
name="catalog",
help="Manage integration catalog sources",
add_completion=False,
)
integration_app.add_typer(integration_catalog_app, name="catalog")


INTEGRATION_JSON = ".specify/integration.json"

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


# ===== Integration catalog discovery commands =====
#
# These commands mirror the workflow catalog CLI shape:
# - `search` / `info` for discovery over the active catalog stack
# - `catalog list/add/remove` for managing catalog sources
#
# They deliberately do NOT add `integration add/remove/enable/disable/
# set-priority`: integrations are single-active (install / uninstall / switch),
# not additive like extensions and presets.


def _require_specify_project() -> Path:
"""Return the current project root if it is a spec-kit project, else exit."""
project_root = Path.cwd()
if not (project_root / ".specify").exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
return project_root


@integration_app.command("search")
def integration_search(
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
):
"""Search for integrations in the active catalog stack."""
from .integrations import INTEGRATION_REGISTRY
from .integrations.catalog import (
IntegrationCatalog,
IntegrationCatalogError,
IntegrationValidationError,
)

project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)

try:
results = catalog.search(query=query, tag=tag, author=author)
except IntegrationValidationError as exc:
console.print(f"[red]Error:[/red] {exc}")
console.print(
"\nTip: Check the configuration file path shown above for invalid catalog configuration "
"(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
)
raise typer.Exit(1)
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL"):
console.print(
"\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid "
"catalog URL, or unset it to use the configured catalog files "
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
)
else:
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
raise typer.Exit(1)
Comment on lines +2592 to +2602
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handler prints a network-oriented hint ("catalog may be temporarily unavailable") for every IntegrationCatalogError, but IntegrationCatalogError is also raised for local config/YAML validation failures (e.g., invalid .specify/integration-catalogs.yml). This can mislead users into retrying instead of fixing the config. Consider catching IntegrationValidationError separately (or narrowing the hint to fetch/URLError cases) and tailoring the guidance accordingly.

Copilot uses AI. Check for mistakes.

if not results:
console.print("\n[yellow]No integrations found matching criteria[/yellow]")
if query or tag or author:
console.print("\nTry:")
console.print(" • Broader search terms")
console.print(" • Remove filters")
console.print(" • specify integration search (show all)")
return

installed_key = _read_integration_json(project_root).get("integration")

console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
for integ in sorted(results, key=lambda e: e.get("id", "")):
iid = integ.get("id", "?")
name = integ.get("name", iid)
version = integ.get("version", "?")
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
desc = integ.get("description", "")
if desc:
console.print(f" {desc}")

console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
tags = integ.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")

cat_name = integ.get("_catalog_name", "")
install_allowed = integ.get("_install_allowed", True)
if cat_name:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {cat_name}")
else:
console.print(
f" [dim]Catalog:[/dim] {cat_name} "
"[yellow](discovery only — not installable)[/yellow]"
)

if iid == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
elif iid in INTEGRATION_REGISTRY:
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
elif install_allowed:
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
else:
console.print(
f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'."
)
console.print()


@integration_app.command("info")
def integration_info(
integration_id: str = typer.Argument(..., help="Integration ID"),
):
"""Show catalog details for a single integration."""
from .integrations import INTEGRATION_REGISTRY
from .integrations.catalog import (
IntegrationCatalog,
IntegrationCatalogError,
IntegrationValidationError,
)

project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
installed_key = _read_integration_json(project_root).get("integration")

try:
info = catalog.get_integration_info(integration_id)
except IntegrationCatalogError as exc:
info = None
# Keep the live exception so the fallback branch below can give
# different guidance for local-config vs. network failures.
catalog_error: Optional[IntegrationCatalogError] = exc
else:
catalog_error = None

if info:
name = info.get("name", integration_id)
version = info.get("version", "?")
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
if info.get("description"):
console.print(f" {info['description']}")
console.print()

console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
if info.get("license"):
console.print(f" [dim]License:[/dim] {info['license']}")

tags = info.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")

cat_name = info.get("_catalog_name", "")
install_allowed = info.get("_install_allowed", True)
if cat_name:
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")

if info.get("repository"):
console.print(f" [dim]Repository:[/dim] {info['repository']}")

if integration_id == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
elif integration_id in INTEGRATION_REGISTRY:
console.print("\n [dim]Built-in integration (not currently active)[/dim]")
return

if integration_id in INTEGRATION_REGISTRY:
integration = INTEGRATION_REGISTRY[integration_id]
cfg = integration.config or {}
name = cfg.get("name", integration_id)
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})")
console.print(" [dim]Built-in integration (not listed in catalog)[/dim]")
if integration_id == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
if catalog_error:
console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}")
return

if catalog_error:
console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}")
if isinstance(catalog_error, IntegrationValidationError):
console.print(
"\nCheck the configuration file path shown above "
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), "
"or use a built-in integration ID directly."
)
else:
console.print("\nTry again when online, or use a built-in integration ID directly.")
else:
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
console.print("\nTry: specify integration search")
raise typer.Exit(1)


@integration_catalog_app.command("list")
def integration_catalog_list():
"""List configured integration catalog sources."""
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError

project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip()

try:
if env_override:
project_configs = None
configs = catalog.get_catalog_configs()
else:
project_configs = catalog.get_project_catalog_configs()
configs = project_configs if project_configs is not None else catalog.get_catalog_configs()
except IntegrationCatalogError as exc:
Comment on lines +2739 to +2755
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

integration catalog list currently renders the active catalog stack via get_active_catalogs() (env override → project config → user config → built-ins), but catalog add/remove only operate on the project-level file. If a user has a user-level config (or an env override), catalog list will show indexes that catalog remove <index> cannot act on (it will error with “No catalog config file found.”). Consider either (a) making catalog list show only the project-level config entries (plus a separate built-in section), or (b) explicitly labeling each entry’s source (env/project/user/built-in) and restricting/remapping indexes so remove applies only to removable project entries.

Copilot uses AI. Check for mistakes.
Comment on lines +2748 to +2755
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

integration catalog list prefers get_project_catalog_configs() whenever a project config file exists, which means it will ignore the higher-precedence SPECKIT_INTEGRATION_CATALOG_URL override in that case (even though IntegrationCatalog.get_active_catalogs() will use the env var). This can make the command output disagree with the actual catalog sources used by integration search/info. Consider always showing the active stack (or at least explicitly detecting the env override and noting that it supersedes project config).

Copilot uses AI. Check for mistakes.
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)

console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n")
if env_override:
console.print(
" SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files."
)
console.print(
" Project/user catalog sources are not active while the env override is set.\n"
)
console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n")
elif project_configs is None:
console.print(" No project-level catalog sources configured.\n")
console.print("[bold]Active catalog sources (non-removable here):[/bold]\n")
else:
console.print("[bold]Project catalog sources (removable):[/bold]\n")

for i, cfg in enumerate(configs):
install_status = (
"[green]install allowed[/green]"
if cfg.get("install_allowed")
else "[yellow]discovery only[/yellow]"
)
if env_override or project_configs is None:
console.print(f" - [bold]{cfg.get('name', 'catalog')}[/bold] — {install_status}")
else:
console.print(f" [{i}] [bold]{cfg.get('name', f'catalog-{i + 1}')}[/bold] — {install_status}")
console.print(f" {cfg.get('url', '')}")
if cfg.get("description"):
console.print(f" [dim]{cfg['description']}[/dim]")
console.print()


@integration_catalog_app.command("add")
def integration_catalog_add(
url: str = typer.Argument(
...,
help=(
"Catalog URL to add (HTTPS required, except http://localhost, "
"http://127.0.0.1, or http://[::1] for local testing)"
),
),
name: Optional[str] = typer.Option(None, "--name", help="Catalog name"),
):
"""Add an integration catalog source to the project config."""
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError

project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)

# Normalize once here so the success message reflects what was actually
# stored. ``IntegrationCatalog.add_catalog`` strips again defensively.
normalized_url = url.strip()

try:
catalog.add_catalog(normalized_url, name)
except IntegrationCatalogError as exc:
# Covers both URL validation (base class) and config-file validation
# (IntegrationValidationError subclass).
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)

console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")


@integration_catalog_app.command("remove")
def integration_catalog_remove(
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
):
"""Remove an integration catalog source by 0-based index."""
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError

project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)

try:
removed_name = catalog.remove_catalog(index)
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)

console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")


# ===== Preset Commands =====


Expand Down
Loading
Loading