@cognite/cli 0.5.2 → 0.6.0-alpha.26
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.
- package/README.md +94 -33
- package/_templates/app/new/config/eslint.config.mjs.ejs.t +99 -0
- package/_templates/app/new/config/tsconfig.json.ejs.t +35 -0
- package/_templates/app/new/config/tsconfig.node.json.ejs.t +27 -0
- package/_templates/app/new/config/vite.config.ts.ejs.t +28 -0
- package/_templates/app/new/config/vitest.config.ts.ejs.t +14 -0
- package/_templates/app/new/config/vitest.setup.ts.ejs.t +4 -0
- package/_templates/app/new/github/ci.yml.ejs.t +36 -0
- package/_templates/app/new/prompt.js +49 -0
- package/_templates/app/new/root/.npmrc.ejs.t +4 -0
- package/_templates/app/new/root/AGENTS.md.ejs.t +215 -0
- package/_templates/app/new/root/SPEC.md.ejs.t +77 -0
- package/_templates/app/new/root/app.json.ejs.t +20 -0
- package/_templates/app/new/root/gitignore.ejs.t +21 -0
- package/_templates/app/new/root/index.html.ejs.t +36 -0
- package/_templates/app/new/root/manifest.json.ejs.t +9 -0
- package/_templates/app/new/root/package.json.ejs.t +65 -0
- package/_templates/app/new/src/App.test.tsx.ejs.t +45 -0
- package/_templates/app/new/src/App.tsx.ejs.t +234 -0
- package/_templates/app/new/src/lib/utils.ts.ejs.t +9 -0
- package/_templates/app/new/src/main.tsx.ejs.t +27 -0
- package/_templates/app/new/src/styles.css.ejs.t +12 -0
- package/_vendor/spec-kit/.version +4 -0
- package/_vendor/spec-kit/README.md +39 -0
- package/_vendor/spec-kit/commands/speckit.analyze.md +249 -0
- package/_vendor/spec-kit/commands/speckit.checklist.md +361 -0
- package/_vendor/spec-kit/commands/speckit.clarify.md +247 -0
- package/_vendor/spec-kit/commands/speckit.implement.md +198 -0
- package/_vendor/spec-kit/commands/speckit.plan.md +149 -0
- package/_vendor/spec-kit/commands/speckit.specify.md +327 -0
- package/_vendor/spec-kit/commands/speckit.tasks.md +200 -0
- package/_vendor/spec-kit/scripts/bash/check-prerequisites.sh +190 -0
- package/_vendor/spec-kit/scripts/bash/common.sh +645 -0
- package/_vendor/spec-kit/scripts/bash/setup-plan.sh +75 -0
- package/_vendor/spec-kit/templates/checklist-template.md +40 -0
- package/_vendor/spec-kit/templates/plan-template.md +104 -0
- package/_vendor/spec-kit/templates/spec-template.md +128 -0
- package/_vendor/spec-kit/templates/tasks-template.md +251 -0
- package/dist/chunk-6IFTGM5Y.js +6 -0
- package/dist/chunk-6JBK3X6U.js +2 -0
- package/dist/chunk-7BIIU2MQ.js +8 -0
- package/dist/chunk-CQ5OFVL5.js +2 -0
- package/dist/chunk-F3TJC2SP.js +2 -0
- package/dist/cli/cli.js +350 -0
- package/dist/esm-OFTP7G2W.js +34 -0
- package/dist/getMachineId-bsd-3GB6MPGO.js +2 -0
- package/dist/getMachineId-darwin-4AJ74CH4.js +3 -0
- package/dist/getMachineId-linux-IEUC3AW3.js +2 -0
- package/dist/getMachineId-unsupported-YOCUE26C.js +2 -0
- package/dist/getMachineId-win-DDKCA2D6.js +2 -0
- package/dist/skills-R7PLBJFQ.js +2 -0
- package/package.json +26 -17
- package/index.js +0 -116
- 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
|
+
|