Skip to content
Draft
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
67 changes: 61 additions & 6 deletions presets/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,57 @@ flowchart TD

Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag.

## Preset Stacks

`.specify/preset-stacks.yml` holds named, ordered lists of preset entries (`preset`, `priority`,
optional `source`). `load_stacks_config()` parses and validates the file (unique stack names,
`none` reserved, no duplicate `preset` within a stack); `apply_stack()` then drives the same
install path as `specify preset add` for each entry, in the order the entries are listed. An
entry's `priority` is passed through to the install so the resolver knows its precedence — it does
not reorder installs. `default` is an ordinary, definable stack name; it is only special in that
`select_stack()` picks it when `specify init` runs without `--preset-stack`.

```mermaid
flowchart TD
A["apply_stack(project_root, stack)"] --> B["load stack-state.json\n(prior applied stack, if any)"]
B --> C{"entry has explicit source?"}
C -- Yes --> D["install_from_directory / install_from_archive"]
C -- No --> E["catalog.download_pack(bypass_install_allowed=True)"]
E --> D
D --> F["record entry result (success/error)\nplus the installed manifest ID"]
F --> G{"more entries?"}
G -- Yes --> C
G -- No --> H["diff this stack's member IDs vs\nprior stack-state for this name"]
H --> K{"did any entry fail?"}
K -- Yes --> L["defer all uninstalls\n(keep prior IDs in state)"]
K -- No --> I["uninstall presets dropped from the stack,\nunless still listed by another applied stack"]
L --> J["write updated stack-state.json"]
I --> J
```

Membership in `stack-state.json` follows `stack.entries`, not the outcome of a given run: a
successful entry is tracked under the ID its manifest actually declares (which is what
`PresetManager` installs and removes under), and a failed entry stays a member so a transient
failure never makes a still-listed preset look dropped. Because a failed entry never yields a
manifest ID, a run with any failure also defers uninstalls entirely (`deferred_removals`) rather
than risk removing a working preset; the next clean apply performs them.

Per-entry failures are collected but never abort the run — `apply_stack()` returns a result with
one entry per attempted install, the removed preset IDs, and any deferred ones. `specify preset
stack install <name>` renders those lines and exits non-zero if any entry failed. `specify init`
renders the same lines but treats stack application as best-effort — failures print warnings and
init still exits zero, matching how `--preset` failures are handled there.

`specify init`'s implicit-default behavior and `specify preset stack install <name>` share this
same `apply_stack()` call — resolving which stack to apply (named, implicit `default`, or none) is
the only logic that differs between the two entry points.

- **Python**: `load_stacks_config()`, `select_stack()`, `apply_stack()`, `_resolve_entry_source()`
in `src/specify_cli/presets/stacks.py`
- **CLI**: `specify preset stack list/install/add/remove` in
`src/specify_cli/presets/_commands.py`; `--preset-stack` on `specify init` in
`src/specify_cli/commands/init.py`

## Repository Layout

```
Expand Down Expand Up @@ -178,10 +229,14 @@ presets/

```
src/specify_cli/
├── agents.py # CommandRegistrar — shared infrastructure for writing
│ # command files to agent directories
├── presets.py # PresetManifest, PresetRegistry, PresetManager,
│ # PresetCatalog, PresetCatalogEntry, PresetResolver
└── __init__.py # CLI commands: specify preset list/add/remove/search/
# resolve/info, specify preset catalog list/add/remove
├── agents.py # CommandRegistrar — shared infrastructure for writing
│ # command files to agent directories
└── presets/
├── __init__.py # PresetManifest, PresetRegistry, PresetManager,
│ # PresetCatalog, PresetCatalogEntry, PresetResolver
├── _commands.py # CLI commands: specify preset list/add/remove/search/
│ # resolve/info, specify preset catalog list/add/remove,
│ # specify preset stack list/install/add/remove
└── stacks.py # PresetStackEntry, PresetStack, PresetStacksConfig,
# load_stacks_config(), apply_stack()
```
61 changes: 61 additions & 0 deletions presets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,66 @@ specify preset catalog add https://example.com/catalog.json --name my-org --inst
specify preset catalog remove my-org
```

## Preset Stacks

A preset stack is a named, ordered list of `specify preset add` calls saved to
`.specify/preset-stacks.yml`, so a team can apply its whole preset lineup in one step instead of
running each `add` by hand:

```yaml
stacks:
- name: team-baseline
entries:
- preset: healthcare-compliance
priority: 10
- preset: enterprise-safe
priority: 5
- name: default
entries:
- preset: healthcare-compliance
priority: 10
```

Entries are installed in the order they are listed; `priority` is the resolution precedence the
preset is installed with (lower wins), not an install order.

A stack named `default` is applied automatically by `specify init` — no `--preset` or
`--preset-stack` flag needed. `default` is otherwise an ordinary stack you define like any other;
only `none` is reserved, since `--preset-stack none` means "apply no stack".

