Skip to content

feat(presets): preset stacks — named, reusable preset lineups - #4102

Draft
ira-at-work wants to merge 6 commits into
github:mainfrom
ira-at-work:feat/preset-stacks
Draft

feat(presets): preset stacks — named, reusable preset lineups#4102
ira-at-work wants to merge 6 commits into
github:mainfrom
ira-at-work:feat/preset-stacks

Conversation

@ira-at-work

@ira-at-work ira-at-work commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds .specify/preset-stacks.yml support: named, ordered lists of preset entries (id/priority/optional source), equivalent to a saved sequence of specify preset add calls.
  • A stack named default is applied automatically at specify init (no flags needed); specify init --preset-stack <name>|none selects a specific stack or opts out. --preset and --preset-stack are mutually exclusive.
  • Adds specify preset stack list/add/remove (alongside the existing install), mirroring preset catalog add/remove's exact edit pattern — add/remove only ever touch the config file, never install/uninstall anything.
  • Reapplying a stack diffs against its previous state and uninstalls dropped entries, unless another currently-applied stack still lists them. A run with any failing entry defers the uninstall half until the stack applies cleanly, so a transient failure can never remove a working preset.
  • A stack entry with no explicit source bypasses the discovery-only install_allowed gate when resolved through the catalog, since listing a preset in your own stack is itself the trust decision.

Scope

Everything new lives under presets/ and src/specify_cli/presets/. Two files outside that tree are touched, both required by --preset-stack itself and nothing else:

  • src/specify_cli/commands/init.py (+40): the --preset-stack option and its call into presets.stacks. Stack selection and output rendering live in presets/stacks.py (select_stack(), render_apply_result()), shared with specify preset stack install.
  • src/specify_cli/commands/bundle/__init__.py (+1): _run_init() invokes init's Typer callback with a fully-enumerated kwarg list, bypassing Click's option-default resolution, so every new init parameter has to be listed there too. Without the line, preset_stack keeps the raw typer.Option sentinel and bundle-driven bootstrap fails on a string comparison.

Test plan

  • pytest tests/test_preset_stacks.py tests/test_presets.py — all green (641 passed)
  • Full suite (pytest) — 6682 passed, 4 pre-existing/unrelated failures (branch-naming short-word retention, confirmed via git stash baseline diff, not caused by this change)
  • Manually walked all 8 steps of specs/001-preset-stacks/quickstart.md against a real scratch project with the built CLI
  • Updated presets/README.md and presets/ARCHITECTURE.md

🤖 Generated with Claude Code

Introduces named, reusable "preset stacks" defined in
.specify/preset-stacks.yml: PresetStackEntry/PresetStack/PresetStacksConfig
dataclasses, load_stacks_config() validation, and apply_stack() which drives
installs through the existing PresetManager.install_from_directory/
install_from_zip/remove primitives only — no new install/uninstall logic.

apply_stack() diffs against .stack-state.json on reapply so entries dropped
from a stack are uninstalled, unless another applied stack still lists them.

download_pack() gains bypass_install_allowed (default False) so a stack
entry resolved through the catalog can skip the install_allowed gate:
listing a preset in one's own stack is itself the trust decision (FR-2025).
Adds the specify preset stack list/add/remove CLI verbs, mirroring
preset_catalog_add/remove's exact YAML-dict-edit pattern: list shows every
stack defined in .specify/preset-stacks.yml; add/remove only edit that
config file (never install or uninstall anything).

(The install verb and its wiring into stacks.py were added in the prior
commit.)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds reusable preset stacks with configuration, CLI management, automatic init application, synchronization, and documentation.

Changes:

  • Implements stack parsing, validation, installation, and state synchronization.
  • Adds stack CLI commands and init --preset-stack.
  • Adds comprehensive tests and preset documentation.
Show a summary per file
File Description
tests/test_preset_stacks.py Tests stack configuration, application, synchronization, and CLI behavior.
src/specify_cli/presets/stacks.py Implements preset stack models and application logic.
src/specify_cli/presets/_commands.py Adds stack list, install, add, and remove commands.
src/specify_cli/presets/__init__.py Supports trusted stack-driven catalog downloads.
src/specify_cli/commands/init.py Adds stack selection and implicit default application.
src/specify_cli/commands/bundle/__init__.py Updates the in-process init invocation.
presets/README.md Documents preset stack usage.
presets/ARCHITECTURE.md Documents stack architecture and control flow.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 8/8 changed files
  • Comments generated: 6
  • Review effort level: Balanced

