Skip to content
102 changes: 101 additions & 1 deletion docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ keep their exit code (normally 2), as with `list --json`.
specify preset resolve <name>
```

Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file.
Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file. When a layer was contributed by a `regex:` selector, the trace names the declaration that matched the requested name.

## Enable / Disable a Preset

Expand Down Expand Up @@ -291,6 +291,106 @@ Templates and scripts are looked up from the stack when Spec Kit needs them. Com

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.

### Regex Selectors

A `provides.templates` entry with `type: command` can match several commands with a `regex:` prefix on its `name`:

```yaml
provides:
templates:
- type: command
name: 'regex:^speckit\.(plan|tasks|implement)$'
file: commands/workflow-guidance.md
strategy: append

```

An entry without the prefix keeps exact-name matching and is unaffected:

```yaml
provides:
templates:
- type: command
name: speckit.plan
file: commands/plan-guidance.md
strategy: append
```

How selectors behave:

- `regex:` selectors are supported only for `command` declarations. Template and script declarations reject the prefix.
- A `name` beginning with `regex:` uses the bounded regular-expression syntax described below. The pattern is matched with **full-match** semantics against the logical command name (for example `speckit.plan`) — the same name you pass to `specify preset resolve` and see in `specify preset info`. It never matches file paths, directory names, or file extensions.
- A selector is eligible only against concrete resources provided by **lower layers** of the resolution stack: lower-priority presets, installed and enabled extensions, and Spec Kit core (including the bundled core pack in a wheel install). Project-local overrides and other `regex:` declarations are never matched, so selectors cannot chain off one another and cannot be satisfied by an override.
- Expansion preserves manifest declaration order, including overlapping exact and regex entries and declarations sharing the same file. Within each selector, concrete names are sorted.
- Every concrete resource a selector matches behaves exactly as though the preset contained a separate exact-name entry with the same `file` and `strategy`. One declaration therefore expands into zero, one, or several concrete resources.
- Matches are evaluated during resolution and existing registration points, including preset installation and active integration registration. Provider changes do not automatically refresh every previously generated match. Re-register affected presets with `specify integration use <key>` when their concrete providers change. Re-registration also retires tracked matches whose providers have disappeared.
- For `command` entries the matches are expanded to concrete command names **before** registration. Registration, composition, reconciliation, and cleanup all run against those concrete names; the literal `regex:...` string is never stored as a command name in the preset registry, an agent directory, or the `.composed` cache.
- Composition and priority are unchanged. `replace`, `prepend`, `append`, and `wrap` compose against the normal resolution stack, including when command declarations render as agent skills.

Zero-match and invalid patterns are handled as follows:

- A `regex:` selector that currently matches no lower-layer resource contributes nothing. Installing such a preset emits a warning unless an exact command declaration in the same preset satisfies the target name; it does **not** fail the installation.
- `specify preset info` lists each selector's current concrete matches nested beneath the declaration, or `No current matches` when there are none.
- `specify preset resolve <name>` attributes the layers it traces back to the declaration that matched, for example `sel-pack v1.0.0 → …/guidance.md (via regex:^speckit\.plan$)`, so a selector-driven resolution is visible in the stack trace and not only in `preset info`.

#### Regex selector safety boundaries

A `regex:` selector pattern is validated when a preset is installed, and again
whenever a selector is evaluated — including a selector found in a manifest that is
already on disk. The pattern, compiled-state and compiled-edge limits are enforced when the manifest
is validated, and a pattern past one of them is rejected with a
`PresetValidationError` that names the limit it broke. The resource-name limit applies
to the names a selector is matched against, not to the manifest: an oversized concrete
name is a warned non-match (`selector_matches` returns `False`) rather than an invalid
preset:

| Limit | Value |
| --- | --- |
| pattern length | 4096 characters after the `regex:` prefix |
| compiled states | 4096 |
| compiled edges | 2048 |
| resource name length | 1024 characters |

Matching never uses Python's backtracking engine. A selector is compiled to a state
machine whose work is bounded by `(resource name length + 1) × compiled edges`, so no
valid selector can make the CLI hang, however long or unlucky the resource name is.
The state and edge counts are capped separately because a branch reuses one entry
state for all of its alternatives: `(?:a|\B|\B|…){4000}` keeps the state count small
while producing millions of edges.

A selector found in a manifest that is already on disk — written by an older version,
or edited by hand — does not raise during resolution. It is reported with a warning
and treated as matching nothing, so an unsafe manifest cannot break unrelated
commands. Installation fails hard, and `specify preset info` revalidates an installed
manifest and exits with status 1, so both surfaces report the validation error rather
than silently using the preset.

The supported syntax is a subset of Python's regular expressions: literal characters
and escapes, `.`, character classes (`[a-z]`, `[^x]`), the predefined classes (`\d`,
`\w`, `\s` and their negations), grouping (`(...)`, `(?:...)`), alternation, the
anchors `^ $ \A \Z \b \B`, inline flags (`(?i)`, `(?i:...)`), and the quantifiers
`* + ? {m} {m,} {m,n}` including their lazy forms. The following are **refused**,
because their behaviour cannot be analysed from the parsed pattern alone or because
they are the shapes that make backtracking matchers hang:

- backreferences (`\1`) and named backreferences (`(?P=name)`);
- lookarounds (`(?=…)`, `(?!…)`, `(?<=…)`, `(?<!…)`);
- atomic groups (`(?>…)`) and possessive quantifiers (`a*+`);
- a repetition nested inside another repetition (`(a+)+`, `(a*)*`);
- a repetition whose body can match the empty string (`(a|)*`, `(?:)*`, `\B*`);
- an unbounded repetition over a zero-width alternation, such as `(a|a)*`, which the
parser factorises into `a` followed by a branch whose alternatives are all empty.

Two similar-looking cases are deliberately treated differently:

- `(ab|a)*` and `(ba|b)*c` are **accepted**. The parser factorises the shared prefix
out of the alternation, which leaves a branch with an empty alternative, but the
repeated body still has to consume at least `a`/`b` in every alternative. It cannot match
the empty string.
- `(a|a)*` is **refused**, because there the factorised branch has no non-empty
alternative at all. The selector safety rule rejects that unbounded form.
- An invalid regular expression is rejected during manifest validation, so a preset with a malformed pattern fails to install with a clear validation error rather than at resolution time.

The resolution stack, from highest to lowest precedence:

1. **Project-local overrides** — `.specify/templates/overrides/`
Expand Down
77 changes: 67 additions & 10 deletions src/specify_cli/presets/_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
)
from ._registry import PresetRegistry
from ._resolver import PresetResolver
from ._selectors import is_regex_selector, selector_matches

_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync"
Expand Down Expand Up @@ -435,20 +436,32 @@ def install_from_directory(
registered_commands: Dict[str, List[str]] = {}
registered_skills: Dict[str, List[str]] = {}
try:
command_templates = self._expand_command_selectors(
PresetResolver(self.project_root), dest_dir,
[t for t in manifest.templates if t.get("type") == "command"],
)
self._warn_unmatched_command_selectors(manifest, command_templates, dest_dir)
# Register command overrides with AI agents and persist the result
# immediately so cleanup can recover even if installation stops
# before later phases complete.
registered_commands = self._register_commands(manifest, dest_dir)
registered_commands = self._register_commands(
manifest, dest_dir, command_templates=command_templates
)
self.registry.update(manifest.id, {
"registered_commands": registered_commands,
})

# Update corresponding skills when skills mode was previously used
# and persist that result as well.
registered_skills = self._register_skills(manifest, dest_dir)
registered_skills = self._register_skills(
manifest, dest_dir, command_templates=command_templates
)
self.registry.update(manifest.id, {
"registered_skills": registered_skills,
})
if any(t.get("_selector") for t in command_templates):
for agent in registered_commands.keys() | registered_skills.keys():
self._refresh_selector_matches(manifest.id, agent, command_templates)
except Exception:
# Roll back all side effects. _register_skills persists each
# successful write immediately, so reload that partial map when
Expand All @@ -474,11 +487,7 @@ def install_from_directory(

# Reconcile all affected commands from the full priority stack so that
# install order doesn't determine the winning command file.
cmd_names = [
t["name"]
for t in manifest.templates
if t.get("type") == "command"
]
cmd_names = [t["name"] for t in command_templates]
if cmd_names:
try:
self._reconcile_composed_commands(cmd_names)
Expand All @@ -502,6 +511,37 @@ def install_from_directory(

return manifest

@staticmethod
def _warn_unmatched_command_selectors(
manifest: PresetManifest,
command_templates: List[Dict[str, Any]],
preset_dir: Path,
) -> None:
"""Warn for valid command selectors with no concrete lower-layer match.

An exact declaration only suppresses the warning when its payload is still
usable: a declared-but-missing fallback registers nothing, so staying
silent about the unmatched selector would hide that nothing was written.
"""
import warnings

usable = [
template
for template in command_templates
if isinstance(template.get("file"), str)
and (preset_dir / template["file"]).is_file()
]
for declaration in manifest.templates:
selector = declaration["name"]
if declaration["type"] != "command" or not is_regex_selector(selector):
continue
if not any(selector_matches(selector, t["name"]) for t in usable):
warnings.warn(
f"Preset '{manifest.id}' selector '{selector}' currently matches no command resources. "
"Regex selectors only match concrete commands from lower layers.",
stacklevel=2,
)

def _seed_constitution_from_preset(
self, manifest: PresetManifest, preset_dir: Path
) -> None:
Expand Down Expand Up @@ -689,7 +729,7 @@ def remove(self, pack_id: str) -> bool:
# branch of _unregister_skills restores only the currently
# active agent's directory, leaving this preset's overrides in
# every previously active agent's directory orphaned. Infer
# real per-agent ownership from the on-disk preset marker now,
# real per-agent provenance from the on-disk preset marker now,
# while pack_id is still known, and hand the resulting mapping
# through the same dict-based cleanup path already used for
# non-legacy registries (#2948).
Expand Down Expand Up @@ -759,12 +799,29 @@ def remove(self, pack_id: str) -> bool:
)
)
for cmd_names in registered_commands.values():
removed_cmd_names.update(cmd_names)
removed_cmd_names.update(
name for name in cmd_names
if isinstance(name, str) and not is_regex_selector(name)
)
# Disabled selectors no longer expand, and command-backed integrations
# in AI-skills mode have no registered_commands. Retain their recorded
# concrete names so removal still reconciles surviving preset layers.
selector_commands = (metadata or {}).get("selector_commands", {})
for cmd_names in selector_commands.values():
removed_cmd_names.update(
name for name in cmd_names
if isinstance(name, str) and not is_regex_selector(name)
)
manifest_path = pack_dir / "preset.yml"
if manifest_path.exists():
try:
manifest = PresetManifest(manifest_path)
for tmpl in manifest.templates:
declarations = [t for t in manifest.templates if t.get("type") != "command"]
declarations += self._expand_command_selectors(
PresetResolver(self.project_root), pack_dir,
[t for t in manifest.templates if t.get("type") == "command"],
)
for tmpl in declarations:
if (
tmpl.get("type") == "template"
and tmpl.get("name") == "constitution-template"
Expand Down
Loading