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
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