Comment thread src/specify_cli/presets/stacks.py Outdated
Comment thread presets/README.md Outdated
Comment thread presets/ARCHITECTURE.md Outdated
Comment thread presets/ARCHITECTURE.md Outdated
Comment thread presets/ARCHITECTURE.md Outdated
Comment thread src/specify_cli/presets/stacks.py Outdated
@mnriem

mnriem commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback and limit the changes to presets as indicated by the PR title and description

ira-at-work and others added 4 commits August 14, 2026 12:31
Applies a named stack (or the implicit "default" stack, if defined and no
flag is given) automatically at init time via apply_stack(). --preset-stack
none skips stack resolution entirely; --preset and --preset-stack are
mutually exclusive.

bundle/_run_init() invokes init's raw Typer callback with a fully-enumerated
kwarg list, bypassing Click's option-default resolution, so the new
preset_stack parameter has to be passed through there as well; without it the
parameter keeps the raw typer.Option sentinel and bundle-driven bootstrap
fails on a string comparison.
Adds a Preset Stacks section to README.md (config format, CLI verbs,
--preset-stack) and ARCHITECTURE.md (apply_stack() flow diagram, module
cross-references), and corrects ARCHITECTURE.md's Module Structure listing
to reflect the real presets/ package layout.
Addresses Copilot review feedback on the stack sync logic:

- Stack membership now follows stack.entries, not a run's install
  outcomes. A transient failure previously dropped the entry from
  current_ids, so the diff treated a still-listed preset as removed and
  uninstalled a working installation.
- Successful entries are recorded under the ID their manifest actually
  declares. PresetManager keys the registry off the manifest, so a source
  shipping a different ID left the requested ID in stack state and the
  real one orphaned on removal.
- A run with any failing entry now defers uninstalls (deferred_removals)
  instead of guessing: a failed entry yields no manifest ID, so a
  previously tracked ID that differs from the requested one cannot be
  attributed to it, and removal is destructive.

Also moves stack selection (select_stack) and result rendering
(render_apply_result) into stacks.py, so `specify init` and
`specify preset stack install` share them and the init.py diff shrinks
from 83 to 40 lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses Copilot review feedback:

- Only `none` is reserved; `default` is an ordinary, definable stack name
  that init picks when no --preset-stack is given.
- apply_stack() installs entries in listed order; `priority` is the
  resolver precedence recorded on the install, not an install order.
- The non-zero exit claim only holds for `specify preset stack install`;
  `specify init` treats stack application as best-effort, like --preset.
- Documents membership/ID tracking and deferred removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ira-at-work

Copy link
Copy Markdown
Contributor Author

Thanks — Copilot's six comments are all addressed, and I've clarified the scope question in the PR description.

Copilot feedback

Two of them were real defects in the sync logic, now fixed in presets/stacks.py with regression tests:

  • Failed entry treated as dropped — stack membership now comes from stack.entries, with StackEntryResult recording the attempt's outcome separately, so a transient failure can no longer make a still-listed preset look dropped and uninstall a working installation.
  • Installed manifest ID never checked — a successful entry is now tracked under the ID its manifest actually declares (what PresetManager installs and removes under) and reported as Preset 'x' installed (as 'y') when the two differ, so dropping the entry later removes the right preset instead of orphaning it.
  • Following from those two: a run with any failing entry now defers uninstalls entirely (deferred_removals, surfaced in the CLI output) rather than guessing at attribution, since a failed entry yields no manifest ID and removal is destructive. The next clean apply performs them.

The other four were documentation inaccuracies, all corrected:

  • Only none is reserved; default is an ordinary definable stack name that init picks when no --preset-stack is given.
  • apply_stack() installs in listed order; priority is the resolver precedence recorded on the install, not an install order.
  • The non-zero-exit claim now applies only to specify preset stack install. specify init treats stack application as best-effort — failures warn and init still exits zero, matching how a failing --preset already behaves there. Documented rather than changed, so init keeps one consistent contract for optional preset installs.

