@ainova-systems/intelligence 0.11.0-rc.1
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/LICENSE +21 -0
- package/README.md +46 -0
- package/bin/intelligence.js +59 -0
- package/cli/commands/add.sh +133 -0
- package/cli/commands/doctor.sh +100 -0
- package/cli/commands/init.sh +96 -0
- package/cli/commands/install.sh +84 -0
- package/cli/commands/list.sh +28 -0
- package/cli/commands/migrate.sh +251 -0
- package/cli/commands/registry.sh +51 -0
- package/cli/commands/remove.sh +39 -0
- package/cli/commands/status.sh +34 -0
- package/cli/commands/sync.sh +22 -0
- package/cli/commands/update.sh +59 -0
- package/cli/commands/upgrade.sh +27 -0
- package/cli/intelligence +71 -0
- package/cli/lib/cli-common.sh +149 -0
- package/cli/lib/lockfile.sh +97 -0
- package/cli/lib/manifest.sh +211 -0
- package/cli/lib/registry.sh +141 -0
- package/cli/lib/semver.sh +127 -0
- package/engine/INIT.md +498 -0
- package/engine/agents/intelligence-architect.md +53 -0
- package/engine/agents/intelligence-operator.md +49 -0
- package/engine/docs/ADAPTERS.md +212 -0
- package/engine/docs/CLI.md +91 -0
- package/engine/docs/CONVENTIONS.md +440 -0
- package/engine/rules/intelligence-authoring.md +114 -0
- package/engine/scripts/VERSION +1 -0
- package/engine/scripts/adapters/_template.sh +86 -0
- package/engine/scripts/adapters/agents.sh +299 -0
- package/engine/scripts/adapters/claude.sh +136 -0
- package/engine/scripts/adapters/codex.sh +118 -0
- package/engine/scripts/adapters/copilot.sh +193 -0
- package/engine/scripts/adapters/cursor.sh +146 -0
- package/engine/scripts/adapters/opencode.sh +200 -0
- package/engine/scripts/adapters/pi.sh +256 -0
- package/engine/scripts/lib/common.sh +1602 -0
- package/engine/scripts/lib/layout.sh +51 -0
- package/engine/scripts/lib/migrations.sh +708 -0
- package/engine/scripts/sync.sh +311 -0
- package/engine/scripts/update.sh +237 -0
- package/engine/skills/intelligence-add-agent/SKILL.md +62 -0
- package/engine/skills/intelligence-add-rule/SKILL.md +54 -0
- package/engine/skills/intelligence-add-skill/SKILL.md +53 -0
- package/engine/skills/intelligence-extract-skill/SKILL.md +47 -0
- package/engine/skills/intelligence-install-adapter/SKILL.md +31 -0
- package/engine/skills/intelligence-learn-from-context/SKILL.md +69 -0
- package/engine/skills/intelligence-review-skills/SKILL.md +86 -0
- package/engine/skills/intelligence-sync/SKILL.md +18 -0
- package/engine/skills/intelligence-uninstall-adapter/SKILL.md +42 -0
- package/engine/skills/intelligence-update/SKILL.md +159 -0
- package/package.json +39 -0
- package/registry/index.yaml +15 -0
|
@@ -0,0 +1,1602 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# intelligence-sync: Core library functions
|
|
3
|
+
# Source this file — never execute directly.
|
|
4
|
+
#
|
|
5
|
+
# Usage: source "$(dirname "$0")/lib/common.sh"
|
|
6
|
+
|
|
7
|
+
# --- File Utilities ---
|
|
8
|
+
|
|
9
|
+
# Convert CRLF to LF in a file (safe for Windows/Git Bash)
|
|
10
|
+
normalize_file_to_lf() {
|
|
11
|
+
local target="$1"
|
|
12
|
+
local tmp_file="$target.tmp"
|
|
13
|
+
awk '{ sub(/\r$/, ""); print }' "$target" > "$tmp_file"
|
|
14
|
+
mv "$tmp_file" "$target"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# --- Layout tokens -----------------------------------------------------------
|
|
18
|
+
#
|
|
19
|
+
# The umbrella folder is named by the project (`intelligence/`, `Intelligence/`,
|
|
20
|
+
# a codename) — so an artifact SHIPPED BY THE ENGINE cannot write that name
|
|
21
|
+
# down. A rule that scopes itself to the intelligence layer needs `paths:` to
|
|
22
|
+
# say "the umbrella", and an agent body needs to name the sync command. Both are
|
|
23
|
+
# spelled with tokens, expanded here at output time:
|
|
24
|
+
#
|
|
25
|
+
# <umbrella> -> the repo-relative umbrella dir (e.g. `Intelligence`)
|
|
26
|
+
# <module> -> the repo-relative engine module (e.g. `Intelligence/sync`)
|
|
27
|
+
#
|
|
28
|
+
# Values come from IS_UMBRELLA_REL / IS_MODULE_REL, which sync.sh derives from
|
|
29
|
+
# the detected layout (never hardcoded) and exports before any adapter runs.
|
|
30
|
+
# Expansion happens in EVERY generated file, frontmatter and body alike, so a
|
|
31
|
+
# scoped rule reaches Claude's `paths:`, Cursor's `globs:` and Copilot's
|
|
32
|
+
# `applyTo:` already carrying the project's real folder name.
|
|
33
|
+
|
|
34
|
+
# finalize_output_file <file>
|
|
35
|
+
# The single exit gate for every file an adapter writes: expand layout tokens,
|
|
36
|
+
# then normalize CRLF -> LF. Adapters MUST call this (not normalize_file_to_lf)
|
|
37
|
+
# on each output — a missed call ships a literal `<umbrella>` into an IDE.
|
|
38
|
+
finalize_output_file() {
|
|
39
|
+
local target="$1"
|
|
40
|
+
local umb="${IS_UMBRELLA_REL:-intelligence}"
|
|
41
|
+
local mod="${IS_MODULE_REL:-intelligence/sync}"
|
|
42
|
+
# `<sync-cmd>` is how engine content says "run a sync": vendored setups
|
|
43
|
+
# expand it to the script invocation (built from $mod, so it reproduces the
|
|
44
|
+
# exact pre-token string), the CLI overrides it via IS_SYNC_CMD
|
|
45
|
+
# (`intelligence sync`).
|
|
46
|
+
local sc="${IS_SYNC_CMD:-bash $mod/scripts/sync.sh}"
|
|
47
|
+
local tmp_file="$target.tmp"
|
|
48
|
+
# Literal (index-based) substitution, not gsub: a regex replacement would
|
|
49
|
+
# give `&` in a path its special meaning, and POSIX awk has no way to pass a
|
|
50
|
+
# replacement string verbatim.
|
|
51
|
+
awk -v umb="$umb" -v mod="$mod" -v sc="$sc" '
|
|
52
|
+
function repl(s, from, to, out, i) {
|
|
53
|
+
out = ""
|
|
54
|
+
while ((i = index(s, from)) > 0) {
|
|
55
|
+
out = out substr(s, 1, i - 1) to
|
|
56
|
+
s = substr(s, i + length(from))
|
|
57
|
+
}
|
|
58
|
+
return out s
|
|
59
|
+
}
|
|
60
|
+
{
|
|
61
|
+
sub(/\r$/, "")
|
|
62
|
+
$0 = repl($0, "<sync-cmd>", sc)
|
|
63
|
+
$0 = repl($0, "<module>", mod)
|
|
64
|
+
$0 = repl($0, "<umbrella>", umb)
|
|
65
|
+
print
|
|
66
|
+
}
|
|
67
|
+
' "$target" > "$tmp_file"
|
|
68
|
+
mv "$tmp_file" "$target"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Escape a string for safe interpolation into a TOML basic string ("..").
|
|
72
|
+
# Backslash and double-quote are escaped; control chars stripped.
|
|
73
|
+
toml_escape() {
|
|
74
|
+
local s="$1"
|
|
75
|
+
s="${s//\\/\\\\}"
|
|
76
|
+
s="${s//\"/\\\"}"
|
|
77
|
+
# Strip any literal newline / carriage return — TOML basic strings
|
|
78
|
+
# do not allow them; multi-line content belongs in `"""..."""`.
|
|
79
|
+
s="${s//$'\n'/ }"
|
|
80
|
+
s="${s//$'\r'/}"
|
|
81
|
+
printf '%s' "$s"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Escape a string for safe interpolation into a YAML double-quoted scalar.
|
|
85
|
+
yaml_dq_escape() {
|
|
86
|
+
local s="$1"
|
|
87
|
+
s="${s//\\/\\\\}"
|
|
88
|
+
s="${s//\"/\\\"}"
|
|
89
|
+
s="${s//$'\n'/ }"
|
|
90
|
+
s="${s//$'\r'/}"
|
|
91
|
+
printf '%s' "$s"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# --- Source Resolution -------------------------------------------------------
|
|
95
|
+
#
|
|
96
|
+
# A `sources.*` entry is normally a LOCAL path resolved as `$repo_root/<entry>`.
|
|
97
|
+
# It may instead be a REMOTE git spec, which is materialized (shallow-cloned)
|
|
98
|
+
# and resolved to a local directory inside the clone. This is the SINGLE point
|
|
99
|
+
# where remote sources are detected and fetched — every adapter and sync.sh
|
|
100
|
+
# routes its `$repo_root/$src` through resolve_source_dir, so no other file
|
|
101
|
+
# needs to know about remote sources.
|
|
102
|
+
#
|
|
103
|
+
# Spec format (inline string, so read_yaml_list parses it unchanged):
|
|
104
|
+
# git+<url>[@<ref>][#<subpath>]
|
|
105
|
+
# <url> explicit-scheme URL (https/http/ssh/git/file). Other transports
|
|
106
|
+
# (notably the command-executing ext::/fd::) are rejected.
|
|
107
|
+
# @<ref> optional tag / branch / SHA — the segment after the last `@` in
|
|
108
|
+
# the post-scheme part, accepted only if it has no `/` (so
|
|
109
|
+
# userinfo like `ssh://git@host/...` is not mistaken for a ref;
|
|
110
|
+
# branch names containing `/` are unsupported — use a tag, SHA,
|
|
111
|
+
# or slashless branch, which is the recommended pin anyway).
|
|
112
|
+
# #<subpath> optional dir inside the clone holding rules/agents/skills.
|
|
113
|
+
|
|
114
|
+
# True (0) if a source token is a remote git spec.
|
|
115
|
+
source_is_remote() {
|
|
116
|
+
case "$1" in
|
|
117
|
+
git+*) return 0 ;;
|
|
118
|
+
*) return 1 ;;
|
|
119
|
+
esac
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# True (0) if a source token references a pack declared under `packs:`
|
|
123
|
+
# (`@<name>` or `@<name>/<subpath>`).
|
|
124
|
+
source_is_pack() {
|
|
125
|
+
case "$1" in
|
|
126
|
+
@?*) return 0 ;;
|
|
127
|
+
*) return 1 ;;
|
|
128
|
+
esac
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# True (0) if a source token is a plain repo-relative path — i.e. NOT a pack
|
|
132
|
+
# reference and NOT an inline remote spec. The inverse of "resolves through a
|
|
133
|
+
# clone", which is what every caller that pattern-matches a token against a
|
|
134
|
+
# real directory needs.
|
|
135
|
+
source_is_local_path() {
|
|
136
|
+
source_is_pack "$1" && return 1
|
|
137
|
+
source_is_remote "$1" && return 1
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Map an absolute file path to a repo-root-relative path for use as a link
|
|
142
|
+
# target inside a COMMITTED file (e.g. AGENTS.md). A file under $repo_root gets
|
|
143
|
+
# its repo-relative path. A file resolved OUTSIDE $repo_root comes from a remote
|
|
144
|
+
# source materialized in the transient clone cache and has NO stable,
|
|
145
|
+
# committable path, so this returns the empty string — callers MUST then emit
|
|
146
|
+
# the bare name, never the absolute path. The `${path#"$repo_root"/}` strip is a
|
|
147
|
+
# no-op when $path is not under $repo_root, which is how the out-of-repo case is
|
|
148
|
+
# detected.
|
|
149
|
+
repo_rel_link() {
|
|
150
|
+
local repo_root="$1" path="$2" rel
|
|
151
|
+
rel="${path#"$repo_root"/}"
|
|
152
|
+
[ "$rel" = "$path" ] && return 0
|
|
153
|
+
printf '%s' "$rel"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Repo-root-relative path of an existing DIRECTORY — by identity, not spelling.
|
|
157
|
+
# `${dir#"$repo_root"/}` is a plain string strip, which silently yields the
|
|
158
|
+
# unchanged absolute path when the two were produced from different spellings of
|
|
159
|
+
# the same location. That is not hypothetical: Git Bash reaches the Windows temp
|
|
160
|
+
# dir through two mounts (`/tmp/...` and `/c/Users/.../Temp/...`) and `pwd`
|
|
161
|
+
# prints whichever one you arrived through, so `REPO_ROOT` (from `git
|
|
162
|
+
# rev-parse`) and `LS_UMBRELLA_DIR` (from the invocation path) can disagree
|
|
163
|
+
# character-by-character while naming the same directory. AGENTS.md is
|
|
164
|
+
# committed, so a failed strip would bake a machine-specific absolute path into
|
|
165
|
+
# version control.
|
|
166
|
+
#
|
|
167
|
+
# Walks up from <dir> comparing each ancestor to <repo_root> with `-ef`
|
|
168
|
+
# (device+inode — identity, immune to spelling), collecting basenames.
|
|
169
|
+
# Echoes "" when <dir> is not inside <repo_root>, or does not exist.
|
|
170
|
+
# Usage: rel="$(repo_rel_dir "$repo_root" "$LS_MODULE_DIR")" # -> intelligence/sync
|
|
171
|
+
repo_rel_dir() {
|
|
172
|
+
local repo_root="$1" dir="$2"
|
|
173
|
+
local cur rel="" parent base depth=0
|
|
174
|
+
cur="$(cd "$dir" 2>/dev/null && pwd)" || return 0
|
|
175
|
+
[ -d "$repo_root" ] || return 0
|
|
176
|
+
while [ "$depth" -lt 64 ]; do
|
|
177
|
+
if [ "$cur" -ef "$repo_root" ]; then
|
|
178
|
+
printf '%s' "$rel"
|
|
179
|
+
return 0
|
|
180
|
+
fi
|
|
181
|
+
parent="$(dirname "$cur")"
|
|
182
|
+
base="$(basename "$cur")"
|
|
183
|
+
[ "$parent" = "$cur" ] && return 0 # reached the filesystem root
|
|
184
|
+
rel="$base${rel:+/$rel}"
|
|
185
|
+
cur="$parent"
|
|
186
|
+
depth=$((depth + 1))
|
|
187
|
+
done
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Resolve a single source token to an absolute local directory.
|
|
191
|
+
# Local token -> "$repo_root/$token".
|
|
192
|
+
# Remote token -> shallow-clone into the run cache, echo "<clone>/<subpath>".
|
|
193
|
+
# ALWAYS returns 0 (echoes nothing on failure) so `set -e` callers using
|
|
194
|
+
# `dir="$(resolve_source_dir ...)"` never abort; the caller's existing
|
|
195
|
+
# `[ -d "$dir" ] || continue` guard then skips an unresolved source.
|
|
196
|
+
# Usage: dir="$(resolve_source_dir "$repo_root" "$src")"
|
|
197
|
+
resolve_source_dir() {
|
|
198
|
+
local repo_root="$1" token="$2" config_file="${3:-${IS_CONFIG_FILE:-}}"
|
|
199
|
+
|
|
200
|
+
# A declared pack (`@<name>[/<subpath>]`) keeps its url / ref / mirror in
|
|
201
|
+
# config.yaml, so the token itself carries nothing but the reference.
|
|
202
|
+
if source_is_pack "$token"; then
|
|
203
|
+
resolve_pack_source "$repo_root" "$config_file" "$token"
|
|
204
|
+
return 0
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
if ! source_is_remote "$token"; then
|
|
208
|
+
printf '%s' "$repo_root/$token"
|
|
209
|
+
return 0
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
# --- parse: git+<url>[@<ref>][#<subpath>] ---
|
|
213
|
+
# An inline spec is an ANONYMOUS pack: it has no declared name and no
|
|
214
|
+
# mirror, so it is always transient. Declare it under `packs:` to commit it.
|
|
215
|
+
local rest="${token#git+}"
|
|
216
|
+
local subpath="" urlref="$rest"
|
|
217
|
+
case "$rest" in
|
|
218
|
+
*\#*) subpath="${rest#*#}"; urlref="${rest%%#*}" ;;
|
|
219
|
+
esac
|
|
220
|
+
|
|
221
|
+
# ref = segment after the last `@` in the post-scheme part, only if it has
|
|
222
|
+
# no `/` (else it is userinfo such as `git@host`, not a ref).
|
|
223
|
+
local url="$urlref" ref="" after_scheme="${urlref#*://}"
|
|
224
|
+
case "$after_scheme" in
|
|
225
|
+
*@*)
|
|
226
|
+
local cand="${after_scheme##*@}"
|
|
227
|
+
case "$cand" in
|
|
228
|
+
*/*|"") ;; # userinfo / empty -> no ref
|
|
229
|
+
*) ref="$cand"; url="${urlref%@$ref}" ;;
|
|
230
|
+
esac
|
|
231
|
+
;;
|
|
232
|
+
esac
|
|
233
|
+
|
|
234
|
+
fetch_remote_source "$token" "$url" "$ref" "$subpath" ""
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Resolve `@<name>[/<subpath>]` against the `packs:` block and fetch it.
|
|
238
|
+
# An undeclared name is a HARD error: unlike a mistyped local path (which the
|
|
239
|
+
# caller's `[ -d ]` guard silently skips), a pack reference names something the
|
|
240
|
+
# config claims to know, so a typo must not quietly drop a whole rule set.
|
|
241
|
+
# Usage: resolve_pack_source "$repo_root" "$config_file" "@shared/rules"
|
|
242
|
+
resolve_pack_source() {
|
|
243
|
+
local repo_root="$1" config_file="$2" token="$3"
|
|
244
|
+
|
|
245
|
+
local rest="${token#@}" name subpath=""
|
|
246
|
+
case "$rest" in
|
|
247
|
+
*/*) name="${rest%%/*}"; subpath="${rest#*/}" ;;
|
|
248
|
+
*) name="$rest" ;;
|
|
249
|
+
esac
|
|
250
|
+
|
|
251
|
+
local url ref mirror_rel mirror_abs=""
|
|
252
|
+
url="$(get_pack_field "$config_file" "$name" "url")"
|
|
253
|
+
# Defensive only: validate_pack_refs has already failed the run for an
|
|
254
|
+
# undeclared pack. It has to, because every caller invokes this inside `$( )`
|
|
255
|
+
# — an exit here would end the substitution subshell, not the sync.
|
|
256
|
+
if [ -z "$url" ]; then
|
|
257
|
+
echo " WARN: pack '$name' is not declared under 'packs:': $token" >&2
|
|
258
|
+
return 0
|
|
259
|
+
fi
|
|
260
|
+
ref="$(get_pack_field "$config_file" "$name" "ref")"
|
|
261
|
+
mirror_rel="$(get_pack_field "$config_file" "$name" "mirror")"
|
|
262
|
+
[ -n "$mirror_rel" ] && mirror_abs="$(resolve_mirror_dir "$repo_root" "$config_file" "$name" "$mirror_rel")"
|
|
263
|
+
|
|
264
|
+
fetch_remote_source "$token" "$url" "$ref" "$subpath" "$mirror_abs" "$mirror_rel"
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# Shallow-clone <url>@<ref> into the run cache, echo "<clone>/<subpath>", and —
|
|
268
|
+
# when <mirror_abs> is set — additionally materialize it there so the content is
|
|
269
|
+
# committed. <token> is only used for messages.
|
|
270
|
+
# ALWAYS returns 0 (echoes nothing on failure), per resolve_source_dir's contract.
|
|
271
|
+
# Usage: fetch_remote_source <token> <url> <ref> <subpath> <mirror_abs> [<mirror_rel>]
|
|
272
|
+
fetch_remote_source() {
|
|
273
|
+
local token="$1" url="$2" ref="$3" subpath="$4" mirror="$5" mirror_rel="${6:-}"
|
|
274
|
+
|
|
275
|
+
# Reject path traversal in the subpath: a remote spec must not be able to
|
|
276
|
+
# escape the clone dir (e.g. `#../../etc`). Checked before any clone.
|
|
277
|
+
case "/$subpath/" in
|
|
278
|
+
*/../*)
|
|
279
|
+
echo " WARN: remote source rejected (subpath traversal '..'): $token" >&2
|
|
280
|
+
return 0
|
|
281
|
+
;;
|
|
282
|
+
esac
|
|
283
|
+
|
|
284
|
+
# Scheme whitelist — reject everything but plain fetch transports. The
|
|
285
|
+
# ext::/fd:: transports execute arbitrary commands on clone, so a malicious
|
|
286
|
+
# or mistyped config must never reach `git clone` with them.
|
|
287
|
+
case "$url" in
|
|
288
|
+
https://*|http://*|ssh://*|git://*|file://*) ;;
|
|
289
|
+
*)
|
|
290
|
+
echo " WARN: remote source rejected (unsupported scheme): $token" >&2
|
|
291
|
+
return 0
|
|
292
|
+
;;
|
|
293
|
+
esac
|
|
294
|
+
|
|
295
|
+
if ! command -v git >/dev/null 2>&1; then
|
|
296
|
+
echo " WARN: remote source needs git, which is not installed: $token" >&2
|
|
297
|
+
return 0
|
|
298
|
+
fi
|
|
299
|
+
|
|
300
|
+
# Cache root: run-scoped (set + cleaned by sync.sh) or a stable fallback so
|
|
301
|
+
# direct adapter calls still avoid re-cloning the same spec within a run.
|
|
302
|
+
local cache_root="${IS_REMOTE_CACHE:-${TMPDIR:-/tmp}/intelligence-sync-remotes}"
|
|
303
|
+
mkdir -p "$cache_root" 2>/dev/null || true
|
|
304
|
+
# Key on repo URL + ref ONLY (not the subpath): sources that point at the
|
|
305
|
+
# same repo@ref but different subpaths (e.g. `...repo.git@main#rules` and
|
|
306
|
+
# `...repo.git@main#skills`) share a SINGLE clone; the subpath only selects
|
|
307
|
+
# a directory inside it. Different ref → different clone (distinct versions).
|
|
308
|
+
local key
|
|
309
|
+
key="$(printf '%s' "$url@$ref" | cksum | awk '{print $1 "-" $2}')"
|
|
310
|
+
local dest="$cache_root/$key"
|
|
311
|
+
|
|
312
|
+
if [ ! -d "$dest/.git" ]; then
|
|
313
|
+
rm -rf "$dest"
|
|
314
|
+
# Untrusted remote content: never materialize symlinks from the cloned
|
|
315
|
+
# repo. With core.symlinks=false git writes each symlink as a plain text
|
|
316
|
+
# file holding its target path, so a hostile link like `skills -> /etc`
|
|
317
|
+
# cannot make the copy pipeline read host files outside the clone.
|
|
318
|
+
#
|
|
319
|
+
# Line endings are pinned for the same reason the engine's own
|
|
320
|
+
# `.gitattributes` pins them: the checkout must not depend on the host's
|
|
321
|
+
# `core.autocrlf`. On Windows that default rewrites a pack declaring no
|
|
322
|
+
# attributes to CRLF, `materialize_pack` copies bytes verbatim, and the
|
|
323
|
+
# mirror lands CRLF inside the project repo — a phantom diff that returns
|
|
324
|
+
# on every sync. `autocrlf=false` covers packs with no attributes,
|
|
325
|
+
# `eol=lf` covers those that mark files `text`.
|
|
326
|
+
local git_cfg=(-c core.symlinks=false -c core.autocrlf=false -c core.eol=lf)
|
|
327
|
+
local ok=0
|
|
328
|
+
if [ -n "$ref" ]; then
|
|
329
|
+
if GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --depth 1 --branch "$ref" --quiet \
|
|
330
|
+
"$url" "$dest" 2>/dev/null; then
|
|
331
|
+
ok=1
|
|
332
|
+
else
|
|
333
|
+
# ref is likely a SHA (not a branch/tag) — full clone + checkout.
|
|
334
|
+
rm -rf "$dest"
|
|
335
|
+
if GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --quiet "$url" "$dest" 2>/dev/null \
|
|
336
|
+
&& git -C "$dest" "${git_cfg[@]}" checkout --quiet "$ref" 2>/dev/null; then
|
|
337
|
+
ok=1
|
|
338
|
+
fi
|
|
339
|
+
fi
|
|
340
|
+
elif GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --depth 1 --quiet "$url" "$dest" 2>/dev/null; then
|
|
341
|
+
ok=1
|
|
342
|
+
fi
|
|
343
|
+
if [ "$ok" -ne 1 ]; then
|
|
344
|
+
rm -rf "$dest"
|
|
345
|
+
echo " WARN: remote source clone failed (url=$url ref=${ref:-<default>}): $token" >&2
|
|
346
|
+
return 0
|
|
347
|
+
fi
|
|
348
|
+
echo " remote: cloned $url${ref:+ @$ref}" >&2
|
|
349
|
+
fi
|
|
350
|
+
|
|
351
|
+
local out="$dest"
|
|
352
|
+
[ -n "$subpath" ] && out="$dest/$subpath"
|
|
353
|
+
if [ ! -d "$out" ]; then
|
|
354
|
+
echo " WARN: remote source subpath not found ('${subpath:-/}') in $url: $token" >&2
|
|
355
|
+
return 0
|
|
356
|
+
fi
|
|
357
|
+
# Containment (defense in depth on top of the `..` reject + symlink-free
|
|
358
|
+
# checkout): the resolved dir must stay inside the clone. Canonicalize both
|
|
359
|
+
# with `pwd -P` so a symlinked TMPDIR (e.g. macOS /tmp -> /private/tmp)
|
|
360
|
+
# resolves consistently on each side.
|
|
361
|
+
local real_dest real_out
|
|
362
|
+
real_dest="$(cd "$dest" 2>/dev/null && pwd -P)"
|
|
363
|
+
real_out="$(cd "$out" 2>/dev/null && pwd -P)"
|
|
364
|
+
case "${real_out:-/nonexistent}" in
|
|
365
|
+
"$real_dest"|"$real_dest"/*) ;;
|
|
366
|
+
*)
|
|
367
|
+
echo " WARN: remote source subpath escapes the clone ('${subpath:-/}'): $token" >&2
|
|
368
|
+
return 0
|
|
369
|
+
;;
|
|
370
|
+
esac
|
|
371
|
+
|
|
372
|
+
# No `mirror:` -> the clone stays in the transient run cache and nothing
|
|
373
|
+
# lands in the repo. This is every inline `git+` spec, and any pack that
|
|
374
|
+
# declares no mirror.
|
|
375
|
+
if [ -z "$mirror" ]; then
|
|
376
|
+
printf '%s' "$out"
|
|
377
|
+
return 0
|
|
378
|
+
fi
|
|
379
|
+
materialize_pack "$dest" "$out" "$url" "$ref" "$subpath" "$mirror" "$mirror_rel"
|
|
380
|
+
return 0
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
# Copy a resolved remote source out of the transient clone into the pack's
|
|
384
|
+
# declared `mirror:` directory, so pack content is committed and an upstream
|
|
385
|
+
# bump shows up in `git diff` instead of only in the generated output. Echoes
|
|
386
|
+
# the materialized directory; on any failure echoes the clone dir instead, so a
|
|
387
|
+
# broken mirror degrades to the transient behaviour rather than losing the
|
|
388
|
+
# source.
|
|
389
|
+
#
|
|
390
|
+
# The directory is DECLARED, never derived — `mirror:` says exactly where the
|
|
391
|
+
# pack lives, so there is no name to sanitize and no collision to resolve.
|
|
392
|
+
#
|
|
393
|
+
# The FIRST token to touch a pack in a run wipes it (clearing content left by a
|
|
394
|
+
# previous ref, or by a source entry that has since been removed); later tokens
|
|
395
|
+
# for the same pack only replace their own subpath. The claim is recorded in the
|
|
396
|
+
# clone cache, which is what makes "wipe once per run" work across the separate
|
|
397
|
+
# subshells each resolve_source_dir call runs in.
|
|
398
|
+
#
|
|
399
|
+
# The wipe is guarded by the stamp: a NON-EMPTY directory with no `.pack` in it
|
|
400
|
+
# is never deleted — it belongs to the project, not to us.
|
|
401
|
+
# <mirror_rel> is the path as authored in config.yaml, used only in messages.
|
|
402
|
+
# Usage: materialize_pack <clone> <src_dir> <url> <ref> <subpath> <mirror> <mirror_rel>
|
|
403
|
+
materialize_pack() {
|
|
404
|
+
local clone="$1" src_dir="$2" url="$3" ref="$4" subpath="$5" pack_dir="$6" mirror_rel="${7:-}"
|
|
405
|
+
|
|
406
|
+
# The claim is keyed on the DIRECTORY, not on url@ref: it records "this run
|
|
407
|
+
# already cleared this path". Keying it on the clone would let two packs
|
|
408
|
+
# that share a url@ref but declare different mirrors claim each other's,
|
|
409
|
+
# leaving the second mirror unstamped and never pruned.
|
|
410
|
+
#
|
|
411
|
+
# Same cache-root fallback as the clone: the claim must exist even when the
|
|
412
|
+
# caller is not sync.sh, or every token would re-wipe the pack and only the
|
|
413
|
+
# last subpath would survive. That fallback root is NOT run-scoped, though,
|
|
414
|
+
# so the claim carries `$$` — stable across the command-substitution
|
|
415
|
+
# subshells of one run, different for the next. A claim left behind by an
|
|
416
|
+
# earlier run must never suppress this run's wipe: that would rebuild the
|
|
417
|
+
# mirror with no `.pack` in it and freeze it against the guard below.
|
|
418
|
+
local cache_root claim
|
|
419
|
+
cache_root="${IS_REMOTE_CACHE:-${TMPDIR:-/tmp}/intelligence-sync-remotes}"
|
|
420
|
+
mkdir -p "$cache_root" 2>/dev/null || true
|
|
421
|
+
claim="$cache_root/$$-$(printf '%s' "$pack_dir" | cksum | awk '{print $1 "-" $2}').packdir"
|
|
422
|
+
|
|
423
|
+
if [ ! -f "$claim" ]; then
|
|
424
|
+
# Refuse to wipe a directory that is not ours. Ownership is the PRESENCE
|
|
425
|
+
# of the stamp, not the url inside it: a mirror is declared per pack, so
|
|
426
|
+
# a stamped directory is this pack's even after its `url:` is edited —
|
|
427
|
+
# a moved or renamed upstream must refresh the mirror, not freeze it at
|
|
428
|
+
# the old content while the generated output silently follows the new.
|
|
429
|
+
if [ -d "$pack_dir" ] && [ -n "$(find "$pack_dir" -mindepth 1 -maxdepth 1 2>/dev/null)" ] \
|
|
430
|
+
&& [ ! -f "$pack_dir/.pack" ]; then
|
|
431
|
+
echo " WARN: mirror '$pack_dir' holds content that is not a pack's (no .pack stamp) — skipping materialization" >&2
|
|
432
|
+
printf '%s' "$src_dir"
|
|
433
|
+
return 0
|
|
434
|
+
fi
|
|
435
|
+
|
|
436
|
+
rm -rf "$pack_dir"
|
|
437
|
+
if ! mkdir -p "$pack_dir"; then
|
|
438
|
+
echo " WARN: cannot create mirror dir '$pack_dir' — using the run cache" >&2
|
|
439
|
+
printf '%s' "$src_dir"
|
|
440
|
+
return 0
|
|
441
|
+
fi
|
|
442
|
+
{
|
|
443
|
+
printf 'url=%s\n' "$url"
|
|
444
|
+
printf 'ref=%s\n' "${ref:-<default>}"
|
|
445
|
+
printf 'sha=%s\n' "$(git -C "$clone" rev-parse HEAD 2>/dev/null || echo unknown)"
|
|
446
|
+
} > "$pack_dir/.pack"
|
|
447
|
+
printf '%s\n' "$pack_dir" > "$claim"
|
|
448
|
+
echo " pack: $url${ref:+ @$ref} -> ${mirror_rel:-$pack_dir}" >&2
|
|
449
|
+
fi
|
|
450
|
+
|
|
451
|
+
# Only a subpath is cleared here — clearing the pack root would delete the
|
|
452
|
+
# `.pack` stamp written above (and any sibling subpath already copied in
|
|
453
|
+
# this run). The root is already clean: the claim step wiped it.
|
|
454
|
+
local dest="$pack_dir"
|
|
455
|
+
if [ -n "$subpath" ]; then
|
|
456
|
+
dest="$pack_dir/$subpath"
|
|
457
|
+
rm -rf "$dest"
|
|
458
|
+
fi
|
|
459
|
+
mkdir -p "$dest"
|
|
460
|
+
|
|
461
|
+
# Copy the subpath's contents, skipping `.git` — it only exists when the
|
|
462
|
+
# source IS the clone root (no `#subpath`), and a nested `.git` inside the
|
|
463
|
+
# project repo would be recorded as a gitlink, which is exactly the
|
|
464
|
+
# untrackable state this whole feature exists to avoid.
|
|
465
|
+
local entry base
|
|
466
|
+
for entry in "$src_dir"/* "$src_dir"/.[!.]*; do
|
|
467
|
+
[ -e "$entry" ] || continue
|
|
468
|
+
base="${entry##*/}"
|
|
469
|
+
[ "$base" = ".git" ] && continue
|
|
470
|
+
cp -R "$entry" "$dest/"
|
|
471
|
+
done
|
|
472
|
+
|
|
473
|
+
printf '%s' "$dest"
|
|
474
|
+
return 0
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
# Copy a markdown file with frontmatter, ensuring free-text string fields are
|
|
478
|
+
# wrapped in double quotes. Used by adapters that feed strict-YAML consumers
|
|
479
|
+
# (Codex CLI rejects unquoted colons / booleans). Idempotent — already-quoted
|
|
480
|
+
# values pass through untouched. Operates only inside the first `---` ... `---`
|
|
481
|
+
# block; body is preserved verbatim.
|
|
482
|
+
#
|
|
483
|
+
# Quoted fields: description, argument-hint
|
|
484
|
+
# When wrapping an unquoted value, literal `\` and `"` inside it are escaped
|
|
485
|
+
# (`\\`, `\"`) so an inner quote — e.g. `Use as a quick "what do we have" view`
|
|
486
|
+
# — cannot prematurely terminate the generated double-quoted scalar. Values the
|
|
487
|
+
# author already wrapped (in `"` or `'`) pass through untouched.
|
|
488
|
+
#
|
|
489
|
+
# Usage: copy_md_with_quoted_frontmatter "src.md" "dst.md"
|
|
490
|
+
copy_md_with_quoted_frontmatter() {
|
|
491
|
+
local src="$1"
|
|
492
|
+
local dst="$2"
|
|
493
|
+
awk '
|
|
494
|
+
function yamlq(s, out, i, c) {
|
|
495
|
+
out = ""
|
|
496
|
+
for (i = 1; i <= length(s); i++) {
|
|
497
|
+
c = substr(s, i, 1)
|
|
498
|
+
if (c == "\\") out = out "\\\\"
|
|
499
|
+
else if (c == "\"") out = out "\\\""
|
|
500
|
+
else out = out c
|
|
501
|
+
}
|
|
502
|
+
return out
|
|
503
|
+
}
|
|
504
|
+
BEGIN { state = "before" }
|
|
505
|
+
{ sub(/\r$/, "") }
|
|
506
|
+
state == "before" {
|
|
507
|
+
if (NR == 1 && $0 == "---") { state = "in_fm"; print; next }
|
|
508
|
+
state = "after"; print; next
|
|
509
|
+
}
|
|
510
|
+
state == "in_fm" {
|
|
511
|
+
if ($0 == "---") { state = "after"; print; next }
|
|
512
|
+
idx = index($0, ":")
|
|
513
|
+
if (idx == 0) { print; next }
|
|
514
|
+
key = substr($0, 1, idx - 1)
|
|
515
|
+
sub(/^[[:space:]]+/, "", key); sub(/[[:space:]]+$/, "", key)
|
|
516
|
+
if (key != "description" && key != "argument-hint") { print; next }
|
|
517
|
+
val = substr($0, idx + 1)
|
|
518
|
+
sub(/^[[:space:]]+/, "", val); sub(/[[:space:]]+$/, "", val)
|
|
519
|
+
if (val == "") { print; next }
|
|
520
|
+
first = substr(val, 1, 1)
|
|
521
|
+
last = substr(val, length(val), 1)
|
|
522
|
+
if ((first == "\"" && last == "\"") || (first == "\047" && last == "\047")) { print; next }
|
|
523
|
+
print key ": \"" yamlq(val) "\""
|
|
524
|
+
next
|
|
525
|
+
}
|
|
526
|
+
state == "after" { print }
|
|
527
|
+
' "$src" > "$dst"
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
# Copy a skill directory in full: SKILL.md plus any bundled resources
|
|
531
|
+
# (references/, scripts/, assets/ — the Agent Skills standard lets a skill
|
|
532
|
+
# ship support files beside SKILL.md, and SKILL.md bodies point at them by
|
|
533
|
+
# relative path, so dropping them breaks the skill at runtime). Markdown is
|
|
534
|
+
# normalized to LF; every other file is copied byte-for-byte so potentially
|
|
535
|
+
# binary assets survive. `cp -R` copies symlinks as symlinks (POSIX), so a
|
|
536
|
+
# link inside a source skill never leaks host file content into the output.
|
|
537
|
+
#
|
|
538
|
+
# SKILL.md frontmatter is quoted for EVERY consumer, not only the strict-YAML
|
|
539
|
+
# ones. `argument-hint: [pr-number]` is a YAML *flow sequence*, so an unquoted
|
|
540
|
+
# hint arrives as a list and Claude Code refuses the whole skill with
|
|
541
|
+
# "argument-hint must be a string" — the skill silently disappears from the
|
|
542
|
+
# picker. Quoting is idempotent: an already-quoted value passes through
|
|
543
|
+
# untouched.
|
|
544
|
+
# Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir"
|
|
545
|
+
copy_skill_bundle() {
|
|
546
|
+
local src_dir="${1%/}"
|
|
547
|
+
local dest_dir="$2"
|
|
548
|
+
mkdir -p "$dest_dir"
|
|
549
|
+
cp -R "$src_dir/." "$dest_dir/"
|
|
550
|
+
# A symlinked SKILL.md is left exactly as `cp -R` produced it — a symlink.
|
|
551
|
+
# `[ -f ]` follows links, so quoting it would read the link's TARGET and
|
|
552
|
+
# write that content into a real file, turning `skills/x/SKILL.md -> /etc/…`
|
|
553
|
+
# into a copy of a host file inside the output. That is the leak the
|
|
554
|
+
# symlink-preserving copy exists to prevent, so skip the rewrite and say so
|
|
555
|
+
# (the same reason `find -type f` below never matches a symlink).
|
|
556
|
+
if [ -L "$dest_dir/SKILL.md" ]; then
|
|
557
|
+
echo " WARN: $(basename "$dest_dir")/SKILL.md is a symlink — emitted as-is (frontmatter not quoted, tokens not expanded)" >&2
|
|
558
|
+
elif [ -f "$dest_dir/SKILL.md" ]; then
|
|
559
|
+
copy_md_with_quoted_frontmatter "$dest_dir/SKILL.md" "$dest_dir/SKILL.md.tmp-q"
|
|
560
|
+
mv "$dest_dir/SKILL.md.tmp-q" "$dest_dir/SKILL.md"
|
|
561
|
+
fi
|
|
562
|
+
while IFS= read -r f; do
|
|
563
|
+
[ -n "$f" ] || continue
|
|
564
|
+
finalize_output_file "$f"
|
|
565
|
+
done < <(find "$dest_dir" -type f -name '*.md')
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
# Copy skill directories into an Agent Skills open-standard location.
|
|
569
|
+
# The destination is a directory whose immediate children are skill folders
|
|
570
|
+
# containing SKILL.md (e.g. .agents/skills/<name>/SKILL.md). Free-text
|
|
571
|
+
# frontmatter fields are quoted for strict YAML consumers; lenient consumers
|
|
572
|
+
# accept the result unchanged, so this one copy can be shared across tools.
|
|
573
|
+
#
|
|
574
|
+
# Owns the full lifecycle of `$output_dir`: removes every existing skill
|
|
575
|
+
# subfolder, recreates the directory, then populates it. Sibling FILES at
|
|
576
|
+
# `$output_dir` are preserved (only immediate subdirectories are pruned).
|
|
577
|
+
# Multiple adapters may target the same path (e.g. Codex + Pi both write to
|
|
578
|
+
# `.agents/skills/`); calls are idempotent because every caller writes the
|
|
579
|
+
# same content from `intelligence/skills/`. Adapters MUST NOT do their own
|
|
580
|
+
# clean / mkdir for this dir — the helper is the single owner.
|
|
581
|
+
#
|
|
582
|
+
# Usage: sync_open_skill_dirs "$REPO_ROOT" "$CONFIG_FILE" "$dest_dir"
|
|
583
|
+
sync_open_skill_dirs() {
|
|
584
|
+
local repo_root="$1"
|
|
585
|
+
local config_file="$2"
|
|
586
|
+
local output_dir="$3"
|
|
587
|
+
|
|
588
|
+
if [ -d "$output_dir" ]; then
|
|
589
|
+
# Prune both real subdirectories and symlinks (incl. dir-symlinks):
|
|
590
|
+
# "-type d" alone would leave a stale symlinked skill in place and
|
|
591
|
+
# break the "helper owns the full lifecycle" contract.
|
|
592
|
+
find "$output_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec rm -rf {} +
|
|
593
|
+
fi
|
|
594
|
+
mkdir -p "$output_dir"
|
|
595
|
+
|
|
596
|
+
local count=0
|
|
597
|
+
while IFS= read -r src; do
|
|
598
|
+
[ -z "$src" ] && continue
|
|
599
|
+
local dir
|
|
600
|
+
dir="$(resolve_source_dir "$repo_root" "$src")"
|
|
601
|
+
[ -d "$dir" ] || continue
|
|
602
|
+
for d in "$dir"/*/; do
|
|
603
|
+
[ -d "$d" ] || continue
|
|
604
|
+
local skill_name
|
|
605
|
+
skill_name="$(basename "$d")"
|
|
606
|
+
[ -f "$d/SKILL.md" ] || continue
|
|
607
|
+
# copy_skill_bundle now owns the frontmatter-quoting pass, so every
|
|
608
|
+
# target gets it — not just this open-standard dir.
|
|
609
|
+
copy_skill_bundle "$d" "$output_dir/$skill_name"
|
|
610
|
+
count=$((count + 1))
|
|
611
|
+
echo " skill: $skill_name"
|
|
612
|
+
done
|
|
613
|
+
done < <(read_yaml_list "$config_file" "skills")
|
|
614
|
+
|
|
615
|
+
echo " -> Skills: $count"
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
# Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
|
|
619
|
+
# Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
|
|
620
|
+
# these files with cryptic messages — catching them in sync gives better DX.
|
|
621
|
+
# Usage: lint_frontmatter "path/to/file.md"
|
|
622
|
+
lint_frontmatter() {
|
|
623
|
+
local file="$1"
|
|
624
|
+
awk -v f="$file" '
|
|
625
|
+
BEGIN { in_fm = 0; line = 0 }
|
|
626
|
+
{ sub(/\r$/, ""); line++ }
|
|
627
|
+
line == 1 && $0 != "---" { exit }
|
|
628
|
+
line == 1 { in_fm = 1; next }
|
|
629
|
+
in_fm && $0 == "---" { exit }
|
|
630
|
+
in_fm && /^\t/ {
|
|
631
|
+
printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", f, line > "/dev/stderr"
|
|
632
|
+
}
|
|
633
|
+
in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ {
|
|
634
|
+
value_start = index($0, ":") + 1
|
|
635
|
+
value = substr($0, value_start)
|
|
636
|
+
sub(/^[[:space:]]+/, "", value)
|
|
637
|
+
if (value ~ /:[[:space:]]/ || value ~ /:$/) {
|
|
638
|
+
col = index(value, ":") + value_start
|
|
639
|
+
printf " WARN: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", f, line, col > "/dev/stderr"
|
|
640
|
+
}
|
|
641
|
+
if (value ~ /"/) {
|
|
642
|
+
printf " WARN: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", f, line > "/dev/stderr"
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
# Field-length limits. Both Claude Code and the Agent Skills standard
|
|
646
|
+
# reject an over-long description outright ("Skill description must be
|
|
647
|
+
# at most 1024 characters") and the skill vanishes from the picker with
|
|
648
|
+
# no other signal, so catching it at sync time is the only cheap warning
|
|
649
|
+
# the author ever gets. Measured on the value, quotes excluded; a block
|
|
650
|
+
# scalar (`description: |`) is skipped — its length is not on this line.
|
|
651
|
+
in_fm && /^(description|name):[[:space:]]*[^|>[:space:]]/ {
|
|
652
|
+
key = substr($0, 1, index($0, ":") - 1)
|
|
653
|
+
val = substr($0, index($0, ":") + 1)
|
|
654
|
+
sub(/^[[:space:]]+/, "", val); sub(/[[:space:]]+$/, "", val)
|
|
655
|
+
first = substr(val, 1, 1); last = substr(val, length(val), 1)
|
|
656
|
+
if ((first == "\"" && last == "\"") || (first == "\047" && last == "\047")) {
|
|
657
|
+
val = substr(val, 2, length(val) - 2)
|
|
658
|
+
}
|
|
659
|
+
limit = (key == "name") ? 64 : 1024
|
|
660
|
+
if (length(val) > limit) {
|
|
661
|
+
printf " WARN: %s:%d %s is %d chars — over the %d-char limit; the skill/agent will be REJECTED at load time\n", f, line, key, length(val), limit > "/dev/stderr"
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
' "$file"
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
# --- Frontmatter Parsing ---
|
|
668
|
+
|
|
669
|
+
# Extract a single value from YAML frontmatter by key.
|
|
670
|
+
# Splits on the FIRST colon only, so values containing additional colons
|
|
671
|
+
# (e.g. `description: "Use when: fixing APIs"`) are preserved. Reads only
|
|
672
|
+
# inside the first `---` ... `---` frontmatter block; body content is ignored.
|
|
673
|
+
# Strips surrounding double or single quotes from the value.
|
|
674
|
+
# Usage: get_frontmatter_value "tier" "path/to/file.md"
|
|
675
|
+
get_frontmatter_value() {
|
|
676
|
+
local key="$1"
|
|
677
|
+
local file="$2"
|
|
678
|
+
awk -v k="$key" '
|
|
679
|
+
{ sub(/\r$/, "") }
|
|
680
|
+
NR == 1 && $0 != "---" { exit }
|
|
681
|
+
NR == 1 { in_fm = 1; next }
|
|
682
|
+
in_fm && $0 == "---" { exit }
|
|
683
|
+
!in_fm { next }
|
|
684
|
+
|
|
685
|
+
{
|
|
686
|
+
idx = index($0, ":")
|
|
687
|
+
if (idx == 0) next
|
|
688
|
+
line_key = substr($0, 1, idx - 1)
|
|
689
|
+
if (line_key != k) next
|
|
690
|
+
val = substr($0, idx + 1)
|
|
691
|
+
sub(/^[[:space:]]+/, "", val)
|
|
692
|
+
sub(/[[:space:]]+$/, "", val)
|
|
693
|
+
n = length(val)
|
|
694
|
+
if (n >= 2) {
|
|
695
|
+
first = substr(val, 1, 1)
|
|
696
|
+
last = substr(val, n, 1)
|
|
697
|
+
if ((first == "\"" && last == "\"") || (first == "\047" && last == "\047")) {
|
|
698
|
+
val = substr(val, 2, n - 2)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
print val
|
|
702
|
+
exit
|
|
703
|
+
}
|
|
704
|
+
' "$file"
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
# Check if a file starts with YAML frontmatter (---)
|
|
708
|
+
has_frontmatter() {
|
|
709
|
+
local file="$1"
|
|
710
|
+
awk 'NR==1 { sub(/\r$/, ""); if ($0 == "---") print 1; else print 0; exit }' "$file"
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
# Strip YAML frontmatter, print body only.
|
|
714
|
+
# Reads the first `---` ... `---` block at the top of the file and emits
|
|
715
|
+
# everything after the closing fence verbatim. CRLF-safe (trailing \r stripped
|
|
716
|
+
# per line). If the file has no frontmatter, the entire file is printed.
|
|
717
|
+
# Shared by adapters that wrap source agent bodies into IDE-native templates
|
|
718
|
+
# (currently pi.sh, opencode.sh) — do NOT inline-duplicate this awk block.
|
|
719
|
+
# Usage: body="$(strip_frontmatter "path/to/file.md")"
|
|
720
|
+
strip_frontmatter() {
|
|
721
|
+
local file="$1"
|
|
722
|
+
awk '
|
|
723
|
+
BEGIN { in_fm = 0; past_fm = 0 }
|
|
724
|
+
{ sub(/\r$/, "") }
|
|
725
|
+
# No frontmatter: first line is not a `---` fence — treat the whole
|
|
726
|
+
# file as body so the helper honors its "print everything if no
|
|
727
|
+
# frontmatter" contract instead of emitting nothing.
|
|
728
|
+
NR == 1 && $0 != "---" { past_fm = 1 }
|
|
729
|
+
/^---$/ {
|
|
730
|
+
if (!past_fm) {
|
|
731
|
+
in_fm = !in_fm
|
|
732
|
+
if (!in_fm) { past_fm = 1 }
|
|
733
|
+
next
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
past_fm { print }
|
|
737
|
+
' "$file"
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
# Check if a file has a paths: field in frontmatter.
|
|
741
|
+
# Scoped to the first `---` ... `---` block — body content like a code
|
|
742
|
+
# example referencing `paths: foo` will not be miscounted.
|
|
743
|
+
has_paths() {
|
|
744
|
+
local file="$1"
|
|
745
|
+
awk '
|
|
746
|
+
{ sub(/\r$/, "") }
|
|
747
|
+
NR == 1 && $0 != "---" { print 0; done=1; exit }
|
|
748
|
+
NR == 1 { in_fm = 1; next }
|
|
749
|
+
in_fm && $0 == "---" { print c+0; done=1; exit }
|
|
750
|
+
in_fm && /^paths:/ { c++ }
|
|
751
|
+
END { if (!done) print c+0 }
|
|
752
|
+
' "$file"
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
# --- Tier/Access Mappings ---
|
|
756
|
+
|
|
757
|
+
# Hardcoded defaults: ide:tier -> model name.
|
|
758
|
+
# When you bump these, re-run sync in projects; any project whose config.yaml
|
|
759
|
+
# `models:` section diverges from these will print a drift warning so users
|
|
760
|
+
# know their override is now stale.
|
|
761
|
+
get_model_default() {
|
|
762
|
+
local ide="$1"
|
|
763
|
+
local tier="$2"
|
|
764
|
+
case "$ide:$tier" in
|
|
765
|
+
claude:heavy) echo "opus" ;;
|
|
766
|
+
claude:standard) echo "sonnet" ;;
|
|
767
|
+
claude:light) echo "haiku" ;;
|
|
768
|
+
cursor:heavy) echo "inherit" ;;
|
|
769
|
+
cursor:standard) echo "inherit" ;;
|
|
770
|
+
cursor:light) echo "fast" ;;
|
|
771
|
+
copilot:heavy) echo "gpt-5.6-sol" ;;
|
|
772
|
+
copilot:standard) echo "gpt-5.6-terra" ;;
|
|
773
|
+
copilot:light) echo "gpt-5.6-luna" ;;
|
|
774
|
+
codex:heavy) echo "gpt-5.6-sol" ;;
|
|
775
|
+
codex:standard) echo "gpt-5.6-terra" ;;
|
|
776
|
+
codex:light) echo "gpt-5.6-luna" ;;
|
|
777
|
+
opencode:heavy) echo "anthropic/claude-opus-4-8" ;;
|
|
778
|
+
opencode:standard) echo "anthropic/claude-sonnet-5" ;;
|
|
779
|
+
opencode:light) echo "anthropic/claude-haiku-4-5-20251001" ;;
|
|
780
|
+
*) echo "" ;;
|
|
781
|
+
esac
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
# Read a nested key from config.yaml: section -> sub -> key.
|
|
785
|
+
# Resolves `models.<ide>.<tier>` overrides and `packs.<name>.<field>`.
|
|
786
|
+
#
|
|
787
|
+
# The value strip is anchored at the FIRST colon (`[^:]*:`), never a greedy
|
|
788
|
+
# `.*:` — a greedy match cuts at the LAST colon on the line, which turns
|
|
789
|
+
# `url: https://host/repo.git` into `//host/repo.git`. An unquoted value also
|
|
790
|
+
# drops a trailing ` # comment`, per YAML; inside quotes a `#` is content.
|
|
791
|
+
#
|
|
792
|
+
# The sub-key is matched LITERALLY (`index(...) == 1`), never interpolated into
|
|
793
|
+
# a regex: a pack name may contain `.`, which as a pattern is any character, so
|
|
794
|
+
# `packs.a.b` would happily read a pack named `axb`.
|
|
795
|
+
get_nested_yaml_value() {
|
|
796
|
+
local file="$1"
|
|
797
|
+
local section="$2"
|
|
798
|
+
local sub="$3"
|
|
799
|
+
local key="$4"
|
|
800
|
+
awk -v section="$section" -v subname="$sub" -v key="$key" '
|
|
801
|
+
{ sub(/\r$/, "") }
|
|
802
|
+
$0 ~ "^" section ":[[:space:]]*$" { in_section=1; in_sub=0; next }
|
|
803
|
+
in_section && /^[a-zA-Z]/ && $0 !~ "^" section ":" { in_section=0; in_sub=0 }
|
|
804
|
+
in_section && index($0, " " subname ":") == 1 {
|
|
805
|
+
if (substr($0, length(subname) + 4) ~ /^[[:space:]]*$/) { in_sub=1; next }
|
|
806
|
+
}
|
|
807
|
+
in_section && in_sub && /^ [A-Za-z0-9_]/ { in_sub=0 }
|
|
808
|
+
in_section && in_sub && index($0, " " key ":") == 1 {
|
|
809
|
+
val = $0
|
|
810
|
+
sub(/^[[:space:]]*[^:]*:[[:space:]]*/, "", val)
|
|
811
|
+
if (val ~ /^"/ || val ~ /^\047/) {
|
|
812
|
+
q = substr(val, 1, 1)
|
|
813
|
+
val = substr(val, 2)
|
|
814
|
+
i = index(val, q)
|
|
815
|
+
if (i > 0) val = substr(val, 1, i - 1)
|
|
816
|
+
} else {
|
|
817
|
+
sub(/[[:space:]]+#.*$/, "", val)
|
|
818
|
+
sub(/[[:space:]]+$/, "", val)
|
|
819
|
+
}
|
|
820
|
+
print val
|
|
821
|
+
exit
|
|
822
|
+
}
|
|
823
|
+
' "$file"
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
# Resolve a model: config.yaml `models:` override wins, otherwise default.
|
|
827
|
+
# Usage: get_model "$CONFIG_FILE" "claude" "$tier"
|
|
828
|
+
get_model() {
|
|
829
|
+
local config_file="$1"
|
|
830
|
+
local ide="$2"
|
|
831
|
+
local tier="${3:-heavy}"
|
|
832
|
+
local override
|
|
833
|
+
if [ -n "$config_file" ] && [ -f "$config_file" ]; then
|
|
834
|
+
override=$(get_nested_yaml_value "$config_file" "models" "$ide" "$tier")
|
|
835
|
+
fi
|
|
836
|
+
if [ -n "${override:-}" ]; then
|
|
837
|
+
echo "$override"
|
|
838
|
+
else
|
|
839
|
+
get_model_default "$ide" "$tier"
|
|
840
|
+
fi
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
# Print info message for each model override that differs from the
|
|
844
|
+
# hardcoded default. Helps users notice when a script update brings new
|
|
845
|
+
# defaults that their config still overrides with the old value.
|
|
846
|
+
# One awk pass extracts every `<ide>.<tier>=<value>` triple under `models:`;
|
|
847
|
+
# comparison against defaults happens in shell.
|
|
848
|
+
report_model_drift() {
|
|
849
|
+
local config_file="$1"
|
|
850
|
+
[ -f "$config_file" ] || return 0
|
|
851
|
+
|
|
852
|
+
local triples
|
|
853
|
+
triples=$(awk '
|
|
854
|
+
{ sub(/\r$/, "") }
|
|
855
|
+
/^models:[[:space:]]*$/ { in_models=1; next }
|
|
856
|
+
in_models && /^[a-zA-Z]/ { in_models=0; in_ide="" }
|
|
857
|
+
in_models && /^ [a-zA-Z][a-zA-Z0-9_-]*:[[:space:]]*$/ {
|
|
858
|
+
line=$0
|
|
859
|
+
sub(/^ /, "", line); sub(/:[[:space:]]*$/, "", line)
|
|
860
|
+
in_ide=line
|
|
861
|
+
next
|
|
862
|
+
}
|
|
863
|
+
in_models && in_ide && /^ [a-zA-Z][a-zA-Z0-9_-]*:/ {
|
|
864
|
+
line=$0; sub(/^ /, "", line)
|
|
865
|
+
key=line; sub(/:.*/, "", key)
|
|
866
|
+
val=line; sub(/[^:]+:[[:space:]]*/, "", val)
|
|
867
|
+
gsub(/^["\047]|["\047][[:space:]]*$/, "", val)
|
|
868
|
+
print in_ide "\t" key "\t" val
|
|
869
|
+
}
|
|
870
|
+
' "$config_file")
|
|
871
|
+
|
|
872
|
+
[ -z "$triples" ] && return 0
|
|
873
|
+
|
|
874
|
+
local printed_header=0
|
|
875
|
+
while IFS=$'\t' read -r ide tier from_config; do
|
|
876
|
+
[ -z "$from_config" ] && continue
|
|
877
|
+
local default
|
|
878
|
+
default=$(get_model_default "$ide" "$tier")
|
|
879
|
+
[ "$from_config" = "$default" ] && continue
|
|
880
|
+
if [ $printed_header -eq 0 ]; then
|
|
881
|
+
echo ""
|
|
882
|
+
echo "=== Model overrides (config.yaml differs from intelligence-sync defaults) ==="
|
|
883
|
+
printed_header=1
|
|
884
|
+
fi
|
|
885
|
+
printf " %-8s %-9s config=%-20s default=%s\n" "$ide" "$tier" "\"$from_config\"" "\"$default\""
|
|
886
|
+
done <<< "$triples"
|
|
887
|
+
|
|
888
|
+
if [ $printed_header -eq 1 ]; then
|
|
889
|
+
echo " (To accept new defaults: remove the entry from config.yaml \`models:\` section.)"
|
|
890
|
+
fi
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
# Map access level to Claude tools string
|
|
894
|
+
map_access_to_claude_tools() {
|
|
895
|
+
local access="$1"
|
|
896
|
+
case "$access" in
|
|
897
|
+
readonly) echo "Read, Grep, Glob, Bash" ;;
|
|
898
|
+
# full: emit NO tools list at all. Confirmed empirically in Copilot (VSCode,
|
|
899
|
+
# reading .claude/agents): a closed tools list restricts the agent to exactly
|
|
900
|
+
# those tools and loses MCP; omitting the field lets it inherit every session
|
|
901
|
+
# tool, MCP servers included. tools: ["*"] did NOT enable MCP - omission does.
|
|
902
|
+
# (Claude Code behaves the same: no tools field = inherit all incl MCP.)
|
|
903
|
+
# NOTE: omission also inherits Pylance + all built-ins, which can push the
|
|
904
|
+
# request over Copilot's 128-tool cap and trigger virtual-tools grouping that
|
|
905
|
+
# intermittently hides MCP. The durable fix is an explicit allowlist that
|
|
906
|
+
# NAMES the MCP servers (umbraco-mcp/*, figma/*) and stays under 128; that
|
|
907
|
+
# needs the project's MCP server list, so it is tracked, not encoded here yet.
|
|
908
|
+
*) echo "" ;;
|
|
909
|
+
esac
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
# Map access level to Claude disallowedTools (empty if full access)
|
|
913
|
+
map_access_to_claude_disallowed() {
|
|
914
|
+
local access="$1"
|
|
915
|
+
case "$access" in
|
|
916
|
+
readonly) echo "Write, Edit" ;;
|
|
917
|
+
*) echo "" ;;
|
|
918
|
+
esac
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
# --- Validation ---
|
|
922
|
+
|
|
923
|
+
# Lexically canonicalize a path: collapse `//`, `.` and `..` by pure string
|
|
924
|
+
# surgery, so a path that does not exist yet still normalizes (realpath -m is
|
|
925
|
+
# not POSIX and `cd` only works on dirs that exist). Symlinks are NOT resolved
|
|
926
|
+
# — validate_output_path pairs this with a `cd -P` check for paths that do
|
|
927
|
+
# exist.
|
|
928
|
+
# Usage: canon="$(normalize_path "/repo/a/../b")" # -> /repo/b
|
|
929
|
+
normalize_path() {
|
|
930
|
+
local path="$1" p out=""
|
|
931
|
+
local -a parts
|
|
932
|
+
IFS='/' read -r -a parts <<< "$path"
|
|
933
|
+
for p in "${parts[@]+"${parts[@]}"}"; do
|
|
934
|
+
case "$p" in
|
|
935
|
+
""|".") ;;
|
|
936
|
+
"..") out="${out%/*}" ;;
|
|
937
|
+
*) out="$out/$p" ;;
|
|
938
|
+
esac
|
|
939
|
+
done
|
|
940
|
+
printf '%s' "${out:-/}"
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
# Refuse to operate on output paths that could clobber content.
|
|
944
|
+
# Adapters `rm -rf` subdirectories of $output_dir, and the `agents` adapter
|
|
945
|
+
# overwrites whatever single file it is pointed at; if config.yaml aims an
|
|
946
|
+
# adapter at the repo root, outside the repo, at the intelligence source tree
|
|
947
|
+
# (whatever the user named it), or at any configured source directory, that
|
|
948
|
+
# write would destroy real work. Call this from sync.sh before invoking EVERY
|
|
949
|
+
# adapter — `agents` included: writing one file to an arbitrary path is a
|
|
950
|
+
# config-file-to-arbitrary-write path, no less than a cleanup is.
|
|
951
|
+
#
|
|
952
|
+
# All forbidden paths are derived dynamically — no folder name is
|
|
953
|
+
# hardcoded, so projects that renamed `intelligence/` (capital I, custom
|
|
954
|
+
# name) are protected the same way.
|
|
955
|
+
#
|
|
956
|
+
# Exits 1 with a clear message on rejection.
|
|
957
|
+
# Usage: validate_output_path "$REPO_ROOT" "$CONFIG_FILE" "$adapter" "$output_dir"
|
|
958
|
+
validate_output_path() {
|
|
959
|
+
local repo_root="$1"
|
|
960
|
+
local config_file="$2"
|
|
961
|
+
local adapter="$3"
|
|
962
|
+
local output_dir="$4"
|
|
963
|
+
|
|
964
|
+
# Canonicalize FIRST. Every check below is a string comparison, so a `../`
|
|
965
|
+
# left in the raw value would walk straight past all of them.
|
|
966
|
+
local canon
|
|
967
|
+
canon="$(normalize_path "$output_dir")"
|
|
968
|
+
|
|
969
|
+
case "$canon" in
|
|
970
|
+
""|"/"|"$repo_root")
|
|
971
|
+
echo "ERROR: targets.$adapter.output resolves to repo root or empty path: '$output_dir'" >&2
|
|
972
|
+
echo " Refusing to run — the adapter would destroy repository content." >&2
|
|
973
|
+
exit 1
|
|
974
|
+
;;
|
|
975
|
+
esac
|
|
976
|
+
|
|
977
|
+
# Must stay inside the repository.
|
|
978
|
+
case "$canon" in
|
|
979
|
+
"$repo_root"/*) ;;
|
|
980
|
+
*)
|
|
981
|
+
echo "ERROR: targets.$adapter.output escapes the repository: '$output_dir'" >&2
|
|
982
|
+
echo " Resolves to '$canon', outside '$repo_root'." >&2
|
|
983
|
+
exit 1
|
|
984
|
+
;;
|
|
985
|
+
esac
|
|
986
|
+
|
|
987
|
+
# A symlink ANYWHERE on the path can still lead out of the repo, which the
|
|
988
|
+
# lexical pass above cannot see. The output itself usually does not exist on
|
|
989
|
+
# a first sync, so checking only an existing final directory would miss the
|
|
990
|
+
# common case (`.cursor` absent, but its parent `generated/` symlinked out).
|
|
991
|
+
# Walk up to the deepest component that does exist and resolve THAT
|
|
992
|
+
# physically. `-L` in the loop guard so a broken symlink is caught rather
|
|
993
|
+
# than stepped over.
|
|
994
|
+
local probe="$canon" parent
|
|
995
|
+
while [ ! -e "$probe" ] && [ ! -L "$probe" ]; do
|
|
996
|
+
parent="$(dirname "$probe")"
|
|
997
|
+
[ "$parent" = "$probe" ] && break
|
|
998
|
+
probe="$parent"
|
|
999
|
+
done
|
|
1000
|
+
|
|
1001
|
+
if [ -e "$probe" ] || [ -L "$probe" ]; then
|
|
1002
|
+
# A symlinked FILE target (e.g. `AGENTS.md` -> /etc/hosts, or a dangling
|
|
1003
|
+
# link) would be written straight through. Refuse rather than follow it;
|
|
1004
|
+
# a generated output is never legitimately a file symlink.
|
|
1005
|
+
if [ -L "$probe" ] && [ ! -d "$probe" ]; then
|
|
1006
|
+
echo "ERROR: targets.$adapter.output ('$output_dir') resolves through a symlink ('$probe')." >&2
|
|
1007
|
+
echo " Refusing to write through it." >&2
|
|
1008
|
+
exit 1
|
|
1009
|
+
fi
|
|
1010
|
+
# Symlinked directory: allowed only while its physical target stays
|
|
1011
|
+
# inside the repo (`pwd -P` on both sides so a symlinked repo root
|
|
1012
|
+
# resolves consistently).
|
|
1013
|
+
local probe_dir phys repo_phys
|
|
1014
|
+
if [ -d "$probe" ]; then probe_dir="$probe"; else probe_dir="$(dirname "$probe")"; fi
|
|
1015
|
+
phys="$(cd "$probe_dir" 2>/dev/null && pwd -P)" || phys=""
|
|
1016
|
+
repo_phys="$(cd "$repo_root" && pwd -P)"
|
|
1017
|
+
case "${phys:-/nonexistent}" in
|
|
1018
|
+
"$repo_phys"|"$repo_phys"/*) ;;
|
|
1019
|
+
*)
|
|
1020
|
+
echo "ERROR: targets.$adapter.output ('$output_dir') resolves through a symlink to '$phys'," >&2
|
|
1021
|
+
echo " which is outside the repository. Refusing to run." >&2
|
|
1022
|
+
exit 1
|
|
1023
|
+
;;
|
|
1024
|
+
esac
|
|
1025
|
+
fi
|
|
1026
|
+
|
|
1027
|
+
local rel="${canon#"$repo_root"/}"
|
|
1028
|
+
|
|
1029
|
+
# Reject the intelligence source directory itself (parent of config.yaml).
|
|
1030
|
+
# Folder name is whatever the user chose — we read it from the filesystem.
|
|
1031
|
+
local intel_dir intel_rel
|
|
1032
|
+
intel_dir="$(cd "$(dirname "$config_file")" && pwd)"
|
|
1033
|
+
intel_rel="${intel_dir#"$repo_root"/}"
|
|
1034
|
+
if [ -n "$intel_rel" ] && [ "$intel_rel" != "$intel_dir" ]; then
|
|
1035
|
+
case "$rel" in
|
|
1036
|
+
"$intel_rel"|"$intel_rel"/*)
|
|
1037
|
+
echo "ERROR: targets.$adapter.output points into the intelligence source tree ('$intel_rel'): '$rel'" >&2
|
|
1038
|
+
echo " The adapter would overwrite or delete rules / agents / skills source files." >&2
|
|
1039
|
+
exit 1
|
|
1040
|
+
;;
|
|
1041
|
+
esac
|
|
1042
|
+
fi
|
|
1043
|
+
|
|
1044
|
+
# Explicitly protected directories (colon-separated, repo-relative). In CLI
|
|
1045
|
+
# mode the manifest sits at the repo root, so the parent-of-config
|
|
1046
|
+
# derivation above degrades to a no-op — the CLI names the source tree and
|
|
1047
|
+
# the package store here instead. Unset (every vendored setup) → inert.
|
|
1048
|
+
if [ -n "${IS_PROTECTED_DIRS:-}" ]; then
|
|
1049
|
+
local -a prot_arr=()
|
|
1050
|
+
local prot
|
|
1051
|
+
IFS=':' read -r -a prot_arr <<< "$IS_PROTECTED_DIRS"
|
|
1052
|
+
for prot in "${prot_arr[@]+"${prot_arr[@]}"}"; do
|
|
1053
|
+
[ -n "$prot" ] || continue
|
|
1054
|
+
case "$rel" in
|
|
1055
|
+
"$prot"|"$prot"/*)
|
|
1056
|
+
echo "ERROR: targets.$adapter.output points into a protected directory ('$prot'): '$rel'" >&2
|
|
1057
|
+
echo " The adapter would overwrite or delete source or package content." >&2
|
|
1058
|
+
exit 1
|
|
1059
|
+
;;
|
|
1060
|
+
esac
|
|
1061
|
+
done
|
|
1062
|
+
fi
|
|
1063
|
+
|
|
1064
|
+
# Reject any configured source directory (rules, agents, skills).
|
|
1065
|
+
local section src src_rel
|
|
1066
|
+
for section in rules agents skills; do
|
|
1067
|
+
while IFS= read -r src; do
|
|
1068
|
+
[ -z "$src" ] && continue
|
|
1069
|
+
# Remote sources never resolve to a local output path — skip them
|
|
1070
|
+
# so a `git+...` spec or an `@pack` reference is not pattern-matched
|
|
1071
|
+
# against the output dir. A mirrored pack is covered separately,
|
|
1072
|
+
# below, by its declared `mirror:`.
|
|
1073
|
+
source_is_local_path "$src" || continue
|
|
1074
|
+
src_rel="$(normalize_path "$repo_root/$src")"
|
|
1075
|
+
src_rel="${src_rel#"$repo_root"/}"
|
|
1076
|
+
case "$rel" in
|
|
1077
|
+
"$src_rel"|"$src_rel"/*)
|
|
1078
|
+
echo "ERROR: targets.$adapter.output ('$rel') overlaps a configured source ('$src')." >&2
|
|
1079
|
+
echo " The adapter would overwrite or delete source content." >&2
|
|
1080
|
+
exit 1
|
|
1081
|
+
;;
|
|
1082
|
+
esac
|
|
1083
|
+
done < <(read_yaml_list "$config_file" "$section")
|
|
1084
|
+
done
|
|
1085
|
+
|
|
1086
|
+
# Reject any pack mirror — materialized pack content is source, and it is
|
|
1087
|
+
# committed, so an adapter cleanup aimed at it would delete work that is not
|
|
1088
|
+
# regenerated until the next successful clone.
|
|
1089
|
+
local mirror_rel
|
|
1090
|
+
while IFS= read -r mirror_rel; do
|
|
1091
|
+
[ -z "$mirror_rel" ] && continue
|
|
1092
|
+
case "$rel" in
|
|
1093
|
+
"$mirror_rel"|"$mirror_rel"/*)
|
|
1094
|
+
echo "ERROR: targets.$adapter.output ('$rel') points into a pack mirror ('$mirror_rel')." >&2
|
|
1095
|
+
echo " The adapter would delete materialized pack content." >&2
|
|
1096
|
+
exit 1
|
|
1097
|
+
;;
|
|
1098
|
+
esac
|
|
1099
|
+
done < <(list_pack_mirrors "$repo_root" "$config_file")
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
# Resolve and validate one pack's `mirror:` into an absolute directory.
|
|
1103
|
+
#
|
|
1104
|
+
# materialize_pack `rm -rf`s this path, so the same class of check that guards
|
|
1105
|
+
# adapter outputs applies — with one deliberate difference: a mirror is ALLOWED
|
|
1106
|
+
# inside the intelligence umbrella, since `<umbrella>/external/<pack>` is the
|
|
1107
|
+
# recommended place for it.
|
|
1108
|
+
#
|
|
1109
|
+
# Echoes the absolute path; exits 1 with a clear message on rejection.
|
|
1110
|
+
# Usage: resolve_mirror_dir "$REPO_ROOT" "$CONFIG_FILE" <pack-name> <mirror-rel>
|
|
1111
|
+
resolve_mirror_dir() {
|
|
1112
|
+
local repo_root="$1" config_file="$2" name="$3" rel="$4"
|
|
1113
|
+
|
|
1114
|
+
local canon
|
|
1115
|
+
canon="$(normalize_path "$repo_root/$rel")"
|
|
1116
|
+
|
|
1117
|
+
case "$canon" in
|
|
1118
|
+
""|"/"|"$repo_root")
|
|
1119
|
+
echo "ERROR: packs.$name.mirror resolves to repo root or empty path: '$rel'" >&2
|
|
1120
|
+
exit 1
|
|
1121
|
+
;;
|
|
1122
|
+
esac
|
|
1123
|
+
case "$canon" in
|
|
1124
|
+
"$repo_root"/*) ;;
|
|
1125
|
+
*)
|
|
1126
|
+
echo "ERROR: packs.$name.mirror escapes the repository: '$rel' (resolves to '$canon')." >&2
|
|
1127
|
+
exit 1
|
|
1128
|
+
;;
|
|
1129
|
+
esac
|
|
1130
|
+
|
|
1131
|
+
# Never inside a configured source tree: a pack directory created there
|
|
1132
|
+
# would be `rm -rf`d alongside authored rules / agents / skills.
|
|
1133
|
+
local canon_rel section src src_rel
|
|
1134
|
+
canon_rel="${canon#"$repo_root"/}"
|
|
1135
|
+
for section in rules agents skills; do
|
|
1136
|
+
while IFS= read -r src; do
|
|
1137
|
+
[ -z "$src" ] && continue
|
|
1138
|
+
source_is_local_path "$src" || continue
|
|
1139
|
+
src_rel="$(normalize_path "$repo_root/$src")"
|
|
1140
|
+
src_rel="${src_rel#"$repo_root"/}"
|
|
1141
|
+
case "$canon_rel" in
|
|
1142
|
+
"$src_rel"|"$src_rel"/*)
|
|
1143
|
+
echo "ERROR: packs.$name.mirror ('$rel') is inside a configured source ('$src')." >&2
|
|
1144
|
+
echo " Materializing a pack there would overwrite authored content." >&2
|
|
1145
|
+
exit 1
|
|
1146
|
+
;;
|
|
1147
|
+
esac
|
|
1148
|
+
done < <(read_yaml_list "$config_file" "$section")
|
|
1149
|
+
done
|
|
1150
|
+
|
|
1151
|
+
printf '%s' "$canon"
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
# Fail the run on a `@<pack>` source that names a pack the config does not
|
|
1155
|
+
# declare, and on a declared `mirror:` that is unsafe to `rm -rf`.
|
|
1156
|
+
#
|
|
1157
|
+
# This runs UP FRONT, before any adapter, because resolve_source_dir is always
|
|
1158
|
+
# called inside `$( )`: an error raised down there would exit the substitution
|
|
1159
|
+
# subshell only, and the caller's `[ -d "$dir" ] || continue` guard would turn a
|
|
1160
|
+
# typo into a silently dropped rule set — the exact failure this feature exists
|
|
1161
|
+
# to remove. A missing local path stays a warning; a bad pack reference does not,
|
|
1162
|
+
# because the config claims to know that name.
|
|
1163
|
+
#
|
|
1164
|
+
# Exits 1 with a clear message on rejection.
|
|
1165
|
+
# Usage: validate_pack_refs "$REPO_ROOT" "$CONFIG_FILE"
|
|
1166
|
+
validate_pack_refs() {
|
|
1167
|
+
local repo_root="$1" config_file="$2"
|
|
1168
|
+
local section src name known bad=0
|
|
1169
|
+
|
|
1170
|
+
for section in rules agents skills; do
|
|
1171
|
+
while IFS= read -r src; do
|
|
1172
|
+
[ -z "$src" ] && continue
|
|
1173
|
+
source_is_pack "$src" || continue
|
|
1174
|
+
name="${src#@}"
|
|
1175
|
+
name="${name%%/*}"
|
|
1176
|
+
if [ -z "$(get_pack_field "$config_file" "$name" "url")" ]; then
|
|
1177
|
+
echo "ERROR: sources.$section entry '$src' references pack '$name', which has no 'packs.$name.url' in $config_file." >&2
|
|
1178
|
+
bad=1
|
|
1179
|
+
fi
|
|
1180
|
+
done < <(read_yaml_list "$config_file" "$section")
|
|
1181
|
+
done
|
|
1182
|
+
|
|
1183
|
+
if [ "$bad" -ne 0 ]; then
|
|
1184
|
+
known="$(read_yaml_keys "$config_file" "packs" | tr '\n' ' ')"
|
|
1185
|
+
echo " Declared packs: ${known:-<none>}" >&2
|
|
1186
|
+
# `targets:` accepts the flow form, so a user reasonably writes
|
|
1187
|
+
# `packs:\n shared: { url: … }` — which reads as zero declared packs and
|
|
1188
|
+
# makes the message above point at a typo that is not there. Scoped to
|
|
1189
|
+
# the `packs:` block: every shipped example writes `targets:` in flow
|
|
1190
|
+
# form, so an unscoped match would print this note on every failure.
|
|
1191
|
+
if awk '
|
|
1192
|
+
{ sub(/\r$/, "") }
|
|
1193
|
+
/^packs:[[:space:]]*$/ { in_p = 1; next }
|
|
1194
|
+
/^[A-Za-z]/ { in_p = 0 }
|
|
1195
|
+
in_p && /^ [A-Za-z0-9_][A-Za-z0-9._-]*:[[:space:]]*\{/ { found = 1; exit }
|
|
1196
|
+
END { exit !found }
|
|
1197
|
+
' "$config_file"; then
|
|
1198
|
+
echo " Note: a pack must be declared in block form — 'name:' on its own line," >&2
|
|
1199
|
+
echo " then indented 'url:' / 'ref:' / 'mirror:'. The '{ … }' form is not read here." >&2
|
|
1200
|
+
fi
|
|
1201
|
+
exit 1
|
|
1202
|
+
fi
|
|
1203
|
+
|
|
1204
|
+
# Validate every declared mirror once, before a single clone runs, so an
|
|
1205
|
+
# unsafe path fails the run rather than being discovered mid-materialization.
|
|
1206
|
+
#
|
|
1207
|
+
# Two packs sharing one mirror is refused here too: the wipe is claimed per
|
|
1208
|
+
# DIRECTORY, so the second pack would skip the clear and copy its subpaths
|
|
1209
|
+
# in beside the first's, leaving one directory holding two packs' content
|
|
1210
|
+
# under a single `.pack`. Nothing downstream can untangle that.
|
|
1211
|
+
local rel canon i
|
|
1212
|
+
local mirror_dirs=() mirror_owners=()
|
|
1213
|
+
while IFS= read -r name; do
|
|
1214
|
+
[ -z "$name" ] && continue
|
|
1215
|
+
rel="$(get_pack_field "$config_file" "$name" "mirror")"
|
|
1216
|
+
[ -n "$rel" ] || continue
|
|
1217
|
+
canon="$(resolve_mirror_dir "$repo_root" "$config_file" "$name" "$rel")"
|
|
1218
|
+
i=0
|
|
1219
|
+
while [ "$i" -lt "${#mirror_dirs[@]}" ]; do
|
|
1220
|
+
if [ "${mirror_dirs[$i]}" = "$canon" ]; then
|
|
1221
|
+
echo "ERROR: packs.$name.mirror ('$rel') is already the mirror of pack '${mirror_owners[$i]}'." >&2
|
|
1222
|
+
echo " Each pack needs its own directory — sharing one leaves a single '.pack' stamp" >&2
|
|
1223
|
+
echo " naming one pack over a directory holding both packs' content." >&2
|
|
1224
|
+
exit 1
|
|
1225
|
+
fi
|
|
1226
|
+
i=$((i + 1))
|
|
1227
|
+
done
|
|
1228
|
+
mirror_dirs+=("$canon"); mirror_owners+=("$name")
|
|
1229
|
+
done < <(read_yaml_keys "$config_file" "packs")
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
# Every declared pack's `mirror:`, one repo-relative path per line (packs with
|
|
1233
|
+
# no mirror contribute nothing). Used by the guards that must not mistake
|
|
1234
|
+
# materialized pack content for either an adapter output or an unsynced source.
|
|
1235
|
+
# Usage: readarray -t mirrors < <(list_pack_mirrors "$REPO_ROOT" "$CONFIG_FILE")
|
|
1236
|
+
list_pack_mirrors() {
|
|
1237
|
+
local repo_root="$1" config_file="$2"
|
|
1238
|
+
local name rel canon
|
|
1239
|
+
while IFS= read -r name; do
|
|
1240
|
+
[ -z "$name" ] && continue
|
|
1241
|
+
rel="$(get_pack_field "$config_file" "$name" "mirror")"
|
|
1242
|
+
[ -n "$rel" ] || continue
|
|
1243
|
+
canon="$(normalize_path "$repo_root/$rel")"
|
|
1244
|
+
printf '%s\n' "${canon#"$repo_root"/}"
|
|
1245
|
+
done < <(read_yaml_keys "$config_file" "packs")
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
# Warn about prompt directories not listed in sources.
|
|
1249
|
+
# Scans for `rules/` / `agents/` / `skills/` directories anywhere under the
|
|
1250
|
+
# intelligence source tree and any sibling tree with the same basename
|
|
1251
|
+
# (e.g. nested per-component intelligence folders). Anything found that is
|
|
1252
|
+
# not in `sources.*` is flagged. No folder name is hardcoded — the
|
|
1253
|
+
# intelligence directory is whatever holds `config.yaml`.
|
|
1254
|
+
warn_unsynced() {
|
|
1255
|
+
local repo_root="$1"
|
|
1256
|
+
local config_file="$2"
|
|
1257
|
+
|
|
1258
|
+
# Collect all configured source paths (local only — a remote spec and an
|
|
1259
|
+
# `@pack` reference are not filesystem dirs and cannot collide with an
|
|
1260
|
+
# unsynced local directory).
|
|
1261
|
+
local all_sources=()
|
|
1262
|
+
for section in rules agents skills; do
|
|
1263
|
+
while IFS= read -r src; do
|
|
1264
|
+
[ -z "$src" ] && continue
|
|
1265
|
+
source_is_local_path "$src" || continue
|
|
1266
|
+
all_sources+=("$src")
|
|
1267
|
+
done < <(read_yaml_list "$config_file" "$section")
|
|
1268
|
+
done
|
|
1269
|
+
|
|
1270
|
+
# Collect ignore + submodule patterns.
|
|
1271
|
+
local ignores=()
|
|
1272
|
+
while IFS= read -r ign; do
|
|
1273
|
+
[ -z "$ign" ] && continue
|
|
1274
|
+
ignores+=("$ign")
|
|
1275
|
+
done < <(read_yaml_list "$config_file" "ignore")
|
|
1276
|
+
while IFS= read -r sub; do
|
|
1277
|
+
[ -z "$sub" ] && continue
|
|
1278
|
+
ignores+=("$sub")
|
|
1279
|
+
done < <(read_yaml_list "$config_file" "submodules")
|
|
1280
|
+
# Materialized packs hold rules/ agents/ skills/ dirs that are reached
|
|
1281
|
+
# through their `@<pack>` source entry, never listed as local sources — so
|
|
1282
|
+
# the scan below would flag every one of them as unsynced.
|
|
1283
|
+
local mirror_rel
|
|
1284
|
+
while IFS= read -r mirror_rel; do
|
|
1285
|
+
[ -n "$mirror_rel" ] && ignores+=("${mirror_rel%/}")
|
|
1286
|
+
done < <(list_pack_mirrors "$repo_root" "$config_file")
|
|
1287
|
+
|
|
1288
|
+
# Derive the intelligence folder basename from config.yaml's location —
|
|
1289
|
+
# whatever the user named it (`intelligence`, `Intelligence`, `prompts`).
|
|
1290
|
+
# In CLI mode the manifest sits at the repo root, so that derivation would
|
|
1291
|
+
# yield the repo directory's own name and never match — the content dir
|
|
1292
|
+
# comes from the env contract instead.
|
|
1293
|
+
local intel_basename
|
|
1294
|
+
if [ "${IS_CLI:-0}" = "1" ]; then
|
|
1295
|
+
intel_basename="$(basename "${IS_UMBRELLA_REL:-intelligence}")"
|
|
1296
|
+
else
|
|
1297
|
+
intel_basename="$(basename "$(dirname "$config_file")")"
|
|
1298
|
+
fi
|
|
1299
|
+
|
|
1300
|
+
local warnings=0
|
|
1301
|
+
|
|
1302
|
+
while IFS= read -r found_dir; do
|
|
1303
|
+
local rel_dir="${found_dir#$repo_root/}"
|
|
1304
|
+
|
|
1305
|
+
# Skip generated output directories and common excludes. The package
|
|
1306
|
+
# store (.intelligence/) is CLI-managed content reached through its own
|
|
1307
|
+
# sources entries — flagging it would warn on every installed package.
|
|
1308
|
+
case "$rel_dir" in
|
|
1309
|
+
.claude/*|.cursor/*|.github/*|.codex/*|.agents/*|.intelligence/*|*/node_modules/*|*/vendor/*|*/dist/*) continue ;;
|
|
1310
|
+
esac
|
|
1311
|
+
|
|
1312
|
+
# Skip ignore/submodule patterns.
|
|
1313
|
+
local skip=false
|
|
1314
|
+
for ign in "${ignores[@]+"${ignores[@]}"}"; do
|
|
1315
|
+
case "$rel_dir" in
|
|
1316
|
+
"$ign"/*|*/"$ign"/*) skip=true; break ;;
|
|
1317
|
+
esac
|
|
1318
|
+
done
|
|
1319
|
+
[ "$skip" = true ] && continue
|
|
1320
|
+
|
|
1321
|
+
# Only flag directories whose ancestry includes a folder with the
|
|
1322
|
+
# same basename as the intelligence source dir (so we catch
|
|
1323
|
+
# `Intelligence/rules`, `apps/billing/intelligence/rules`, etc.,
|
|
1324
|
+
# but not unrelated `rules/` / `agents/` directories elsewhere).
|
|
1325
|
+
case "/$rel_dir/" in
|
|
1326
|
+
*"/$intel_basename/"*) ;;
|
|
1327
|
+
*) continue ;;
|
|
1328
|
+
esac
|
|
1329
|
+
|
|
1330
|
+
# Check if directory has content worth syncing.
|
|
1331
|
+
local has_content=false
|
|
1332
|
+
if [ -n "$(find "$found_dir" -maxdepth 1 -name '*.md' 2>/dev/null | head -1)" ]; then
|
|
1333
|
+
has_content=true
|
|
1334
|
+
fi
|
|
1335
|
+
if [ -n "$(find "$found_dir" -maxdepth 2 -name 'SKILL.md' 2>/dev/null | head -1)" ]; then
|
|
1336
|
+
has_content=true
|
|
1337
|
+
fi
|
|
1338
|
+
[ "$has_content" = false ] && continue
|
|
1339
|
+
|
|
1340
|
+
# Check if this directory is in any source array.
|
|
1341
|
+
local matched=false
|
|
1342
|
+
for src in "${all_sources[@]+"${all_sources[@]}"}"; do
|
|
1343
|
+
if [ "$rel_dir" = "$src" ]; then
|
|
1344
|
+
matched=true
|
|
1345
|
+
break
|
|
1346
|
+
fi
|
|
1347
|
+
done
|
|
1348
|
+
|
|
1349
|
+
if [ "$matched" = false ]; then
|
|
1350
|
+
if [ $warnings -eq 0 ]; then
|
|
1351
|
+
echo ""
|
|
1352
|
+
echo "=== WARNING: Unsynced directories ==="
|
|
1353
|
+
fi
|
|
1354
|
+
echo " NOT SYNCED: $rel_dir"
|
|
1355
|
+
warnings=$((warnings + 1))
|
|
1356
|
+
fi
|
|
1357
|
+
done < <(find "$repo_root" -type d \( -name "rules" -o -name "agents" -o -name "skills" -o -name "Rules" -o -name "Agents" -o -name "Skills" \) 2>/dev/null)
|
|
1358
|
+
|
|
1359
|
+
if [ $warnings -gt 0 ]; then
|
|
1360
|
+
echo " Add these paths to sources: in $(basename "$config_file")"
|
|
1361
|
+
fi
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
# --- Config Parsing ---
|
|
1365
|
+
|
|
1366
|
+
# Read a simple list from config.yaml
|
|
1367
|
+
# Format: key:\n - "value1"\n - "value2"
|
|
1368
|
+
# Usage: readarray -t arr < <(read_yaml_list "config.yaml" "rules")
|
|
1369
|
+
read_yaml_list() {
|
|
1370
|
+
local file="$1"
|
|
1371
|
+
local section="$2"
|
|
1372
|
+
awk -v section="$section" '
|
|
1373
|
+
{
|
|
1374
|
+
sub(/\r$/, "")
|
|
1375
|
+
}
|
|
1376
|
+
/^[a-z]/ { current_section = ""; depth = 0 }
|
|
1377
|
+
/^ [a-z]/ { current_section = ""; depth = 0 }
|
|
1378
|
+
$0 ~ "^" section ":" { current_section = section; depth = 0; next }
|
|
1379
|
+
$0 ~ "^ " section ":" { current_section = section; depth = 2; next }
|
|
1380
|
+
current_section == section && depth == 0 && /^ - / {
|
|
1381
|
+
val = $0
|
|
1382
|
+
sub(/^ - /, "", val)
|
|
1383
|
+
gsub(/["\047]/, "", val)
|
|
1384
|
+
print val
|
|
1385
|
+
}
|
|
1386
|
+
current_section == section && depth == 2 && /^ - / {
|
|
1387
|
+
val = $0
|
|
1388
|
+
sub(/^ - /, "", val)
|
|
1389
|
+
gsub(/["\047]/, "", val)
|
|
1390
|
+
print val
|
|
1391
|
+
}
|
|
1392
|
+
' "$file"
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
# Check if a target is enabled in config.yaml (scoped to targets: section)
|
|
1396
|
+
# Usage: is_target_enabled "config.yaml" "claude"
|
|
1397
|
+
is_target_enabled() {
|
|
1398
|
+
local file="$1"
|
|
1399
|
+
local target="$2"
|
|
1400
|
+
awk -v target="$target" '
|
|
1401
|
+
{ sub(/\r$/, "") }
|
|
1402
|
+
|
|
1403
|
+
# Enter/leave the targets: section
|
|
1404
|
+
/^targets:[[:space:]]*$/ { in_targets = 1; next }
|
|
1405
|
+
/^[a-zA-Z]/ { in_targets = 0 }
|
|
1406
|
+
|
|
1407
|
+
in_targets && $0 ~ "^ " target ":" {
|
|
1408
|
+
if ($0 ~ /enabled:[[:space:]]*true/) { print 1; exit }
|
|
1409
|
+
if ($0 ~ /enabled:[[:space:]]*false/) { print 0; exit }
|
|
1410
|
+
in_target = 1; next
|
|
1411
|
+
}
|
|
1412
|
+
in_target && /enabled:/ {
|
|
1413
|
+
if ($0 ~ /true/) { print 1 } else { print 0 }
|
|
1414
|
+
exit
|
|
1415
|
+
}
|
|
1416
|
+
in_target && /^ [a-zA-Z]/ { print 0; exit }
|
|
1417
|
+
' "$file"
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
# Get an arbitrary field from a target's config block.
|
|
1421
|
+
# Handles both inline (`claude: { enabled: true, output: ".claude" }`)
|
|
1422
|
+
# and block form. Uses POSIX awk only — no gawk-specific 3-arg match().
|
|
1423
|
+
# Usage: get_target_field "config.yaml" "claude" "output"
|
|
1424
|
+
get_target_field() {
|
|
1425
|
+
local file="$1"
|
|
1426
|
+
local target="$2"
|
|
1427
|
+
local field="$3"
|
|
1428
|
+
awk -v target="$target" -v field="$field" '
|
|
1429
|
+
{ sub(/\r$/, "") }
|
|
1430
|
+
/^targets:[[:space:]]*$/ { in_targets = 1; next }
|
|
1431
|
+
/^[a-zA-Z]/ { in_targets = 0 }
|
|
1432
|
+
|
|
1433
|
+
in_targets && $0 ~ "^ " target ":" {
|
|
1434
|
+
line = $0
|
|
1435
|
+
inline_pat = "[ {,]" field ":[[:space:]]*"
|
|
1436
|
+
if (match(line, inline_pat)) {
|
|
1437
|
+
# Strip everything up to and including the field key.
|
|
1438
|
+
rest = substr(line, RSTART + RLENGTH)
|
|
1439
|
+
# Cut at the next comma or closing brace.
|
|
1440
|
+
if (match(rest, /[,}]/)) {
|
|
1441
|
+
rest = substr(rest, 1, RSTART - 1)
|
|
1442
|
+
}
|
|
1443
|
+
# Strip surrounding quotes and trailing space.
|
|
1444
|
+
gsub(/^["\047]|["\047][[:space:]]*$/, "", rest)
|
|
1445
|
+
sub(/[[:space:]]+$/, "", rest)
|
|
1446
|
+
if (rest != "") { print rest; exit }
|
|
1447
|
+
}
|
|
1448
|
+
in_target = 1; next
|
|
1449
|
+
}
|
|
1450
|
+
in_target && $0 ~ "^ " field ":[[:space:]]*" {
|
|
1451
|
+
val = $0
|
|
1452
|
+
sub(/.*:[[:space:]]*["\047]?/, "", val)
|
|
1453
|
+
sub(/["\047]?[[:space:]]*$/, "", val)
|
|
1454
|
+
print val
|
|
1455
|
+
exit
|
|
1456
|
+
}
|
|
1457
|
+
in_target && /^ [a-zA-Z]/ { exit }
|
|
1458
|
+
' "$file"
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
# Get output directory for a target (scoped to targets: section).
|
|
1462
|
+
# POSIX awk — no 3-arg match().
|
|
1463
|
+
# Usage: get_target_output "config.yaml" "claude"
|
|
1464
|
+
get_target_output() {
|
|
1465
|
+
local file="$1"
|
|
1466
|
+
local target="$2"
|
|
1467
|
+
awk -v target="$target" '
|
|
1468
|
+
{ sub(/\r$/, "") }
|
|
1469
|
+
|
|
1470
|
+
/^targets:[[:space:]]*$/ { in_targets = 1; next }
|
|
1471
|
+
/^[a-zA-Z]/ { in_targets = 0 }
|
|
1472
|
+
|
|
1473
|
+
in_targets && $0 ~ "^ " target ":" {
|
|
1474
|
+
line = $0
|
|
1475
|
+
if (match(line, /output:[[:space:]]*/)) {
|
|
1476
|
+
rest = substr(line, RSTART + RLENGTH)
|
|
1477
|
+
if (match(rest, /[,}]/)) {
|
|
1478
|
+
rest = substr(rest, 1, RSTART - 1)
|
|
1479
|
+
}
|
|
1480
|
+
gsub(/^["\047]|["\047][[:space:]]*$/, "", rest)
|
|
1481
|
+
sub(/[[:space:]]+$/, "", rest)
|
|
1482
|
+
if (rest != "") { print rest; exit }
|
|
1483
|
+
}
|
|
1484
|
+
in_target = 1; next
|
|
1485
|
+
}
|
|
1486
|
+
in_target && /output:/ {
|
|
1487
|
+
val = $0
|
|
1488
|
+
sub(/.*output:[[:space:]]*["\047]?/, "", val)
|
|
1489
|
+
sub(/["\047]?[[:space:]]*}?$/, "", val)
|
|
1490
|
+
print val
|
|
1491
|
+
exit
|
|
1492
|
+
}
|
|
1493
|
+
in_target && /^ [a-zA-Z]/ { exit }
|
|
1494
|
+
' "$file"
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
# Read a multi-line block scalar (| or > style) from a target's field.
|
|
1498
|
+
# Usage: get_target_block "config.yaml" "agents" "header"
|
|
1499
|
+
# Reads YAML of the shape:
|
|
1500
|
+
# targets:
|
|
1501
|
+
# agents:
|
|
1502
|
+
# header: |
|
|
1503
|
+
# # Project
|
|
1504
|
+
# one-liner
|
|
1505
|
+
# Strips the common content indent from all lines in the block.
|
|
1506
|
+
get_target_block() {
|
|
1507
|
+
local file="$1"
|
|
1508
|
+
local target="$2"
|
|
1509
|
+
local field="$3"
|
|
1510
|
+
awk -v target="$target" -v field="$field" '
|
|
1511
|
+
{ sub(/\r$/, "") }
|
|
1512
|
+
|
|
1513
|
+
/^targets:[[:space:]]*$/ { in_targets = 1; next }
|
|
1514
|
+
/^[a-zA-Z]/ { in_targets = 0 }
|
|
1515
|
+
|
|
1516
|
+
# State 0: looking for " <target>:" under targets
|
|
1517
|
+
state == 0 && in_targets && $0 ~ "^ " target ":[[:space:]]*$" {
|
|
1518
|
+
state = 1
|
|
1519
|
+
next
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
# State 1: inside target block, looking for " <field>: |"
|
|
1523
|
+
state == 1 {
|
|
1524
|
+
if ($0 ~ /^[^ ]/) { exit } # top-level key — exit
|
|
1525
|
+
if ($0 ~ /^ [a-zA-Z]/) { exit } # another target — exit
|
|
1526
|
+
if ($0 ~ "^[[:space:]]+" field ":[[:space:]]*[|>][[:space:]]*$") {
|
|
1527
|
+
match($0, /^[[:space:]]+/)
|
|
1528
|
+
field_indent = RLENGTH
|
|
1529
|
+
state = 2
|
|
1530
|
+
block_indent = 0
|
|
1531
|
+
}
|
|
1532
|
+
next
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
# State 2: collecting block contents
|
|
1536
|
+
state == 2 {
|
|
1537
|
+
if ($0 ~ /^[[:space:]]*$/) {
|
|
1538
|
+
print ""
|
|
1539
|
+
next
|
|
1540
|
+
}
|
|
1541
|
+
match($0, /^[[:space:]]*/)
|
|
1542
|
+
cur_indent = RLENGTH
|
|
1543
|
+
if (cur_indent <= field_indent) { exit }
|
|
1544
|
+
if (block_indent == 0) { block_indent = cur_indent }
|
|
1545
|
+
if (cur_indent < block_indent) { exit }
|
|
1546
|
+
print substr($0, block_indent + 1)
|
|
1547
|
+
}
|
|
1548
|
+
' "$file"
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
# Read a scalar field out of a top-level config block: `section:` -> ` key:`.
|
|
1552
|
+
# The single two-level reader — `get_nested_yaml_value` and `get_target_field`
|
|
1553
|
+
# cover the three-level shapes (`models.<ide>.<tier>`, `targets.<name>.<field>`).
|
|
1554
|
+
# Usage: get_yaml_field "config.yaml" "external" "dir"
|
|
1555
|
+
get_yaml_field() {
|
|
1556
|
+
local file="$1"
|
|
1557
|
+
local section="$2"
|
|
1558
|
+
local key="$3"
|
|
1559
|
+
awk -v section="$section" -v key="$key" '
|
|
1560
|
+
{ sub(/\r$/, "") }
|
|
1561
|
+
$0 ~ "^" section ":" { in_section = 1; next }
|
|
1562
|
+
in_section && /^[a-zA-Z]/ { exit }
|
|
1563
|
+
in_section && $0 ~ "^ " key ":" {
|
|
1564
|
+
val = $0
|
|
1565
|
+
sub(/.*:[[:space:]]*["\047]?/, "", val)
|
|
1566
|
+
sub(/["\047]?[[:space:]]*$/, "", val)
|
|
1567
|
+
print val
|
|
1568
|
+
exit
|
|
1569
|
+
}
|
|
1570
|
+
' "$file"
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
# Get project name from config.yaml (project.name)
|
|
1574
|
+
get_project_name() {
|
|
1575
|
+
get_yaml_field "$1" "project" "name"
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
# One field of a declared pack: `packs.<name>.<url|ref|mirror>`.
|
|
1579
|
+
# `mirror` empty means the pack is transient — cloned per run, never committed.
|
|
1580
|
+
get_pack_field() {
|
|
1581
|
+
get_nested_yaml_value "$1" "packs" "$2" "$3"
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
# Immediate sub-keys of a top-level block, one per line (`packs:` → pack names).
|
|
1585
|
+
# Block form only: a pack always spans several lines, so the inline `{...}` form
|
|
1586
|
+
# that `get_target_field` accommodates has no use here.
|
|
1587
|
+
# Usage: readarray -t names < <(read_yaml_keys "config.yaml" "packs")
|
|
1588
|
+
read_yaml_keys() {
|
|
1589
|
+
local file="$1" section="$2"
|
|
1590
|
+
[ -f "$file" ] || return 0
|
|
1591
|
+
awk -v section="$section" '
|
|
1592
|
+
{ sub(/\r$/, "") }
|
|
1593
|
+
$0 ~ "^" section ":[[:space:]]*$" { in_sec = 1; next }
|
|
1594
|
+
/^[A-Za-z]/ { in_sec = 0 }
|
|
1595
|
+
in_sec && /^ [A-Za-z0-9_][A-Za-z0-9._-]*:[[:space:]]*$/ {
|
|
1596
|
+
k = $0
|
|
1597
|
+
sub(/^[[:space:]]+/, "", k)
|
|
1598
|
+
sub(/:[[:space:]]*$/, "", k)
|
|
1599
|
+
print k
|
|
1600
|
+
}
|
|
1601
|
+
' "$file"
|
|
1602
|
+
}
|