Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
268 changes: 268 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,267 @@ 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 .specify/integration-catalogs.yml for invalid catalog configuration."
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.

The local-config failure tip hardcodes .specify/integration-catalogs.yml, but IntegrationCatalog.get_active_catalogs() can also fail on the user-level config at ~/.specify/integration-catalogs.yml. When that happens, this guidance points users to the wrong file. Consider wording the tip to reference the path in the exception (already included), or mention both project and user config locations.

Suggested change
"\nTip: Check .specify/integration-catalogs.yml for invalid catalog configuration."
"\nTip: Check the configuration file path shown above for invalid catalog configuration "
"(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."

Copilot uses AI. Check for mistakes.
)
raise typer.Exit(1)
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
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.

The generic except IntegrationCatalogError branch prints a network-oriented tip ("catalog may be temporarily unavailable"), but an invalid SPECKIT_INTEGRATION_CATALOG_URL currently raises IntegrationCatalogError (not IntegrationValidationError) in IntegrationCatalog.get_active_catalogs(). That means env-var misconfiguration will incorrectly show the network tip instead of local-config guidance. Consider re-wrapping env-var URL validation failures as IntegrationValidationError, or explicitly detecting env-var failures here and showing the local-config/env-var fix tip.

Suggested change
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
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.")

Copilot uses AI. Check for mistakes.
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 .specify/integration-catalogs.yml, "
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.

Similar to integration search, this local-config guidance hardcodes the project path .specify/integration-catalogs.yml, but the thrown IntegrationValidationError may be for the user-level config (~/.specify/...). Suggest adjusting the message to reference the failing config path (included in the error) or mention both possible locations.

Suggested change
"\nCheck .specify/integration-catalogs.yml, "
"\nCheck .specify/integration-catalogs.yml or "
"~/.specify/integration-catalogs.yml, "

Copilot uses AI. Check for mistakes.
"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)

try:
configs = 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")
for i, cfg in enumerate(configs):
install_status = (
"[green]install allowed[/green]"
if cfg.get("install_allowed")
else "[yellow]discovery only[/yellow]"
)
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 for local testing)",
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.

The add command help text says HTTP is only allowed for http://localhost, but _validate_catalog_url also allows http://127.0.0.1 and http://[::1]. Update the help string to match the actual validation rules so users aren't misled.

Suggested change
help="Catalog URL to add (HTTPS required, except http://localhost for local testing)",
help="Catalog URL to add (HTTPS required, except http://localhost, http://127.0.0.1, or http://[::1] for local testing)",

Copilot uses AI. Check for mistakes.
),
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