duaer-spec 0.1.0
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/.cursor/rules/agents-workflow.mdc +50 -0
- package/.cursor/rules/ai-ui-copy.mdc +12 -0
- package/.cursor/rules/duaer-spec.mdc +33 -0
- package/.cursor/skills/duaer-analyze/SKILL.md +259 -0
- package/.cursor/skills/duaer-checklist/SKILL.md +383 -0
- package/.cursor/skills/duaer-clarify/SKILL.md +291 -0
- package/.cursor/skills/duaer-constitution/SKILL.md +177 -0
- package/.cursor/skills/duaer-converge/SKILL.md +277 -0
- package/.cursor/skills/duaer-git-commit/SKILL.md +68 -0
- package/.cursor/skills/duaer-git-feature/SKILL.md +94 -0
- package/.cursor/skills/duaer-git-initialize/SKILL.md +54 -0
- package/.cursor/skills/duaer-git-remote/SKILL.md +50 -0
- package/.cursor/skills/duaer-git-validate/SKILL.md +54 -0
- package/.cursor/skills/duaer-implement/SKILL.md +226 -0
- package/.cursor/skills/duaer-plan/SKILL.md +166 -0
- package/.cursor/skills/duaer-specify/SKILL.md +345 -0
- package/.cursor/skills/duaer-tasks/SKILL.md +214 -0
- package/.cursor/skills/duaer-taskstoissues/SKILL.md +109 -0
- package/.duaer/extensions/.registry +23 -0
- package/.duaer/extensions/git/README.md +119 -0
- package/.duaer/extensions/git/commands/duaer.git.commit.md +63 -0
- package/.duaer/extensions/git/commands/duaer.git.feature.md +82 -0
- package/.duaer/extensions/git/commands/duaer.git.initialize.md +49 -0
- package/.duaer/extensions/git/commands/duaer.git.remote.md +45 -0
- package/.duaer/extensions/git/commands/duaer.git.validate.md +49 -0
- package/.duaer/extensions/git/config-template.yml +79 -0
- package/.duaer/extensions/git/extension.yml +142 -0
- package/.duaer/extensions/git/git-config.yml +79 -0
- package/.duaer/extensions/git/scripts/bash/auto-commit.sh +211 -0
- package/.duaer/extensions/git/scripts/bash/create-new-feature-branch.sh +626 -0
- package/.duaer/extensions/git/scripts/bash/git-common.sh +56 -0
- package/.duaer/extensions/git/scripts/bash/initialize-repo.sh +54 -0
- package/.duaer/extensions/git/scripts/powershell/auto-commit.ps1 +230 -0
- package/.duaer/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +592 -0
- package/.duaer/extensions/git/scripts/powershell/git-common.ps1 +52 -0
- package/.duaer/extensions/git/scripts/powershell/initialize-repo.ps1 +69 -0
- package/.duaer/extensions/git/scripts/python/auto_commit.py +195 -0
- package/.duaer/extensions/git/scripts/python/create_new_feature_branch.py +634 -0
- package/.duaer/extensions/git/scripts/python/git_common.py +81 -0
- package/.duaer/extensions/git/scripts/python/initialize_repo.py +89 -0
- package/.duaer/extensions.yml +167 -0
- package/.duaer/init-options.json +9 -0
- package/.duaer/integration.json +15 -0
- package/.duaer/integrations/cursor-agent.manifest.json +17 -0
- package/.duaer/integrations/duaer.manifest.json +19 -0
- package/.duaer/memory/.constitution-template.json +4 -0
- package/.duaer/memory/constitution.md +37 -0
- package/.duaer/memory/project-context.md +24 -0
- package/.duaer/memory/testing.md +14 -0
- package/.duaer/scripts/bash/check-prerequisites.sh +243 -0
- package/.duaer/scripts/bash/common.sh +926 -0
- package/.duaer/scripts/bash/create-new-feature.sh +407 -0
- package/.duaer/scripts/bash/resolve-template.sh +57 -0
- package/.duaer/scripts/bash/setup-plan.sh +85 -0
- package/.duaer/scripts/bash/setup-tasks.sh +94 -0
- package/.duaer/templates/checklist-template.md +45 -0
- package/.duaer/templates/constitution-template.md +50 -0
- package/.duaer/templates/plan-template.md +113 -0
- package/.duaer/templates/spec-template.md +131 -0
- package/.duaer/templates/tasks-template.md +252 -0
- package/.duaer/workflows/duaer/workflow.yml +78 -0
- package/.duaer/workflows/workflow-registry.json +13 -0
- package/ADOPT.md +75 -0
- package/AGENTS.md +290 -0
- package/CHANGELOG.md +28 -0
- package/DUADER.md +57 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/bin/duaer.mjs +298 -0
- package/docs/agent/README.md +12 -0
- package/docs/agent/change-checklist.md +130 -0
- package/docs/agent/e2e-test-plan.md +33 -0
- package/docs/agent/workflow.md +127 -0
- package/docs/baseline.md +10 -0
- package/package.json +49 -0
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Common functions and variables for all scripts
|
|
3
|
+
|
|
4
|
+
# Find repository root by searching upward for .duaer directory
|
|
5
|
+
# This is the primary marker for duaer-spec 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/.duaer" ]; 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
|
+
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
|
|
28
|
+
# *contains* .duaer/), for non-interactive / CI use — e.g. running a Duaer
|
|
29
|
+
# command against a member project from a monorepo root without cd.
|
|
30
|
+
#
|
|
31
|
+
# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute
|
|
32
|
+
# project root, or prints an error and returns 1. Strict by design: the path
|
|
33
|
+
# must exist and contain .duaer/, with no silent fallback to cwd or the
|
|
34
|
+
# script-location default (which would silently write to the wrong project).
|
|
35
|
+
#
|
|
36
|
+
# This is the single resolver: bundled extensions inherit it by sourcing core
|
|
37
|
+
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
|
38
|
+
resolve_specify_init_dir() {
|
|
39
|
+
local init_root
|
|
40
|
+
# Normalize: relative paths resolve against $(pwd); a trailing slash collapses.
|
|
41
|
+
# CDPATH="" so a relative value cannot be resolved against the caller's CDPATH
|
|
42
|
+
# (which would also echo to stdout and corrupt the captured path).
|
|
43
|
+
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
|
|
44
|
+
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
|
|
45
|
+
return 1
|
|
46
|
+
fi
|
|
47
|
+
if [[ ! -d "$init_root/.duaer" ]]; then
|
|
48
|
+
echo "ERROR: SPECIFY_INIT_DIR is not a Duaer project (no .duaer/ directory): $init_root" >&2
|
|
49
|
+
return 1
|
|
50
|
+
fi
|
|
51
|
+
printf '%s\n' "$init_root"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Get repository root, prioritizing .duaer directory
|
|
55
|
+
# This prevents using a parent repository when duaer-spec is initialized in a subdirectory
|
|
56
|
+
get_repo_root() {
|
|
57
|
+
# Explicit project override wins (see resolve_specify_init_dir).
|
|
58
|
+
if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then
|
|
59
|
+
resolve_specify_init_dir
|
|
60
|
+
return
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# First, look for .duaer directory (duaer-spec's own marker)
|
|
64
|
+
local specify_root
|
|
65
|
+
if specify_root=$(find_specify_root); then
|
|
66
|
+
echo "$specify_root"
|
|
67
|
+
return
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# Final fallback to script location
|
|
71
|
+
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
72
|
+
(cd "$script_dir/../../.." && pwd)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Get current feature name from explicit state only.
|
|
76
|
+
# Returns the feature identifier or empty string if none is set.
|
|
77
|
+
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
|
78
|
+
# the git extension) or implicitly via .duaer/feature.json.
|
|
79
|
+
get_current_branch() {
|
|
80
|
+
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
|
81
|
+
echo "$SPECIFY_FEATURE"
|
|
82
|
+
return
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# No explicit feature set — caller must handle this via feature.json
|
|
86
|
+
# in get_feature_paths(). Return empty to signal "unknown".
|
|
87
|
+
echo ""
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Safely read .duaer/feature.json's "feature_directory" value.
|
|
91
|
+
# Prints the raw value (possibly relative) to stdout, or empty string if the file
|
|
92
|
+
# is missing, unparseable, or does not contain the key. Always returns 0 so callers
|
|
93
|
+
# under `set -e` cannot be aborted by parser failure.
|
|
94
|
+
# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
|
|
95
|
+
read_feature_json_feature_directory() {
|
|
96
|
+
local repo_root="$1"
|
|
97
|
+
local fj="$repo_root/.duaer/feature.json"
|
|
98
|
+
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
|
99
|
+
|
|
100
|
+
# Try parsers in order (jq -> python3 -> grep/sed), falling through on
|
|
101
|
+
# failure. Selection is by *parse success*, not mere availability: on
|
|
102
|
+
# Windows `python3` commonly resolves to the Microsoft Store App Execution
|
|
103
|
+
# Alias stub, which passes `command -v` but fails at runtime (exit 49), so
|
|
104
|
+
# an availability-gated `elif` would pick python3, swallow its failure, and
|
|
105
|
+
# never reach the grep/sed fallback -- leaving feature.json unreadable even
|
|
106
|
+
# though it is valid (issue #3304).
|
|
107
|
+
local _fd=''
|
|
108
|
+
if command -v jq >/dev/null 2>&1; then
|
|
109
|
+
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
|
110
|
+
_fd=''
|
|
111
|
+
fi
|
|
112
|
+
fi
|
|
113
|
+
if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then
|
|
114
|
+
# Use Python so pretty-printed/multi-line JSON still parses correctly.
|
|
115
|
+
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
|
|
116
|
+
_fd=''
|
|
117
|
+
fi
|
|
118
|
+
fi
|
|
119
|
+
if [[ -z "$_fd" ]]; then
|
|
120
|
+
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
|
121
|
+
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
|
122
|
+
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
|
123
|
+
| head -n 1 \
|
|
124
|
+
| sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
printf '%s' "$_fd"
|
|
128
|
+
return 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Persist a feature_directory value to .duaer/feature.json.
|
|
132
|
+
# Writes only when the file is missing or the value differs from what's stored.
|
|
133
|
+
# Accepts the raw (possibly relative) path — callers should pass the original
|
|
134
|
+
# user-supplied value, not the normalized absolute path.
|
|
135
|
+
_persist_feature_json() {
|
|
136
|
+
local repo_root="$1"
|
|
137
|
+
local feature_dir_value="$2"
|
|
138
|
+
local fj="$repo_root/.duaer/feature.json"
|
|
139
|
+
|
|
140
|
+
# Strip repo_root prefix if the value is absolute and under repo_root
|
|
141
|
+
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
|
|
142
|
+
feature_dir_value="${feature_dir_value#"$repo_root/"}"
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
# Read current value (if any) and skip write when unchanged
|
|
146
|
+
local current_val
|
|
147
|
+
current_val=$(read_feature_json_feature_directory "$repo_root")
|
|
148
|
+
if [[ "$current_val" == "$feature_dir_value" ]]; then
|
|
149
|
+
return 0
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
# Ensure .duaer/ directory exists
|
|
153
|
+
mkdir -p "$repo_root/.duaer"
|
|
154
|
+
|
|
155
|
+
# Write feature.json — prefer jq for safe JSON, fall back to printf
|
|
156
|
+
if command -v jq >/dev/null 2>&1; then
|
|
157
|
+
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
|
|
158
|
+
else
|
|
159
|
+
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
|
|
160
|
+
fi
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get_feature_paths() {
|
|
164
|
+
# Read-only callers (e.g. check-prerequisites.sh --paths-only) pass
|
|
165
|
+
# --no-persist so pure path resolution never writes .duaer/feature.json,
|
|
166
|
+
# which would dirty the working tree or overwrite a pinned value (issue #3025).
|
|
167
|
+
local no_persist=false
|
|
168
|
+
if [[ "${1:-}" == "--no-persist" ]]; then
|
|
169
|
+
no_persist=true
|
|
170
|
+
shift
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
|
|
174
|
+
# get_repo_root propagates as a hard error instead of being masked by `local`.
|
|
175
|
+
local repo_root
|
|
176
|
+
repo_root=$(get_repo_root) || return 1
|
|
177
|
+
local current_branch
|
|
178
|
+
current_branch=$(get_current_branch)
|
|
179
|
+
|
|
180
|
+
# Resolve feature directory. Priority:
|
|
181
|
+
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
|
182
|
+
# 2. .duaer/feature.json "feature_directory" key (persisted by specify command)
|
|
183
|
+
# 3. Error — no feature context available
|
|
184
|
+
local feature_dir
|
|
185
|
+
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
|
186
|
+
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
|
187
|
+
# Normalize relative paths to absolute under repo root
|
|
188
|
+
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
|
189
|
+
# Persist to feature.json so future sessions without the env var still
|
|
190
|
+
# work — unless the caller opted out for read-only resolution (#3025).
|
|
191
|
+
if [[ "$no_persist" != true ]]; then
|
|
192
|
+
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
|
193
|
+
fi
|
|
194
|
+
elif [[ -f "$repo_root/.duaer/feature.json" ]]; then
|
|
195
|
+
local _fd
|
|
196
|
+
_fd=$(read_feature_json_feature_directory "$repo_root")
|
|
197
|
+
if [[ -n "$_fd" ]]; then
|
|
198
|
+
feature_dir="$_fd"
|
|
199
|
+
# Normalize relative paths to absolute under repo root
|
|
200
|
+
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
|
201
|
+
else
|
|
202
|
+
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .duaer/feature.json contains feature_directory." >&2
|
|
203
|
+
return 1
|
|
204
|
+
fi
|
|
205
|
+
else
|
|
206
|
+
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .duaer/feature.json." >&2
|
|
207
|
+
return 1
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
|
|
211
|
+
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
|
|
212
|
+
# directory basename so CURRENT_BRANCH is a usable identifier rather than
|
|
213
|
+
# an empty, misleading value (issue #3026).
|
|
214
|
+
if [[ -z "$current_branch" ]]; then
|
|
215
|
+
local feature_dir_trimmed="${feature_dir%/}"
|
|
216
|
+
current_branch="${feature_dir_trimmed##*/}"
|
|
217
|
+
fi
|
|
218
|
+
|
|
219
|
+
# Use printf '%q' to safely quote values, preventing shell injection
|
|
220
|
+
# via crafted branch names or paths containing special characters
|
|
221
|
+
printf 'REPO_ROOT=%q\n' "$repo_root"
|
|
222
|
+
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
|
223
|
+
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
|
224
|
+
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
|
225
|
+
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
|
226
|
+
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
|
|
227
|
+
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
|
|
228
|
+
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
|
|
229
|
+
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
|
|
230
|
+
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Check if jq is available for safe JSON construction
|
|
234
|
+
has_jq() {
|
|
235
|
+
command -v jq >/dev/null 2>&1
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
get_invoke_separator() {
|
|
239
|
+
local repo_root="${1:-$(get_repo_root)}"
|
|
240
|
+
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
|
241
|
+
printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
|
242
|
+
return 0
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
local integration_json="$repo_root/.duaer/integration.json"
|
|
246
|
+
local separator="."
|
|
247
|
+
local parsed=0
|
|
248
|
+
|
|
249
|
+
if [[ -f "$integration_json" ]]; then
|
|
250
|
+
# Try parsers in order (jq -> python3 -> awk), falling through on
|
|
251
|
+
# failure. Selection is by *parse success*, not mere availability: on
|
|
252
|
+
# Windows `python3` commonly resolves to the Microsoft Store App
|
|
253
|
+
# Execution Alias stub, which passes `command -v` but fails at runtime
|
|
254
|
+
# (exit 49). An availability-gated branch would pick python3, swallow
|
|
255
|
+
# its failure, and — because this function historically had no text
|
|
256
|
+
# fallback — silently return "." even for `-`-separator integrations
|
|
257
|
+
# (e.g. forge, cline), yielding wrong command hints (issue #3304).
|
|
258
|
+
if command -v jq >/dev/null 2>&1; then
|
|
259
|
+
local jq_separator
|
|
260
|
+
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
|
|
261
|
+
case "$jq_separator" in
|
|
262
|
+
"."|"-") separator="$jq_separator"; parsed=1 ;;
|
|
263
|
+
esac
|
|
264
|
+
fi
|
|
265
|
+
fi
|
|
266
|
+
|
|
267
|
+
if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
|
268
|
+
local py_separator
|
|
269
|
+
if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
|
270
|
+
import json
|
|
271
|
+
import sys
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
with open(sys.argv[1], encoding="utf-8") as fh:
|
|
275
|
+
state = json.load(fh)
|
|
276
|
+
key = state.get("default_integration") or state.get("integration") or ""
|
|
277
|
+
settings = state.get("integration_settings")
|
|
278
|
+
separator = "."
|
|
279
|
+
if isinstance(key, str) and isinstance(settings, dict):
|
|
280
|
+
entry = settings.get(key)
|
|
281
|
+
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
|
282
|
+
separator = entry["invoke_separator"]
|
|
283
|
+
print(separator)
|
|
284
|
+
except Exception:
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
PY
|
|
287
|
+
); then
|
|
288
|
+
case "$py_separator" in
|
|
289
|
+
"."|"-") separator="$py_separator"; parsed=1 ;;
|
|
290
|
+
esac
|
|
291
|
+
fi
|
|
292
|
+
fi
|
|
293
|
+
|
|
294
|
+
if [[ "$parsed" -eq 0 ]]; then
|
|
295
|
+
# Last-resort text fallback for environments with neither jq nor a
|
|
296
|
+
# working python3 (e.g. stock Windows + Git Bash). Reads the active
|
|
297
|
+
# integration key (default_integration, else integration) and its
|
|
298
|
+
# invoke_separator from within the integration_settings object.
|
|
299
|
+
# Handles both pretty-printed (the written form) and compact JSON.
|
|
300
|
+
# Accumulate all lines into one buffer in END rather than using
|
|
301
|
+
# gawk-only whole-file slurp (RS="^$"), so this stays portable to
|
|
302
|
+
# the BSD awk on macOS.
|
|
303
|
+
local awk_separator
|
|
304
|
+
awk_separator=$(awk '
|
|
305
|
+
function keyval(d, name, v) {
|
|
306
|
+
if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) {
|
|
307
|
+
v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v
|
|
308
|
+
}
|
|
309
|
+
return ""
|
|
310
|
+
}
|
|
311
|
+
{ doc = doc $0 "\n" }
|
|
312
|
+
END {
|
|
313
|
+
key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration")
|
|
314
|
+
sep="."
|
|
315
|
+
if (key!="") {
|
|
316
|
+
settings=doc
|
|
317
|
+
if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) {
|
|
318
|
+
settings=substr(doc, RSTART+RLENGTH-1)
|
|
319
|
+
}
|
|
320
|
+
if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) {
|
|
321
|
+
start=RSTART+RLENGTH-1
|
|
322
|
+
depth=0
|
|
323
|
+
obj=""
|
|
324
|
+
for (i=start; i<=length(settings); i++) {
|
|
325
|
+
c=substr(settings,i,1)
|
|
326
|
+
obj=obj c
|
|
327
|
+
if (c=="{") depth++
|
|
328
|
+
else if (c=="}") { depth--; if (depth==0) break }
|
|
329
|
+
}
|
|
330
|
+
if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) {
|
|
331
|
+
tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1)
|
|
332
|
+
if (s=="." || s=="-") sep=s
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
print sep
|
|
337
|
+
}
|
|
338
|
+
' "$integration_json" 2>/dev/null)
|
|
339
|
+
case "$awk_separator" in
|
|
340
|
+
"."|"-") separator="$awk_separator" ;;
|
|
341
|
+
esac
|
|
342
|
+
fi
|
|
343
|
+
fi
|
|
344
|
+
|
|
345
|
+
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
|
346
|
+
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
|
347
|
+
printf '%s\n' "$separator"
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
format_speckit_command() {
|
|
351
|
+
local command_name="$1"
|
|
352
|
+
local repo_root="${2:-$(get_repo_root)}"
|
|
353
|
+
local separator
|
|
354
|
+
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
|
355
|
+
separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
|
356
|
+
else
|
|
357
|
+
separator=$(get_invoke_separator "$repo_root")
|
|
358
|
+
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
|
359
|
+
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
|
360
|
+
fi
|
|
361
|
+
|
|
362
|
+
command_name="${command_name#/}"
|
|
363
|
+
command_name="${command_name#duaer.}"
|
|
364
|
+
command_name="${command_name#duaer-}"
|
|
365
|
+
command_name="${command_name//./$separator}"
|
|
366
|
+
|
|
367
|
+
printf '/duaer%s%s\n' "$separator" "$command_name"
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
|
371
|
+
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
|
372
|
+
json_escape() {
|
|
373
|
+
local s="$1"
|
|
374
|
+
s="${s//\\/\\\\}"
|
|
375
|
+
s="${s//\"/\\\"}"
|
|
376
|
+
s="${s//$'\n'/\\n}"
|
|
377
|
+
s="${s//$'\t'/\\t}"
|
|
378
|
+
s="${s//$'\r'/\\r}"
|
|
379
|
+
s="${s//$'\b'/\\b}"
|
|
380
|
+
s="${s//$'\f'/\\f}"
|
|
381
|
+
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
|
|
382
|
+
# (U+0000/NUL cannot appear in bash strings and is excluded.)
|
|
383
|
+
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
|
|
384
|
+
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
|
|
385
|
+
local LC_ALL=C
|
|
386
|
+
local i char code
|
|
387
|
+
for (( i=0; i<${#s}; i++ )); do
|
|
388
|
+
char="${s:$i:1}"
|
|
389
|
+
printf -v code '%d' "'$char" 2>/dev/null || code=256
|
|
390
|
+
if (( code >= 1 && code <= 31 )); then
|
|
391
|
+
printf '\\u%04x' "$code"
|
|
392
|
+
else
|
|
393
|
+
printf '%s' "$char"
|
|
394
|
+
fi
|
|
395
|
+
done
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
|
399
|
+
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
|
400
|
+
|
|
401
|
+
_python3_command() {
|
|
402
|
+
if command -v python3 >/dev/null 2>&1 &&
|
|
403
|
+
python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
|
|
404
|
+
printf '%s\n' "python3"
|
|
405
|
+
elif command -v python >/dev/null 2>&1 &&
|
|
406
|
+
python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
|
|
407
|
+
printf '%s\n' "python"
|
|
408
|
+
elif command -v py >/dev/null 2>&1 &&
|
|
409
|
+
py -3 -c 'import sys' >/dev/null 2>&1; then
|
|
410
|
+
printf '%s\n' "py -3"
|
|
411
|
+
else
|
|
412
|
+
return 1
|
|
413
|
+
fi
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
_sorted_extension_ids() {
|
|
417
|
+
local ext_dir="$1"
|
|
418
|
+
local python_spec
|
|
419
|
+
if python_spec=$(_python3_command); then
|
|
420
|
+
local -a python_cmd
|
|
421
|
+
read -r -a python_cmd <<< "$python_spec"
|
|
422
|
+
local py_stderr sorted_ids
|
|
423
|
+
py_stderr=$(mktemp)
|
|
424
|
+
if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c "
|
|
425
|
+
import json, os, re, sys
|
|
426
|
+
from pathlib import Path
|
|
427
|
+
|
|
428
|
+
root = Path(os.environ['SPECKIT_EXTENSIONS'])
|
|
429
|
+
registered = {}
|
|
430
|
+
registry = root / '.registry'
|
|
431
|
+
if os.path.lexists(registry):
|
|
432
|
+
if not registry.is_file():
|
|
433
|
+
print('registry_invalid: not a regular file', file=sys.stderr)
|
|
434
|
+
sys.exit(1)
|
|
435
|
+
try:
|
|
436
|
+
data = json.loads(registry.read_text(encoding='utf-8'))
|
|
437
|
+
except Exception as exc:
|
|
438
|
+
print('registry_invalid: ' + str(exc), file=sys.stderr)
|
|
439
|
+
sys.exit(1)
|
|
440
|
+
if not isinstance(data, dict):
|
|
441
|
+
print('registry_invalid: root must be a mapping', file=sys.stderr)
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
raw_extensions = data.get('extensions', {})
|
|
444
|
+
if not isinstance(raw_extensions, dict):
|
|
445
|
+
print('registry_invalid: extensions must be a mapping', file=sys.stderr)
|
|
446
|
+
sys.exit(1)
|
|
447
|
+
registered = raw_extensions
|
|
448
|
+
|
|
449
|
+
def priority(value):
|
|
450
|
+
if isinstance(value, bool):
|
|
451
|
+
return 10
|
|
452
|
+
try:
|
|
453
|
+
parsed = int(value)
|
|
454
|
+
return parsed if parsed >= 1 else 10
|
|
455
|
+
except (TypeError, ValueError, OverflowError):
|
|
456
|
+
return 10
|
|
457
|
+
|
|
458
|
+
ranked = []
|
|
459
|
+
for ext_id, meta in registered.items():
|
|
460
|
+
if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)):
|
|
461
|
+
ranked.append((priority(meta.get('priority')), ext_id))
|
|
462
|
+
for path in root.iterdir():
|
|
463
|
+
if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered:
|
|
464
|
+
ranked.append((10, path.name))
|
|
465
|
+
for _, ext_id in sorted(ranked):
|
|
466
|
+
print(ext_id)
|
|
467
|
+
" 2>"$py_stderr"); then
|
|
468
|
+
rm -f "$py_stderr"
|
|
469
|
+
printf '%s\n' "$sorted_ids"
|
|
470
|
+
return 0
|
|
471
|
+
else
|
|
472
|
+
echo "Error: invalid extension registry $ext_dir/.registry" >&2
|
|
473
|
+
rm -f "$py_stderr"
|
|
474
|
+
return 1
|
|
475
|
+
fi
|
|
476
|
+
fi
|
|
477
|
+
|
|
478
|
+
if [ -e "$ext_dir/.registry" ] || [ -L "$ext_dir/.registry" ]; then
|
|
479
|
+
if [ ! -f "$ext_dir/.registry" ] || [ ! -r "$ext_dir/.registry" ]; then
|
|
480
|
+
echo "Error: invalid extension registry $ext_dir/.registry" >&2
|
|
481
|
+
return 1
|
|
482
|
+
fi
|
|
483
|
+
echo "Error: Python 3 is required to honor the extension registry" >&2
|
|
484
|
+
return 2
|
|
485
|
+
fi
|
|
486
|
+
|
|
487
|
+
local ext extension_id
|
|
488
|
+
for ext in "$ext_dir"/*/; do
|
|
489
|
+
[ -d "$ext" ] || continue
|
|
490
|
+
extension_id=$(basename "$ext")
|
|
491
|
+
case "$extension_id" in *[!a-z0-9-]*) continue ;; esac
|
|
492
|
+
printf '%s\n' "$extension_id"
|
|
493
|
+
done
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
# Resolve a template name to a file path using the priority stack:
|
|
497
|
+
# 1. .duaer/templates/overrides/
|
|
498
|
+
# 2. .duaer/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
|
499
|
+
# 3. .duaer/extensions/<ext-id>/templates/
|
|
500
|
+
# 4. .duaer/templates/ (core)
|
|
501
|
+
resolve_template() {
|
|
502
|
+
local template_name="$1"
|
|
503
|
+
local repo_root="$2"
|
|
504
|
+
local base="$repo_root/.duaer/templates"
|
|
505
|
+
|
|
506
|
+
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
|
|
507
|
+
|
|
508
|
+
# Priority 1: Project overrides
|
|
509
|
+
local override="$base/overrides/${template_name}.md"
|
|
510
|
+
[ -f "$override" ] && echo "$override" && return 0
|
|
511
|
+
|
|
512
|
+
# Priority 2: Installed presets (sorted by priority from .registry)
|
|
513
|
+
local presets_dir="$repo_root/.duaer/presets"
|
|
514
|
+
if [ -d "$presets_dir" ]; then
|
|
515
|
+
local registry_file="$presets_dir/.registry"
|
|
516
|
+
local python_spec=""
|
|
517
|
+
local -a python_cmd=()
|
|
518
|
+
if python_spec=$(_python3_command); then
|
|
519
|
+
read -r -a python_cmd <<< "$python_spec"
|
|
520
|
+
fi
|
|
521
|
+
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
|
|
522
|
+
# Read preset IDs sorted by priority (lower number = higher precedence).
|
|
523
|
+
# The python3 call is wrapped in an if-condition so that set -e does not
|
|
524
|
+
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
|
525
|
+
local sorted_presets=""
|
|
526
|
+
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
|
|
527
|
+
import json, re, sys, os
|
|
528
|
+
try:
|
|
529
|
+
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
|
|
530
|
+
data = json.load(f)
|
|
531
|
+
presets = data.get('presets', {})
|
|
532
|
+
def priority(meta):
|
|
533
|
+
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
|
|
534
|
+
return 10
|
|
535
|
+
try:
|
|
536
|
+
value = int(meta.get('priority', 10))
|
|
537
|
+
return value if value >= 1 else 10
|
|
538
|
+
except (TypeError, ValueError, OverflowError):
|
|
539
|
+
return 10
|
|
540
|
+
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
|
|
541
|
+
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
|
|
542
|
+
print(pid)
|
|
543
|
+
except Exception:
|
|
544
|
+
sys.exit(1)
|
|
545
|
+
" 2>/dev/null); then
|
|
546
|
+
if [ -n "$sorted_presets" ]; then
|
|
547
|
+
# python3 succeeded and returned preset IDs — search in priority order
|
|
548
|
+
while IFS= read -r preset_id; do
|
|
549
|
+
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
|
550
|
+
[ -f "$candidate" ] && echo "$candidate" && return 0
|
|
551
|
+
candidate="$presets_dir/$preset_id/${template_name}.md"
|
|
552
|
+
[ -f "$candidate" ] && echo "$candidate" && return 0
|
|
553
|
+
done <<< "$sorted_presets"
|
|
554
|
+
fi
|
|
555
|
+
# python3 succeeded but registry has no presets — nothing to search
|
|
556
|
+
else
|
|
557
|
+
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
|
|
558
|
+
for preset in "$presets_dir"/*/; do
|
|
559
|
+
[ -d "$preset" ] || continue
|
|
560
|
+
local candidate="$preset/templates/${template_name}.md"
|
|
561
|
+
[ -f "$candidate" ] && echo "$candidate" && return 0
|
|
562
|
+
candidate="$preset/${template_name}.md"
|
|
563
|
+
[ -f "$candidate" ] && echo "$candidate" && return 0
|
|
564
|
+
done
|
|
565
|
+
fi
|
|
566
|
+
else
|
|
567
|
+
# Fallback: alphabetical directory order (no python3 available)
|
|
568
|
+
for preset in "$presets_dir"/*/; do
|
|
569
|
+
[ -d "$preset" ] || continue
|
|
570
|
+
local candidate="$preset/templates/${template_name}.md"
|
|
571
|
+
[ -f "$candidate" ] && echo "$candidate" && return 0
|
|
572
|
+
candidate="$preset/${template_name}.md"
|
|
573
|
+
[ -f "$candidate" ] && echo "$candidate" && return 0
|
|
574
|
+
done
|
|
575
|
+
fi
|
|
576
|
+
fi
|
|
577
|
+
|
|
578
|
+
# Priority 3: Extension-provided templates
|
|
579
|
+
local ext_dir="$repo_root/.duaer/extensions"
|
|
580
|
+
if [ -d "$ext_dir" ]; then
|
|
581
|
+
local sorted_extensions=""
|
|
582
|
+
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
|
|
583
|
+
return 2
|
|
584
|
+
fi
|
|
585
|
+
while IFS= read -r extension_id; do
|
|
586
|
+
[ -n "$extension_id" ] || continue
|
|
587
|
+
local ext="$ext_dir/$extension_id"
|
|
588
|
+
local candidate="$ext/templates/${template_name}.md"
|
|
589
|
+
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
|
|
590
|
+
[ -f "$candidate" ] && echo "$candidate" && return 0
|
|
591
|
+
done <<< "$sorted_extensions"
|
|
592
|
+
fi
|
|
593
|
+
|
|
594
|
+
# Priority 4: Core templates
|
|
595
|
+
local core="$base/${template_name}.md"
|
|
596
|
+
[ -f "$core" ] && echo "$core" && return 0
|
|
597
|
+
|
|
598
|
+
# Template not found in any location.
|
|
599
|
+
# Return 1 so callers can distinguish "not found" from "found".
|
|
600
|
+
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
|
601
|
+
return 1
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
# Resolve a template name to composed content using composition strategies.
|
|
605
|
+
# Reads strategy metadata from preset manifests and composes content
|
|
606
|
+
# from multiple layers using prepend, append, or wrap strategies.
|
|
607
|
+
#
|
|
608
|
+
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
|
|
609
|
+
# Returns composed content string on stdout; exit code 1 if not found.
|
|
610
|
+
resolve_template_content() {
|
|
611
|
+
local template_name="$1"
|
|
612
|
+
local repo_root="$2"
|
|
613
|
+
local base="$repo_root/.duaer/templates"
|
|
614
|
+
|
|
615
|
+
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
|
|
616
|
+
|
|
617
|
+
# Collect all layers (highest priority first)
|
|
618
|
+
local -a layer_paths=()
|
|
619
|
+
local -a layer_strategies=()
|
|
620
|
+
|
|
621
|
+
# Priority 1: Project overrides (always "replace")
|
|
622
|
+
local override="$base/overrides/${template_name}.md"
|
|
623
|
+
if [ -f "$override" ]; then
|
|
624
|
+
if ! cat "$override"; then
|
|
625
|
+
echo "Error: failed to read template layer $override" >&2
|
|
626
|
+
return 2
|
|
627
|
+
fi
|
|
628
|
+
return 0
|
|
629
|
+
fi
|
|
630
|
+
|
|
631
|
+
local effective_base_found=false
|
|
632
|
+
|
|
633
|
+
# Priority 2: Installed presets (sorted by priority from .registry)
|
|
634
|
+
local presets_dir="$repo_root/.duaer/presets"
|
|
635
|
+
if [ -d "$presets_dir" ]; then
|
|
636
|
+
local registry_file="$presets_dir/.registry"
|
|
637
|
+
local sorted_presets=""
|
|
638
|
+
local registry_parsed=false
|
|
639
|
+
local python_spec=""
|
|
640
|
+
local -a python_cmd=()
|
|
641
|
+
if python_spec=$(_python3_command); then
|
|
642
|
+
read -r -a python_cmd <<< "$python_spec"
|
|
643
|
+
fi
|
|
644
|
+
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
|
|
645
|
+
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
|
|
646
|
+
import json, re, sys, os
|
|
647
|
+
try:
|
|
648
|
+
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
|
|
649
|
+
data = json.load(f)
|
|
650
|
+
presets = data.get('presets', {})
|
|
651
|
+
def priority(meta):
|
|
652
|
+
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
|
|
653
|
+
return 10
|
|
654
|
+
try:
|
|
655
|
+
value = int(meta.get('priority', 10))
|
|
656
|
+
return value if value >= 1 else 10
|
|
657
|
+
except (TypeError, ValueError, OverflowError):
|
|
658
|
+
return 10
|
|
659
|
+
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
|
|
660
|
+
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
|
|
661
|
+
print(pid)
|
|
662
|
+
except Exception:
|
|
663
|
+
sys.exit(1)
|
|
664
|
+
" 2>/dev/null); then
|
|
665
|
+
registry_parsed=true
|
|
666
|
+
fi
|
|
667
|
+
fi
|
|
668
|
+
if [ "$registry_parsed" = false ]; then
|
|
669
|
+
for preset in "$presets_dir"/*/; do
|
|
670
|
+
[ -d "$preset" ] || continue
|
|
671
|
+
local fallback_id
|
|
672
|
+
fallback_id=$(basename "$preset")
|
|
673
|
+
case "$fallback_id" in *[!a-z0-9-]*) continue ;; esac
|
|
674
|
+
sorted_presets+="${sorted_presets:+$'\n'}$fallback_id"
|
|
675
|
+
done
|
|
676
|
+
fi
|
|
677
|
+
|
|
678
|
+
if [ -n "$sorted_presets" ]; then
|
|
679
|
+
while IFS= read -r preset_id; do
|
|
680
|
+
local strategy="replace"
|
|
681
|
+
local manifest_file=""
|
|
682
|
+
local manifest="$presets_dir/$preset_id/preset.yml"
|
|
683
|
+
local manifest_declared=false
|
|
684
|
+
if [ -f "$manifest" ]; then
|
|
685
|
+
if [ "${#python_cmd[@]}" -eq 0 ]; then
|
|
686
|
+
echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2
|
|
687
|
+
return 2
|
|
688
|
+
fi
|
|
689
|
+
local result
|
|
690
|
+
local py_stderr
|
|
691
|
+
local parse_status
|
|
692
|
+
py_stderr=$(mktemp)
|
|
693
|
+
if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c "
|
|
694
|
+
import sys, os
|
|
695
|
+
try:
|
|
696
|
+
import yaml
|
|
697
|
+
except ImportError:
|
|
698
|
+
print('yaml_missing', file=sys.stderr)
|
|
699
|
+
sys.exit(2)
|
|
700
|
+
try:
|
|
701
|
+
with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f:
|
|
702
|
+
data = yaml.safe_load(f)
|
|
703
|
+
if not isinstance(data, dict):
|
|
704
|
+
raise ValueError('manifest root must be a mapping')
|
|
705
|
+
if 'provides' not in data:
|
|
706
|
+
raise ValueError('manifest missing provides section')
|
|
707
|
+
provides = data['provides']
|
|
708
|
+
if not isinstance(provides, dict):
|
|
709
|
+
raise ValueError('manifest provides must be a mapping')
|
|
710
|
+
if 'templates' not in provides:
|
|
711
|
+
raise ValueError('manifest provides missing templates')
|
|
712
|
+
templates = provides['templates']
|
|
713
|
+
if not isinstance(templates, list):
|
|
714
|
+
raise ValueError('manifest templates must be a list')
|
|
715
|
+
if not templates:
|
|
716
|
+
raise ValueError('manifest must provide at least one template')
|
|
717
|
+
valid_types = ('template', 'command', 'script')
|
|
718
|
+
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
|
|
719
|
+
for t in templates:
|
|
720
|
+
if not isinstance(t, dict):
|
|
721
|
+
raise ValueError('manifest template entries must be mappings')
|
|
722
|
+
if 'type' not in t or 'name' not in t or 'file' not in t:
|
|
723
|
+
raise ValueError('manifest template entry missing type, name, or file')
|
|
724
|
+
for field in ('type', 'name', 'file'):
|
|
725
|
+
if not isinstance(t[field], str):
|
|
726
|
+
raise ValueError('manifest template ' + field + ' must be a string')
|
|
727
|
+
if t['type'] not in valid_types:
|
|
728
|
+
raise ValueError('invalid manifest template type')
|
|
729
|
+
strategy = t.get('strategy', 'replace')
|
|
730
|
+
if not isinstance(strategy, str):
|
|
731
|
+
raise ValueError('manifest template strategy must be a string')
|
|
732
|
+
strategy = strategy.lower()
|
|
733
|
+
if strategy not in valid_strategies:
|
|
734
|
+
raise ValueError('invalid manifest template strategy')
|
|
735
|
+
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
|
|
736
|
+
raise ValueError('invalid manifest script strategy')
|
|
737
|
+
for t in templates:
|
|
738
|
+
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
|
739
|
+
file_value = t.get('file', '')
|
|
740
|
+
strategy = t.get('strategy', 'replace')
|
|
741
|
+
print('found\t' + strategy + '\t' + file_value)
|
|
742
|
+
sys.exit(0)
|
|
743
|
+
print('absent\treplace\t')
|
|
744
|
+
except Exception as exc:
|
|
745
|
+
print(f'manifest_invalid: {exc}', file=sys.stderr)
|
|
746
|
+
sys.exit(3)
|
|
747
|
+
" 2>"$py_stderr"); then
|
|
748
|
+
parse_status=0
|
|
749
|
+
else
|
|
750
|
+
parse_status=$?
|
|
751
|
+
fi
|
|
752
|
+
if [ "$parse_status" -ne 0 ]; then
|
|
753
|
+
if [ "$parse_status" -eq 2 ]; then
|
|
754
|
+
echo "Error: PyYAML is required to resolve preset template composition" >&2
|
|
755
|
+
else
|
|
756
|
+
echo "Error: invalid preset manifest $manifest" >&2
|
|
757
|
+
fi
|
|
758
|
+
rm -f "$py_stderr"
|
|
759
|
+
return 2
|
|
760
|
+
fi
|
|
761
|
+
if [ -n "$result" ]; then
|
|
762
|
+
local declaration
|
|
763
|
+
IFS=$'\t' read -r declaration strategy manifest_file <<< "$result"
|
|
764
|
+
[ "$declaration" = "found" ] && manifest_declared=true
|
|
765
|
+
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
|
766
|
+
fi
|
|
767
|
+
rm -f "$py_stderr"
|
|
768
|
+
fi
|
|
769
|
+
|
|
770
|
+
local candidate=""
|
|
771
|
+
if [ -n "$manifest_file" ]; then
|
|
772
|
+
case "$manifest_file" in
|
|
773
|
+
/*|*../*|../*) manifest_file="" ;;
|
|
774
|
+
esac
|
|
775
|
+
fi
|
|
776
|
+
if [ -n "$manifest_file" ]; then
|
|
777
|
+
local mf="$presets_dir/$preset_id/$manifest_file"
|
|
778
|
+
[ -f "$mf" ] && candidate="$mf"
|
|
779
|
+
fi
|
|
780
|
+
if [ -z "$candidate" ] && [ "$manifest_declared" = false ]; then
|
|
781
|
+
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
|
782
|
+
[ -f "$cf" ] && candidate="$cf"
|
|
783
|
+
if [ -z "$candidate" ]; then
|
|
784
|
+
cf="$presets_dir/$preset_id/${template_name}.md"
|
|
785
|
+
[ -f "$cf" ] && candidate="$cf"
|
|
786
|
+
fi
|
|
787
|
+
fi
|
|
788
|
+
if [ -n "$candidate" ]; then
|
|
789
|
+
layer_paths+=("$candidate")
|
|
790
|
+
layer_strategies+=("$strategy")
|
|
791
|
+
if [ "$strategy" = "replace" ]; then
|
|
792
|
+
effective_base_found=true
|
|
793
|
+
break
|
|
794
|
+
fi
|
|
795
|
+
fi
|
|
796
|
+
done <<< "$sorted_presets"
|
|
797
|
+
fi
|
|
798
|
+
fi
|
|
799
|
+
|
|
800
|
+
# Priority 3: Extension-provided templates (always "replace")
|
|
801
|
+
local ext_dir="$repo_root/.duaer/extensions"
|
|
802
|
+
if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then
|
|
803
|
+
local sorted_extensions=""
|
|
804
|
+
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
|
|
805
|
+
return 2
|
|
806
|
+
fi
|
|
807
|
+
while IFS= read -r extension_id; do
|
|
808
|
+
[ -n "$extension_id" ] || continue
|
|
809
|
+
local ext="$ext_dir/$extension_id"
|
|
810
|
+
local candidate="$ext/templates/${template_name}.md"
|
|
811
|
+
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
|
|
812
|
+
if [ -f "$candidate" ]; then
|
|
813
|
+
layer_paths+=("$candidate")
|
|
814
|
+
layer_strategies+=("replace")
|
|
815
|
+
effective_base_found=true
|
|
816
|
+
break
|
|
817
|
+
fi
|
|
818
|
+
done <<< "$sorted_extensions"
|
|
819
|
+
fi
|
|
820
|
+
|
|
821
|
+
# Priority 4: Core templates (always "replace")
|
|
822
|
+
local core="$base/${template_name}.md"
|
|
823
|
+
if [ "$effective_base_found" = false ] && [ -f "$core" ]; then
|
|
824
|
+
layer_paths+=("$core")
|
|
825
|
+
layer_strategies+=("replace")
|
|
826
|
+
fi
|
|
827
|
+
|
|
828
|
+
local count=${#layer_paths[@]}
|
|
829
|
+
[ "$count" -eq 0 ] && return 1
|
|
830
|
+
|
|
831
|
+
# Check if any layer uses a non-replace strategy
|
|
832
|
+
local has_composition=false
|
|
833
|
+
for s in "${layer_strategies[@]}"; do
|
|
834
|
+
[ "$s" != "replace" ] && has_composition=true && break
|
|
835
|
+
done
|
|
836
|
+
|
|
837
|
+
# If the top (highest-priority) layer is replace, it wins entirely —
|
|
838
|
+
# lower layers are irrelevant regardless of their strategies.
|
|
839
|
+
if [ "${layer_strategies[0]}" = "replace" ]; then
|
|
840
|
+
if ! cat "${layer_paths[0]}"; then
|
|
841
|
+
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
|
|
842
|
+
return 2
|
|
843
|
+
fi
|
|
844
|
+
return 0
|
|
845
|
+
fi
|
|
846
|
+
|
|
847
|
+
if [ "$has_composition" = false ]; then
|
|
848
|
+
if ! cat "${layer_paths[0]}"; then
|
|
849
|
+
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
|
|
850
|
+
return 2
|
|
851
|
+
fi
|
|
852
|
+
return 0
|
|
853
|
+
fi
|
|
854
|
+
|
|
855
|
+
# Find the effective base: scan from highest priority (index 0) downward
|
|
856
|
+
# to find the nearest replace layer. Only compose layers above that base.
|
|
857
|
+
local base_idx=-1
|
|
858
|
+
local i
|
|
859
|
+
for (( i=0; i<count; i++ )); do
|
|
860
|
+
if [ "${layer_strategies[$i]}" = "replace" ]; then
|
|
861
|
+
base_idx=$i
|
|
862
|
+
break
|
|
863
|
+
fi
|
|
864
|
+
done
|
|
865
|
+
|
|
866
|
+
if [ $base_idx -lt 0 ]; then
|
|
867
|
+
echo "Error: template '$template_name' has composing layers but no replace base" >&2
|
|
868
|
+
return 2
|
|
869
|
+
fi
|
|
870
|
+
|
|
871
|
+
# Read the base content; compose layers above the base (higher priority)
|
|
872
|
+
local content
|
|
873
|
+
if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then
|
|
874
|
+
echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2
|
|
875
|
+
return 2
|
|
876
|
+
fi
|
|
877
|
+
content="${content%x}"
|
|
878
|
+
|
|
879
|
+
for (( i=base_idx-1; i>=0; i-- )); do
|
|
880
|
+
local path="${layer_paths[$i]}"
|
|
881
|
+
local strat="${layer_strategies[$i]}"
|
|
882
|
+
local layer_content
|
|
883
|
+
# Preserve trailing newlines
|
|
884
|
+
if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then
|
|
885
|
+
echo "Error: failed to read template layer $path" >&2
|
|
886
|
+
return 2
|
|
887
|
+
fi
|
|
888
|
+
layer_content="${layer_content%x}"
|
|
889
|
+
|
|
890
|
+
case "$strat" in
|
|
891
|
+
replace) content="$layer_content" ;;
|
|
892
|
+
prepend)
|
|
893
|
+
content=$(printf '%s\n\n%s' "$layer_content" "$content"; printf x)
|
|
894
|
+
content="${content%x}"
|
|
895
|
+
;;
|
|
896
|
+
append)
|
|
897
|
+
content=$(printf '%s\n\n%s' "$content" "$layer_content"; printf x)
|
|
898
|
+
content="${content%x}"
|
|
899
|
+
;;
|
|
900
|
+
wrap)
|
|
901
|
+
case "$layer_content" in
|
|
902
|
+
*'{CORE_TEMPLATE}'*) ;;
|
|
903
|
+
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;;
|
|
904
|
+
esac
|
|
905
|
+
# Consume the wrapper left to right instead of rewriting it in
|
|
906
|
+
# place. Rewriting re-scanned the string just modified, so base
|
|
907
|
+
# content holding a literal {CORE_TEMPLATE} reintroduced the
|
|
908
|
+
# token every pass and the loop never terminated. Advancing over
|
|
909
|
+
# ``rest`` bounds the work by the tokens in the original wrapper
|
|
910
|
+
# and leaves inserted content untouched, matching the single-pass
|
|
911
|
+
# semantics of .Replace()/.replace() in the PowerShell and Python
|
|
912
|
+
# ports.
|
|
913
|
+
local wrapped="" rest="$layer_content"
|
|
914
|
+
while [[ "$rest" == *'{CORE_TEMPLATE}'* ]]; do
|
|
915
|
+
wrapped="${wrapped}${rest%%\{CORE_TEMPLATE\}*}${content}"
|
|
916
|
+
rest="${rest#*\{CORE_TEMPLATE\}}"
|
|
917
|
+
done
|
|
918
|
+
content="${wrapped}${rest}"
|
|
919
|
+
;;
|
|
920
|
+
*) echo "Error: unknown strategy '$strat'" >&2; return 2 ;;
|
|
921
|
+
esac
|
|
922
|
+
done
|
|
923
|
+
|
|
924
|
+
printf '%s' "$content"
|
|
925
|
+
return 0
|
|
926
|
+
}
|