@cognite/cli 0.5.2 → 0.6.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +118 -33
  2. package/_templates/app/new/config/eslint.config.mjs.ejs.t +99 -0
  3. package/_templates/app/new/config/tsconfig.json.ejs.t +35 -0
  4. package/_templates/app/new/config/tsconfig.node.json.ejs.t +27 -0
  5. package/_templates/app/new/config/vite.config.ts.ejs.t +26 -0
  6. package/_templates/app/new/config/vitest.config.ts.ejs.t +14 -0
  7. package/_templates/app/new/config/vitest.setup.ts.ejs.t +4 -0
  8. package/_templates/app/new/cursor/data-modeling.mdc.ejs.t +1996 -0
  9. package/_templates/app/new/cursor/mcp.json.ejs.t +10 -0
  10. package/_templates/app/new/cursor/rules.mdc.ejs.t +10 -0
  11. package/_templates/app/new/github/ci.yml.ejs.t +36 -0
  12. package/_templates/app/new/prompt.js +49 -0
  13. package/_templates/app/new/root/.npmrc.ejs.t +4 -0
  14. package/_templates/app/new/root/AGENTS.md.ejs.t +215 -0
  15. package/_templates/app/new/root/SPEC.md.ejs.t +77 -0
  16. package/_templates/app/new/root/app.json.ejs.t +22 -0
  17. package/_templates/app/new/root/gitignore.ejs.t +21 -0
  18. package/_templates/app/new/root/index.html.ejs.t +36 -0
  19. package/_templates/app/new/root/package.json.ejs.t +67 -0
  20. package/_templates/app/new/src/App.test.tsx.ejs.t +45 -0
  21. package/_templates/app/new/src/App.tsx.ejs.t +265 -0
  22. package/_templates/app/new/src/lib/utils.ts.ejs.t +9 -0
  23. package/_templates/app/new/src/main.tsx.ejs.t +36 -0
  24. package/_templates/app/new/src/styles.css.ejs.t +12 -0
  25. package/_vendor/spec-kit/.version +4 -0
  26. package/_vendor/spec-kit/README.md +39 -0
  27. package/_vendor/spec-kit/commands/speckit.analyze.md +249 -0
  28. package/_vendor/spec-kit/commands/speckit.checklist.md +361 -0
  29. package/_vendor/spec-kit/commands/speckit.clarify.md +247 -0
  30. package/_vendor/spec-kit/commands/speckit.implement.md +198 -0
  31. package/_vendor/spec-kit/commands/speckit.plan.md +149 -0
  32. package/_vendor/spec-kit/commands/speckit.specify.md +327 -0
  33. package/_vendor/spec-kit/commands/speckit.tasks.md +200 -0
  34. package/_vendor/spec-kit/scripts/bash/check-prerequisites.sh +190 -0
  35. package/_vendor/spec-kit/scripts/bash/common.sh +645 -0
  36. package/_vendor/spec-kit/scripts/bash/setup-plan.sh +75 -0
  37. package/_vendor/spec-kit/templates/checklist-template.md +40 -0
  38. package/_vendor/spec-kit/templates/plan-template.md +104 -0
  39. package/_vendor/spec-kit/templates/spec-template.md +128 -0
  40. package/_vendor/spec-kit/templates/tasks-template.md +251 -0
  41. package/dist/chunk-GFMJ4MJZ.js +8 -0
  42. package/dist/cli/cli.js +347 -0
  43. package/dist/skills-4R3OUI44.js +2 -0
  44. package/package.json +25 -17
  45. package/index.js +0 -116
  46. package/operations.js +0 -113
