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.
Files changed (75) hide show
  1. package/.cursor/rules/agents-workflow.mdc +50 -0
  2. package/.cursor/rules/ai-ui-copy.mdc +12 -0
  3. package/.cursor/rules/duaer-spec.mdc +33 -0
  4. package/.cursor/skills/duaer-analyze/SKILL.md +259 -0
  5. package/.cursor/skills/duaer-checklist/SKILL.md +383 -0
  6. package/.cursor/skills/duaer-clarify/SKILL.md +291 -0
  7. package/.cursor/skills/duaer-constitution/SKILL.md +177 -0
  8. package/.cursor/skills/duaer-converge/SKILL.md +277 -0
  9. package/.cursor/skills/duaer-git-commit/SKILL.md +68 -0
  10. package/.cursor/skills/duaer-git-feature/SKILL.md +94 -0
  11. package/.cursor/skills/duaer-git-initialize/SKILL.md +54 -0
  12. package/.cursor/skills/duaer-git-remote/SKILL.md +50 -0
  13. package/.cursor/skills/duaer-git-validate/SKILL.md +54 -0
  14. package/.cursor/skills/duaer-implement/SKILL.md +226 -0
  15. package/.cursor/skills/duaer-plan/SKILL.md +166 -0
  16. package/.cursor/skills/duaer-specify/SKILL.md +345 -0
  17. package/.cursor/skills/duaer-tasks/SKILL.md +214 -0
  18. package/.cursor/skills/duaer-taskstoissues/SKILL.md +109 -0
  19. package/.duaer/extensions/.registry +23 -0
  20. package/.duaer/extensions/git/README.md +119 -0
  21. package/.duaer/extensions/git/commands/duaer.git.commit.md +63 -0
  22. package/.duaer/extensions/git/commands/duaer.git.feature.md +82 -0
  23. package/.duaer/extensions/git/commands/duaer.git.initialize.md +49 -0
  24. package/.duaer/extensions/git/commands/duaer.git.remote.md +45 -0
  25. package/.duaer/extensions/git/commands/duaer.git.validate.md +49 -0
  26. package/.duaer/extensions/git/config-template.yml +79 -0
  27. package/.duaer/extensions/git/extension.yml +142 -0
  28. package/.duaer/extensions/git/git-config.yml +79 -0
  29. package/.duaer/extensions/git/scripts/bash/auto-commit.sh +211 -0
  30. package/.duaer/extensions/git/scripts/bash/create-new-feature-branch.sh +626 -0
  31. package/.duaer/extensions/git/scripts/bash/git-common.sh +56 -0
  32. package/.duaer/extensions/git/scripts/bash/initialize-repo.sh +54 -0
  33. package/.duaer/extensions/git/scripts/powershell/auto-commit.ps1 +230 -0
  34. package/.duaer/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +592 -0
  35. package/.duaer/extensions/git/scripts/powershell/git-common.ps1 +52 -0
  36. package/.duaer/extensions/git/scripts/powershell/initialize-repo.ps1 +69 -0
  37. package/.duaer/extensions/git/scripts/python/auto_commit.py +195 -0
  38. package/.duaer/extensions/git/scripts/python/create_new_feature_branch.py +634 -0
  39. package/.duaer/extensions/git/scripts/python/git_common.py +81 -0
  40. package/.duaer/extensions/git/scripts/python/initialize_repo.py +89 -0
  41. package/.duaer/extensions.yml +167 -0
  42. package/.duaer/init-options.json +9 -0
  43. package/.duaer/integration.json +15 -0
  44. package/.duaer/integrations/cursor-agent.manifest.json +17 -0
  45. package/.duaer/integrations/duaer.manifest.json +19 -0
  46. package/.duaer/memory/.constitution-template.json +4 -0
  47. package/.duaer/memory/constitution.md +37 -0
  48. package/.duaer/memory/project-context.md +24 -0
  49. package/.duaer/memory/testing.md +14 -0
  50. package/.duaer/scripts/bash/check-prerequisites.sh +243 -0
  51. package/.duaer/scripts/bash/common.sh +926 -0
  52. package/.duaer/scripts/bash/create-new-feature.sh +407 -0
  53. package/.duaer/scripts/bash/resolve-template.sh +57 -0
  54. package/.duaer/scripts/bash/setup-plan.sh +85 -0
  55. package/.duaer/scripts/bash/setup-tasks.sh +94 -0
  56. package/.duaer/templates/checklist-template.md +45 -0
  57. package/.duaer/templates/constitution-template.md +50 -0
  58. package/.duaer/templates/plan-template.md +113 -0
  59. package/.duaer/templates/spec-template.md +131 -0
  60. package/.duaer/templates/tasks-template.md +252 -0
  61. package/.duaer/workflows/duaer/workflow.yml +78 -0
  62. package/.duaer/workflows/workflow-registry.json +13 -0
  63. package/ADOPT.md +75 -0
  64. package/AGENTS.md +290 -0
  65. package/CHANGELOG.md +28 -0
  66. package/DUADER.md +57 -0
  67. package/LICENSE +21 -0
  68. package/README.md +74 -0
  69. package/bin/duaer.mjs +298 -0
  70. package/docs/agent/README.md +12 -0
  71. package/docs/agent/change-checklist.md +130 -0
  72. package/docs/agent/e2e-test-plan.md +33 -0
  73. package/docs/agent/workflow.md +127 -0
  74. package/docs/baseline.md +10 -0
  75. 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
+ }