diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md index d0e6547816..3a119cbd5f 100644 --- a/presets/ARCHITECTURE.md +++ b/presets/ARCHITECTURE.md @@ -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` (templates, commands, and scripts) +- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver) +- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver) + ## 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`. diff --git a/presets/README.md b/presets/README.md index 72751b4bfb..7d7b9ae8a2 100644 --- a/presets/README.md +++ b/presets/README.md @@ -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/.md`): + +```yaml +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-addendum.md" + 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 @@ -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` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented). -- **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. diff --git a/presets/scaffold/preset.yml b/presets/scaffold/preset.yml index 975a92a413..65111ba9f3 100644 --- a/presets/scaffold/preset.yml +++ b/presets/scaffold/preset.yml @@ -32,6 +32,15 @@ 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 (templates/commands) or + # $CORE_SCRIPT placeholder (scripts), replaced with lower-priority content + # + # Note: Scripts only support "replace" and "wrap" strategies. - type: "template" name: "spec-template" file: "templates/spec-template.md" @@ -45,6 +54,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/.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" + # 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. diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index b41d17dec3..cad10bdb39 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -320,8 +320,9 @@ 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) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) except Exception: sys.exit(1) " 2>/dev/null); then @@ -373,3 +374,225 @@ 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) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + local yaml_warned=false + while IFS= read -r preset_id; do + # 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 + local py_stderr + py_stderr=$(mktemp) + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " +import sys, os +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + 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>"$py_stderr") + local parse_status=$? + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then + IFS=$'\t' read -r strategy manifest_file <<< "$result" + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then + echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 + yaml_warned=true + fi + rm -f "$py_stderr" + fi + # Try manifest file path first, then convention path + local candidate="" + if [ -n "$manifest_file" ]; then + # Reject absolute paths and parent traversal + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + 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 + + # 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 the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if [ "${layer_strategies[0]}" = "replace" ]; then + cat "${layer_paths[0]}" + return 0 + fi + + if [ "$has_composition" = false ]; then + cat "${layer_paths[0]}" + return 0 + fi + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + local base_idx=-1 + local i + for (( i=0; i=0; i-- )); do + local path="${layer_paths[$i]}" + local strat="${layer_strategies[$i]}" + local layer_content + # Preserve trailing newlines + layer_content=$(cat "$path"; printf x) + layer_content="${layer_content%x}" + + 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) + case "$layer_content" in + *'{CORE_TEMPLATE}'*) ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + esac + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do + local before="${layer_content%%\{CORE_TEMPLATE\}*}" + local after="${layer_content#*\{CORE_TEMPLATE\}}" + layer_content="${before}${content}${after}" + done + content="$layer_content" + ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + esac + done + + printf '%s' "$content" + return 0 +} + diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 0d6544aaf4..d799e4f7e7 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -287,6 +287,21 @@ function Test-DirHasFiles { } } +# Find a usable Python 3 executable (python3, python, or py -3). +# Returns the command/arguments as an array, or $null if none found. +function Get-Python3Command { + if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } + if (Get-Command python -ErrorAction SilentlyContinue) { + $ver = & python --version 2>&1 + if ($ver -match 'Python 3') { return @('python') } + } + if (Get-Command py -ErrorAction SilentlyContinue) { + $ver = & py -3 --version 2>&1 + if ($ver -match 'Python 3') { return @('py', '-3') } + } + return $null +} + # Resolve a template name to a file path using the priority stack: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) @@ -315,6 +330,7 @@ function Resolve-Template { $presets = $registryData.presets if ($presets) { $sortedPresets = $presets.PSObject.Properties | + Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | ForEach-Object { $_.Name } } @@ -354,3 +370,206 @@ function Resolve-Template { return $null } +# 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. +function Resolve-TemplateContent { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + $base = Join-Path $RepoRoot '.specify/templates' + + # Collect all layers (highest priority first) + $layerPaths = @() + $layerStrategies = @() + + # Priority 1: Project overrides (always "replace") + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { + $layerPaths += $override + $layerStrategies += 'replace' + } + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + if (Test-Path $registryFile) { + try { + $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json + $presets = $registryData.presets + if ($presets) { + $sortedPresets = $presets.PSObject.Properties | + Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | + Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | + ForEach-Object { $_.Name } + } + } catch { + $sortedPresets = @() + } + } + + if ($sortedPresets.Count -gt 0) { + $pyCmd = Get-Python3Command + if (-not $pyCmd) { + # Check if any preset has strategy fields that would be ignored + foreach ($pid in $sortedPresets) { + $mf = Join-Path $presetsDir "$pid/preset.yml" + if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) { + Write-Warning "No Python 3 found; preset composition strategies will be ignored" + break + } + } + } + $yamlWarned = $false + foreach ($presetId in $sortedPresets) { + # Read strategy and file path from preset manifest + $strategy = 'replace' + $manifestFilePath = '' + $manifest = Join-Path $presetsDir "$presetId/preset.yml" + if ((Test-Path $manifest) -and $pyCmd) { + try { + # Use Python to parse YAML manifest for strategy and file path + $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() } + $pyStderrFile = [System.IO.Path]::GetTempFileName() + $stratResult = & $pyCmd[0] @pyArgs -c @" +import sys +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(sys.argv[1]) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == sys.argv[2] 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') +"@ $manifest $TemplateName 2>$pyStderrFile + if ($stratResult) { + $parts = $stratResult.Trim() -split "`t", 2 + $strategy = $parts[0].ToLowerInvariant() + if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] } + } + if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') { + Write-Warning "PyYAML not available; composition strategies may be ignored" + $yamlWarned = $true + } + Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue + } catch { + $strategy = 'replace' + if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue } + } + } + # Try manifest file path first, then convention path + $candidate = $null + if ($manifestFilePath) { + # Reject absolute paths and parent traversal + if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') { + $manifestFilePath = '' + } + } + if ($manifestFilePath) { + $mf = Join-Path $presetsDir "$presetId/$manifestFilePath" + if (Test-Path $mf) { $candidate = $mf } + } + if (-not $candidate) { + $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $cf) { $candidate = $cf } + } + if ($candidate) { + $layerPaths += $candidate + $layerStrategies += $strategy + } + } + } else { + # Fallback: alphabetical directory order (no registry or parse failure) + foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { + $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + } + } + } + } + + # Priority 3: Extension-provided templates (always "replace") + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (Test-Path $extDir) { + foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { + $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + } + } + } + + # Priority 4: Core templates (always "replace") + $core = Join-Path $base "$TemplateName.md" + if (Test-Path $core) { + $layerPaths += $core + $layerStrategies += 'replace' + } + + if ($layerPaths.Count -eq 0) { return $null } + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if ($layerStrategies[0] -eq 'replace') { + return (Get-Content $layerPaths[0] -Raw) + } + + # Check if any layer uses a non-replace strategy + $hasComposition = $false + foreach ($s in $layerStrategies) { + if ($s -ne 'replace') { $hasComposition = $true; break } + } + + if (-not $hasComposition) { + return (Get-Content $layerPaths[0] -Raw) + } + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + $baseIdx = -1 + for ($i = 0; $i -lt $layerPaths.Count; $i++) { + if ($layerStrategies[$i] -eq 'replace') { + $baseIdx = $i + break + } + } + if ($baseIdx -lt 0) { return $null } + + $content = Get-Content $layerPaths[$baseIdx] -Raw + + for ($i = $baseIdx - 1; $i -ge 0; $i--) { + $path = $layerPaths[$i] + $strat = $layerStrategies[$i] + $layerContent = Get-Content $path -Raw + + switch ($strat) { + 'replace' { $content = $layerContent } + 'prepend' { $content = "$layerContent`n`n$content" } + 'append' { $content = "$content`n`n$layerContent" } + 'wrap' { + if (-not $layerContent.Contains('{CORE_TEMPLATE}')) { + throw "Wrap strategy missing {CORE_TEMPLATE} placeholder" + } + $content = $layerContent.Replace('{CORE_TEMPLATE}', $content) + } + default { throw "Unknown strategy: $strat" } + } + } + + return $content +} \ No newline at end of file diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 97cb993a96..7f744f74c7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2595,14 +2595,58 @@ def preset_resolve( raise typer.Exit(1) resolver = PresetResolver(project_root) - result = resolver.resolve_with_source(template_name) - - if result: - console.print(f" [bold]{template_name}[/bold]: {result['path']}") - console.print(f" [dim](from: {result['source']})[/dim]") + layers = resolver.collect_all_layers(template_name) + + if layers: + # Use the highest-priority layer for display because the final output + # may be composed and may not map to resolve_with_source()'s single path. + display_layer = layers[0] + console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}") + console.print(f" [dim](top layer from: {display_layer['source']})[/dim]") + + has_composition = ( + layers[0]["strategy"] != "replace" + and any(layer["strategy"] != "replace" for layer in layers) + ) + if has_composition: + # Verify composition is actually possible + try: + composed = resolver.resolve_content(template_name) + except Exception as exc: + composed = None + console.print(f" [yellow]Warning: composition error: {exc}[/yellow]") + if composed is None: + console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") + else: + console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") + console.print("\n [bold]Composition chain:[/bold]") + # Compute the effective base: first replace layer scanning from + # highest priority (matching resolve_content top-down logic). + # Only show layers from the base upward (lower layers are ignored). + effective_base_idx = None + for idx, lyr in enumerate(layers): + if lyr["strategy"] == "replace": + effective_base_idx = idx + break + # Show only contributing layers (base and above) + if effective_base_idx is not None: + contributing = layers[:effective_base_idx + 1] + else: + contributing = layers + for i, layer in enumerate(reversed(contributing)): + strategy_label = layer["strategy"] + if strategy_label == "replace" and i == 0: + strategy_label = "base" + console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}") else: - console.print(f" [yellow]{template_name}[/yellow]: not found") - console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + # No layers found — fall back to resolve_with_source for non-composition cases + result = resolver.resolve_with_source(template_name) + if result: + console.print(f" [bold]{template_name}[/bold]: {result['path']}") + console.print(f" [dim](from: {result['source']})[/dim]") + else: + console.print(f" [yellow]{template_name}[/yellow]: not found") + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") @preset_app.command("info") diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c5e25a7085..515ec1e973 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -8,7 +8,7 @@ import os from pathlib import Path -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional import platform import re @@ -651,6 +651,49 @@ def register_commands_for_all_agents( return results + def register_commands_for_non_skill_agents( + self, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + project_root: Path, + context_note: Optional[str] = None, + ) -> Dict[str, List[str]]: + """Register commands for all non-skill agents in the project. + + Like register_commands_for_all_agents but skips skill-based agents + (those with extension '/SKILL.md'). Used by reconciliation to avoid + overwriting properly formatted SKILL.md files. + + Args: + commands: List of command info dicts + source_id: Identifier of the source + source_dir: Directory containing command source files + project_root: Path to project root + context_note: Custom context comment for markdown output + + Returns: + Dictionary mapping agent names to list of registered commands + """ + results = {} + self._ensure_configs() + for agent_name, agent_config in self.AGENT_CONFIGS.items(): + if agent_config.get("extension") == "/SKILL.md": + continue + agent_dir = project_root / agent_config["dir"] + if agent_dir.exists(): + try: + registered = self.register_commands( + agent_name, commands, source_id, + source_dir, project_root, + context_note=context_note, + ) + if registered: + results[agent_name] = registered + except ValueError: + continue + return results + def unregister_commands( self, registered_commands: Dict[str, List[str]], project_root: Path ) -> None: diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 5f28be7204..ed33f992c3 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -109,6 +109,9 @@ class PresetCompatibilityError(PresetError): VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"} +VALID_PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} +# Scripts only support replace and wrap (prepend/append don't make semantic sense for executable code) +VALID_SCRIPT_STRATEGIES = {"replace", "wrap"} class PresetManifest: @@ -207,6 +210,28 @@ def _validate(self): "must be a relative path within the preset directory" ) + # Validate strategy field (optional, defaults to "replace") + strategy = tmpl.get("strategy", "replace") + if not isinstance(strategy, str): + raise PresetValidationError( + f"Invalid strategy value: must be a string, " + f"got {type(strategy).__name__}" + ) + strategy = strategy.lower() + # Persist normalized value so downstream code sees lowercase + if "strategy" in tmpl: + tmpl["strategy"] = strategy + if strategy not in VALID_PRESET_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}': " + f"must be one of {sorted(VALID_PRESET_STRATEGIES)}" + ) + if tmpl["type"] == "script" and strategy not in VALID_SCRIPT_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}' for script: " + f"scripts only support {sorted(VALID_SCRIPT_STRATEGIES)}" + ) + # Validate template name format if tmpl["type"] == "command": # Commands use dot notation (e.g. speckit.specify) @@ -558,6 +583,10 @@ def _register_commands( file, and writes it to every detected agent directory using the CommandRegistrar from the agents module. + When a command uses a composition strategy (prepend, append, wrap), + the content is composed with the lower-priority command before + registration. + Args: manifest: Preset manifest preset_dir: Installed preset directory @@ -587,6 +616,50 @@ def _register_commands( if not filtered: return {} + # Handle composition strategies: resolve composed content for non-replace commands + resolver = PresetResolver(self.project_root) + composed_dir = None + commands_to_register = [] + for cmd in filtered: + strategy = cmd.get("strategy", "replace") + if strategy != "replace": + # Only pre-compose if this preset is the top composing layer. + # If a higher-priority replace already wins, skip composition + # here — reconciliation will write the correct content. + layers = resolver.collect_all_layers(cmd["name"], "command") + top_layer_is_ours = ( + layers and layers[0]["path"].is_relative_to(preset_dir) + ) + if top_layer_is_ours: + composed = resolver.resolve_content(cmd["name"], "command") + if composed is not None: + if composed_dir is None: + composed_dir = preset_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd['name']}.md" + composed_file.write_text(composed, encoding="utf-8") + commands_to_register.append({ + **cmd, + "file": f".composed/{cmd['name']}.md", + }) + else: + raise PresetValidationError( + f"Command '{cmd['name']}' uses '{strategy}' strategy " + f"but no base command layer exists to compose onto. " + f"Ensure a lower-priority preset, extension, or core " + f"command provides this command before using " + f"composition strategies." + ) + else: + # Not the top layer — register raw file; reconciliation + # will overwrite with the correct composed/winning content. + # Note: CommandRegistrar may process frontmatter strategy: wrap + # from the raw file (legacy compat), but reconciliation runs + # immediately after install and corrects the final output. + commands_to_register.append(cmd) + else: + commands_to_register.append(cmd) + try: from .agents import CommandRegistrar except ImportError: @@ -594,7 +667,7 @@ def _register_commands( registrar = CommandRegistrar() return registrar.register_commands_for_all_agents( - filtered, manifest.id, preset_dir, self.project_root + commands_to_register, manifest.id, preset_dir, self.project_root ) def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: @@ -611,231 +684,402 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) - def _replay_wraps_for_command(self, cmd_name: str) -> None: - """Recompose and rewrite agent files for a wrap-strategy command. - - Collects all installed presets that declare cmd_name in their - wrap_commands registry field, sorts them so the highest-precedence - preset (lowest priority number) wraps outermost, then writes the - fully composed output to every agent directory. + def _reconcile_composed_commands(self, command_names: List[str]) -> None: + """Re-resolve and re-register composed commands from the full stack. - Called after every install and remove to keep agent files correct - regardless of installation order. + After install or remove, recompute the effective content for each + command name that participates in composition, and write the winning + content to the agent directories. This ensures command files always + reflect the current priority stack rather than depending on + install/remove order. Args: - cmd_name: Full command name (e.g. "speckit.specify") + command_names: List of command names to reconcile """ + if not command_names: + return + try: from .agents import CommandRegistrar except ImportError: return - # Collect enabled presets that wrap this command, sorted ascending - # (lowest priority number = highest precedence = outermost). - wrap_presets = [] - for pack_id, metadata in self.registry.list_by_priority(include_disabled=False): - if cmd_name not in metadata.get("wrap_commands", []): - continue - pack_dir = self.presets_dir / pack_id - if not pack_dir.is_dir(): - continue # corrupted state — skip - wrap_presets.append((pack_id, pack_dir)) + resolver = PresetResolver(self.project_root) + registrar = CommandRegistrar() - if not wrap_presets: - return + # Cache registry and manifests outside the loop to avoid + # repeated filesystem reads for each command name. + presets_by_priority = list(self.registry.list_by_priority()) - # Derive short name for core resolution fallback. - short_name = cmd_name - if short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: + continue - resolver = PresetResolver(self.project_root) - core_file = ( - resolver.resolve_core(cmd_name, "command") - or resolver.resolve_extension_command_via_manifest(cmd_name) - or ( - resolver.resolve_extension_command_via_manifest(short_name) - if short_name != cmd_name - else None + # If the top layer is replace, it wins entirely — lower layers + # are irrelevant regardless of their strategies. + top_is_replace = layers[0]["strategy"] == "replace" + has_composition = not top_is_replace and any( + layer["strategy"] != "replace" for layer in layers ) - or resolver.resolve_core(short_name, "command") - ) - if core_file is None: - return - - registrar = CommandRegistrar() - core_frontmatter, core_body = registrar.parse_frontmatter( - core_file.read_text(encoding="utf-8") - ) - replay_aliases: List[str] = [] - seen_aliases: set[str] = set() - - # Apply wraps innermost-first (reverse of ascending list). - accumulated_body = core_body - outermost_frontmatter = {} - outermost_pack_id = wrap_presets[0][0] # fallback; updated per contributing preset - for pack_id, pack_dir in reversed(wrap_presets): - manifest_path = pack_dir / "preset.yml" - cmd_file: Optional[Path] = None - if manifest_path.exists(): - try: - manifest = PresetManifest(manifest_path) - except (PresetValidationError, KeyError, TypeError, ValueError): - manifest = None - if manifest is not None: - for template in manifest.templates: - if template.get("type") != "command" or template.get("name") != cmd_name: - continue - file_rel = template.get("file") - if isinstance(file_rel, str): - rel_path = Path(file_rel) - if not rel_path.is_absolute(): - try: - preset_root = pack_dir.resolve() - candidate = (preset_root / rel_path).resolve() - candidate.relative_to(preset_root) - except (OSError, ValueError): - candidate = None - if candidate is not None: - cmd_file = candidate - aliases = template.get("aliases", []) - if not isinstance(aliases, list): - aliases = [] - for alias in aliases: - if isinstance(alias, str) and alias not in seen_aliases: - replay_aliases.append(alias) - seen_aliases.add(alias) + if not has_composition: + # Pure replace — the top layer wins. + top_layer = layers[0] + top_path = top_layer["path"] + # Try to find which preset owns this layer + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + manifest = resolver._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + self._register_for_non_skill_agents( + registrar, [tmpl], manifest.id, pack_dir + ) + registered = True + break break - if cmd_file is None: - cmd_file = pack_dir / "commands" / f"{cmd_name}.md" - if not cmd_file.exists(): - continue - wrap_fm, wrap_body = registrar.parse_frontmatter( - cmd_file.read_text(encoding="utf-8") - ) - accumulated_body = wrap_body.replace("{CORE_TEMPLATE}", accumulated_body) - outermost_frontmatter = wrap_fm # last iteration = outermost preset - outermost_pack_id = pack_id - - # Build final frontmatter: outermost preset wins; fall back to core for - # scripts/agent_scripts if the outermost preset does not define them. - final_frontmatter = dict(outermost_frontmatter) - final_frontmatter.pop("strategy", None) - for key in ("scripts", "agent_scripts"): - if key not in final_frontmatter and key in core_frontmatter: - final_frontmatter[key] = core_frontmatter[key] - - composed_content = ( - registrar.render_frontmatter(final_frontmatter) + "\n" + accumulated_body - ) - - self._replay_skill_override(cmd_name, composed_content, outermost_pack_id) - - with tempfile.TemporaryDirectory() as tmpdir: - tmp_path = Path(tmpdir) - cmd_dir = tmp_path / "commands" - cmd_dir.mkdir() - (cmd_dir / f"{cmd_name}.md").write_text(composed_content, encoding="utf-8") - registrar._ensure_configs() - for agent_name, agent_config in registrar.AGENT_CONFIGS.items(): - if agent_config.get("extension") == "/SKILL.md": - continue - agent_dir = self.project_root / agent_config["dir"] - if not agent_dir.exists(): - continue - try: - registrar.register_commands( - agent_name, - [{ - "name": cmd_name, - "file": f"commands/{cmd_name}.md", - "aliases": replay_aliases, - }], - f"preset:{outermost_pack_id}", - tmp_path, + if not registered: + # Top layer is a non-preset source (extension, core, or + # project override). Register directly from the layer path. + source = layers[0]["source"] + if source.startswith("extension:"): + # Use extension's own registration to preserve context formatting + ext_id = source.split(":", 1)[1].split(" ", 1)[0] + ext_dir = self.project_root / ".specify" / "extensions" / ext_id + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from .extensions import ExtensionManifest + ext_manifest = ExtensionManifest(ext_manifest_path) + # Filter to only the command being reconciled + matching_cmds = [ + c for c in ext_manifest.commands + if c.get("name") == cmd_name + ] + if matching_cmds: + registrar.register_commands_for_non_skill_agents( + matching_cmds, ext_id, ext_dir, + self.project_root, + context_note=f"\n\n\n", + ) + registered = True + except Exception: + # Extension registration failed; fall back to + # generic path-based registration below. + pass + if not registered: + source_id = source.split(":", 1)[1].split(" ", 1)[0] if source.startswith("extension:") else source + self._register_command_from_path( + registrar, cmd_name, top_path, + source_id=source_id, + ) + else: + # Composed command — resolve from full stack + composed = resolver.resolve_content(cmd_name, "command") + if composed is None: + # Composition no longer possible (e.g. base layer removed). + # Unregister any stale command file from non-skill agents. + import warnings + warnings.warn( + f"Cannot compose command '{cmd_name}': no base layer. " + f"Stale command files may remain.", + stacklevel=2, + ) + registrar._ensure_configs() + # Include aliases from the top layer's manifest + cmd_names_to_unregister = [cmd_name] + for _pid, _meta in presets_by_priority: + _pd = self.presets_dir / _pid + _m = resolver._get_manifest(_pd) + if _m: + for _t in _m.templates: + if _t.get("name") == cmd_name and _t.get("type") == "command": + for alias in _t.get("aliases", []): + if isinstance(alias, str): + cmd_names_to_unregister.append(alias) + break + registrar.unregister_commands( + {agent: cmd_names_to_unregister for agent in registrar.AGENT_CONFIGS + if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"}, self.project_root, ) - except ValueError: continue - def _replay_skill_override( + # Write to the highest-priority preset's .composed dir + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + if not manifest: + continue + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + composed_dir = pack_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + self._register_for_non_skill_agents( + registrar, + [{**tmpl, "file": f".composed/{cmd_name}.md"}], + manifest.id, pack_dir, + ) + registered = True + break + else: + continue + break + if not registered: + # No preset owns this composed command — write to a + # shared .composed dir and register from the top layer. + shared_composed = self.presets_dir / ".composed" + shared_composed.mkdir(parents=True, exist_ok=True) + composed_file = shared_composed / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + source = layers[0]["source"] + if source.startswith("extension:"): + source_id = source.split(":", 1)[1].split(" ", 1)[0] + else: + source_id = source + self._register_command_from_path( + registrar, cmd_name, composed_file, + source_id=source_id, + ) + + def _register_command_from_path( self, + registrar: Any, cmd_name: str, - composed_content: str, - outermost_pack_id: str, + cmd_path: Path, + source_id: str = "reconciled", ) -> None: - """Rewrite any active SKILL.md override for a replayed wrap command.""" - skills_dir = self._get_skills_dir() - if not skills_dir: - return + """Register a single command from a file path (non-preset source). - from . import SKILL_DESCRIPTIONS, load_init_options - from .agents import CommandRegistrar - from .integrations import get_integration + Used by reconciliation when the winning layer is an extension, + core template, or project override rather than a preset. - init_opts = load_init_options(self.project_root) - if not isinstance(init_opts, dict): - init_opts = {} - selected_ai = init_opts.get("ai") - if not isinstance(selected_ai, str): + Args: + registrar: CommandRegistrar instance + cmd_name: Command name + cmd_path: Path to the command file + source_id: Source attribution for rendered output + """ + if not cmd_path.exists(): return + cmd_tmpl: Dict[str, Any] = { + "name": cmd_name, + "type": "command", + "file": cmd_path.name, + } + # Load aliases from extension manifest when the winning layer is an extension + if source_id and not source_id.startswith("preset:"): + try: + from .extensions import ExtensionManifest + for ext_dir in (self.project_root / ".specify" / "extensions").iterdir(): + if not ext_dir.is_dir(): + continue + if cmd_path.is_relative_to(ext_dir): + manifest_path = ext_dir / "extension.yml" + if manifest_path.exists(): + ext_manifest = ExtensionManifest(manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == cmd_name: + aliases = cmd.get("aliases", []) + if isinstance(aliases, list) and aliases: + cmd_tmpl["aliases"] = aliases + break + break + except Exception: + pass # best-effort alias loading + self._register_for_non_skill_agents( + registrar, [cmd_tmpl], source_id, cmd_path.parent + ) - registrar = CommandRegistrar() - integration = get_integration(selected_ai) - agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) - create_missing_skills = bool(init_opts.get("ai_skills")) and agent_config.get("extension") != "/SKILL.md" - - skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) - target_skill_names: List[str] = [] - if (skills_dir / skill_name).is_dir(): - target_skill_names.append(skill_name) - if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): - target_skill_names.append(legacy_skill_name) - if not target_skill_names and create_missing_skills: - missing_skill_dir = skills_dir / skill_name - if not missing_skill_dir.exists(): - target_skill_names.append(skill_name) - if not target_skill_names: + def _register_for_non_skill_agents( + self, + registrar: Any, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + ) -> None: + """Register commands for non-skill agents during reconciliation. + + Skill-based agents (``/SKILL.md`` layout) are handled separately: + - On removal: ``_unregister_skills()`` restores from core/extension, + then ``_reconcile_skills()`` re-runs ``_register_skills()`` for the + next winning preset so SKILL.md files get proper frontmatter and + descriptions. + - On install: ``_register_skills()`` writes formatted SKILL.md, then + ``_reconcile_skills()`` ensures the actual priority winner is used. + + Writing raw command content to skill agents would produce invalid + SKILL.md files (missing skill frontmatter, descriptions, etc.). + """ + registrar.register_commands_for_non_skill_agents( + commands, source_id, source_dir, self.project_root + ) + + class _FilteredManifest: + """Wrapper that exposes only selected command templates from a manifest. + + Used by _reconcile_skills to avoid overwriting skills for commands + that aren't being reconciled. + """ + + def __init__(self, manifest: "PresetManifest", cmd_names: set): + self._manifest = manifest + self._cmd_names = cmd_names + + def __getattr__(self, name: str): + return getattr(self._manifest, name) + + @property + def templates(self) -> List[Dict[str, Any]]: + return [ + t for t in self._manifest.templates + if t.get("name") in self._cmd_names + ] + + def _reconcile_skills(self, command_names: List[str]) -> None: + """Re-register skills for commands whose winning layer changed. + + After a preset is removed, finds the next preset in the priority + stack that provides each command and re-runs skill registration + for that preset so SKILL.md files reflect the current winner. + + Args: + command_names: List of command names to reconcile skills for + """ + if not command_names: return - raw_short_name = cmd_name - if raw_short_name.startswith("speckit."): - raw_short_name = raw_short_name[len("speckit."):] - short_name = raw_short_name.replace(".", "-") - skill_title = self._skill_title_from_command(cmd_name) - - frontmatter, body = registrar.parse_frontmatter(composed_content) - original_desc = frontmatter.get("description", "") - enhanced_desc = SKILL_DESCRIPTIONS.get( - short_name, - original_desc or f"Spec-kit workflow command: {short_name}", - ) - body = registrar.resolve_skill_placeholders( - selected_ai, dict(frontmatter), body, self.project_root - ) + resolver = PresetResolver(self.project_root) + skills_dir = self._get_skills_dir() - for target_skill_name in target_skill_names: - skill_subdir = skills_dir / target_skill_name - if skill_subdir.exists() and not skill_subdir.is_dir(): + # Cache registry once to avoid repeated filesystem reads + presets_by_priority = list(self.registry.list_by_priority()) + + # Group command names by winning preset to batch _register_skills calls + # while only registering skills for the specific commands being reconciled. + preset_cmds: Dict[str, List[str]] = {} + non_preset_skills: List[tuple] = [] + + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: continue - skill_subdir.mkdir(parents=True, exist_ok=True) - frontmatter_data = registrar.build_skill_frontmatter( - selected_ai, - target_skill_name, - enhanced_desc, - f"preset:{outermost_pack_id}", - ) - frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {skill_title} Skill\n\n" - f"{body}\n" - ) - if integration is not None and hasattr(integration, "post_process_skill_content"): - skill_content = integration.post_process_skill_content(skill_content) - (skill_subdir / "SKILL.md").write_text(skill_content, encoding="utf-8") + + # Re-create the skill directory only if it was previously managed + # (i.e., listed in some preset's registered_skills). This avoids + # creating new skill dirs that _register_skills would normally skip. + if skills_dir: + skill_name, _ = self._skill_names_for_command(cmd_name) + skill_subdir = skills_dir / skill_name + if not skill_subdir.exists(): + # Check if any preset previously registered this skill + was_managed = False + for _pid, meta in presets_by_priority: + if not isinstance(meta, dict): + continue + if skill_name in meta.get("registered_skills", []): + was_managed = True + break + if was_managed: + skill_subdir.mkdir(parents=True, exist_ok=True) + + top_path = layers[0]["path"] + # Find the preset that owns the winning layer + found_preset = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + preset_cmds.setdefault(pack_id, []).append(cmd_name) + found_preset = True + break + if not found_preset: + # Winner is a non-preset source (core/extension/override). + # Track the winning layer path for skill restoration. + skill_name, _ = self._skill_names_for_command(cmd_name) + non_preset_skills.append((skill_name, cmd_name, layers[0])) + + # Restore skills for commands whose winner is non-preset. + if non_preset_skills and skills_dir: + # Separate override-backed skills from core/extension-backed ones. + # _unregister_skills can rmtree the skill dir, so overrides must + # be handled directly (create dir + write) without that call. + core_ext_skills = [] + override_skills = [] + for item in non_preset_skills: + if item[2]["source"] == "project override": + override_skills.append(item) + else: + core_ext_skills.append(item) + + if core_ext_skills: + self._unregister_skills( + [s[0] for s in core_ext_skills], self.presets_dir + ) + + for skill_name, cmd_name, top_layer in override_skills: + skill_subdir = skills_dir / skill_name + skill_subdir.mkdir(parents=True, exist_ok=True) + skill_file = skill_subdir / "SKILL.md" + try: + from .agents import CommandRegistrar + from . import SKILL_DESCRIPTIONS, load_init_options + registrar = CommandRegistrar() + content = top_layer["path"].read_text(encoding="utf-8") + fm, body = registrar.parse_frontmatter(content) + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + desc = SKILL_DESCRIPTIONS.get( + short_name.replace(".", "-"), + fm.get("description", f"Command: {short_name}"), + ) + init_opts = load_init_options(self.project_root) + selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else "" + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, fm, body, self.project_root + ) + fm_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, desc, + f"override:{cmd_name}", + ) + fm_text = yaml.safe_dump(fm_data, sort_keys=False).strip() + skill_title = self._skill_title_from_command(cmd_name) + skill_content = ( + f"---\n{fm_text}\n---\n\n" + f"# Speckit {skill_title} Skill\n\n{body}\n" + ) + # Apply integration post-processing (e.g. Claude flags) + from .integrations import get_integration + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content(skill_content) + skill_file.write_text(skill_content, encoding="utf-8") + except Exception: + pass # best-effort override skill restoration + + # Register skills only for the specific commands being reconciled, + # not all commands in each winning preset's manifest. + for pack_id, cmds in preset_cmds.items(): + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + if not manifest_path.exists(): + continue + try: + manifest = PresetManifest(manifest_path) + except PresetValidationError: + continue + # Filter manifest to only the commands being reconciled + cmds_set = set(cmds) + filtered_manifest = self._FilteredManifest(manifest, cmds_set) + self._register_skills(filtered_manifest, pack_dir) def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. @@ -1016,6 +1260,12 @@ def _register_skills( if not source_file.exists(): continue + # Use composed content if available (written by _register_commands + # for commands with non-replace strategies), otherwise the original. + composed_file = preset_dir / ".composed" / f"{cmd_name}.md" + if composed_file.exists(): + source_file = composed_file + # Derive the short command name (e.g. "specify" from "speckit.specify") raw_short_name = cmd_name if raw_short_name.startswith("speckit."): @@ -1257,43 +1507,81 @@ def install_from_directory( shutil.copytree(source_dir, dest_dir) - # Register command overrides with AI agents - registered_commands = self._register_commands(manifest, dest_dir) - - # Update corresponding skills when --ai-skills was previously used - registered_skills = self._register_skills(manifest, dest_dir) - - # Detect wrap commands before registry.add() so a read failure doesn't - # leave a partially-committed registry entry. - wrap_commands = [] - try: - from .agents import CommandRegistrar as _CR - _registrar = _CR() - for cmd_tmpl in manifest.templates: - if cmd_tmpl.get("type") != "command": - continue - cmd_file = dest_dir / cmd_tmpl["file"] - if not cmd_file.exists(): - continue - cmd_fm, _ = _registrar.parse_frontmatter(cmd_file.read_text(encoding="utf-8")) - if cmd_fm.get("strategy") == "wrap": - wrap_commands.append(cmd_tmpl["name"]) - except ImportError: - pass - + # Pre-register the preset so that composition resolution can see it + # in the priority stack when resolving composed command content. self.registry.add(manifest.id, { "version": manifest.version, "source": "local", "manifest_hash": manifest.get_hash(), "enabled": True, "priority": priority, - "registered_commands": registered_commands, - "registered_skills": registered_skills, - "wrap_commands": wrap_commands, + "registered_commands": {}, + "registered_skills": [], }) - for cmd_name in wrap_commands: - self._replay_wraps_for_command(cmd_name) + registered_commands: Dict[str, List[str]] = {} + registered_skills: List[str] = [] + try: + # 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) + self.registry.update(manifest.id, { + "registered_commands": registered_commands, + }) + + # Update corresponding skills when --ai-skills was previously used + # and persist that result as well. + registered_skills = self._register_skills(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_skills": registered_skills, + }) + except Exception: + # Roll back all side effects. Note: if _register_commands or + # _register_skills raised mid-way (e.g. I/O error after writing + # some files), registered_commands/registered_skills may be empty + # and some agent command files could be orphaned. Removing dest_dir + # (which contains .composed/) and the registry entry ensures the + # preset system is consistent even if orphaned files remain. + if registered_commands: + self._unregister_commands(registered_commands) + if registered_skills: + self._unregister_skills(registered_skills, dest_dir) + try: + if dest_dir.exists(): + shutil.rmtree(dest_dir) + except OSError: + pass # best-effort cleanup; don't mask the original error + self.registry.remove(manifest.id) + raise + + # Reconcile all affected commands from the full priority stack so that + # install order doesn't determine the winning command file. + # Apply the same extension-installed filter as _register_commands to + # avoid reconciling extension commands when the extension isn't installed. + extensions_dir = self.project_root / ".specify" / "extensions" + cmd_names = [] + for t in manifest.templates: + if t.get("type") != "command": + continue + name = t["name"] + parts = name.split(".") + if len(parts) >= 3 and parts[0] == "speckit": + ext_id = parts[1] + if not (extensions_dir / ext_id).is_dir(): + continue + cmd_names.append(name) + if cmd_names: + try: + self._reconcile_composed_commands(cmd_names) + self._reconcile_skills(cmd_names) + except Exception as exc: + import warnings + warnings.warn( + f"Post-install reconciliation failed for {manifest.id}: {exc}. " + f"Agent command files may not reflect the current priority stack.", + stacklevel=2, + ) return manifest @@ -1369,16 +1657,31 @@ def remove(self, pack_id: str) -> bool: # Restore original skills when preset is removed registered_skills = metadata.get("registered_skills", []) if metadata else [] registered_commands = metadata.get("registered_commands", {}) if metadata else {} - wrap_commands = metadata.get("wrap_commands", []) if metadata else [] pack_dir = self.presets_dir / pack_id - # _unregister_skills must run before directory deletion (reads preset files) + # Collect ALL command names before filtering for reconciliation, + # so commands registered only for skill-based agents are also reconciled. + # Also include aliases from the manifest as a safety net for registries + # populated by older versions that may not track aliases. + removed_cmd_names = set() + for cmd_names in registered_commands.values(): + removed_cmd_names.update(cmd_names) + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + manifest = PresetManifest(manifest_path) + for tmpl in manifest.templates: + if tmpl.get("type") == "command": + for alias in tmpl.get("aliases", []): + if isinstance(alias, str): + removed_cmd_names.add(alias) + except PresetValidationError: + # Invalid manifest — skip alias extraction; primary command + # names from registered_commands are still unregistered. + pass + if registered_skills: self._unregister_skills(registered_skills, pack_dir) - # When _unregister_skills has already handled skill-agent files, strip - # those entries from registered_commands to avoid double-deletion. - # (When registered_skills is empty, skill-agent entries in - # registered_commands are the only deletion path for those files.) try: from .agents import CommandRegistrar except ImportError: @@ -1390,43 +1693,29 @@ def remove(self, pack_id: str) -> bool: if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" } - # Delete the preset directory before mutating the registry so a - # filesystem failure cannot leave files on disk without a registry entry. + # Unregister non-skill command files from AI agents. + if registered_commands: + self._unregister_commands(registered_commands) + if pack_dir.exists(): shutil.rmtree(pack_dir) - # Remove from registry before replaying so _replay_wraps_for_command sees - # the post-removal registry state. self.registry.remove(pack_id) - # Separate wrap commands from non-wrap commands in registered_commands. - non_wrap_commands = { - agent_name: [c for c in cmd_names if c not in wrap_commands] - for agent_name, cmd_names in registered_commands.items() - } - non_wrap_commands = {k: v for k, v in non_wrap_commands.items() if v} - - # Unregister non-wrap command files from AI agents. - if non_wrap_commands: - self._unregister_commands(non_wrap_commands) - - # For each wrapped command, either re-compose remaining wraps or delete. - for cmd_name in wrap_commands: - remaining = [ - pid for pid, meta in self.registry.list().items() - if cmd_name in meta.get("wrap_commands", []) - ] - if remaining: - self._replay_wraps_for_command(cmd_name) - else: - # No wrap presets remain — delete the agent file entirely. - wrap_agent_commands = { - agent_name: [c for c in cmd_names if c == cmd_name] - for agent_name, cmd_names in registered_commands.items() - } - wrap_agent_commands = {k: v for k, v in wrap_agent_commands.items() if v} - if wrap_agent_commands: - self._unregister_commands(wrap_agent_commands) + # Reconcile: if other presets still provide these commands, + # re-resolve from the remaining stack so the next layer takes effect. + if removed_cmd_names: + try: + self._reconcile_composed_commands(list(removed_cmd_names)) + self._reconcile_skills(list(removed_cmd_names)) + except Exception as exc: + import warnings + warnings.warn( + f"Post-removal reconciliation failed for {pack_id}: {exc}. " + f"Agent command files may be stale; reinstall affected presets " + f"or run 'specify preset add' to refresh.", + stacklevel=2, + ) return True @@ -2036,6 +2325,21 @@ def __init__(self, project_root: Path): self.presets_dir = project_root / ".specify" / "presets" self.overrides_dir = self.templates_dir / "overrides" self.extensions_dir = project_root / ".specify" / "extensions" + self._manifest_cache: Dict[str, Optional["PresetManifest"]] = {} + + def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]: + """Get a cached preset manifest, parsing it on first access.""" + key = str(pack_dir) + if key not in self._manifest_cache: + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + self._manifest_cache[key] = PresetManifest(manifest_path) + except PresetValidationError: + self._manifest_cache[key] = None + else: + self._manifest_cache[key] = None + return self._manifest_cache[key] def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: """Build unified list of registered and unregistered extensions sorted by priority. @@ -2079,6 +2383,19 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: all_extensions.sort(key=lambda x: (x[0], x[1])) return all_extensions + @staticmethod + def _core_stem(template_name: str) -> Optional[str]: + """Extract the stem for core command lookup. + + Commands use dot notation (e.g. ``speckit.specify``), but core + command files are named by stem (e.g. ``specify.md``). Returns + the stem if *template_name* follows the ``speckit.`` pattern, + or ``None`` otherwise. + """ + if template_name.startswith("speckit."): + return template_name[len("speckit."):] + return None + def resolve( self, template_name: str, @@ -2156,6 +2473,12 @@ def resolve( core = self.templates_dir / "commands" / f"{template_name}.md" if core.exists(): return core + # Fallback: speckit..md + stem = self._core_stem(template_name) + if stem: + core = self.templates_dir / "commands" / f"{stem}.md" + if core.exists(): + return core elif template_type == "script": core = self.templates_dir / "scripts" / f"{template_name}{ext}" if core.exists(): @@ -2173,6 +2496,10 @@ def resolve( candidate = _core_pack / "templates" / f"{template_name}.md" elif template_type == "command": candidate = _core_pack / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = _core_pack / "commands" / f"{stem}.md" elif template_type == "script": candidate = _core_pack / "scripts" / f"{template_name}{ext}" else: @@ -2186,6 +2513,10 @@ def resolve( candidate = repo_root / "templates" / f"{template_name}.md" elif template_type == "command": candidate = repo_root / "templates" / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = repo_root / "templates" / "commands" / f"{stem}.md" elif template_type == "script": candidate = repo_root / "scripts" / f"{template_name}{ext}" else: @@ -2317,3 +2648,428 @@ def resolve_with_source( continue return {"path": resolved_str, "source": "core"} + + def collect_all_layers( + self, + template_name: str, + template_type: str = "template", + ) -> List[Dict[str, Any]]: + """Collect all layers in the priority stack for a template. + + Returns layers from highest priority (checked first) to lowest priority. + Each layer is a dict with 'path', 'source', and 'strategy' keys. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + List of layer dicts ordered highest-to-lowest priority. + """ + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + else: + subdirs = [""] + + ext = ".md" + if template_type == "script": + ext = ".sh" + + layers: List[Dict[str, Any]] = [] + + def _find_in_subdirs(base_dir: Path) -> Optional[Path]: + for subdir in subdirs: + if subdir: + candidate = base_dir / subdir / f"{template_name}{ext}" + else: + candidate = base_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + return None + + # Priority 1: Project-local overrides (always "replace" strategy) + if template_type == "script": + override = self.overrides_dir / "scripts" / f"{template_name}{ext}" + else: + override = self.overrides_dir / f"{template_name}{ext}" + if override.exists(): + layers.append({ + "path": override, + "source": "project override", + "strategy": "replace", + }) + + # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) + if self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + # Read strategy and manifest file path from preset manifest + strategy = "replace" + manifest_file_path = None + manifest_has_strategy = False + manifest_found_entry = False + manifest = self._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if (tmpl.get("name") == template_name + and tmpl.get("type") == template_type): + strategy = tmpl.get("strategy", "replace") + manifest_has_strategy = "strategy" in tmpl + manifest_file_path = tmpl.get("file") + manifest_found_entry = True + break + # Use manifest file path if specified, otherwise convention-based + # lookup — but only when the manifest doesn't exist or doesn't + # list this template, so preset.yml stays authoritative. + candidate = None + if manifest_file_path: + manifest_candidate = pack_dir / manifest_file_path + if manifest_candidate.exists(): + candidate = manifest_candidate + # Explicit file path that doesn't exist: skip convention + # fallback to avoid masking typos or picking up unintended files. + elif not manifest_found_entry: + # Manifest doesn't list this template — check convention paths + candidate = _find_in_subdirs(pack_dir) + if candidate: + # Legacy fallback: if manifest doesn't explicitly declare a + # strategy, check the command file's frontmatter for any valid + # strategy. Skip when the manifest entry includes strategy key + # (even if it's "replace") to avoid overriding explicit declarations. + if not manifest_has_strategy and strategy == "replace" and template_type == "command": + try: + cmd_content = candidate.read_text(encoding="utf-8") + lines = cmd_content.splitlines(keepends=True) + if lines and lines[0].rstrip("\r\n") == "---": + fence_end = -1 + for fi, fline in enumerate(lines[1:], start=1): + if fline.rstrip("\r\n") == "---": + fence_end = fi + break + if fence_end > 0: + fm_text = "".join(lines[1:fence_end]) + fm_data = yaml.safe_load(fm_text) + if isinstance(fm_data, dict): + fm_strategy = fm_data.get("strategy") + if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES: + strategy = fm_strategy.lower() + except (yaml.YAMLError, OSError): + # Best-effort legacy frontmatter parsing: keep default + # strategy ("replace") when content is unreadable/invalid. + pass + version = metadata.get("version", "?") if metadata else "?" + layers.append({ + "path": candidate, + "source": f"{pack_id} v{version}", + "strategy": strategy, + }) + + # Priority 3: Extension-provided templates (always "replace") + for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + if not ext_dir.is_dir(): + continue + # Try convention-based lookup first + candidate = _find_in_subdirs(ext_dir) + # If not found and this is a command, check extension manifest + if candidate is None and template_type == "command": + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from .extensions import ExtensionManifest, ValidationError as ExtValidationError + ext_manifest = ExtensionManifest(ext_manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == template_name: + cmd_file = cmd.get("file") + if cmd_file: + c = ext_dir / cmd_file + if c.exists(): + candidate = c + break + except (ExtValidationError, yaml.YAMLError): + # Invalid extension manifest — fall back to + # convention-based lookup (already attempted above). + pass + if candidate: + if ext_meta: + version = ext_meta.get("version", "?") + source = f"extension:{ext_id} v{version}" + else: + source = f"extension:{ext_id} (unregistered)" + layers.append({ + "path": candidate, + "source": source, + "strategy": "replace", + }) + + # Priority 4: Core templates (always "replace") + core = None + if template_type == "template": + c = self.templates_dir / f"{template_name}.md" + if c.exists(): + core = c + elif template_type == "command": + c = self.templates_dir / "commands" / f"{template_name}.md" + if c.exists(): + core = c + else: + # Fallback: speckit..md + stem = self._core_stem(template_name) + if stem: + c = self.templates_dir / "commands" / f"{stem}.md" + if c.exists(): + core = c + elif template_type == "script": + c = self.templates_dir / "scripts" / f"{template_name}{ext}" + if c.exists(): + core = c + if core: + layers.append({ + "path": core, + "source": "core", + "strategy": "replace", + }) + else: + # Priority 5: Bundled core_pack (wheel install) or repo-root + # templates (source-checkout), matching resolve()'s tier-5 fallback. + bundled = self._find_bundled_core(template_name, template_type, ext) + if bundled: + layers.append({ + "path": bundled, + "source": "core (bundled)", + "strategy": "replace", + }) + + return layers + + def _find_bundled_core( + self, + template_name: str, + template_type: str, + ext: str, + ) -> Optional[Path]: + """Find a core template from the bundled pack or source checkout. + + Mirrors the tier-5 fallback logic in ``resolve()`` so that + ``collect_all_layers()`` can locate base layers even when + ``.specify/templates/`` doesn't contain the core file. + """ + try: + from specify_cli import _locate_core_pack + except ImportError: + return None + + stem = self._core_stem(template_name) + names = [template_name] + if stem and stem != template_name: + names.append(stem) + + core_pack = _locate_core_pack() + if core_pack is not None: + for name in names: + if template_type == "template": + c = core_pack / "templates" / f"{name}.md" + elif template_type == "command": + c = core_pack / "commands" / f"{name}.md" + elif template_type == "script": + c = core_pack / "scripts" / f"{name}{ext}" + else: + c = core_pack / f"{name}.md" + if c.exists(): + return c + else: + repo_root = Path(__file__).parent.parent.parent + for name in names: + if template_type == "template": + c = repo_root / "templates" / f"{name}.md" + elif template_type == "command": + c = repo_root / "templates" / "commands" / f"{name}.md" + elif template_type == "script": + c = repo_root / "scripts" / f"{name}{ext}" + else: + c = repo_root / f"{name}.md" + if c.exists(): + return c + return None + + def resolve_content( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[str]: + """Resolve a template name and return composed content. + + Walks the priority stack and composes content using strategies: + - replace (default): highest-priority content wins entirely + - prepend: content is placed before lower-priority content + - append: content is placed after lower-priority content + - wrap: content contains {CORE_TEMPLATE} placeholder replaced + with lower-priority content (or $CORE_SCRIPT for scripts) + + Composition is recursive — multiple composing presets chain. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Composed content string, or None if not found + """ + layers = self.collect_all_layers(template_name, template_type) + if not layers: + return None + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if layers[0]["strategy"] == "replace": + return layers[0]["path"].read_text(encoding="utf-8") + + # Composition: build content bottom-up from the effective base. + # The base is the nearest replace layer scanning from highest priority + # downward. Only layers above the base contribute to composition. + # + # layers is ordered highest-priority first. We process in reverse. + reversed_layers = list(reversed(layers)) + + # Find the effective base: scan from highest priority (layers[0]) downward + # to find the nearest replace layer. Only compose layers above that base. + # layers is highest-priority first; reversed_layers is lowest first. + base_layer_idx = None # index in layers[] (highest-priority first) + for idx, layer in enumerate(layers): + if layer["strategy"] == "replace": + base_layer_idx = idx + break + + if base_layer_idx is None: + return None # no replace base found + + # Convert to reversed_layers index + base_reversed_idx = len(layers) - 1 - base_layer_idx + content = layers[base_layer_idx]["path"].read_text(encoding="utf-8") + # Compose only the layers above the base (higher priority = lower index in layers, + # higher index in reversed_layers). Process bottom-up from base+1. + start_idx = base_reversed_idx + 1 + + # For command composition, strip frontmatter from each layer to avoid + # leaking YAML metadata into the composed body. The highest-priority + # layer's frontmatter will be reattached at the end. + is_command = template_type == "command" + top_frontmatter_text = None + base_frontmatter_text = None + + def _split_frontmatter(text: str) -> tuple: + """Return (frontmatter_block_with_fences, body) or (None, text). + + Uses line-based fence detection (fence must be ``---`` on its + own line) to avoid false matches on ``---`` inside YAML values. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return None, text + + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + + if fence_end == -1: + return None, text + + fm_block = "".join(lines[:fence_end + 1]).rstrip("\r\n") + body = "".join(lines[fence_end + 1:]) + return fm_block, body + + if is_command: + fm, body = _split_frontmatter(content) + if fm: + top_frontmatter_text = fm + base_frontmatter_text = fm + content = body + + # Apply composition layers from bottom to top + for layer in reversed_layers[start_idx:]: + layer_content = layer["path"].read_text(encoding="utf-8") + strategy = layer["strategy"] + + if is_command: + fm, layer_body = _split_frontmatter(layer_content) + layer_content = layer_body + # Track the highest-priority frontmatter seen; + # replace layers reset both top and base frontmatter since + # they replace the entire command including metadata. + if strategy == "replace": + top_frontmatter_text = fm + base_frontmatter_text = fm + elif fm: + top_frontmatter_text = fm + + if strategy == "replace": + content = layer_content + elif strategy == "prepend": + content = layer_content + "\n\n" + content + elif strategy == "append": + content = content + "\n\n" + layer_content + elif strategy == "wrap": + if template_type == "script": + placeholder = "$CORE_SCRIPT" + else: + placeholder = "{CORE_TEMPLATE}" + if placeholder not in layer_content: + raise PresetValidationError( + f"Wrap strategy in '{layer['source']}' is missing " + f"the {placeholder} placeholder. The wrapper must " + f"contain {placeholder} to indicate where the " + f"lower-priority content should be inserted." + ) + content = layer_content.replace(placeholder, content) + + # Reattach the highest-priority frontmatter for commands, + # inheriting scripts/agent_scripts from the base if missing + # and stripping the strategy key (internal-only, not for agent output). + if is_command and top_frontmatter_text: + def _parse_fm_yaml(fm_block: str) -> dict: + """Parse YAML from a frontmatter block (with --- fences).""" + lines = fm_block.splitlines() + # Parse only interior lines (between --- fences) + if len(lines) >= 2: + yaml_lines = lines[1:-1] + else: + yaml_lines = [] + try: + return yaml.safe_load("\n".join(yaml_lines)) or {} + except yaml.YAMLError: + return {} + + top_fm = _parse_fm_yaml(top_frontmatter_text) + + # Inherit scripts/agent_scripts from base frontmatter if missing + if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text: + base_fm = _parse_fm_yaml(base_frontmatter_text) + for key in ("scripts", "agent_scripts"): + if key not in top_fm and key in base_fm: + top_fm[key] = base_fm[key] + + # Strip strategy key — it's an internal composition directive, + # not meant for rendered agent command files + top_fm.pop("strategy", None) + + if top_fm: + top_frontmatter_text = ( + "---\n" + + yaml.safe_dump(top_fm, sort_keys=False).strip() + + "\n---" + ) + else: + # Empty frontmatter — omit rather than emitting {} + top_frontmatter_text = None + + if top_frontmatter_text: + content = top_frontmatter_text + "\n\n" + content + + return content diff --git a/tests/test_presets.py b/tests/test_presets.py index d913c3b195..64bdc1f0b5 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3666,647 +3666,736 @@ def _make_wrap_preset_dir( return preset_dir -class TestReplayWrapsForCommand: - """Tests for PresetManager._replay_wraps_for_command().""" - def test_replay_no_op_when_no_wrap_presets(self, project_dir): - """replay does nothing when no presets declare wrap_commands for the command.""" - manager = PresetManager(project_dir) - # Should not raise - manager._replay_wraps_for_command("speckit.specify") - - def test_replay_no_op_when_core_missing(self, project_dir, temp_dir): - """replay exits gracefully when resolve_core returns None.""" - from specify_cli.agents import CommandRegistrar - import copy - - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.nonexistent-cmd", "pre-a", "post-a") - installed = project_dir / ".specify" / "presets" / "preset-a" - import shutil as _shutil - _shutil.copytree(preset_dir, installed) +class TestCompositionStrategyValidation: + """Test strategy field validation in PresetManifest.""" - manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.nonexistent-cmd"], - }) + def test_valid_replace_strategy(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "replace" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) + def test_valid_prepend_strategy(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - # No core file exists for this command — replay should return without writing - manager._replay_wraps_for_command("speckit.nonexistent-cmd") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + def test_valid_append_strategy(self, temp_dir, valid_pack_data): + """Test that append strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "append" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "append" - assert not (agent_dir / "speckit.nonexistent-cmd.md").exists() + def test_valid_wrap_strategy(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" - def test_replay_single_preset_writes_composed_output(self, project_dir, temp_dir): - """Single wrap preset: replay writes pre + core + post to agent dirs.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + def test_default_strategy_is_replace(self, pack_dir): + """Test that omitting strategy defaults to replace (key is absent).""" + manifest = PresetManifest(pack_dir / "preset.yml") + # Strategy key should not be present in the manifest data + assert "strategy" not in manifest.templates[0] + # But consumers should treat missing strategy as "replace" + assert manifest.templates[0].get("strategy", "replace") == "replace" + + def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data): + """Test that invalid strategy values are rejected.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "merge" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy"): + PresetManifest(manifest_path) - # Core template - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that prepend strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) - # Install preset-a - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) + def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that append strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "append", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) - manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "wrap", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" + + def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "replace", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" + + def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for commands.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - written = (agent_dir / "speckit.specify.md").read_text() - assert "[pre-a]" in written - assert "core body" in written - assert "[post-a]" in written - assert "{CORE_TEMPLATE}" not in written - assert "strategy" not in written +class TestResolveContent: + """Test PresetResolver.resolve_content() composition.""" - def test_replay_uses_manifest_command_file_mapping(self, project_dir, temp_dir): - """Replay reads wrapper files from preset.yml instead of assuming command-name paths.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + def test_resolve_content_core_template(self, project_dir): + """Test resolve_content returns core template when no composition.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - preset_dir = _make_wrap_preset_dir( - temp_dir, - "preset-a", - "speckit.specify", - "pre-a", - "post-a", - file_rel="commands/custom-wrapper.md", - ) - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) + def test_resolve_content_nonexistent(self, project_dir): + """Test resolve_content returns None for nonexistent template.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("nonexistent") + assert content is None + def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with default replace strategy.""" manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory( + _create_pack(temp_dir, valid_pack_data, "replace-pack", + "# Replaced Content\n"), + "0.1.5" + ) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Replaced Content" in content + assert "Core Spec Template" not in content + + def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with append strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") - written = (agent_dir / "speckit.specify.md").read_text() - assert "[pre-a]" in written - assert "CORE" in written - assert "[post-a]" in written + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - def test_replay_resolves_extension_core_via_manifest_mapping(self, project_dir, temp_dir): - """Replay finds extension core commands whose manifest file differs from command name.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content + assert "Appended Section" in content + # Core should come first, appended after + assert content.index("Core Spec Template") < content.index("Appended Section") + + def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with prepend strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] + } + pack_dir = temp_dir / "prepend-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n") - ext_dir = project_dir / ".specify" / "extensions" / "selftest" - cmd_dir = ext_dir / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "selftest.md").write_text( - "---\ndescription: selftest core\n---\n\nEXTENSION-CORE\n" - ) - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " commands:\n" - " - name: speckit.selftest.extension\n" - " file: commands/selftest.md\n" - " description: Selftest command\n" - ) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - preset_dir = _make_wrap_preset_dir( - temp_dir, "preset-a", "speckit.selftest.extension", "pre-a", "post-a" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Security Header" in content + assert "Core Spec Template" in content + # Prepended content should come first + assert content.index("Security Header") < content.index("Core Spec Template") + + def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for templates.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] + } + pack_dir = temp_dir / "wrap-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text( + "# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n" ) - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.selftest.extension"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Wrapper Start" in content + assert "Core Spec Template" in content + assert "Wrapper End" in content + # Wrapper should surround core + assert content.index("Wrapper Start") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Wrapper End") + + def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" + # Create core script + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "script", + "name": "test-script", + "file": "scripts/test-script.sh", + "strategy": "wrap", + }] } - try: - manager._replay_wraps_for_command("speckit.selftest.extension") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.selftest.extension.md").read_text() - assert "[pre-a]" in written - assert "EXTENSION-CORE" in written - assert "[post-a]" in written - - def test_replay_priority_order_lower_number_outermost(self, project_dir, temp_dir): - """Two wrap presets: lower priority number = outermost wrapper.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - for pid in ("preset-outer", "preset-inner"): - src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") - _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + pack_dir = temp_dir / "script-wrap" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "scripts").mkdir() + (pack_dir / "scripts" / "test-script.sh").write_text( + "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" + ) manager = PresetManager(project_dir) - # preset-outer has priority 1 (highest precedence = outermost) - # preset-inner has priority 10 (lowest precedence = innermost) - for pid, pri in (("preset-outer", 1), ("preset-inner", 10)): - manager.registry.add(pid, { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": pri, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("test-script", "script") + assert content is not None + assert "echo 'before'" in content + assert "echo 'core script'" in content + assert "echo 'after'" in content + + def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): + """Test multi-preset composition chain: prepend + append stacking.""" + # Create preset A (priority 1): prepend security header + pack_a_data = {**valid_pack_data} + pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} + pack_a_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - # Outermost (preset-outer, p=1) wraps everything; innermost (preset-inner, p=10) is next - outer_pre = written.index("[pre-preset-outer]") - inner_pre = written.index("[pre-preset-inner]") - core_pos = written.index("CORE") - inner_post = written.index("[post-preset-inner]") - outer_post = written.index("[post-preset-outer]") - assert outer_pre < inner_pre < core_pos < inner_post < outer_post - - def test_replay_install_order_independent(self, project_dir, temp_dir): - """Nesting order is determined by priority, not install order.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - for pid in ("preset-a", "preset-b"): - src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") - _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + pack_a_dir = temp_dir / "preset-a" + pack_a_dir.mkdir() + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(pack_a_data, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + + # Create preset B (priority 2): append compliance footer + pack_b_data = {**valid_pack_data} + pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} + pack_b_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_b_dir = temp_dir / "preset-b" + pack_b_dir.mkdir() + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(pack_b_data, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") manager = PresetManager(project_dir) - # preset-a priority=5 (outermost), preset-b priority=10 (innermost) - # Install in reverse order to verify install order doesn't affect nesting - for pid, pri in (("preset-b", 10), ("preset-a", 5)): - manager.registry.add(pid, { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": pri, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + # Result: + + + assert "Security Header" in content + assert "Core Spec Template" in content + assert "Compliance Footer" in content + assert content.index("Security Header") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Compliance Footer") + + def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): + """Test that project overrides trump composition (replace at top priority).""" + # Install a composing preset + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - a_pre = written.index("[pre-preset-a]") - b_pre = written.index("[pre-preset-b]") - core_pos = written.index("CORE") - b_post = written.index("[post-preset-b]") - a_post = written.index("[post-preset-a]") - # preset-a (p=5) is outermost regardless of install order - assert a_pre < b_pre < core_pos < b_post < a_post - - def test_replay_updates_skill_outputs(self, project_dir, temp_dir): - """Replay also rewrites SKILL.md-backed agent outputs.""" - import json - import shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) - - skills_dir = project_dir / ".claude" / "skills" - skill_subdir = skills_dir / "speckit-specify" - skill_subdir.mkdir(parents=True) - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) - ) - - manager._replay_wraps_for_command("speckit.specify") - - written = (skill_subdir / "SKILL.md").read_text() - assert "[pre-a]" in written - assert "CORE" in written - assert "[post-a]" in written - - def test_replay_applies_integration_post_processing_to_skill(self, project_dir, temp_dir): - """_replay_skill_override must call post_process_skill_content, matching _register_skills.""" - import json - import shutil as _shutil + manager.install_from_directory(pack_dir, "0.1.5") - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + # Create project override (replaces everything) + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override Only\n") - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Override Only" in content + # Override replaces, so appended content should not be visible + assert "Core Spec Template" not in content + + def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with command template type.""" + # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "plan.md").write_text("# Core Plan Command\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "cmd-append" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - skills_dir = project_dir / ".claude" / "skills" - skill_subdir = skills_dir / "speckit-specify" - skill_subdir.mkdir(parents=True) - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.plan", "command") + assert content is not None + assert "Core Plan Command" in content + assert "Additional Instructions" in content + + def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): + """Test that command composition strips frontmatter from lower layers + and reattaches only the highest-priority frontmatter.""" + # Create core command with frontmatter + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "check.md").write_text( + "---\ndescription: Core check command\n---\nCore body content\n" ) - manager._replay_wraps_for_command("speckit.specify") - - # ClaudeIntegration.post_process_skill_content injects these flags. - # Their presence proves the integration hook ran during replay. - written = (skill_subdir / "SKILL.md").read_text() - assert "disable-model-invocation: false" in written, ( - "_replay_skill_override must call post_process_skill_content " - "(same as _register_skills)" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.check", + "file": "commands/speckit.check.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "fm-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.check.md").write_text( + "---\ndescription: Preset check override\n---\nPreset body content\n" ) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") -class TestInstallRemoveWrapLifecycle: - """Tests for wrap_commands stored on install and replayed on remove.""" - - def _setup_agent(self, project_dir, registrar, agent_configs_dict): - """Register a test markdown agent and return its commands dir.""" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - agent_configs_dict["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.check", "command") + assert content is not None + # Should have the preset (highest-priority) frontmatter + assert "Preset check override" in content + # Should have both bodies + assert "Core body content" in content + assert "Preset body content" in content + # Core frontmatter should NOT appear in the body + assert content.count("---") == 2 # only one frontmatter block (opening + closing) + + def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): + """Test that prepend/append use blank line separator.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - return agent_dir + pack_dir = temp_dir / "sep-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("appended") - def test_install_stores_wrap_commands_in_registry(self, project_dir, temp_dir): - """install_from_directory stores wrap_commands in the registry entry.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore\n") - - preset_src = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre", "post") + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + # Should have blank line separator + assert "\n\n" in content + + def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): + """Top-priority replace layer should win even if a lower layer uses wrap.""" + # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) + wrap_data = {**valid_pack_data} + wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} + wrap_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] } - try: - manager = PresetManager(project_dir) - manager.install_from_directory(preset_src, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - meta = manager.registry.get("preset-a") - assert "wrap_commands" in meta - assert "speckit.specify" in meta["wrap_commands"] - - def test_install_replay_produces_correct_nested_output(self, project_dir, temp_dir): - """After installing two wrap presets, agent file contains correctly nested output.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + wrap_dir = temp_dir / "wrap-lo" + wrap_dir.mkdir() + with open(wrap_dir / "preset.yml", "w") as f: + yaml.dump(wrap_data, f) + (wrap_dir / "templates").mkdir() + # Intentionally missing {CORE_TEMPLATE} — would error if composition ran + (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + manager = PresetManager(project_dir) + manager.install_from_directory(wrap_dir, "0.1.5", priority=10) + + # Install a high-priority replace preset + rep_data = {**valid_pack_data} + rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} + rep_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + }] } - try: - manager = PresetManager(project_dir) - # Install outermost first (priority=5), then innermost (priority=10) - outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") - # Rename to avoid id conflict with fixture - inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - outer_pre = written.index("OUTER-PRE") - inner_pre = written.index("INNER-PRE") - core_pos = written.index("CORE") - inner_post = written.index("INNER-POST") - outer_post = written.index("OUTER-POST") - assert outer_pre < inner_pre < core_pos < inner_post < outer_post - - def test_remove_replays_remaining_wraps(self, project_dir, temp_dir): - """Removing one wrap preset re-composes the remaining wraps correctly.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + rep_dir = temp_dir / "rep-hi" + rep_dir.mkdir() + with open(rep_dir / "preset.yml", "w") as f: + yaml.dump(rep_data, f) + (rep_dir / "templates").mkdir() + (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager = PresetManager(project_dir) - outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") - inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - manager.remove("preset-outer") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + manager.install_from_directory(rep_dir, "0.1.5", priority=1) - written = (agent_dir / "speckit.specify.md").read_text() - # Only inner wrap remains — should be: INNER-PRE + CORE + INNER-POST, no OUTER - assert "INNER-PRE" in written - assert "CORE" in written - assert "INNER-POST" in written - assert "OUTER-PRE" not in written - assert "OUTER-POST" not in written - - def test_wrap_aliases_are_replayed_and_removed(self, project_dir, temp_dir): - """Replay preserves wrap aliases across install/remove lifecycle changes.""" - from specify_cli.agents import CommandRegistrar - import copy + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content == "# Replaced content\n" - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager = PresetManager(project_dir) - outer_src = _make_wrap_preset_dir( - temp_dir, - "preset-outer", - "speckit.specify", - "OUTER-PRE", - "OUTER-POST", - aliases=["speckit.alias"], - ) - inner_src = _make_wrap_preset_dir( - temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST" - ) - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - - alias_file = agent_dir / "speckit.alias.md" - written = alias_file.read_text() - assert "OUTER-PRE" in written - assert "INNER-PRE" in written - assert "INNER-POST" in written - assert "OUTER-POST" in written - - manager.remove("preset-inner") - written = alias_file.read_text() - assert "OUTER-PRE" in written - assert "OUTER-POST" in written - assert "INNER-PRE" not in written - assert "INNER-POST" not in written - - manager.remove("preset-outer") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) +class TestCollectAllLayers: + """Test PresetResolver.collect_all_layers() method.""" - assert not (agent_dir / "speckit.alias.md").exists() + def test_single_core_layer(self, project_dir): + """Test collecting layers with only core template.""" + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 1 + assert layers[0]["source"] == "core" + assert layers[0]["strategy"] == "replace" - def test_remove_last_wrap_preset_deletes_agent_file(self, project_dir, temp_dir): - """Removing the only wrap preset deletes the agent command file.""" - from specify_cli.agents import CommandRegistrar - import copy + def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): + """Test that layers include installed preset.""" + manager = PresetManager(project_dir) + pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack", + "# From Pack\n") + manager.install_from_directory(pack_dir, "0.1.5") - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 2 + # Highest priority first + assert "test-pack" in layers[0]["source"] + assert layers[1]["source"] == "core" + + def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): + """Test that layers are ordered by priority (highest first).""" + manager = PresetManager(project_dir) + for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]: + d = {**valid_pack_data} + d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid} + p = temp_dir / pid + p.mkdir() + with open(p / "preset.yml", 'w') as f: + yaml.dump(d, f) + (p / "templates").mkdir() + (p / "templates" / "spec-template.md").write_text(f"# {pid}\n") + manager.install_from_directory(p, "0.1.5", priority=prio) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 3 # pack-hi, pack-lo, core + assert "pack-hi" in layers[0]["source"] + assert "pack-lo" in layers[1]["source"] + assert layers[2]["source"] == "core" + + def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): + """Test that layers read strategy from preset manifest.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager = PresetManager(project_dir) - src = _make_wrap_preset_dir(temp_dir, "preset-only", "speckit.specify", "PRE", "POST") - manager.install_from_directory(src, "0.1.0", priority=10) - assert (agent_dir / "speckit.specify.md").exists() - manager.remove("preset-only") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + pack_dir = temp_dir / "strat-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Footer\n") - assert not (agent_dir / "speckit.specify.md").exists() - - def test_remove_keeps_registry_entry_when_directory_delete_fails(self, project_dir, monkeypatch): - """A failed preset directory delete must not leave files untracked by the registry.""" manager = PresetManager(project_dir) - pack_dir = manager.presets_dir / "preset-a" - pack_dir.mkdir(parents=True) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": [], - }) + manager.install_from_directory(pack_dir, "0.1.5") - def fail_rmtree(_path): - raise OSError("locked") + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + # Preset layer should have strategy=append + assert layers[0]["strategy"] == "append" + # Core layer should be replace + assert layers[1]["strategy"] == "replace" - monkeypatch.setattr(shutil, "rmtree", fail_rmtree) - with pytest.raises(OSError, match="locked"): - manager.remove("preset-a") +class TestRemoveReconciliation: + """Test that removing a preset re-registers the next layer's command.""" - assert manager.registry.is_installed("preset-a") - assert pack_dir.exists() + def test_remove_restores_lower_priority_command( + self, project_dir, temp_dir, valid_pack_data + ): + """After removing the top-priority preset, the next preset's command + should be re-registered in agent directories.""" + manager = PresetManager(project_dir) - def test_non_wrap_commands_unaffected_by_wrap_lifecycle(self, project_dir, temp_dir): - """wrap_commands is empty for a preset with no strategy:wrap commands.""" - from specify_cli.agents import CommandRegistrar - import copy - import yaml as _yaml + # Create a gemini commands dir so reconciliation writes there + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) - # Create a preset with a non-wrap command - preset_dir = temp_dir / "non-wrap-preset" - cmd_dir = preset_dir / "commands" - cmd_dir.mkdir(parents=True) - manifest = { - "schema_version": "1.0", - "preset": { - "id": "non-wrap-preset", "name": "Non-wrap", "version": "1.0.0", - "description": "no wrap", "author": "test", - "repository": "https://example.com", "license": "MIT", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [ - {"type": "command", "name": "speckit.specify", - "file": "commands/speckit.specify.md", "description": "override"}, - ]}, - "tags": [], + # Install a low-priority preset with a command + lo_data = {**valid_pack_data} + lo_data["preset"] = { + **valid_pack_data["preset"], + "id": "lo-preset", + "name": "Lo", } - (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: plain override\n---\n\nplain body\n" + lo_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + lo_dir = temp_dir / "lo-preset" + lo_dir.mkdir() + with open(lo_dir / "preset.yml", "w") as f: + yaml.dump(lo_data, f) + (lo_dir / "commands").mkdir() + (lo_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: lo\n---\nLo content\n" ) - - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + manager.install_from_directory(lo_dir, "0.1.5", priority=10) + + # Install a high-priority preset overriding the same command + hi_data = {**valid_pack_data} + hi_data["preset"] = { + **valid_pack_data["preset"], + "id": "hi-preset", + "name": "Hi", } - try: - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + hi_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + hi_dir = temp_dir / "hi-preset" + hi_dir.mkdir() + with open(hi_dir / "preset.yml", "w") as f: + yaml.dump(hi_data, f) + (hi_dir / "commands").mkdir() + (hi_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: hi\n---\nHi content\n" + ) + manager.install_from_directory(hi_dir, "0.1.5", priority=1) - meta = manager.registry.get("non-wrap-preset") - assert meta.get("wrap_commands", []) == [] - written = (agent_dir / "speckit.specify.md").read_text() - assert "plain body" in written + # Verify the hi-preset's content is active in agent dir + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should exist in gemini dir" + assert "Hi content" in cmd_files[0].read_text() + + # Remove the high-priority preset + manager.remove("hi-preset") + + # The low-priority preset's command should now be in the resolution stack + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.specify", "command") + assert len(layers) >= 1 + assert "lo-preset" in layers[0]["source"] + + # Verify on-disk agent command file switched to lo-preset content + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should still exist after removal" + assert "Lo content" in cmd_files[0].read_text() + + +def _create_pack(temp_dir, valid_pack_data, pack_id, content, + strategy="replace", template_type="template", + template_name="spec-template"): + """Helper to create a preset pack directory.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id} + + tmpl_entry = { + "type": template_type, + "name": template_name, + } + if template_type == "script": + tmpl_entry["file"] = f"scripts/{template_name}.sh" + elif template_type == "command": + tmpl_entry["file"] = f"commands/{template_name}.md" + else: + tmpl_entry["file"] = f"templates/{template_name}.md" + if strategy != "replace": + tmpl_entry["strategy"] = strategy + pack_data["provides"] = {"templates": [tmpl_entry]} + + pack_dir = temp_dir / pack_id + pack_dir.mkdir(exist_ok=True) + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + + if template_type == "script": + subdir = pack_dir / "scripts" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.sh").write_text(content) + elif template_type == "command": + subdir = pack_dir / "commands" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + else: + subdir = pack_dir / "templates" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + + return pack_dir