@@ -0,0 +1,645 @@
1
+ #!/usr/bin/env bash
2
+ # Common functions and variables for all scripts
3
+
4
+ # Find repository root by searching upward for .specify directory
5
+ # This is the primary marker for spec-kit projects
6
+ find_specify_root() {
7
+ local dir="${1:-$(pwd)}"
8
+ # Normalize to absolute path to prevent infinite loop with relative paths
9
+ # Use -- to handle paths starting with - (e.g., -P, -L)
10
+ dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
11
+ local prev_dir=""
12
+ while true; do
13
+ if [ -d "$dir/.specify" ]; then
14
+ echo "$dir"
15
+ return 0
16
+ fi
17
+ # Stop if we've reached filesystem root or dirname stops changing
18
+ if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
19
+ break
20
+ fi
21
+ prev_dir="$dir"
22
+ dir="$(dirname "$dir")"
23
+ done
24
+ return 1
25
+ }
26
+
27
+ # Get repository root, prioritizing .specify directory over git
28
+ # This prevents using a parent git repo when spec-kit is initialized in a subdirectory
29
+ get_repo_root() {
30
+ # First, look for .specify directory (spec-kit's own marker)
31
+ local specify_root
32
+ if specify_root=$(find_specify_root); then
33
+ echo "$specify_root"
34
+ return
35
+ fi
36
+
37
+ # Fallback to git if no .specify found
38
+ if git rev-parse --show-toplevel >/dev/null 2>&1; then
39
+ git rev-parse --show-toplevel
40
+ return
41
+ fi
42
+
43
+ # Final fallback to script location for non-git repos
44
+ local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
45
+ (cd "$script_dir/../../.." && pwd)
46
+ }
47
+
48
+ # Get current branch, with fallback for non-git repositories
49
+ get_current_branch() {
50
+ # First check if SPECIFY_FEATURE environment variable is set
51
+ if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
52
+ echo "$SPECIFY_FEATURE"
53
+ return
54
+ fi
55
+
56
+ # Then check git if available at the spec-kit root (not parent)
57
+ local repo_root=$(get_repo_root)
58
+ if has_git; then
59
+ git -C "$repo_root" rev-parse --abbrev-ref HEAD
60
+ return
61
+ fi
62
+
63
+ # For non-git repos, try to find the latest feature directory
64
+ local specs_dir="$repo_root/specs"
65
+
66
+ if [[ -d "$specs_dir" ]]; then
67
+ local latest_feature=""
68
+ local highest=0
69
+ local latest_timestamp=""
70
+
71
+ for dir in "$specs_dir"/*; do
72
+ if [[ -d "$dir" ]]; then
73
+ local dirname=$(basename "$dir")
74
+ if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
75
+ # Timestamp-based branch: compare lexicographically
76
+ local ts="${BASH_REMATCH[1]}"
77
+ if [[ "$ts" > "$latest_timestamp" ]]; then
78
+ latest_timestamp="$ts"
79
+ latest_feature=$dirname
80
+ fi
81
+ elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
82
+ local number=${BASH_REMATCH[1]}
83
+ number=$((10#$number))
84
+ if [[ "$number" -gt "$highest" ]]; then
85
+ highest=$number
86
+ # Only update if no timestamp branch found yet
87
+ if [[ -z "$latest_timestamp" ]]; then
88
+ latest_feature=$dirname
89
+ fi
90
+ fi
91
+ fi
92
+ fi
93
+ done
94
+
95
+ if [[ -n "$latest_feature" ]]; then
96
+ echo "$latest_feature"
97
+ return
98
+ fi
99
+ fi
100
+
101
+ echo "main" # Final fallback
102
+ }
103
+
104
+ # Check if we have git available at the spec-kit root level
105
+ # Returns true only if git is installed and the repo root is inside a git work tree
106
+ # Handles both regular repos (.git directory) and worktrees/submodules (.git file)
107
+ has_git() {
108
+ # First check if git command is available (before calling get_repo_root which may use git)
109
+ command -v git >/dev/null 2>&1 || return 1
110
+ local repo_root=$(get_repo_root)
111
+ # Check if .git exists (directory or file for worktrees/submodules)
112
+ [ -e "$repo_root/.git" ] || return 1
113
+ # Verify it's actually a valid git work tree
114
+ git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
115
+ }
116
+
117
+ # Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
118
+ # Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
119
+ spec_kit_effective_branch_name() {
120
+ local raw="$1"
121
+ if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
122
+ printf '%s\n' "${BASH_REMATCH[2]}"
123
+ else
124
+ printf '%s\n' "$raw"
125
+ fi
126
+ }
127
+
128
+ check_feature_branch() {
129
+ local raw="$1"
130
+ local has_git_repo="$2"
131
+
132
+ # For non-git repos, we can't enforce branch naming but still provide output
133
+ if [[ "$has_git_repo" != "true" ]]; then
134
+ echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
135
+ return 0
136
+ fi
137
+
138
+ local branch
139
+ branch=$(spec_kit_effective_branch_name "$raw")
140
+
141
+ # Accept sequential prefix (3+ digits) but exclude malformed timestamps
142
+ # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
143
+ local is_sequential=false
144
+ if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
145
+ is_sequential=true
146
+ fi
147
+ if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
148
+ echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
149
+ echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
150
+ return 1
151
+ fi
152
+
153
+ return 0
154
+ }
155
+
156
+ # Safely read .specify/feature.json's "feature_directory" value.
157
+ # Prints the raw value (possibly relative) to stdout, or empty string if the file
158
+ # is missing, unparseable, or does not contain the key. Always returns 0 so callers
159
+ # under `set -e` cannot be aborted by parser failure.
160
+ # Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
161
+ read_feature_json_feature_directory() {
162
+ local repo_root="$1"
163
+ local fj="$repo_root/.specify/feature.json"
164
+ [[ -f "$fj" ]] || { printf '%s' ''; return 0; }
165
+
166
+ local _fd=''
167
+ if command -v jq >/dev/null 2>&1; then
168
+ if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
169
+ _fd=''
170
+ fi
171
+ elif command -v python3 >/dev/null 2>&1; then
172
+ # Use Python so pretty-printed/multi-line JSON still parses correctly.
173
+ 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
174
+ _fd=''
175
+ fi
176
+ else
177
+ # Last-resort single-line grep/sed fallback. The `|| true` guards against
178
+ # grep returning 1 (no match) aborting under `set -e` / `pipefail`.
179
+ _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
180
+ | head -n 1 \
181
+ | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
182
+ fi
183
+
184
+ printf '%s' "$_fd"
185
+ return 0
186
+ }
187
+
188
+ # Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
189
+ # and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks).
190
+ # Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
191
+ feature_json_matches_feature_dir() {
192
+ local repo_root="$1"
193
+ local active_feature_dir="$2"
194
+
195
+ local _fd
196
+ _fd=$(read_feature_json_feature_directory "$repo_root")
197
+
198
+ [[ -n "$_fd" ]] || return 1
199
+ [[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
200
+ [[ -d "$_fd" ]] || return 1
201
+
202
+ local norm_json norm_active
203
+ norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
204
+ norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
205
+
206
+ [[ "$norm_json" == "$norm_active" ]]
207
+ }
208
+
209
+ # Find feature directory by numeric prefix instead of exact branch match
210
+ # This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
211
+ find_feature_dir_by_prefix() {
212
+ local repo_root="$1"
213
+ local branch_name
214
+ branch_name=$(spec_kit_effective_branch_name "$2")
215
+ local specs_dir="$repo_root/specs"
216
+
217
+ # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
218
+ local prefix=""
219
+ if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
220
+ prefix="${BASH_REMATCH[1]}"
221
+ elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
222
+ prefix="${BASH_REMATCH[1]}"
223
+ else
224
+ # If branch doesn't have a recognized prefix, fall back to exact match
225
+ echo "$specs_dir/$branch_name"
226
+ return
227
+ fi
228
+
229
+ # Search for directories in specs/ that start with this prefix
230
+ local matches=()
231
+ if [[ -d "$specs_dir" ]]; then
232
+ for dir in "$specs_dir"/"$prefix"-*; do
233
+ if [[ -d "$dir" ]]; then
234
+ matches+=("$(basename "$dir")")
235
+ fi
236
+ done
237
+ fi
238
+
239
+ # Handle results
240
+ if [[ ${#matches[@]} -eq 0 ]]; then
241
+ # No match found - return the branch name path (will fail later with clear error)
242
+ echo "$specs_dir/$branch_name"
243
+ elif [[ ${#matches[@]} -eq 1 ]]; then
244
+ # Exactly one match - perfect!
245
+ echo "$specs_dir/${matches[0]}"
246
+ else
247
+ # Multiple matches - this shouldn't happen with proper naming convention
248
+ echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
249
+ echo "Please ensure only one spec directory exists per prefix." >&2
250
+ return 1
251
+ fi
252
+ }
253
+
254
+ get_feature_paths() {
255
+ local repo_root=$(get_repo_root)
256
+ local current_branch=$(get_current_branch)
257
+ local has_git_repo="false"
258
+
259
+ if has_git; then
260
+ has_git_repo="true"
261
+ fi
262
+
263
+ # Resolve feature directory. Priority:
264
+ # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
265
+ # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
266
+ # 3. Branch-name-based prefix lookup (legacy fallback)
267
+ local feature_dir
268
+ if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
269
+ feature_dir="$SPECIFY_FEATURE_DIRECTORY"
270
+ # Normalize relative paths to absolute under repo root
271
+ [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
272
+ elif [[ -f "$repo_root/.specify/feature.json" ]]; then
273
+ # Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
274
+ # missing/unparseable/unset so we fall through to the branch-prefix lookup.
275
+ local _fd
276
+ _fd=$(read_feature_json_feature_directory "$repo_root")
277
+ if [[ -n "$_fd" ]]; then
278
+ feature_dir="$_fd"
279
+ # Normalize relative paths to absolute under repo root
280
+ [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
281
+ elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
282
+ echo "ERROR: Failed to resolve feature directory" >&2
283
+ return 1
284
+ fi
285
+ elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
286
+ echo "ERROR: Failed to resolve feature directory" >&2
287
+ return 1
288
+ fi
289
+
290
+ # Use printf '%q' to safely quote values, preventing shell injection
291
+ # via crafted branch names or paths containing special characters
292
+ printf 'REPO_ROOT=%q\n' "$repo_root"
293
+ printf 'CURRENT_BRANCH=%q\n' "$current_branch"
294
+ printf 'HAS_GIT=%q\n' "$has_git_repo"
295
+ printf 'FEATURE_DIR=%q\n' "$feature_dir"
296
+ printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
297
+ printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
298
+ printf 'TASKS=%q\n' "$feature_dir/tasks.md"
299
+ printf 'RESEARCH=%q\n' "$feature_dir/research.md"
300
+ printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
301
+ printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
302
+ printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
303
+ }
304
+
305
+ # Check if jq is available for safe JSON construction
306
+ has_jq() {
307
+ command -v jq >/dev/null 2>&1
308
+ }
309
+
310
+ # Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
311
+ # Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
312
+ json_escape() {
313
+ local s="$1"
314
+ s="${s//\\/\\\\}"
315
+ s="${s//\"/\\\"}"
316
+ s="${s//$'\n'/\\n}"
317
+ s="${s//$'\t'/\\t}"
318
+ s="${s//$'\r'/\\r}"
319
+ s="${s//$'\b'/\\b}"
320
+ s="${s//$'\f'/\\f}"
321
+ # Escape any remaining U+0001-U+001F control characters as \uXXXX.
322
+ # (U+0000/NUL cannot appear in bash strings and is excluded.)
323
+ # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
324
+ # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
325
+ local LC_ALL=C
326
+ local i char code
327
+ for (( i=0; i<${#s}; i++ )); do
328
+ char="${s:$i:1}"
329
+ printf -v code '%d' "'$char" 2>/dev/null || code=256
330
+ if (( code >= 1 && code <= 31 )); then
331
+ printf '\\u%04x' "$code"
332
+ else
333
+ printf '%s' "$char"
334
+ fi
335
+ done
336
+ }
337
+
338
+ check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
339
+ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
340
+
341
+ # Resolve a template name to a file path using the priority stack:
342
+ # 1. .specify/templates/overrides/
343
+ # 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
344
+ # 3. .specify/extensions/<ext-id>/templates/
345
+ # 4. .specify/templates/ (core)
346
+ resolve_template() {
347
+ local template_name="$1"
348
+ local repo_root="$2"
349
+ local base="$repo_root/.specify/templates"
350
+
351
+ # Priority 1: Project overrides
352
+ local override="$base/overrides/${template_name}.md"
353
+ [ -f "$override" ] && echo "$override" && return 0
354
+
355
+ # Priority 2: Installed presets (sorted by priority from .registry)
356
+ local presets_dir="$repo_root/.specify/presets"
357
+ if [ -d "$presets_dir" ]; then
358
+ local registry_file="$presets_dir/.registry"
359
+ if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
360
+ # Read preset IDs sorted by priority (lower number = higher precedence).
361
+ # The python3 call is wrapped in an if-condition so that set -e does not
362
+ # abort the function when python3 exits non-zero (e.g. invalid JSON).
363
+ local sorted_presets=""
364
+ if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
365
+ import json, sys, os
366
+ try:
367
+ with open(os.environ['SPECKIT_REGISTRY']) as f:
368
+ data = json.load(f)
369
+ presets = data.get('presets', {})
370
+ for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
371
+ if isinstance(meta, dict) and meta.get('enabled', True) is not False:
372
+ print(pid)
373
+ except Exception:
374
+ sys.exit(1)
375
+ " 2>/dev/null); then
376
+ if [ -n "$sorted_presets" ]; then
377
+ # python3 succeeded and returned preset IDs — search in priority order
378
+ while IFS= read -r preset_id; do
379
+ local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
380
+ [ -f "$candidate" ] && echo "$candidate" && return 0
381
+ done <<< "$sorted_presets"
382
+ fi
383
+ # python3 succeeded but registry has no presets — nothing to search
384
+ else
385
+ # python3 failed (missing, or registry parse error) — fall back to unordered directory scan
386
+ for preset in "$presets_dir"/*/; do
387
+ [ -d "$preset" ] || continue
388
+ local candidate="$preset/templates/${template_name}.md"
389
+ [ -f "$candidate" ] && echo "$candidate" && return 0
390
+ done
391
+ fi
392
+ else
393
+ # Fallback: alphabetical directory order (no python3 available)
394
+ for preset in "$presets_dir"/*/; do
395
+ [ -d "$preset" ] || continue
396
+ local candidate="$preset/templates/${template_name}.md"
397
+ [ -f "$candidate" ] && echo "$candidate" && return 0
398
+ done
399
+ fi
400
+ fi
401
+
402
+ # Priority 3: Extension-provided templates
403
+ local ext_dir="$repo_root/.specify/extensions"
404
+ if [ -d "$ext_dir" ]; then
405
+ for ext in "$ext_dir"/*/; do
406
+ [ -d "$ext" ] || continue
407
+ # Skip hidden directories (e.g. .backup, .cache)
408
+ case "$(basename "$ext")" in .*) continue;; esac
409
+ local candidate="$ext/templates/${template_name}.md"
410
+ [ -f "$candidate" ] && echo "$candidate" && return 0
411
+ done
412
+ fi
413
+
414
+ # Priority 4: Core templates
415
+ local core="$base/${template_name}.md"
416
+ [ -f "$core" ] && echo "$core" && return 0
417
+
418
+ # Template not found in any location.
419
+ # Return 1 so callers can distinguish "not found" from "found".
420
+ # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
421
+ return 1
422
+ }
423
+
424
+ # Resolve a template name to composed content using composition strategies.
425
+ # Reads strategy metadata from preset manifests and composes content
426
+ # from multiple layers using prepend, append, or wrap strategies.
427
+ #
428
+ # Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
429
+ # Returns composed content string on stdout; exit code 1 if not found.
430
+ resolve_template_content() {
431
+ local template_name="$1"
432
+ local repo_root="$2"
433
+ local base="$repo_root/.specify/templates"
434
+
435
+ # Collect all layers (highest priority first)
436
+ local -a layer_paths=()
437
+ local -a layer_strategies=()
438
+
439
+ # Priority 1: Project overrides (always "replace")
440
+ local override="$base/overrides/${template_name}.md"
441
+ if [ -f "$override" ]; then
442
+ layer_paths+=("$override")
443
+ layer_strategies+=("replace")
444
+ fi
445
+
446
+ # Priority 2: Installed presets (sorted by priority from .registry)
447
+ local presets_dir="$repo_root/.specify/presets"
448
+ if [ -d "$presets_dir" ]; then
449
+ local registry_file="$presets_dir/.registry"
450
+ local sorted_presets=""
451
+ if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
452
+ if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
453
+ import json, sys, os
454
+ try:
455
+ with open(os.environ['SPECKIT_REGISTRY']) as f:
456
+ data = json.load(f)
457
+ presets = data.get('presets', {})
458
+ for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
459
+ if isinstance(meta, dict) and meta.get('enabled', True) is not False:
460
+ print(pid)
461
+ except Exception:
462
+ sys.exit(1)
463
+ " 2>/dev/null); then
464
+ if [ -n "$sorted_presets" ]; then
465
+ local yaml_warned=false
466
+ while IFS= read -r preset_id; do
467
+ # Read strategy and file path from preset manifest
468
+ local strategy="replace"
469
+ local manifest_file=""
470
+ local manifest="$presets_dir/$preset_id/preset.yml"
471
+ if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
472
+ # Requires PyYAML; falls back to replace/convention if unavailable
473
+ local result
474
+ local py_stderr
475
+ py_stderr=$(mktemp)
476
+ result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
477
+ import sys, os
478
+ try:
479
+ import yaml
480
+ except ImportError:
481
+ print('yaml_missing', file=sys.stderr)
482
+ print('replace\t')
483
+ sys.exit(0)
484
+ try:
485
+ with open(os.environ['SPECKIT_MANIFEST']) as f:
486
+ data = yaml.safe_load(f)
487
+ for t in data.get('provides', {}).get('templates', []):
488
+ if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
489
+ print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
490
+ sys.exit(0)
491
+ print('replace\t')
492
+ except Exception:
493
+ print('replace\t')
494
+ " 2>"$py_stderr")
495
+ local parse_status=$?
496
+ if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
497
+ IFS=$'\t' read -r strategy manifest_file <<< "$result"
498
+ strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
499
+ fi
500
+ if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
501
+ echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
502
+ yaml_warned=true
503
+ fi
504
+ rm -f "$py_stderr"
505
+ fi
506
+ # Try manifest file path first, then convention path
507
+ local candidate=""
508
+ if [ -n "$manifest_file" ]; then
509
+ # Reject absolute paths and parent traversal
510
+ case "$manifest_file" in
511
+ /*|*../*|../*) manifest_file="" ;;
512
+ esac
513
+ fi
514
+ if [ -n "$manifest_file" ]; then
515
+ local mf="$presets_dir/$preset_id/$manifest_file"
516
+ [ -f "$mf" ] && candidate="$mf"
517
+ fi
518
+ if [ -z "$candidate" ]; then
519
+ local cf="$presets_dir/$preset_id/templates/${template_name}.md"
520
+ [ -f "$cf" ] && candidate="$cf"
521
+ fi
522
+ if [ -n "$candidate" ]; then
523
+ layer_paths+=("$candidate")
524
+ layer_strategies+=("$strategy")
525
+ fi
526
+ done <<< "$sorted_presets"
527
+ fi
528
+ else
529
+ # python3 failed — fall back to unordered directory scan (replace only)
530
+ for preset in "$presets_dir"/*/; do
531
+ [ -d "$preset" ] || continue
532
+ local candidate="$preset/templates/${template_name}.md"
533
+ if [ -f "$candidate" ]; then
534
+ layer_paths+=("$candidate")
535
+ layer_strategies+=("replace")
536
+ fi
537
+ done
538
+ fi
539
+ else
540
+ # No python3 or registry — fall back to unordered directory scan (replace only)
541
+ for preset in "$presets_dir"/*/; do
542
+ [ -d "$preset" ] || continue
543
+ local candidate="$preset/templates/${template_name}.md"
544
+ if [ -f "$candidate" ]; then
545
+ layer_paths+=("$candidate")
546
+ layer_strategies+=("replace")
547
+ fi
548
+ done
549
+ fi
550
+ fi
551
+
552
+ # Priority 3: Extension-provided templates (always "replace")
553
+ local ext_dir="$repo_root/.specify/extensions"
554
+ if [ -d "$ext_dir" ]; then
555
+ for ext in "$ext_dir"/*/; do
556
+ [ -d "$ext" ] || continue
557
+ case "$(basename "$ext")" in .*) continue;; esac
558
+ local candidate="$ext/templates/${template_name}.md"
559
+ if [ -f "$candidate" ]; then
560
+ layer_paths+=("$candidate")
561
+ layer_strategies+=("replace")
562
+ fi
563
+ done
564
+ fi
565
+
566
+ # Priority 4: Core templates (always "replace")
567
+ local core="$base/${template_name}.md"
568
+ if [ -f "$core" ]; then
569
+ layer_paths+=("$core")
570
+ layer_strategies+=("replace")
571
+ fi
572
+
573
+ local count=${#layer_paths[@]}
574
+ [ "$count" -eq 0 ] && return 1
575
+
576
+ # Check if any layer uses a non-replace strategy
577
+ local has_composition=false
578
+ for s in "${layer_strategies[@]}"; do
579
+ [ "$s" != "replace" ] && has_composition=true && break
580
+ done
581
+
582
+ # If the top (highest-priority) layer is replace, it wins entirely —
583
+ # lower layers are irrelevant regardless of their strategies.
584
+ if [ "${layer_strategies[0]}" = "replace" ]; then
585
+ cat "${layer_paths[0]}"
586
+ return 0
587
+ fi
588
+
589
+ if [ "$has_composition" = false ]; then
590
+ cat "${layer_paths[0]}"
591
+ return 0
592
+ fi
593
+
594
+ # Find the effective base: scan from highest priority (index 0) downward
595
+ # to find the nearest replace layer. Only compose layers above that base.
596
+ local base_idx=-1
597
+ local i
598
+ for (( i=0; i<count; i++ )); do
599
+ if [ "${layer_strategies[$i]}" = "replace" ]; then
600
+ base_idx=$i
601
+ break
602
+ fi
603
+ done
604
+
605
+ if [ $base_idx -lt 0 ]; then
606
+ return 1 # no base layer found
607
+ fi
608
+
609
+ # Read the base content; compose layers above the base (higher priority)
610
+ local content
611
+ content=$(cat "${layer_paths[$base_idx]}"; printf x)
612
+ content="${content%x}"
613
+
614
+ for (( i=base_idx-1; i>=0; i-- )); do
615
+ local path="${layer_paths[$i]}"
616
+ local strat="${layer_strategies[$i]}"
617
+ local layer_content
618
+ # Preserve trailing newlines
619
+ layer_content=$(cat "$path"; printf x)
620
+ layer_content="${layer_content%x}"
621
+
622
+ case "$strat" in
623
+ replace) content="$layer_content" ;;
624
+ prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
625
+ append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
626
+ wrap)
627
+ case "$layer_content" in
628
+ *'{CORE_TEMPLATE}'*) ;;
629
+ *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
630
+ esac
631
+ while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
632
+ local before="${layer_content%%\{CORE_TEMPLATE\}*}"
633
+ local after="${layer_content#*\{CORE_TEMPLATE\}}"
634
+ layer_content="${before}${content}${after}"
635
+ done
636
+ content="$layer_content"
637
+ ;;
638
+ *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
639
+ esac
640
+ done
641
+
642
+ printf '%s' "$content"
643
+ return 0
644
+ }
645
+