Skip to content
Merged
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
60 changes: 60 additions & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,13 @@ Searches the active catalog stack for integrations matching the query. Without a

```bash
specify integration info <integration_id>
specify integration info <integration_id> --versions
```

Shows catalog details for a single integration, including its description, author, license, tags, source catalog, repository (when available), and whether it is currently active. Must be run inside a Spec Kit project.
`--versions` lists the advertised current and historical versions from the
winning catalog, with the current release first and older releases in descending
version order. Discovery-only sources are labeled as such.

## Install an Integration

Expand All @@ -118,6 +122,12 @@ specify integration install <key>

Installs the specified integration into the current project. If another integration is already installed, the command only proceeds automatically when all involved integrations are declared multi-install safe. Otherwise, use `switch` to replace the default integration or pass `--force` to explicitly opt in to multi-install. If the installation fails partway through, it automatically rolls back to a clean state.

**Catalog history is metadata only.** `integration install` still resolves
registered built-in implementations, not historical catalog records. There is
no `integration install --version` or catalog-based integration distribution
contract, even when a catalog source is marked install-allowed. Community
catalogs remain discovery-only.

Installing an additional integration does not change the default integration. Use `specify integration use <key>` to change the default.

Installed extensions and presets are not registered for a non-default integration at install time — they follow the currently active (default) integration only. `specify integration use <key>` (or `switch <key>`) is what rescaffolds them for the newly active integration.
Expand Down Expand Up @@ -253,6 +263,56 @@ Catalogs are resolved in this order (first match wins):
3. **User config** — `~/.specify/integration-catalogs.yml`
4. **Built-in defaults** — official catalog + community catalog

### Historical Integration Metadata

An integration entry may retain the existing top-level fields for its advertised
current release and add a `releases` mapping of historical version to metadata.
Single-version entries remain valid without a `releases` key. For example:

```json
{
"schema_version": "1.0",
"integrations": {
"my-agent": {
"id": "my-agent",
"name": "My Agent",
"author": "example",
"version": "2.0.0",
"description": "Current release",
"repository": "https://example.com/my-agent/current",
"releases": {
"1.0.0": {
"description": "Historical release",
"repository": "https://example.com/my-agent/v1",
"requires": {"speckit_version": ">=0.7"}
}
}
}
}
}
```

The top-level fields retain their original meaning for older clients and for
unqualified `search`, `info`, `list --catalog`, and `install` commands. Historical
records may contain `name`, `description`, `author`, `repository`, `license`,
`tags` (a list of strings), and `requires` (a mapping). Shared identity, name,
author, and source policy are retained; current-only metadata such as
description, repository, tags, and requirements is **not** inherited by an
older release unless explicitly supplied in its record. Release records cannot
override `id`, `version`, `releases`, or source policy, and cannot advertise
download URLs or archives.

For programmatic metadata lookup,
`IntegrationCatalog.get_integration_info(id, version="1.0.0")` selects the
exact historical record from the winning source, or returns `None` if that
version is missing. `get_integration_versions(id)` returns the versions
advertised by that same source. PEP 440-equivalent spellings (such as `v1.0`
and `1.0.0`) match the same release while retaining its catalog spelling.
The current release must not be repeated in `releases`; malformed records and
duplicate equivalent versions are rejected. An absent version never falls
through to a lower-priority source or silently selects current. These lookups
do not install an integration.

## Integration-Specific Options

Some integrations accept additional options via `--integration-options`:
Expand Down
27 changes: 24 additions & 3 deletions src/specify_cli/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ def _catalog_shape_error(payload: Any) -> Optional[str]:
return "missing required 'schema_version' or 'integrations' key"
if not isinstance(payload.get("integrations"), dict):
return "'integrations' must be a JSON object"
from ._catalog_versions import _validated_releases

for integration_id, entry in payload["integrations"].items():
if isinstance(entry, dict) and "releases" in entry:
if entry.get("id", integration_id) != integration_id:
return f"Integration '{integration_id}' has an inconsistent id."
try:
_validated_releases({**entry, "id": integration_id})
except IntegrationCatalogError as exc:
return str(exc)
return None


