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
78 changes: 71 additions & 7 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,29 @@ Searches all active catalogs for presets matching the query. Without a query, li

```bash
specify preset add [<preset_id>]
specify preset add <preset_id> --version <version>
```

| Option | Description |
| ---------------- | -------------------------------------------------------- |
| `--dev <path>` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--priority <N>` | Resolution priority (default: 10; lower = higher precedence) |

Installs a preset from the catalog, a URL, or a local directory. Preset commands are automatically registered with supported active AI coding agent integrations. The generic integration currently delivers extension invocations but does not register preset command or skill overrides.
| Option | Description |
| --------------------- | -------------------------------------------------------------------- |
| `--dev <path>` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--version <version>` | Select an exact release from the winning catalog (ID installs only) |
| `--priority <N>` | Resolution priority (default: 10; lower = higher precedence) |

Installs a preset from the catalog, a URL, or a local directory. Preset commands
are automatically registered with supported active AI coding agent integrations.
The generic integration currently delivers extension invocations but does not
register preset command or skill overrides.
`--version` cannot be combined with `--from` or `--dev`. Direct URL installs
remain independent of catalog lookup. Without `--version`, installation still
selects the advertised current release (or the locally bundled preset). A
requested release absent from the winning catalog is an error; lower-priority
catalogs cannot supply it. Discovery-only catalogs cannot install any release.
Version-specific catalog installs verify the selected archive's `preset.yml` ID
and version before modifying installed presets. Historical releases require a
SHA-256 digest, which is also verified on download; a legacy current release
may omit the digest.

> **Note:** All preset commands require a project already initialized with `specify init`.

Expand Down Expand Up @@ -105,9 +119,14 @@ Presets are printed in **resolution/precedence order**: the highest-precedence p

```bash
specify preset info <preset_id>
specify preset info <preset_id> --versions
```

Shows detailed information about an installed or available preset, including its templates, metadata, and tags.
`--versions` lists the advertised current version followed by historical
catalog versions, even for discovery-only entries; listing does not make
them installable. This view consults the catalog rather than the installed
preset.

## Resolve a File

Expand Down Expand Up @@ -182,6 +201,51 @@ Catalogs are resolved in this order (first match wins):
3. **User config** — `~/.specify/preset-catalogs.yml`
4. **Built-in defaults** — official catalog + community catalog

### Versioned catalog entries

Existing single-version entries remain valid: the top-level `version`,
`download_url`, optional `sha256`, and `requires` describe the advertised
current release. To retain older installable releases, add a `releases`
mapping keyed by version. Each historical record needs its own archive
`download_url` (HTTPS, or loopback HTTP for local development) and 64-digit
SHA-256 digest (optionally `sha256:`-prefixed, with surrounding whitespace);
other algorithm prefixes are rejected. Optional `requires` and `provides`
apply to that release instead of inheriting the current release's fields.
Other shared metadata, such as the name and description, is inherited.
Version keys must be distinct, including PEP 440-equivalent spellings, and
cannot repeat the current version.
Duplicate JSON keys are rejected before parsing can discard a release record.
Historical `requires.extensions` entries follow the preset manifest format:
extension IDs or mappings with an `id`, optional version constraint, and
optional boolean `required` flag.
An invalid catalog payload fails resolution rather than allowing an entry
from a lower-priority catalog to bypass its installation policy. Unreachable
catalogs can still be skipped so other configured sources remain available.

```json
{
"presets": {
"my-preset": {
"name": "My Preset",
"version": "2.0.0",
"download_url": "https://example.com/my-preset-2.0.0.zip",
"sha256": "<64 hex digits for the current archive>",
"releases": {
"1.5.0": {
"download_url": "https://example.com/my-preset-1.5.0.zip",
"sha256": "<64 hex digits for the older archive>",
"requires": {"speckit_version": ">=0.8.0"}
}
}
}
}
}
```

The bundled community catalog stays discovery-only and need not publish
release histories. Bundle pin resolution is a separate capability; adding
these preset records alone does not make bundle pins installable.

Example `.specify/preset-catalogs.yml`:

```yaml
Expand Down
121 changes: 99 additions & 22 deletions src/specify_cli/presets/_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,41 @@
build_safe_download_path,
detect_archive_format,
is_https_or_localhost_http,
is_safe_download_redirect,
)
from ._catalog_versions import available_versions, select_release
from ._manifest import PresetError, PresetValidationError


class PresetCatalogValidationError(PresetError):
"""A catalog supplied invalid content rather than being unreachable."""


def _decode_catalog_json(raw: str | bytes, url: str) -> Any:
"""Reject duplicate keys before JSON parsing discards conflicting records."""

def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise PresetCatalogValidationError(
f"Invalid preset catalog format from {url}: duplicate JSON key '{key}'."
)
Comment thread
Copilot marked this conversation as resolved.
result[key] = value
return result

try:
return json.loads(raw, object_pairs_hook=unique_object)
except json.JSONDecodeError as exc:
raise PresetCatalogValidationError(
f"Invalid preset catalog format from {url}: invalid JSON ({exc})"
) from exc
except UnicodeError as exc:
raise PresetCatalogValidationError(
f"Invalid preset catalog format from {url}: invalid encoding ({exc})"
) from exc


@dataclass
class PresetCatalogEntry:
"""Represents a single entry in the preset catalog stack."""
Expand Down Expand Up @@ -172,17 +203,17 @@ def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
PresetError: If the payload's shape is invalid.
"""
if not isinstance(catalog_data, dict):
raise PresetError(
raise PresetCatalogValidationError(
f"Invalid preset catalog format from {url}: "
"expected a JSON object"
)
if (
"schema_version" not in catalog_data
or "presets" not in catalog_data
):
raise PresetError(f"Invalid preset catalog format from {url}")
raise PresetCatalogValidationError(f"Invalid preset catalog format from {url}")
if not isinstance(catalog_data.get("presets"), dict):
raise PresetError(
raise PresetCatalogValidationError(
f"Invalid preset catalog format from {url}: "
"'presets' must be a JSON object"
)
Expand Down Expand Up @@ -424,7 +455,9 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool =
# refreshed.
if not force_refresh and self._is_url_cache_valid(entry.url):
try:
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
cached_data = _decode_catalog_json(
cache_file.read_text(encoding="utf-8"), entry.url
)
self._validate_catalog_payload(cached_data, entry.url)
return cached_data
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
Expand All @@ -451,13 +484,14 @@ def _validate_redirect(_old_url: str, new_url: str) -> None:
final_url = response.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(
catalog_data = _decode_catalog_json(
read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=PresetError,
label=f"preset catalog {entry.url}",
)
),
entry.url,
)

self._validate_catalog_payload(catalog_data, entry.url)
Expand Down Expand Up @@ -524,6 +558,8 @@ def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str,
continue
pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed}
merged[pack_id] = pack_data_with_catalog
except PresetCatalogValidationError:
raise
except PresetError:
continue

Expand Down Expand Up @@ -599,8 +635,8 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
self.cache_metadata_file.read_text(encoding="utf-8")
)
if metadata.get("catalog_url") == catalog_url:
cached_data = json.loads(
self.cache_file.read_text(encoding="utf-8")
cached_data = _decode_catalog_json(
self.cache_file.read_text(encoding="utf-8"), catalog_url
)
self._validate_catalog_payload(cached_data, catalog_url)
return cached_data
Expand All @@ -622,13 +658,14 @@ def _validate_redirect(_old_url: str, new_url: str) -> None:
final_url = response.geturl()
if final_url != catalog_url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(
catalog_data = _decode_catalog_json(
read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=PresetError,
label=f"preset catalog {catalog_url}",
)
),
catalog_url,
)

# Validate catalog structure. Reuses the same helper as
Expand Down Expand Up @@ -691,6 +728,8 @@ def search(
"""
try:
packs = self._get_merged_packs()
except PresetCatalogValidationError:
raise
except PresetError:
return []

Expand Down Expand Up @@ -735,8 +774,8 @@ def search(
return results

def get_pack_info(
self, pack_id: str
) -> Optional[Dict[str, Any]]:
self, pack_id: str, version: str | None = None
) -> dict[str, Any] | None:
"""Get detailed information about a specific preset.

Searches across all active catalogs (merged by priority).
Expand All @@ -749,20 +788,44 @@ def get_pack_info(
"""
try:
packs = self._get_merged_packs()
except PresetCatalogValidationError:
raise
except PresetError:
return None

if pack_id in packs:
return {**packs[pack_id], "id": pack_id}
pack = packs[pack_id]
if "releases" in pack and pack.get("id", pack_id) != pack_id:
raise PresetCatalogValidationError(
f"Preset '{pack_id}' has an inconsistent catalog ID."
)
try:
return select_release({**pack, "id": pack_id}, version)
except PresetError as exc:
raise PresetCatalogValidationError(str(exc)) from exc
return None

def get_pack_versions(self, pack_id: str) -> list[str]:
"""List the versions advertised by the winning catalog entry."""
pack = self.get_pack_info(pack_id)
return available_versions(pack) if pack is not None else []

def download_pack(
self, pack_id: str, target_dir: Optional[Path] = None
) -> Path:
"""Download a preset archive from a catalog.
"""Download the advertised current preset archive from a catalog."""
pack_info = self.get_pack_info(pack_id)
if pack_info is None:
raise PresetError(f"Preset '{pack_id}' not found in catalog")
return self.download_pack_info(pack_info, target_dir)

def download_pack_info(
self, pack_info: dict[str, Any], target_dir: Path | None = None
) -> Path:
"""Download an already-selected release without resolving its ID again.

Args:
pack_id: ID of the preset to download
pack_info: Metadata returned by get_pack_info
target_dir: Directory to save the archive

Returns:
Expand All @@ -775,11 +838,7 @@ def download_pack(

from . import read_response_limited, verify_archive_sha256

pack_info = self.get_pack_info(pack_id)
if not pack_info:
raise PresetError(
f"Preset '{pack_id}' not found in catalog"
)
pack_id = pack_info["id"]

# Bundled presets without a download URL must be installed locally
if pack_info.get("bundled") and not pack_info.get("download_url"):
Expand Down Expand Up @@ -857,17 +916,35 @@ def download_pack(

staging_path: Path | None = None
try:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
def _validate_redirect(old_url: str, new_url: str) -> None:
if not is_safe_download_redirect(old_url, new_url):
raise PresetError(
f"Preset download redirected to a disallowed URL: {new_url}"
)

with self._open_url(
download_url,
timeout=60,
extra_headers=extra_headers,
redirect_validator=_validate_redirect,
) as response:
archive_data = read_response_limited(
response,
error_type=PresetError,
label=f"preset '{pack_id}' download",
)
final_url = (
response_url = (
response.geturl()
if hasattr(response, "geturl")
else download_url
)
final_url = response_url if isinstance(response_url, str) else download_url
if not is_https_or_localhost_http(final_url) or not is_safe_download_redirect(
download_url, final_url
):
raise PresetError(
f"Preset download redirected to a disallowed URL: {final_url}"
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
Expand Down
Loading
Loading