```bash
# List every defined stack
specify preset stack list

# Apply a stack on demand (also re-syncs: entries no longer in the stack are
# uninstalled, unless another applied stack still lists them)
specify preset stack install team-baseline

# Add or update one entry in a stack's definition (never installs anything)
specify preset stack add team-baseline --preset enterprise-safe --priority 5

# Remove a whole stack, or just one entry, from the definition (never uninstalls anything)
specify preset stack remove team-baseline --preset enterprise-safe
specify preset stack remove team-baseline

# Skip the implicit default stack at init time
specify init --preset-stack none

# Apply a specific named stack at init time instead of the default
specify init --preset-stack team-baseline
```

`--preset` and `--preset-stack` are mutually exclusive on `specify init`. A stack entry can pin an
explicit `source` (local directory or archive URL); without one, the preset is resolved through the
normal catalog lookup — and, unlike a bare `specify preset add`, bypasses `install_allowed` for
discovery-only catalogs, since listing a preset in a stack is itself the trust decision.

If an entry fails (unreachable source, unresolvable ID), the other entries still install and the
failure is reported per entry. `specify preset stack install` then exits non-zero; `specify init`
prints a warning and continues, the same way a failing `--preset` is handled. A failed run also
skips the uninstall half of the re-sync — presets dropped from the stack stay installed until the
stack applies cleanly, so a transient failure can never uninstall a working preset.

## Creating a Preset

See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset.
Expand Down Expand Up @@ -159,6 +219,7 @@ The token is attached automatically to requests targeting GitHub domains. Non-Gi
|------|-------|-------------|
| `.specify/preset-catalogs.yml` | Project | Custom catalog stack for this project |
| `~/.specify/preset-catalogs.yml` | User | Custom catalog stack for all projects |
| `.specify/preset-stacks.yml` | Project | Named, reusable preset stacks (see [Preset Stacks](#preset-stacks)) |

## Future Considerations

Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/commands/bundle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
github_token=None,
offline=offline,
preset=None,
preset_stack=None,
integration=integration,
integration_options=None,
extensions=None,
Expand Down
40 changes: 40 additions & 0 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,11 @@ def init(
"--preset",
help="Install a preset during initialization (by preset ID)",
),
preset_stack: str = typer.Option(
None,
"--preset-stack",
help="Apply a named preset stack from .specify/preset-stacks.yml during initialization ('none' to suppress the implicit 'default' stack)",
),
integration: str = typer.Option(
None,
"--integration",
Expand Down Expand Up @@ -381,6 +386,12 @@ def init(
console.print(f"[yellow]Available integrations:[/yellow] {available}")
raise typer.Exit(1)

if preset and preset_stack:
console.print(
"[red]Error:[/red] Cannot specify both --preset and --preset-stack"
)
raise typer.Exit(1)

if project_name == ".":
here = True
project_name = None
Expand Down Expand Up @@ -850,6 +861,35 @@ def init(
preset_err,
continuing="Continuing without the optional preset.",
)
else:
from ..presets import PresetError
from ..presets.stacks import apply_stack, render_apply_result, select_stack

try:
stack_to_apply = select_stack(project_path, preset_stack)
except PresetError as stacks_err:
console.print(f"[red]Error:[/red] {_escape_markup(str(stacks_err))}")
raise typer.Exit(1)

if stack_to_apply is not None:
try:
result = apply_stack(
project_path, stack_to_apply, get_speckit_version()
)
# Stack application is best-effort here, like --preset
# above: failures warn and init still succeeds. Use
# `specify preset stack install <name>` for a non-zero
# exit on failure.
for line in render_apply_result(result, failure_style="warning"):
console.print(line)
except Exception as stack_err:
_print_cli_warning(
"install",
"preset stack",
stack_to_apply.name,
stack_err,
continuing="Continuing without the full preset stack.",
)

# Install extensions specified via --extension
if extensions:
Expand Down
10 changes: 8 additions & 2 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4804,13 +4804,19 @@ def get_pack_info(
return None

def download_pack(
self, pack_id: str, target_dir: Optional[Path] = None
self,
pack_id: str,
target_dir: Optional[Path] = None,
bypass_install_allowed: bool = False,
) -> Path:
"""Download a preset archive from a catalog.

Args:
pack_id: ID of the preset to download
target_dir: Directory to save the archive
bypass_install_allowed: Skip the `install_allowed` gate. Used only by
stack-driven installs, where listing a preset in one's own stack is
itself the trust decision (FR-2025).

Returns:
Path to the downloaded archive
Expand All @@ -4836,7 +4842,7 @@ def download_pack(
f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}"
)

if not pack_info.get("_install_allowed", True):
if not bypass_install_allowed and not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
raise PresetError(
f"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. "
Expand Down
Loading