Scope

Everything new is under presets/ and src/specify_cli/presets/. Two files outside that tree are touched, both required by --preset-stack itself:

  • commands/init.py — the flag and its call into the presets package. I moved stack selection and result rendering into presets/stacks.py (select_stack(), render_apply_result()), shared with specify preset stack install; that cut this diff from +83 to +40 lines.
  • commands/bundle/__init__.py (one line) — _run_init() invokes init's Typer callback with a fully-enumerated kwarg list, bypassing Click's option-default resolution, so every new init parameter has to be listed there. Without it, preset_stack keeps the raw typer.Option sentinel and bundle-driven bootstrap fails on a string comparison. It was a separate commit before, which made it read as an unrelated fix; it's now folded into the --preset-stack commit where it belongs.

Happy to split the init wiring into a follow-up PR if you'd rather land the engine and CLI verbs on their own — just say the word.

Full suite: 6682 passed, with the same 4 pre-existing branch-naming failures present on main.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (5)

src/specify_cli/presets/stacks.py:486

  • PresetManager.remove() returns False when a tracked ID is already absent, but this still records and renders it as successfully removed. Only include IDs for which removal actually occurred so StackApplyResult.removed and the CLI output remain accurate.
            manager.remove(pid)
            removed.append(pid)

src/specify_cli/presets/stacks.py:143

  • Priorities below 1 pass this validator even though PresetManager.install_from_directory rejects them. A manually authored stack therefore loads as valid and only fails during application; reject non-positive values here so the config and installer enforce the same contract.

This issue also appears on line 485 of the same file.

                priority = int(raw_priority)

src/specify_cli/presets/_commands.py:924

  • stack add --priority 0 currently reports success and persists an entry that PresetManager can never install. Constrain this CLI option to the installer's minimum so the command cannot create an unusable stack.
    priority: int = typer.Option(10, "--priority", help="Install priority (lower = higher priority)"),

src/specify_cli/presets/stacks.py:455

  • A local source can pass manifest validation and then raise OSError while being copied or registered. Since only PresetError is collected, that filesystem failure aborts the entire apply, prevents later entries from running, and skips state persistence instead of producing the promised per-entry failure result.
        except PresetError as e:
            any_failed = True
            entries.append(
                StackEntryResult(
                    preset=entry.preset,
                    success=False,
                    error=f"stack '{stack.name}', preset '{entry.preset}': {e}",
                )
            )
            member_ids.append(entry.preset)

src/specify_cli/presets/init.py:4845

  • The stack test replaces download_pack with a fake, so this new trust-gate branch is never exercised in production code. Add a direct PresetCatalog.download_pack test proving a discovery-only entry is still rejected by default and proceeds only when bypass_install_allowed=True.
        if not bypass_install_allowed and not pack_info.get("_install_allowed", True):
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mnriem

mnriem commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Apologies, @ira-at-work — I should have raised this earlier in the review rather than after you'd built the whole thing out.

Digging into this, I think specify bundle install may already cover the use case here, and I'd like you to evaluate it before we take on a parallel presets-only subsystem:

  • It scaffolds an empty project. Run in a directory with no .specify/, bundle install resolves the manifest, picks the integration (from --integration or the bundle manifest), runs the compatibility gates, then calls the real specify init before installing components. So "empty dir → scaffolded project + curated preset lineup" is one command today.
  • Bundles already carry ordered presets with priority, strategy, and source (ComponentRef), installed through the same preset primitives, with provenance records, refcounted removal, conflict detection, and bundle update refresh.
  • Dev/local install worksbundle install ./path accepts a bundle.yml, a bundle directory, or a built .zip, bypassing the catalog (the analog of preset add --dev).

Could you try modeling your target workflow as a bundle and running specify bundle install (including on a fresh dir and from a local path), and clarify whether it solves it for you? If it does, we can retire this in favor of the existing path. If it doesn't, please tell us specifically why not so we can figure out the right fix.

Posted by @mnriem via GitHub Copilot (model: Claude Opus 4.8).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants