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
4 changes: 2 additions & 2 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ catalogs:

Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers.

Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into the active integration's directory only, instead of being re-resolved by agents or written to every detected agent directory (#2948). During preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Install and rescaffold remain active-only, but removal may also update previously targeted inactive directories recorded by the removed preset to restore the surviving command or skill layer. A non-active installed integration does not otherwise receive these command files until it becomes the default — `specify integration use <key>` (or `switch <key>`) rescaffolds enabled presets for the newly active integration. Agents do not re-resolve the stack each time they run a command.
Templates are looked up from the stack when Spec Kit needs them. Commands and scripts use the same stack for replacement and composition, but are materialized to disk at preset-lifecycle time (install, remove, enable, disable, priority change) rather than re-resolved by agents or at script-invocation time. Commands are materialized into the active integration's directory only, instead of being written to every detected agent directory (#2948): during preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Install and rescaffold remain active-only, but removal may also update previously targeted inactive directories recorded by the removed preset to restore the surviving command or skill layer. A non-active installed integration does not otherwise receive these command files until it becomes the default — `specify integration use <key>` (or `switch <key>`) rescaffolds enabled presets for the newly active integration. Scripts are materialized into `.specify/scripts/bash/` as a chain of fixed-path generated launcher files, one per composing layer, each pointing `$CORE_SCRIPT` at the next-lower layer's fixed path (#4551). Agents do not re-resolve the stack each time they run a command or script.

By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.

Expand Down Expand Up @@ -271,7 +271,7 @@ Run `specify preset resolve <name>` to trace the resolution stack and see which

### What's the difference between disabling and removing a preset?

**Disabling** (`specify preset disable`) keeps the preset installed but excludes it from future template and script resolution. Previously registered commands remain available in your AI coding agent until preset removal, so use removal when you need command changes to stop taking effect. Disabling is useful for temporarily testing template/script behavior without a preset, or comparing template/script output with and without it. Re-enable anytime with `specify preset enable`.
**Disabling** (`specify preset disable`) keeps the preset installed but excludes it from future template resolution, and immediately re-materializes its scripts' launcher chains without it. Previously registered commands remain available in your AI coding agent until preset removal, so use removal when you need command changes to stop taking effect. Disabling is useful for temporarily testing template/script behavior without a preset, or comparing template/script output with and without it. Re-enable anytime with `specify preset enable`.

**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry.

Expand Down
19 changes: 19 additions & 0 deletions presets/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,25 @@ those operations may reconcile the live file, but only if its provenance hash pr
generated content. Missing files may be seeded when the preset is installed; authored or edited
constitutions are never overwritten.

### Script chain lifecycle

Unlike templates, scripts are executed rather than read, so composition must be fully resolved
before invocation (#4551). `PresetManager._reconcile_script_chain()` materializes the chain to disk:
each composing layer gets its own fixed-path generated launcher under `.specify/scripts/bash/`, the
topmost landing at the canonical `<name>.sh` agents actually invoke, each pointing `$CORE_SCRIPT` at
the next-lower layer's fixed path and ending in a materialized copy of the base layer at
`<name>.speckit-core.sh`. A single `replace`-strategy layer with no composition needs no intermediate
files at all.

Preset installation, removal, enablement, disablement, and priority changes all call this
materializer for every script name the affected preset declares, so a chain never goes stale — unlike
`constitution-template`, there is no opt-in gate here. The one case that does not self-materialize: a
project-local override added directly to `.specify/templates/overrides/scripts/` outside any
lifecycle command is not picked up automatically and needs an explicit
`PresetManager.reconcile_all_script_chains()` call, the same call a forced shared-infrastructure
refresh (`specify init --force`, a forced integration switch/upgrade) already makes to restore chains
it just overwrote.

## Command Registration

When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
Expand Down
20 changes: 20 additions & 0 deletions src/specify_cli/command_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,26 @@ def init(

ensure_executable_scripts(project_path, tracker=tracker)

# install_shared_infra above may have just overwritten
# .specify/scripts/bash/<name>.sh with the bundled core,
# clobbering any generated launcher chain for a script an
# already-enabled preset provides (e.g. on
# `specify init --force` against an existing project).
# Restore those chains before any *new* --preset install
# below runs its own reconciliation.
try:
from .presets import PresetManager as _ExistingPresetManager

_ExistingPresetManager(project_path).reconcile_all_script_chains()
Comment on lines +831 to +834
except Exception as exc:
_print_cli_warning(
"reconcile script presets after",
"init",
str(project_path),
exc,
continuing="Inspect .specify/scripts/bash/<name>.sh to diagnose.",
)

if preset:
try:
from .presets import PresetCatalog, PresetError, PresetManager
Expand Down
22 changes: 22 additions & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,28 @@ def _set_default_integration(
f"Failed to refresh shared infrastructure for '{key}': {exc}"
) from exc

# _install_shared_infra above may have just overwritten
# .specify/scripts/bash/<name>.sh with the bundled core when
# refresh_templates_force is True, clobbering any generated
# launcher chain for a script an already-enabled preset provides.
# Restore those chains now, mirroring the same call in
# command_init.py and command_upgrade.py.
if refresh_templates_force:
try:
Comment on lines +348 to +355
from ..presets import PresetManager as _ExistingPresetManager

_ExistingPresetManager(project_root).reconcile_all_script_chains()
except Exception as exc:
from .. import _print_cli_warning

_print_cli_warning(
"reconcile script presets after",
"integration use/switch",
str(project_root),
exc,
continuing="Inspect .specify/scripts/bash/<name>.sh to diagnose.",
)

_write_integration_json(project_root, key, installed_keys, settings)
_update_init_options_for_integration(
project_root, integration, script_type=resolved_script, parsed_options=parsed_options
Expand Down
22 changes: 22 additions & 0 deletions src/specify_cli/integrations/command_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,28 @@ def integration_switch(
from .. import ensure_executable_scripts
ensure_executable_scripts(project_root)

# The forced refresh above may have just overwritten
# .specify/scripts/bash/<name>.sh with the bundled core, clobbering any
# generated launcher chain for a script an already-enabled preset
# provides. Restore those chains now: this call happens before
# _set_default_integration below, whose own reconciliation
# (_helpers.py) is gated on refresh_templates_force and would not fire
# here since this phase's force comes from --refresh-shared-infra, not
# from that helper's own parameter.
if refresh_shared_infra:
try:
from ..presets import PresetManager as _ExistingPresetManager

_ExistingPresetManager(project_root).reconcile_all_script_chains()
except Exception as exc:
_print_cli_warning(
"reconcile script presets after",
"integration switch --refresh-shared-infra",
str(project_root),
exc,
continuing="Inspect .specify/scripts/bash/<name>.sh to diagnose.",
)

# Phase 2: Install target integration
console.print(f"Installing integration: [cyan]{target}[/cyan]")
manifest = IntegrationManifest(
Expand Down
21 changes: 21 additions & 0 deletions src/specify_cli/integrations/command_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,27 @@ def integration_upgrade(
from .. import ensure_executable_scripts
ensure_executable_scripts(project_root)

# _install_shared_infra_or_exit above may have just overwritten
# .specify/scripts/bash/<name>.sh with the bundled core when force=True,
# clobbering any generated launcher chain for a script an
# already-enabled preset provides. Restore those chains now,
# mirroring the same call in command_init.py.
if force:
try:
from ..presets import PresetManager as _ExistingPresetManager

_ExistingPresetManager(project_root).reconcile_all_script_chains()
Comment on lines +224 to +228
except Exception as exc:
from .. import _print_cli_warning

_print_cli_warning(
"reconcile script presets after",
"integration upgrade",
str(project_root),
exc,
continuing="Inspect .specify/scripts/bash/<name>.sh to diagnose.",
)

# Phase 1: Install new files (overwrites existing; old-only files remain)
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
Expand Down
Loading