Expand Down Expand Up @@ -464,14 +474,25 @@ def search(
return results

def get_integration_info(
self, integration_id: str
self, integration_id: str, version: str | None = None
) -> Optional[Dict[str, Any]]:
"""Return catalog metadata for a single integration, or None."""
"""Return current or exact-version metadata from the winning catalog."""
from ._catalog_versions import select_release

for item in self._get_merged_integrations():
if item["id"] == integration_id:
return item
return select_release(item, version)
return None

def get_integration_versions(self, integration_id: str) -> list[str]:
"""Return the current and historical versions from the winning catalog."""
from ._catalog_versions import available_versions

for item in self._get_merged_integrations():
if item["id"] == integration_id:
return available_versions(item)
return []

# -- Cache management -------------------------------------------------

def clear_cache(self) -> None:
Expand Down
149 changes: 149 additions & 0 deletions src/specify_cli/integrations/_catalog_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Validate and select display-only integration catalog release metadata."""

from __future__ import annotations

from typing import Any

from packaging.version import InvalidVersion, Version

from . import IntegrationCatalogError

_RELEASE_FIELDS = frozenset(
{
"name",
"description",
"author",
"repository",
"license",
"tags",
"requires",
}
)
_CURRENT_ONLY = frozenset(
{
"version",
"description",
"repository",
"license",
"tags",
"requires",
"provides",
"download_url",
"sha256",
"bundled",
"verified",
"releases",
}
)


def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]:
if "releases" not in entry:
return {}
integration_id = entry.get("id", "<unknown>")
releases = entry["releases"]
if not isinstance(releases, dict):
raise IntegrationCatalogError(
f"Integration '{integration_id}' has an invalid releases mapping."
)

current = entry.get("version")
if not isinstance(current, str) or not current.strip():
raise IntegrationCatalogError(
f"Integration '{integration_id}' has releases but no current version."
)
try:
seen = {Version(current)}
except InvalidVersion:
raise IntegrationCatalogError(
f"Integration '{integration_id}' has an invalid current version '{current}'."
) from None

for advertised, metadata in releases.items():
if not isinstance(advertised, str) or not advertised.strip():
raise IntegrationCatalogError(
f"Integration '{integration_id}' has an invalid release version key."
)
try:
normalized = Version(advertised)
except InvalidVersion:
raise IntegrationCatalogError(
f"Integration '{integration_id}' has invalid release version '{advertised}'."
) from None
if normalized in seen:
raise IntegrationCatalogError(
f"Integration '{integration_id}' repeats release version '{advertised}'."
)
seen.add(normalized)
if not isinstance(metadata, dict):
raise IntegrationCatalogError(
f"Integration '{integration_id}' release '{advertised}' must be an object."
)
for field, value in metadata.items():
if field not in _RELEASE_FIELDS:
label = (
"reserved"
if field
in {
"id",
"version",
"releases",
"_catalog_name",
"_install_allowed",
}
else "unsupported"
)
raise IntegrationCatalogError(
f"Integration '{integration_id}' release '{advertised}' has {label} field '{field}'."
)
if field == "tags":
valid = isinstance(value, list) and all(
isinstance(tag, str) and tag.strip() for tag in value
)
elif field == "requires":
valid = isinstance(value, dict)
else:
valid = isinstance(value, str) and (
bool(value.strip()) or field == "description"
)
if not valid:
raise IntegrationCatalogError(
f"Integration '{integration_id}' release '{advertised}' has invalid {field}."
)
return releases


def select_release(entry: dict[str, Any], version: str | None) -> dict[str, Any] | None:
"""Select from the winning source without inheriting current-only metadata."""
releases = _validated_releases(entry)
current = entry.get("version")
if version is None or (
isinstance(current, str) and current.strip() and version == current
):
return entry
try:
requested = Version(version)
except (InvalidVersion, TypeError):
return None
if isinstance(current, str):
try:
if requested == Version(current):
return entry
except InvalidVersion:
pass # Legacy single-release catalogs may use non-PEP 440 versions.
for advertised, metadata in releases.items():
if requested == Version(advertised):
shared = {
key: value for key, value in entry.items() if key not in _CURRENT_ONLY
}
return {**shared, **metadata, "version": advertised}
return None


def available_versions(entry: dict[str, Any]) -> list[str]:
"""Return current first, then older release spellings in descending order."""
releases = _validated_releases(entry)
current = entry.get("version")
if not isinstance(current, str) or not current:
return []
return [current, *sorted(releases, key=Version, reverse=True)]
31 changes: 31 additions & 0 deletions src/specify_cli/integrations/command_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
@integration_app.command("info")
def integration_info(
integration_id: str = typer.Argument(..., help="Integration ID"),
versions: bool = typer.Option(False, "--versions", help="List catalog versions"),
):
"""Show catalog details for a single integration."""
from . import (
Expand All @@ -41,6 +42,36 @@ def integration_info(
else:
catalog_error = None

if versions is True:
if info:
from ._catalog_versions import available_versions

try:
advertised = available_versions(info)
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {_rich_escape(str(exc))}")
raise typer.Exit(1) from exc
if not advertised:
console.print(
f"[red]Error:[/red] No catalog versions advertised for {safe_integration_id}."
)
raise typer.Exit(1)
console.print(f"Catalog versions for {safe_integration_id}:")
for index, advertised_version in enumerate(advertised):
label = " (current)" if index == 0 else ""
console.print(f" {_rich_escape(advertised_version)}{label}")
if not info.get("_install_allowed", True):
console.print("[yellow]Discovery only; catalog installation is disabled.[/yellow]")
console.print(
"[dim]Historical versions are metadata only; integration install supports registered built-in implementations, not catalog releases.[/dim]"
)
return
if catalog_error:
console.print(f"[red]Error:[/red] Could not query integration catalog: {_rich_escape(str(catalog_error))}")
else:
console.print(f"[red]Error:[/red] No catalog versions found for {safe_integration_id}.")
raise typer.Exit(1)

if info:
name = _rich_escape(str(info.get("name", integration_id)))
version = _rich_escape(str(info.get("version", "?")))
Expand Down
Loading
Loading