Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2eee767
feat(bundles): allow bundles to reference other bundles
Oct 23, 2026
2dd334e
fix(bundles): resolve reference review feedback
Oct 23, 2026
9d538b7
fix(bundles): keep catalog lookup for bundle info IDs
Oct 23, 2026
5d7501e
fix(bundles): deduplicate override records across shared routes
Oct 23, 2026
bba1038
fix(bundles): preserve flat-bundle extension priority rule
Oct 23, 2026
7462c92
fix(bundles): normalize omitted extension priority before comparison
Oct 23, 2026
5570973
fix(bundles): share effective-setting rules with bundle info
Oct 23, 2026
f52f54f
fix(bundles): expose trust status of referenced bundles in bundle info
Oct 23, 2026
608ea3e
docs(bundles): clarify extension priority comparison rules
Oct 23, 2026
4d6084c
fix(bundles): treat blank reference source and version as unset
Oct 23, 2026
51ccfc3
fix(bundles): keep not-found details for offline reference lookups
Oct 23, 2026
bed402c
fix(bundles): label equivalent override history as historical
Oct 23, 2026
d0f19c0
test(bundles): patch both _build_stack namespaces in flat install test
Oct 23, 2026
5f84ffb
docs(bundles): state that presets must declare an integer priority
Oct 23, 2026
757e98d
docs(bundles): clarify component JSON source, origin and overrides
Oct 23, 2026
13ce944
fix(bundles): keep every declaring bundle when merging agreeing copies
Oct 23, 2026
aa414a1
fix(bundles): recommend online retry for missing offline builtin rele…
Oct 23, 2026
bb19398
fix(bundles): keep agreeing declarations behind a bundle's own entry
Oct 24, 2026
7ac2f32
fix(bundles): accept equivalent component spellings when reinstalling
Oct 24, 2026
1c608de
test(bundles): pin bundle info override formatting to component_diffe…
Oct 24, 2026
f1f6edd
fix(bundles): name the origin chain in inherited validate errors
Oct 24, 2026
c90900a
docs(bundles): qualify validate warnings and restate override provenance
Oct 24, 2026
319f507
refactor(bundles): build prefixed entries through _entry_for
Oct 24, 2026
99a1e39
test(bundles): assert cached subplan identity and multi-key ordering
Oct 24, 2026
95c4a9e
fix(bundles): tell authors how to resolve conflicting bundle pins
Oct 24, 2026
2480da6
perf(bundles): index planned components in the reinstall guard
Oct 24, 2026
647b733
fix(bundles): show one v prefix for v-pinned reference labels
Oct 24, 2026
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
96 changes: 92 additions & 4 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ specify bundle info <bundle_id> --versions
| `--json` | Emit machine-readable JSON |
| `--versions` | List the current and historical catalog releases |

Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it.
Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. For bundles with references, text output includes a `References` summary, with each referenced bundle's own trust level (`verified` or `community`), and marks referenced components with their shortest origin chain; overridden components show the overridden version and origin. JSON adds `references` entries with `id`, `version`, resolved `source`, `install_policy`, `via`, and the referenced bundle's own `verified` and `trust`; for bundles with references, component entries also include `source` when the manifest declares one (flat bundles omit it), `origin` (the chain of bundles that contributed the component) when it comes from a referenced bundle, and `overrides` whenever the entry replaced other declarations, including a parent's own declaration, which has no `origin`. Each `overrides` item carries the replaced `version` and `origin`, plus any `source`, `priority`, or `strategy`; an override entry names every distinct bundle that declared the replaced value (keeping the shortest route to each), not only the first route. Discovery-only references are marked `discovery-only, not installable`. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it.

Use `specify bundle info <bundle_id> --versions` to inspect the catalog history without downloading a bundle manifest. With `--json`, it emits the bundle ID, available versions, current version, source, and install policy.

Expand All @@ -72,7 +72,7 @@ specify bundle install <bundle_id | path>
| `--refresh` | Refresh owned components from the supplied bundle source |
| `--version` | Install an exact historical or current catalog release |

Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack.
Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack, unless the manifest declares `provides.bundles`. A local parent with bundle references resolves those children through the catalog stack at command time, so it needs catalog and network access (see [Bundle references](#bundle-references)).

If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped, as are bundle-owned compatible packaged patch releases with a warning (see below). Other differing or missing installed versions cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain.

Expand Down Expand Up @@ -105,6 +105,94 @@ store actual known installed versions under `installed_versions`, keyed by
`specify bundle list --json`. Component version ranges, `latest`, and omitted
extension versions are not supported.

## Bundle References

A bundle can compose other bundles with exact, pinned references in
`provides.bundles`. A non-empty reference list requires manifest schema `1.1`;
empty or null lists are treated as no references. Each reference has a bundle
ID, an exact SemVer `version`, and an optional `source` naming the expected
winning bundle catalog (not a URL). Reference IDs are safe slugs, must be
unique within the manifest, and cannot name the parent bundle:

```yaml
schema_version: "1.1"
bundle:
id: team
name: Team setup
version: 1.0.0
role: developer
description: A team bundle
author: Example team
license: MIT
requires:
speckit_version: ">=0.1.0"
provides:
bundles:
- id: bugfix
version: 1.0.1
source: default
- id: qa-pack
version: 2.0.0
```

References resolve at command time through the effective bundle catalog stack.
The stack's winning entry is selected for each exact pin; an explicit `source`
is an assertion that this is the winning catalog, not a request to change
precedence; a mismatch is an error and does not fall through to another
catalog. A referenced bundle from a discovery-only source can be inspected
by `info`, but `install`, `update`, and online `validate` reject it as
non-installable. Every bundle ID may have only one version in an expansion;
cycles and a second version for the same ID are errors. Resolve a second
version by choosing releases of the referencing bundles that agree on it.

The expansion is one flat component plan. Referenced bundles are walked
recursively in declaration order (depth-first), followed by the declaring
bundle's own components, while preserving the existing extension, preset, step,
and workflow grouping. A component is installed at most once per `kind:id`.
Copies agree when their version and `source` match (omitted and set `source`
values differ); extension and preset copies must also have the same `priority`,
and preset copies must also have the same `strategy`. Extensions may omit
`priority`; an omitted value means the default, `10`, so it agrees with an
explicit `10`. Presets must declare an integer `priority`.
Other differences between sibling references are conflicts that must be fixed
in the parent. At every level, a bundle's own declaration wins over its
references, takes the declaration's position, and emits a warning describing
the override and its origin chain.

Expansion allows at most 10 levels of reference nesting and 50 distinct
referenced bundle IDs, excluding the root. A cycle is rejected with its
reference chain. At install and update time, the Spec Kit version requirement
is checked for every bundle in the graph, and an incompatible requirement is
a hard error, while the root bundle remains the only integration gate: a
referenced bundle's differing or unknown integration is a warning.
`requires.tools` and `requires.mcp` are unioned across the graph
and rendered through the existing tool and MCP warnings.

The installed parent record contains the expanded component set and an
optional `bundle_references` JSON key listing each direct and transitive
reference once with its resolved catalog source. There are no child-bundle
records. `bundle update` follows the parent's current manifest and its pinned
references; remove and refresh retain the existing ownership and component
lifecycle rules.

Referenced manifests require network access. For a local root manifest,
`install` can identify the first reference and fail offline with that reference
named. A catalog-root `install`, `info`, or `update` can instead fail earlier
while fetching its catalog or root manifest, before any reference can be known.
`validate --offline` warns once per declared reference that it was not expanded.
`bundle build` performs only the structural checks for references and does not
resolve or embed them.

**Known limitations.**

- Offline and local resolution of referenced bundles is not supported; referenced
manifests need network-backed catalog downloads.
- First-party bundle catalogs keep no release history, so an exact pin to a
first-party bundle can break when that bundle publishes its next release.
- A local bundle with references cannot be previewed with `bundle info`; local
preview is deferred.
- Broader schema versioning and CLI compatibility checks/ranges are deferred.

> **Step payloads resolve through the step catalog only.** A bundle's `provides.steps` entries still resolve exclusively through the active step catalogs. Bundle-local `steps/<id>/` payloads and relative `provides.steps[].source` overrides are **not** resolved in this release, so a step declared that way cannot be installed offline. To ship a step with a bundle today, publish it to a step catalog the bundle's users can reach.

## Catalog Release History
Expand Down Expand Up @@ -176,7 +264,7 @@ specify bundle list
| -------- | ---------------------------- |
| `--json` | Emit machine-readable JSON |

Lists the bundles installed in the project with their versions, required component counts (including components supplied independently), and install timestamps.
Lists the bundles installed in the project with their versions, required component counts (including components supplied independently), and install timestamps. With `--json`, a reference-bearing record also includes `bundle_references`, containing each resolved direct or transitive reference and its winning catalog source.

## Initialize a Project with a Bundle

Expand Down Expand Up @@ -204,7 +292,7 @@ specify bundle validate
| `--path` | Bundle directory or `bundle.yml` (default: current directory) |
| `--offline` | Verify references against bundled/installed components only |

Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins.
Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. For a reference-bearing manifest, online validation expands the complete reference graph and checks every component in the expanded plan, including structural, cycle, version, source, conflict, and install-policy checks. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; component references that cannot be checked offline or because a catalog is unreachable produce warnings. Bundle-reference fetch failures (such as an unreachable bundle catalog or a failed child-manifest download) fail online validation, while `--offline` keeps the per-reference warning. In `--offline` mode, each declared bundle reference produces a warning that it was not expanded. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. Validation does not evaluate `requires.speckit_version` against the running CLI; `install` and `update` enforce that runtime compatibility gate. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins.

## Build a Bundle Artifact

Expand Down
36 changes: 35 additions & 1 deletion src/specify_cli/bundles/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,40 @@ def _resolve_init_integration(override: str | None, manifest) -> str:
return resolve_default_init_integration()


def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]:
def _expand_manifest(
project_root: Path,
manifest,
*,
offline: bool,
stack=None,
download,
):
"""Expand referenced bundles through the effective catalog stack.

Flat manifests deliberately bypass stack construction: local bundles with
no references must retain their existing offline, no-network behavior.
``download`` is supplied by the calling command so its established
monkeypatch point also controls referenced-manifest downloads.
"""
from .expansion import ExpandedManifest, expand_manifest

if not manifest.bundles:
return ExpandedManifest.flat(manifest)
from .sources import CatalogBundleLoader

if stack is None:
stack = _build_stack(project_root, offline=offline)
loader = CatalogBundleLoader(stack, offline=offline, download=download)
return expand_manifest(manifest, loader)


def _bundle_overlaps(
project_root: Path,
manifest,
*,
offline: bool,
components=None,
) -> list[str]:
"""Return informational overlaps between *manifest* and installed bundles."""
if manifest is None:
return []
Expand All @@ -150,6 +183,7 @@ def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str
manifest,
active_integration(project_root),
load_records(project_root),
components=components,
)
return [*report.overlaps, *report.version_clashes]
except BundlerError:
Expand Down
2 changes: 1 addition & 1 deletion src/specify_cli/bundles/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def fetch(source: CatalogSource) -> dict:
) from None
scheme = parsed.scheme.lower()

if scheme == "builtin":
if source.is_builtin:
repository_url = _BUILTIN_REPOSITORY_URLS.get(url)
if repository_url is None:
raise BundlerError(f"Unknown built-in catalog '{url}'.")
Expand Down
43 changes: 35 additions & 8 deletions src/specify_cli/bundles/catalog_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@
CatalogFetcher = Callable[[CatalogSource], dict]


class BundleNotFoundError(BundlerError):
"""A bundle ID is absent from every configured catalog."""

def __init__(self, bundle_id: str) -> None:
super().__init__(f"Bundle '{bundle_id}' was not found in any configured catalog.")


class ReleaseNotFoundError(BundlerError):
"""A requested release is absent from the winning catalog entry."""

def __init__(
self,
bundle_id: str,
version: str,
source_id: str,
advertised: tuple[str, ...],
) -> None:
self.bundle_id = bundle_id
self.version = version
self.source_id = source_id
self.advertised = advertised
offered = ", ".join(advertised)
super().__init__(
f"Bundle '{bundle_id}' has no release {version} in catalog source "
f"'{source_id}' (advertised: {offered}). Only the "
"highest-precedence source that lists a bundle is consulted."
)


@dataclass
class ResolvedBundle:
entry: CatalogEntry
Expand Down Expand Up @@ -80,19 +109,17 @@ def resolve(self, bundle_id: str, version: str | None = None) -> ResolvedBundle:
if entry is not None:
selected = select_release(entry, version)
if selected is None:
advertised = ", ".join(available_versions(entry))
raise BundlerError(
f"Bundle '{bundle_id}' has no release {version} in catalog source "
f"'{source.id}' (advertised: {advertised}). Only the "
"highest-precedence source that lists a bundle is consulted."
raise ReleaseNotFoundError(
bundle_id=bundle_id,
version=version,
source_id=source.id,
advertised=tuple(available_versions(entry)),
)
return ResolvedBundle(
entry=selected.with_provenance(source),
source=source,
)
raise BundlerError(
f"Bundle '{bundle_id}' was not found in any configured catalog."
)
raise BundleNotFoundError(bundle_id)

def search(self, query: str = "") -> list[ResolvedBundle]:
"""Return entries matching *query* (substring over id/name/role/tags/description).
Expand Down
10 changes: 9 additions & 1 deletion src/specify_cli/bundles/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from enum import Enum
from pathlib import Path
from typing import Any

from urllib.parse import urlparse
from . import BundlerError
from .manifest import _text
from .yamlio import ensure_within, load_yaml
Expand Down Expand Up @@ -67,6 +67,14 @@ class CatalogSource:
def install_allowed(self) -> bool:
return self.install_policy is InstallPolicy.INSTALL_ALLOWED

@property
def is_builtin(self) -> bool:
"""Whether this source is a ``builtin://`` catalog (packaged snapshot offline)."""
try:
return urlparse(self.url).scheme.lower() == "builtin"
except ValueError:
return False

@classmethod
def from_dict(cls, data: Any, scope: Scope) -> "CatalogSource":
if not isinstance(data, dict):
Expand Down
Loading