Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
18 changes: 18 additions & 0 deletions presets/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency:
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`

### Composition Strategies

Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it:

| Strategy | Description | Templates | Commands | Scripts |
|----------|-------------|-----------|----------|---------|
| `replace` (default) | Fully replaces lower-priority content | ✓ | ✓ | ✓ |
| `prepend` | Places content before lower-priority content (separated by a blank line) | ✓ | ✓ | — |
| `append` | Places content after lower-priority content (separated by a blank line) | ✓ | ✓ | — |
| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | ✓ | ✓ | ✓ |

Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy.

Content resolution functions for composition:
- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py`
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh`
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1`

## 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
44 changes: 33 additions & 11 deletions presets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,37 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa
specify preset add pm-workflow --priority 1 # overrides everything
```

Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
Presets **override by default**, they don't merge. If two presets both provide `spec-template` with the default `replace` strategy, the one with the lowest priority number wins entirely. However, presets can use **composition strategies** to augment rather than replace content.

### Composition Strategies

Presets can declare a `strategy` per template to control how content is combined. The `name` field identifies which template to compose with in the priority stack, while `file` points to the actual content file (which can differ from the convention path `templates/<name>.md`):

```yaml
provides:
templates:
- type: "template"
name: "spec-template"
file: "templates/spec-addendum.md"
Comment thread
mnriem marked this conversation as resolved.
strategy: "append" # adds content after the core template
```

| Strategy | Description |
|----------|-------------|
| `replace` (default) | Fully replaces the lower-priority template |
| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line |
| `append` | Places content **after** the resolved lower-priority template, separated by a blank line |
| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content |

**Supported combinations:**

| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |

Multiple composing presets chain recursively. For example, a security preset with `prepend` and a compliance preset with `append` will produce: security header + core content + compliance footer.

## Catalog Management

Expand Down Expand Up @@ -108,13 +138,5 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset

The following enhancements are under consideration for future releases:

- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:

| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |

For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.
- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security").
- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts.
28 changes: 28 additions & 0 deletions presets/scaffold/preset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ provides:
templates:
# CUSTOMIZE: Define your template overrides
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
#
# Strategy options (optional, defaults to "replace"):
# replace - Fully replaces the lower-priority template (default)
# prepend - Places this content BEFORE the lower-priority template
# append - Places this content AFTER the lower-priority template
# wrap - Uses {CORE_TEMPLATE} placeholder, replaced with lower-priority content
#
# Note: Scripts only support "replace" and "wrap" strategies.
- type: "template"
name: "spec-template"
file: "templates/spec-template.md"
Expand All @@ -45,6 +53,26 @@ provides:
# description: "Custom plan template"
# replaces: "plan-template"

# COMPOSITION EXAMPLES:
# The `file` field points to the content file (can differ from the
# convention path `templates/<name>.md`). The `name` field identifies
# which template to compose with in the priority stack.
#
# Append additional sections to an existing template:
# - type: "template"
# name: "spec-template"
# file: "templates/spec-addendum.md"
# description: "Add compliance section to spec template"
# strategy: "append"
#
# Wrap a command with preamble/sign-off:
# - type: "command"
# name: "speckit.specify"
# file: "commands/specify-wrapper.md"
Comment thread
mnriem marked this conversation as resolved.
# description: "Wrap specify command with compliance checks"
# strategy: "wrap"
# # In the wrapper file, use {CORE_TEMPLATE} where the original content goes

# OVERRIDE EXTENSION TEMPLATES:
# Presets sit above extensions in the resolution stack, so you can
# override templates provided by any installed extension.
Expand Down
196 changes: 196 additions & 0 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,199 @@ except Exception:
return 1
}

# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
#
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
# Returns composed content string on stdout; exit code 1 if not found.
resolve_template_content() {
local template_name="$1"
local repo_root="$2"
local base="$repo_root/.specify/templates"

# Collect all layers (highest priority first)
local -a layer_paths=()
local -a layer_strategies=()

# Priority 1: Project overrides (always "replace")
local override="$base/overrides/${template_name}.md"
if [ -f "$override" ]; then
layer_paths+=("$override")
layer_strategies+=("replace")
fi

# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
local sorted_presets=""
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
while IFS= read -r preset_id; do
Comment thread
mnriem marked this conversation as resolved.
# Read strategy and file path from preset manifest
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
# Requires PyYAML; falls back to replace/convention if unavailable
local result
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
import sys, os
try:
import yaml
except ImportError:
print('replace\t')
sys.exit(0)
try:
with open(os.environ['SPECKIT_MANIFEST']) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
" 2>/dev/null) && {
Comment thread
mnriem marked this conversation as resolved.
strategy="${result%% *}"
manifest_file="${result#* }"
}
fi
# Try manifest file path first, then convention path
local candidate=""
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
fi
done <<< "$sorted_presets"
fi
else
# python3 failed — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
else
# No python3 or registry — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
fi
Comment thread
mnriem marked this conversation as resolved.

# Priority 3: Extension-provided templates (always "replace")
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
case "$(basename "$ext")" in .*) continue;; esac
local candidate="$ext/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi

# Priority 4: Core templates (always "replace")
local core="$base/${template_name}.md"
if [ -f "$core" ]; then
layer_paths+=("$core")
layer_strategies+=("replace")
fi

local count=${#layer_paths[@]}
[ "$count" -eq 0 ] && return 1

# Check if any layer uses a non-replace strategy
local has_composition=false
for s in "${layer_strategies[@]}"; do
[ "$s" != "replace" ] && has_composition=true && break
done

if [ "$has_composition" = false ]; then
Comment thread
mnriem marked this conversation as resolved.
cat "${layer_paths[0]}"
return 0
fi

# Compose bottom-up: start from lowest priority
local content=""
local has_base=false
local started=false
local i
for (( i=count-1; i>=0; i-- )); do
local path="${layer_paths[$i]}"
local strat="${layer_strategies[$i]}"
local layer_content
layer_content=$(cat "$path")

if [ "$started" = false ]; then
if [ "$strat" = "replace" ]; then
content="$layer_content"
has_base=true
fi
# Keep consuming replace layers from the bottom until we hit a non-replace
if [ "$strat" != "replace" ]; then
# No base content to compose onto
[ "$has_base" = false ] && return 1
started=true
case "$strat" in
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
wrap)
# Split on placeholder and rejoin with content to avoid
# bash & expansion in parameter substitution replacement.
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
local after="${layer_content#*\{CORE_TEMPLATE\}}"
content="${before}${content}${after}"
;;
Comment thread
mnriem marked this conversation as resolved.
esac
Comment thread
mnriem marked this conversation as resolved.
fi
else
case "$strat" in
replace) content="$layer_content" ;;
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
wrap)
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
local after="${layer_content#*\{CORE_TEMPLATE\}}"
content="${before}${content}${after}"
;;
Comment thread
mnriem marked this conversation as resolved.
Outdated
esac
fi
done

printf '%s' "$content"
return 0
}

Loading
Loading