#!/usr/bin/env bash # Common functions and variables for all scripts # Find repository root by searching upward for .specify directory # This is the primary marker for spec-kit projects find_specify_root() { local dir="${1:-$(pwd)}" # Normalize to absolute path to prevent infinite loop with relative paths # Use -- to handle paths starting with - (e.g., -P, -L) dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 local prev_dir="" while true; do if [ -d "$dir/.specify" ]; then echo "$dir" return 0 fi # Stop if we've reached filesystem root or dirname stops changing if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then break fi prev_dir="$dir" dir="$(dirname "$dir")" done return 1 } # Get repository root, prioritizing .specify directory # This prevents using a parent repository when spec-kit is initialized in a subdirectory get_repo_root() { # First, look for .specify directory (spec-kit's own marker) local specify_root if specify_root=$(find_specify_root); then echo "$specify_root" return fi # Final fallback to script location local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" (cd "$script_dir/../../.." && pwd) } # Get current feature name from explicit state only. # Returns the feature identifier or empty string if none is set. # Feature state is set by SPECIFY_FEATURE (from create-new-feature or # the git extension) or implicitly via .specify/feature.json. get_current_branch() { if [[ -n "${SPECIFY_FEATURE:-}" ]]; then echo "$SPECIFY_FEATURE" return fi # No explicit feature set — caller must handle this via feature.json # in get_feature_paths(). Return empty to signal "unknown". echo "" } # Safely read .specify/feature.json's "feature_directory" value. # Prints the raw value (possibly relative) to stdout, or empty string if the file # is missing, unparseable, or does not contain the key. Always returns 0 so callers # under `set -e` cannot be aborted by parser failure. # Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. read_feature_json_feature_directory() { local repo_root="$1" local fj="$repo_root/.specify/feature.json" [[ -f "$fj" ]] || { printf '%s' ''; return 0; } local _fd='' if command -v jq >/dev/null 2>&1; then if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then _fd='' fi elif command -v python3 >/dev/null 2>&1; then # Use Python so pretty-printed/multi-line JSON still parses correctly. if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then _fd='' fi else # Last-resort single-line grep/sed fallback. The `|| true` guards against # grep returning 1 (no match) aborting under `set -e` / `pipefail`. _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ | head -n 1 \ | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) fi printf '%s' "$_fd" return 0 } # Persist a feature_directory value to .specify/feature.json. # Writes only when the file is missing or the value differs from what's stored. # Accepts the raw (possibly relative) path — callers should pass the original # user-supplied value, not the normalized absolute path. _persist_feature_json() { local repo_root="$1" local feature_dir_value="$2" local fj="$repo_root/.specify/feature.json" # Strip repo_root prefix if the value is absolute and under repo_root if [[ "$feature_dir_value" == "$repo_root/"* ]]; then feature_dir_value="${feature_dir_value#"$repo_root/"}" fi # Read current value (if any) and skip write when unchanged local current_val current_val=$(read_feature_json_feature_directory "$repo_root") if [[ "$current_val" == "$feature_dir_value" ]]; then return 0 fi # Ensure .specify/ directory exists mkdir -p "$repo_root/.specify" # Write feature.json — prefer jq for safe JSON, fall back to printf if command -v jq >/dev/null 2>&1; then jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj" else printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj" fi } get_feature_paths() { local repo_root=$(get_repo_root) local current_branch=$(get_current_branch) # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 2. .specify/feature.json "feature_directory" key (persisted by specify command) # 3. Error — no feature context available local feature_dir if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then feature_dir="$SPECIFY_FEATURE_DIRECTORY" # Normalize relative paths to absolute under repo root [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" # Persist to feature.json so future sessions without the env var still work _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" elif [[ -f "$repo_root/.specify/feature.json" ]]; then local _fd _fd=$(read_feature_json_feature_directory "$repo_root") if [[ -n "$_fd" ]]; then feature_dir="$_fd" # Normalize relative paths to absolute under repo root [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" else echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2 return 1 fi else echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2 return 1 fi # Use printf '%q' to safely quote values, preventing shell injection # via crafted branch names or paths containing special characters printf 'REPO_ROOT=%q\n' "$repo_root" printf 'CURRENT_BRANCH=%q\n' "$current_branch" printf 'FEATURE_DIR=%q\n' "$feature_dir" printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" printf 'TASKS=%q\n' "$feature_dir/tasks.md" printf 'RESEARCH=%q\n' "$feature_dir/research.md" printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" } # Check if jq is available for safe JSON construction has_jq() { command -v jq >/dev/null 2>&1 } get_invoke_separator() { local repo_root="${1:-$(get_repo_root)}" if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" return 0 fi local integration_json="$repo_root/.specify/integration.json" local separator="." local parsed_with_jq=0 if [[ -f "$integration_json" ]]; then if command -v jq >/dev/null 2>&1; then local jq_separator if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then parsed_with_jq=1 case "$jq_separator" in "."|"-") separator="$jq_separator" ;; esac fi fi if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null import json import sys try: with open(sys.argv[1], encoding="utf-8") as fh: state = json.load(fh) key = state.get("default_integration") or state.get("integration") or "" settings = state.get("integration_settings") separator = "." if isinstance(key, str) and isinstance(settings, dict): entry = settings.get(key) if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}: separator = entry["invoke_separator"] print(separator) except Exception: print(".") PY ); then case "$separator" in "."|"-") ;; *) separator="." ;; esac else separator="." fi fi fi _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" printf '%s\n' "$separator" } format_speckit_command() { local command_name="$1" local repo_root="${2:-$(get_repo_root)}" local separator if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" else separator=$(get_invoke_separator "$repo_root") _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" fi command_name="${command_name#/}" command_name="${command_name#speckit.}" command_name="${command_name#speckit-}" command_name="${command_name//./$separator}" printf '/speckit%s%s\n' "$separator" "$command_name" } # Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). # Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). json_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\"/\\\"}" s="${s//$'\n'/\\n}" s="${s//$'\t'/\\t}" s="${s//$'\r'/\\r}" s="${s//$'\b'/\\b}" s="${s//$'\f'/\\f}" # Escape any remaining U+0001-U+001F control characters as \uXXXX. # (U+0000/NUL cannot appear in bash strings and is excluded.) # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. local LC_ALL=C local i char code for (( i=0; i<${#s}; i++ )); do char="${s:$i:1}" printf -v code '%d' "'$char" 2>/dev/null || code=256 if (( code >= 1 && code <= 31 )); then printf '\\u%04x' "$code" else printf '%s' "$char" fi done } check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } # 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) # 3. .specify/extensions//templates/ # 4. .specify/templates/ (core) resolve_template() { local template_name="$1" local repo_root="$2" local base="$repo_root/.specify/templates" # Priority 1: Project overrides local override="$base/overrides/${template_name}.md" [ -f "$override" ] && echo "$override" && return 0 # 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" if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then # Read preset IDs sorted by priority (lower number = higher precedence). # The python3 call is wrapped in an if-condition so that set -e does not # abort the function when python3 exits non-zero (e.g. invalid JSON). local sorted_presets="" 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 # python3 succeeded and returned preset IDs — search in priority order while IFS= read -r preset_id; do local candidate="$presets_dir/$preset_id/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done <<< "$sorted_presets" fi # python3 succeeded but registry has no presets — nothing to search else # python3 failed (missing, or registry parse error) — fall back to unordered directory scan for preset in "$presets_dir"/*/; do [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done fi else # Fallback: alphabetical directory order (no python3 available) for preset in "$presets_dir"/*/; do [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done fi fi # Priority 3: Extension-provided templates local ext_dir="$repo_root/.specify/extensions" if [ -d "$ext_dir" ]; then for ext in "$ext_dir"/*/; do [ -d "$ext" ] || continue # Skip hidden directories (e.g. .backup, .cache) case "$(basename "$ext")" in .*) continue;; esac local candidate="$ext/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done fi # Priority 4: Core templates local core="$base/${template_name}.md" [ -f "$core" ] && echo "$core" && return 0 # Template not found in any location. # Return 1 so callers can distinguish "not found" from "found". # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true 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 }