@sandgarden/gloria 0.0.0-stage → 0.3.88
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/collector/stub.sh +463 -0
- package/gloria.js +150 -0
- package/index.js +1 -0
- package/package.json +16 -4
- package/skills/checking-coding-standards/SKILL.md +137 -0
- package/skills/debugging-production-errors/SKILL.md +124 -0
- package/skills/deriving-agent-code-style/SKILL.md +147 -0
- package/skills/documenting-service-dependencies/SKILL.md +400 -0
- package/skills/extracting-coding-standards/SKILL.md +182 -0
- package/skills/identifying-skills-for-a-project/SKILL.md +111 -0
- package/skills/investigating-production-incidents/SKILL.md +100 -0
- package/skills/mapping-packages-for-coding-agents/SKILL.md +116 -0
- package/skills/setting-up-a-project/SKILL.md +162 -0
- package/skills/setting-up-a-project/TDD.rules.md +196 -0
- package/skills/setting-up-a-project/git-commit-rules.md +16 -0
- package/skills/setting-up-gloria/SKILL.md +139 -0
- package/skills/setting-up-gloria/USING-GLORIA.md +144 -0
- package/skills/using-coding-standards/SKILL.md +117 -0
- package/skills/using-the-skills-library/SKILL.md +89 -0
- package/README.md +0 -3
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# gloria collector download stub
|
|
3
|
+
# (docs/plans/2026-08-21-collector-shell-bootstrap-design.md, superseding the
|
|
4
|
+
# node stub of docs/plans/2026-07-10-collector-binary-distribution-design.md)
|
|
5
|
+
#
|
|
6
|
+
# This single self-contained file ships verbatim into the published plugin as
|
|
7
|
+
# plugins/<name>/collector/stub.sh. It is POSIX `sh` on purpose: every plugin
|
|
8
|
+
# hook command is ALREADY a shell-form string that Claude Code evaluates with
|
|
9
|
+
# `sh -c` on macOS/Linux and Git Bash on Windows, so `sh` is not a new
|
|
10
|
+
# prerequisite — it is what runs the hook line today. The previous stub was
|
|
11
|
+
# node, which made a JS runtime a hard host requirement and silently disabled
|
|
12
|
+
# usage tracking on machines without one (#761).
|
|
13
|
+
#
|
|
14
|
+
# It downloads the compiled collector binary for this platform once per build
|
|
15
|
+
# version from the published repo's GitHub Release, verifies it against the
|
|
16
|
+
# SHA-256 checksums stamped below at publish time, caches it under the
|
|
17
|
+
# collector's state directory (`bin/`), and runs it with argv + stdin passed
|
|
18
|
+
# through, mirroring its exit code.
|
|
19
|
+
#
|
|
20
|
+
# Contract (it runs inside the agent loop, like the hooks it fronts):
|
|
21
|
+
# - ANY failure — offline, GitHub down, unsupported platform, checksum
|
|
22
|
+
# mismatch, no curl, no hashing tool — logs ONE line to the state
|
|
23
|
+
# directory's collector.log and exits 0. The next hook fire retries. It must
|
|
24
|
+
# never break an agent session.
|
|
25
|
+
# - Checksum mismatch NEVER executes the downloaded bytes: the stamped
|
|
26
|
+
# checksums are the trust anchor (a substituted release asset fails closed),
|
|
27
|
+
# and unverified bytes are deleted rather than renamed into the cache.
|
|
28
|
+
# - GLORIA_COLLECTOR_BIN=<path> bypasses download entirely (local builds,
|
|
29
|
+
# air-gapped installs, corp mirrors).
|
|
30
|
+
#
|
|
31
|
+
# Host tools: sh, curl, and one of sha256sum / shasum / openssl / certutil,
|
|
32
|
+
# plus the usual coreutils. There is deliberately no wget fallback: wget cannot
|
|
33
|
+
# report the effective final URL, so a wget path would silently drop the
|
|
34
|
+
# final-host allowlist below.
|
|
35
|
+
|
|
36
|
+
# Stamped by scripts/publish-marketplace/stage.mjs (run by
|
|
37
|
+
# .github/workflows/publish-marketplace.yml) at publish time — the source tree
|
|
38
|
+
# always carries the placeholder values (same mechanism as check-version.mjs's
|
|
39
|
+
# INSTALLED_VERSION). Each placeholder appears exactly once so the workflow's
|
|
40
|
+
# sed + grep verification can't miss.
|
|
41
|
+
#
|
|
42
|
+
# RELEASE_REPO and ASSET_PREFIX are stamped at publish time
|
|
43
|
+
# (scripts/publish-marketplace/stage.mjs): every plugin that bundles this stub
|
|
44
|
+
# now downloads the same release from "sandgardenhq/plugins" with the
|
|
45
|
+
# "miranda-collector" prefix (#813). They stay placeholders rather than
|
|
46
|
+
# literals so the release host can move without editing this file.
|
|
47
|
+
BUILD_VERSION="81ca1fa7a45e"
|
|
48
|
+
RELEASE_TAG="collector-81ca1fa7a45e"
|
|
49
|
+
RELEASE_REPO="sandgardenhq/plugins"
|
|
50
|
+
ASSET_PREFIX="miranda-collector"
|
|
51
|
+
CHECKSUM_DARWIN_ARM64="93d0a631dfb5d6446254f2a78133d2eba9f007855acfc7889f7f94e9349abd08"
|
|
52
|
+
CHECKSUM_DARWIN_X64="bb8d3cd150f965c6c338411accfbd604c0b2fbce44974770ccc79b3526c8a9b7"
|
|
53
|
+
CHECKSUM_LINUX_X64="fd7cd2a516515b4250e7501beac3e53b856556bf4c4fb07eb691d8af922f563d"
|
|
54
|
+
CHECKSUM_LINUX_ARM64="ecc2f57a8c7568b9d3ba951b6ec2eb0bff56da72d95a87b30d90f5ac022f8b54"
|
|
55
|
+
CHECKSUM_WINDOWS_X64="24bda878b843b6fa7dbf2784fde4947c26191ccff71d1569f9293a2766ec5f46"
|
|
56
|
+
|
|
57
|
+
# A download lock older than this is a downloader that died mid-run: take it
|
|
58
|
+
# over (mirrors the collector's sweep-lock staleness cutoff).
|
|
59
|
+
LOCK_STALE_MINUTES=10
|
|
60
|
+
# A *.download-* temp older than this is a download that was SIGKILLed
|
|
61
|
+
# mid-write (the cleanup never ran): safe to sweep — a live download finishes
|
|
62
|
+
# in well under an hour or its lock has long gone stale.
|
|
63
|
+
DOWNLOAD_TEMP_STALE_MINUTES=60
|
|
64
|
+
LOG_MAX_BYTES=1048576
|
|
65
|
+
CURL_MAX_SECONDS=600
|
|
66
|
+
|
|
67
|
+
# What `hook-session-start` tells the agent when no collector could be handed
|
|
68
|
+
# to it (#1568). The binary is the thing that is missing, so nothing downstream
|
|
69
|
+
# can say so; this is the one place that can. The wording is a copy of
|
|
70
|
+
# COLLECTOR_NOT_INSTALLED_NUDGE in hooks.ts (a test pins the two together) and
|
|
71
|
+
# of the fallback in the Miranda plugin's hooks.json. No quote characters in the
|
|
72
|
+
# text, so it needs no JSON escaping here.
|
|
73
|
+
NOT_INSTALLED_NUDGE='{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The usage collector is not installed or could not be set up on this machine, so no token usage is being recorded. Tell the user now: reinstall or update the Miranda plugin and start a new session; the cause is logged in collector.log under ~/.config/sandgarden."}}'
|
|
74
|
+
|
|
75
|
+
# The pre-#783 state directory, kept only as a migration source.
|
|
76
|
+
LEGACY_HOME_DIR_NAME=".gloria"
|
|
77
|
+
|
|
78
|
+
# Shells expand a leading "~" before we ever see it — hook configs and env
|
|
79
|
+
# files don't, so we have to.
|
|
80
|
+
expand_tilde() {
|
|
81
|
+
case "$1" in
|
|
82
|
+
"~/"*) printf '%s' "$HOME/${1#\~/}" ;;
|
|
83
|
+
*) printf '%s' "$1" ;;
|
|
84
|
+
esac
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# The explicit full-path override in effect; returns 1 (printing nothing) when
|
|
88
|
+
# there is none. GLORIA_HOME is the deprecated alias of SANDGARDEN_HOME — it
|
|
89
|
+
# names one of the two products sharing this directory, which is exactly what
|
|
90
|
+
# #783 fixes, but a user who set it deliberately chose that path and must not
|
|
91
|
+
# have their state silently relocated. Empty reads as unset.
|
|
92
|
+
home_override() {
|
|
93
|
+
if [ -n "${SANDGARDEN_HOME-}" ]; then
|
|
94
|
+
expand_tilde "$SANDGARDEN_HOME"
|
|
95
|
+
return 0
|
|
96
|
+
fi
|
|
97
|
+
if [ -n "${GLORIA_HOME-}" ]; then
|
|
98
|
+
expand_tilde "$GLORIA_HOME"
|
|
99
|
+
return 0
|
|
100
|
+
fi
|
|
101
|
+
return 1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Mirrors state.ts's collectorHome(), which this file has to duplicate rather
|
|
105
|
+
# than share: the directory must be resolved before the binary that would
|
|
106
|
+
# resolve it exists. Precedence:
|
|
107
|
+
# SANDGARDEN_HOME | GLORIA_HOME | $XDG_CONFIG_HOME/sandgarden | ~/.config/sandgarden
|
|
108
|
+
collector_home() {
|
|
109
|
+
if ch_override="$(home_override)"; then
|
|
110
|
+
printf '%s' "$ch_override"
|
|
111
|
+
return 0
|
|
112
|
+
fi
|
|
113
|
+
if [ -n "${XDG_CONFIG_HOME-}" ]; then
|
|
114
|
+
printf '%s/sandgarden' "$(expand_tilde "$XDG_CONFIG_HOME")"
|
|
115
|
+
else
|
|
116
|
+
printf '%s/.config/sandgarden' "$HOME"
|
|
117
|
+
fi
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Append one line to the state directory's collector.log (rotating once past
|
|
121
|
+
# 1 MB, like the collector's own logger). Logging must never fail — exit 0
|
|
122
|
+
# still holds.
|
|
123
|
+
log_error() {
|
|
124
|
+
le_home="$(collector_home)"
|
|
125
|
+
mkdir -p "$le_home" 2>/dev/null || return 0
|
|
126
|
+
le_log="$le_home/collector.log"
|
|
127
|
+
if [ -f "$le_log" ]; then
|
|
128
|
+
le_size="$(wc -c <"$le_log" 2>/dev/null | tr -d ' ')"
|
|
129
|
+
case "$le_size" in
|
|
130
|
+
'' | *[!0-9]*) le_size=0 ;;
|
|
131
|
+
esac
|
|
132
|
+
if [ "$le_size" -gt "$LOG_MAX_BYTES" ]; then
|
|
133
|
+
mv -f "$le_log" "$le_log.1" 2>/dev/null || : # rename overwrites the old .1
|
|
134
|
+
fi
|
|
135
|
+
fi
|
|
136
|
+
le_stamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)"
|
|
137
|
+
printf '%s collector-stub error: %s\n' "$le_stamp" "$1" >>"$le_log" 2>/dev/null || :
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Map uname's platform/arch to the release asset. Sets ASSET_KEY, ASSET_EXT and
|
|
142
|
+
# ASSET_NAME; returns 1 (leaving the pair in PLATFORM_LABEL) when unsupported.
|
|
143
|
+
# MINGW/MSYS/CYGWIN are how Git Bash — the shell Claude Code runs hooks in on
|
|
144
|
+
# Windows — reports itself.
|
|
145
|
+
resolve_asset() {
|
|
146
|
+
ra_sys="$(uname -s 2>/dev/null || printf 'unknown')"
|
|
147
|
+
ra_machine="$(uname -m 2>/dev/null || printf 'unknown')"
|
|
148
|
+
case "$ra_sys" in
|
|
149
|
+
Darwin) ra_platform=darwin ;;
|
|
150
|
+
Linux) ra_platform=linux ;;
|
|
151
|
+
MINGW* | MSYS* | CYGWIN* | Windows_NT) ra_platform=windows ;;
|
|
152
|
+
*) ra_platform="$ra_sys" ;;
|
|
153
|
+
esac
|
|
154
|
+
case "$ra_machine" in
|
|
155
|
+
arm64 | aarch64) ra_arch=arm64 ;;
|
|
156
|
+
x86_64 | amd64) ra_arch=x64 ;;
|
|
157
|
+
*) ra_arch="$ra_machine" ;;
|
|
158
|
+
esac
|
|
159
|
+
PLATFORM_LABEL="$ra_platform-$ra_arch"
|
|
160
|
+
ASSET_EXT=""
|
|
161
|
+
case "$PLATFORM_LABEL" in
|
|
162
|
+
darwin-arm64 | darwin-x64 | linux-x64 | linux-arm64) ASSET_KEY="$PLATFORM_LABEL" ;;
|
|
163
|
+
windows-x64)
|
|
164
|
+
ASSET_KEY="windows-x64"
|
|
165
|
+
ASSET_EXT=".exe"
|
|
166
|
+
;;
|
|
167
|
+
*) return 1 ;;
|
|
168
|
+
esac
|
|
169
|
+
ASSET_NAME="$ASSET_PREFIX-$ASSET_KEY$ASSET_EXT"
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# The stamped checksum for an asset key. POSIX sh has no associative arrays,
|
|
174
|
+
# and one variable per platform is also what keeps every checksum placeholder
|
|
175
|
+
# appearing exactly once for the publish workflow's sed (which has no /g, and
|
|
176
|
+
# whose grep verification would otherwise pass on a half-stamped file).
|
|
177
|
+
checksum_for() {
|
|
178
|
+
case "$1" in
|
|
179
|
+
darwin-arm64) printf '%s' "$CHECKSUM_DARWIN_ARM64" ;;
|
|
180
|
+
darwin-x64) printf '%s' "$CHECKSUM_DARWIN_X64" ;;
|
|
181
|
+
linux-x64) printf '%s' "$CHECKSUM_LINUX_X64" ;;
|
|
182
|
+
linux-arm64) printf '%s' "$CHECKSUM_LINUX_ARM64" ;;
|
|
183
|
+
windows-x64) printf '%s' "$CHECKSUM_WINDOWS_X64" ;;
|
|
184
|
+
esac
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Lowercase hex SHA-256 of a file, using whichever tool this host has.
|
|
188
|
+
# certutil is the Windows built-in fallback for a Git Bash without coreutils.
|
|
189
|
+
# Returns 1 (and prints nothing) when the host has NO usable tool, which the
|
|
190
|
+
# caller reports differently from a tool that ran and failed.
|
|
191
|
+
sha256_of() {
|
|
192
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
193
|
+
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
|
194
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
195
|
+
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
|
|
196
|
+
elif command -v openssl >/dev/null 2>&1; then
|
|
197
|
+
openssl dgst -sha256 "$1" 2>/dev/null | awk '{print $NF}'
|
|
198
|
+
elif command -v certutil >/dev/null 2>&1; then
|
|
199
|
+
certutil -hashfile "$1" SHA256 2>/dev/null | sed -n '2p' | tr -d ' \r' | tr 'A-F' 'a-f'
|
|
200
|
+
else
|
|
201
|
+
return 1
|
|
202
|
+
fi
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Print the not-installed nudge, for `hook-session-start` only — the one hook
|
|
206
|
+
# whose stdout reaches the agent's context. Every other subcommand stays quiet.
|
|
207
|
+
# Under `--enrolled-only` (#1566, the gloria plugin) it also stays quiet unless
|
|
208
|
+
# this machine has a config.json: "reinstall the Miranda plugin" is a Miranda
|
|
209
|
+
# prompt, and gloria must never show one to someone who never opted in. Only
|
|
210
|
+
# existence is checked — the binary that could validate the file is the thing
|
|
211
|
+
# that is missing. Always returns 0: the exit-0 contract above still holds.
|
|
212
|
+
nudge_not_installed() {
|
|
213
|
+
[ "${1-}" = "hook-session-start" ] || return 0
|
|
214
|
+
for ni_arg in "$@"; do
|
|
215
|
+
if [ "$ni_arg" = "--enrolled-only" ]; then
|
|
216
|
+
[ -f "$(collector_home)/config.json" ] || return 0
|
|
217
|
+
fi
|
|
218
|
+
done
|
|
219
|
+
printf '%s\n' "$NOT_INSTALLED_NUDGE"
|
|
220
|
+
return 0
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Take the download lock. `mkdir` is the portable atomic create-or-fail. A LIVE
|
|
224
|
+
# lock (younger than LOCK_STALE_MINUTES) returns 1 — the loser exits 0 and lets
|
|
225
|
+
# the winner finish. A stale lock is taken over by rename, not by rm: exactly
|
|
226
|
+
# one contender can win a rename, whereas an rm-based takeover would let a
|
|
227
|
+
# loser delete the winner's freshly-created lock. Mirrors the collector's
|
|
228
|
+
# acquireSweepLock.
|
|
229
|
+
acquire_download_lock() {
|
|
230
|
+
al_lock="$1"
|
|
231
|
+
al_attempt=0
|
|
232
|
+
while [ "$al_attempt" -lt 3 ]; do
|
|
233
|
+
al_attempt=$((al_attempt + 1))
|
|
234
|
+
if mkdir "$al_lock" 2>/dev/null; then
|
|
235
|
+
return 0
|
|
236
|
+
fi
|
|
237
|
+
# `find -mmin` is absent only on exotic finds; when it can't tell, the lock
|
|
238
|
+
# reads as live and this run defers — the next hook fire retries.
|
|
239
|
+
if [ -z "$(find "$al_lock" -maxdepth 0 -mmin "+$LOCK_STALE_MINUTES" 2>/dev/null)" ]; then
|
|
240
|
+
return 1
|
|
241
|
+
fi
|
|
242
|
+
al_takeover="$al_lock.takeover-$$-$al_attempt"
|
|
243
|
+
if mv "$al_lock" "$al_takeover" 2>/dev/null; then
|
|
244
|
+
rm -rf "$al_takeover" 2>/dev/null || :
|
|
245
|
+
continue
|
|
246
|
+
fi
|
|
247
|
+
# The holder released it between our mkdir and our rename: retry the mkdir.
|
|
248
|
+
[ -e "$al_lock" ] || continue
|
|
249
|
+
return 1 # another contender won the takeover rename first
|
|
250
|
+
done
|
|
251
|
+
return 1
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Reuse the binary a pre-#783 install already cached under ~/.gloria/bin,
|
|
255
|
+
# instead of re-downloading it (#783) — an offline machine has to keep working
|
|
256
|
+
# across the rename. Returns 0 when $1 is ready to execute.
|
|
257
|
+
#
|
|
258
|
+
# Only THIS copy's own stamped asset name is considered, so an entry cached by
|
|
259
|
+
# a differently-stamped stub sharing the legacy bin/ (gloria's and Miranda's
|
|
260
|
+
# plugins both installed) is left exactly where its owner expects it. Copy
|
|
261
|
+
# rather than move, for the same reason: a sibling plugin that hasn't updated
|
|
262
|
+
# yet may still be running from it. Skipped when the directory was pinned
|
|
263
|
+
# explicitly — that path was chosen deliberately. Best-effort and silent: any
|
|
264
|
+
# failure just falls through to the normal download.
|
|
265
|
+
migrate_cached_binary() {
|
|
266
|
+
mcb_bin="$1"
|
|
267
|
+
home_override >/dev/null && return 1
|
|
268
|
+
mcb_legacy="$HOME/$LEGACY_HOME_DIR_NAME/bin/${mcb_bin##*/}"
|
|
269
|
+
[ -f "$mcb_legacy" ] || return 1
|
|
270
|
+
mkdir -p "${mcb_bin%/*}" 2>/dev/null || return 1
|
|
271
|
+
mcb_tmp="$mcb_bin.migrate-$$"
|
|
272
|
+
if cp "$mcb_legacy" "$mcb_tmp" 2>/dev/null &&
|
|
273
|
+
chmod 755 "$mcb_tmp" 2>/dev/null &&
|
|
274
|
+
mv -f "$mcb_tmp" "$mcb_bin" 2>/dev/null; then # atomic: no partial file is ever executed
|
|
275
|
+
return 0
|
|
276
|
+
fi
|
|
277
|
+
rm -f "$mcb_tmp" 2>/dev/null || :
|
|
278
|
+
return 1
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# Download the asset to a temp file, verify its SHA-256 against the stamped
|
|
282
|
+
# checksum, then atomically rename into place. Returns 0 when $1 is ready to
|
|
283
|
+
# execute; 1 (after logging) otherwise, or 2 on a silent lock deferral. The
|
|
284
|
+
# temp file and the lock are always cleaned up, and unverified bytes are never
|
|
285
|
+
# left executable at the cache path.
|
|
286
|
+
download_binary() {
|
|
287
|
+
db_bin="$1"
|
|
288
|
+
db_dir="${db_bin%/*}"
|
|
289
|
+
if ! mkdir -p "$db_dir" 2>/dev/null; then
|
|
290
|
+
log_error "cannot create cache directory $db_dir"
|
|
291
|
+
return 1
|
|
292
|
+
fi
|
|
293
|
+
db_lock="$db_dir/.download.lock"
|
|
294
|
+
if ! acquire_download_lock "$db_lock"; then
|
|
295
|
+
return 2 # a concurrent session is downloading — silently defer, no nudge
|
|
296
|
+
fi
|
|
297
|
+
db_tmp="$db_bin.download-$$-$(date -u '+%s' 2>/dev/null || printf '0')"
|
|
298
|
+
db_expected="$(checksum_for "$ASSET_KEY")"
|
|
299
|
+
db_url="https://github.com/$RELEASE_REPO/releases/download/$RELEASE_TAG/$ASSET_NAME"
|
|
300
|
+
db_status=1
|
|
301
|
+
|
|
302
|
+
if ! command -v curl >/dev/null 2>&1; then
|
|
303
|
+
log_error "curl not found; cannot download $ASSET_NAME"
|
|
304
|
+
else
|
|
305
|
+
# --proto/--proto-redir '=https' refuse a plain-http hop outright rather
|
|
306
|
+
# than letting one be detected after the bytes have already moved;
|
|
307
|
+
# --fail turns HTTP >= 400 into a non-zero exit; --write-out reports the
|
|
308
|
+
# URL the transfer actually landed on, for the host allowlist below.
|
|
309
|
+
db_effective="$(
|
|
310
|
+
curl --fail --location --silent \
|
|
311
|
+
--proto '=https' --proto-redir '=https' \
|
|
312
|
+
--max-redirs 5 --max-time "$CURL_MAX_SECONDS" \
|
|
313
|
+
--write-out '%{url_effective}' \
|
|
314
|
+
--output "$db_tmp" "$db_url" 2>/dev/null
|
|
315
|
+
)"
|
|
316
|
+
db_curl_status=$?
|
|
317
|
+
if [ "$db_curl_status" -ne 0 ]; then
|
|
318
|
+
log_error "download of $ASSET_NAME failed: curl exit $db_curl_status"
|
|
319
|
+
else
|
|
320
|
+
# GitHub redirects release-asset downloads to a GitHub-owned object
|
|
321
|
+
# host; it serves them from both objects. and release-assets.. Requiring
|
|
322
|
+
# a literal "/" after the host is what makes github.com.evil.example
|
|
323
|
+
# fail to match. Defense-in-depth on top of the checksum.
|
|
324
|
+
case "$db_effective" in
|
|
325
|
+
https://github.com/* | https://objects.githubusercontent.com/* | https://release-assets.githubusercontent.com/*)
|
|
326
|
+
db_host_ok=yes
|
|
327
|
+
;;
|
|
328
|
+
*) db_host_ok=no ;;
|
|
329
|
+
esac
|
|
330
|
+
if [ "$db_host_ok" = no ]; then
|
|
331
|
+
log_error "download resolved to disallowed URL $db_effective; refusing"
|
|
332
|
+
elif [ ! -s "$db_tmp" ]; then
|
|
333
|
+
log_error "download of $ASSET_NAME returned no body"
|
|
334
|
+
elif ! db_actual="$(sha256_of "$db_tmp")"; then
|
|
335
|
+
log_error "no SHA-256 tool found (sha256sum, shasum, openssl, or certutil); refusing to execute $ASSET_NAME"
|
|
336
|
+
elif [ -z "$db_actual" ]; then
|
|
337
|
+
log_error "failed to compute the SHA-256 of $ASSET_NAME; refusing to execute"
|
|
338
|
+
elif [ "$db_actual" != "$db_expected" ]; then
|
|
339
|
+
log_error "checksum mismatch for $ASSET_NAME: expected $db_expected, got $db_actual; refusing to execute"
|
|
340
|
+
elif ! chmod 755 "$db_tmp" 2>/dev/null; then
|
|
341
|
+
log_error "cannot make $ASSET_NAME executable; refusing to install"
|
|
342
|
+
elif ! mv -f "$db_tmp" "$db_bin" 2>/dev/null; then
|
|
343
|
+
# atomic: readers never see a partial file
|
|
344
|
+
log_error "cannot install $ASSET_NAME into $db_bin"
|
|
345
|
+
else
|
|
346
|
+
db_status=0
|
|
347
|
+
fi
|
|
348
|
+
fi
|
|
349
|
+
fi
|
|
350
|
+
|
|
351
|
+
rm -f "$db_tmp" 2>/dev/null || : # no-op after a successful rename
|
|
352
|
+
rm -rf "$db_lock" 2>/dev/null || :
|
|
353
|
+
return "$db_status"
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Run the collector binary with argv + stdin passed through and echo its exit
|
|
357
|
+
# code. Not `exec`, because the cache still has to be pruned afterwards.
|
|
358
|
+
# 126/127 are the shell's "couldn't execute that" codes — the equivalent of the
|
|
359
|
+
# node stub's spawn `error` event — so they log and become 0 rather than
|
|
360
|
+
# leaking out as a hook failure. The collector itself only ever exits 0 or 1.
|
|
361
|
+
run_binary() {
|
|
362
|
+
rb_bin="$1"
|
|
363
|
+
shift
|
|
364
|
+
if [ ! -x "$rb_bin" ]; then
|
|
365
|
+
log_error "spawn of $rb_bin failed: not executable"
|
|
366
|
+
nudge_not_installed "$@"
|
|
367
|
+
return 0
|
|
368
|
+
fi
|
|
369
|
+
"$rb_bin" "$@"
|
|
370
|
+
rb_status=$?
|
|
371
|
+
if [ "$rb_status" -eq 126 ] || [ "$rb_status" -eq 127 ]; then
|
|
372
|
+
log_error "spawn of $rb_bin failed: exit $rb_status"
|
|
373
|
+
nudge_not_installed "$@"
|
|
374
|
+
return 0
|
|
375
|
+
fi
|
|
376
|
+
return "$rb_status"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Delete cached collector binaries beyond the 2 most recent (by mtime), never
|
|
380
|
+
# the one just run, plus any stranded *.download-* temp file older than an
|
|
381
|
+
# hour. Best-effort: pruning must not fail the hook. Only files carrying THIS
|
|
382
|
+
# copy's stamped asset prefix are considered, so a binary cached by a
|
|
383
|
+
# differently-stamped copy sharing the same bin/ (e.g. gloria's and Miranda's
|
|
384
|
+
# plugins both installed) is never touched.
|
|
385
|
+
prune_cache() {
|
|
386
|
+
pc_dir="$1"
|
|
387
|
+
pc_keep="$2"
|
|
388
|
+
[ -d "$pc_dir" ] || return 0
|
|
389
|
+
find "$pc_dir" -maxdepth 1 \( -name '*.download-*' -o -name '*.migrate-*' \) \
|
|
390
|
+
-mmin "+$DOWNLOAD_TEMP_STALE_MINUTES" -exec rm -f {} + 2>/dev/null || :
|
|
391
|
+
pc_kept=0
|
|
392
|
+
# Cache entries are <prefix>-<hex> — no whitespace — so word splitting on
|
|
393
|
+
# `ls -t` (newest first) is safe here, and it is the portable way to get
|
|
394
|
+
# mtime order without a second sort tool.
|
|
395
|
+
# shellcheck disable=SC2045
|
|
396
|
+
for pc_name in $(ls -t "$pc_dir" 2>/dev/null); do
|
|
397
|
+
case "$pc_name" in
|
|
398
|
+
"$ASSET_PREFIX-"*) ;;
|
|
399
|
+
*) continue ;;
|
|
400
|
+
esac
|
|
401
|
+
case "$pc_name" in
|
|
402
|
+
*.download-* | *.migrate-*) continue ;;
|
|
403
|
+
esac
|
|
404
|
+
if [ "$pc_kept" -lt 2 ]; then
|
|
405
|
+
pc_kept=$((pc_kept + 1))
|
|
406
|
+
continue
|
|
407
|
+
fi
|
|
408
|
+
pc_path="$pc_dir/$pc_name"
|
|
409
|
+
[ "$pc_path" = "$pc_keep" ] && continue
|
|
410
|
+
rm -f "$pc_path" 2>/dev/null || :
|
|
411
|
+
done
|
|
412
|
+
return 0
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# The whole stub: override | unstamped | resolve → (cache | download+verify) →
|
|
416
|
+
# run → prune. Every failure path exits 0.
|
|
417
|
+
main() {
|
|
418
|
+
if [ -n "${GLORIA_COLLECTOR_BIN-}" ]; then
|
|
419
|
+
if [ ! -x "$GLORIA_COLLECTOR_BIN" ]; then
|
|
420
|
+
log_error "GLORIA_COLLECTOR_BIN=$GLORIA_COLLECTOR_BIN is not an executable file; skipping"
|
|
421
|
+
exit 0
|
|
422
|
+
fi
|
|
423
|
+
# Nothing to prune on this path, so exec straight through: argv, stdin and
|
|
424
|
+
# the exit code all pass to the override untouched.
|
|
425
|
+
exec "$GLORIA_COLLECTOR_BIN" "$@"
|
|
426
|
+
fi
|
|
427
|
+
|
|
428
|
+
# Dev tree: the publish workflow never stamped this copy (the placeholders
|
|
429
|
+
# are still in place). Local dev runs `bun src/cli.ts …` or sets
|
|
430
|
+
# GLORIA_COLLECTOR_BIN instead.
|
|
431
|
+
case "$BUILD_VERSION" in
|
|
432
|
+
__*)
|
|
433
|
+
log_error "stub is unstamped (dev checkout?); set GLORIA_COLLECTOR_BIN or install the published plugin"
|
|
434
|
+
exit 0
|
|
435
|
+
;;
|
|
436
|
+
esac
|
|
437
|
+
|
|
438
|
+
if ! resolve_asset; then
|
|
439
|
+
log_error "unsupported platform $PLATFORM_LABEL; skipping"
|
|
440
|
+
nudge_not_installed "$@"
|
|
441
|
+
exit 0
|
|
442
|
+
fi
|
|
443
|
+
|
|
444
|
+
m_bin_dir="$(collector_home)/bin"
|
|
445
|
+
m_bin="$m_bin_dir/$ASSET_PREFIX-$BUILD_VERSION$ASSET_EXT"
|
|
446
|
+
if [ ! -f "$m_bin" ]; then
|
|
447
|
+
if ! migrate_cached_binary "$m_bin"; then
|
|
448
|
+
download_binary "$m_bin"
|
|
449
|
+
m_download=$?
|
|
450
|
+
if [ "$m_download" -ne 0 ]; then
|
|
451
|
+
# Logged inside. 2 is a lock deferral — the winner is installing it.
|
|
452
|
+
[ "$m_download" -eq 2 ] || nudge_not_installed "$@"
|
|
453
|
+
exit 0
|
|
454
|
+
fi
|
|
455
|
+
fi
|
|
456
|
+
fi
|
|
457
|
+
run_binary "$m_bin" "$@"
|
|
458
|
+
m_status=$?
|
|
459
|
+
prune_cache "$m_bin_dir" "$m_bin"
|
|
460
|
+
exit "$m_status"
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
main "$@"
|
package/gloria.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
// Published as the npm package @sandgarden/gloria (#813): the bundled skills
|
|
9
|
+
// and the collector download stub ship inside the package, beside this file —
|
|
10
|
+
// scripts/publish-marketplace/stage.mjs assembles it. In the monorepo neither
|
|
11
|
+
// exists here, so the skills path is empty and spawnCollector's existsSync
|
|
12
|
+
// guard silently no-ops during local dev. #403: gloria and miranda each bundle
|
|
13
|
+
// their own stub — a gloria-only install must still track usage.
|
|
14
|
+
const skillsDir = path.resolve(__dirname, "skills")
|
|
15
|
+
const collectorStubPath = path.resolve(__dirname, "collector/stub.sh")
|
|
16
|
+
|
|
17
|
+
// Stamped at publish time by scripts/publish-marketplace/stage.mjs — the
|
|
18
|
+
// source tree always reads "DEV", exactly like check-plugin-version's hook.
|
|
19
|
+
const INSTALLED_VERSION = "0.3.88"
|
|
20
|
+
const LATEST_VERSION_URL = "https://gloria.dev/api/plugin-version"
|
|
21
|
+
|
|
22
|
+
// Local, dependency-free comparison for this repo's simple `x.y.z` versions
|
|
23
|
+
// (no npm semver package is used anywhere in this repo). The unstamped "DEV"
|
|
24
|
+
// placeholder (the source-tree value before publish-time stamping) always
|
|
25
|
+
// compares as lower than any real version, so it always nudges.
|
|
26
|
+
function compareVersions(a, b) {
|
|
27
|
+
if (a === "DEV" && b === "DEV") return 0
|
|
28
|
+
if (a === "DEV") return -1
|
|
29
|
+
if (b === "DEV") return 1
|
|
30
|
+
|
|
31
|
+
const partsA = a.split(".").map(Number)
|
|
32
|
+
const partsB = b.split(".").map(Number)
|
|
33
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
34
|
+
const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0)
|
|
35
|
+
if (diff !== 0) return diff < 0 ? -1 : 1
|
|
36
|
+
}
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatVersionNudge(installedVersion, latestVersion) {
|
|
41
|
+
if (compareVersions(installedVersion, latestVersion) >= 0) return null
|
|
42
|
+
return `The gloria.dev plugin is out of date (installed ${installedVersion}, latest ${latestVersion}). Update it, then start a new session.`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mutate an OpenCode config object to wire up gloria.dev: register the bundled
|
|
47
|
+
* skills directory and the remote gloria.dev MCP server. Idempotent and
|
|
48
|
+
* non-clobbering — a user-defined gloria MCP entry wins.
|
|
49
|
+
*/
|
|
50
|
+
export function applyGloriaConfig(config, dir = skillsDir) {
|
|
51
|
+
config.skills = config.skills || {}
|
|
52
|
+
config.skills.paths = config.skills.paths || []
|
|
53
|
+
if (!config.skills.paths.includes(dir)) config.skills.paths.push(dir)
|
|
54
|
+
config.mcp = config.mcp || {}
|
|
55
|
+
config.mcp.gloria = config.mcp.gloria || {
|
|
56
|
+
type: "remote",
|
|
57
|
+
url: "https://mcp.gloria.dev/mcp",
|
|
58
|
+
enabled: true,
|
|
59
|
+
}
|
|
60
|
+
return config
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fire-and-forget call into the collector, via the download stub.
|
|
65
|
+
*
|
|
66
|
+
* This runs IN-PROCESS with OpenCode (unlike Claude Code's spawned hook
|
|
67
|
+
* process), and a first run can download a ~50 MB collector binary — so the
|
|
68
|
+
* child is detached and unref'd rather than awaited, and every failure
|
|
69
|
+
* (missing stub, spawn error) is swallowed: a collector bug must never block
|
|
70
|
+
* or slow an OpenCode session.
|
|
71
|
+
*
|
|
72
|
+
* The stub is POSIX sh, not node (#761) — the collector must not require a
|
|
73
|
+
* JS runtime on the host. On Windows that needs `sh` on PATH (Git for
|
|
74
|
+
* Windows' Unix tools); without it the spawn errors and is swallowed, the
|
|
75
|
+
* same silent no-op a missing `node` produced before.
|
|
76
|
+
*/
|
|
77
|
+
function spawnCollector(args, spawnImpl, stubPath) {
|
|
78
|
+
try {
|
|
79
|
+
if (!fs.existsSync(stubPath)) return
|
|
80
|
+
const child = spawnImpl("sh", [stubPath, ...args], {
|
|
81
|
+
detached: true,
|
|
82
|
+
stdio: "ignore",
|
|
83
|
+
})
|
|
84
|
+
child.on("error", () => {})
|
|
85
|
+
child.unref()
|
|
86
|
+
} catch {
|
|
87
|
+
// Must never block or crash the session.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Session start: the same `hook-session-start` entrypoint Claude Code's
|
|
93
|
+
* SessionStart hook uses. Since #847 that entrypoint does not sweep — it makes
|
|
94
|
+
* sure this machine has a collector DAEMON, installing the per-user service
|
|
95
|
+
* the first time, and the daemon's own watcher on OpenCode's storage directory
|
|
96
|
+
* is what ingests the session.
|
|
97
|
+
*
|
|
98
|
+
* OpenCode keeps its sessions in SQLite rather than in a transcript file, so
|
|
99
|
+
* there is no path a spool trigger could name; the watcher is the whole of the
|
|
100
|
+
* handoff here.
|
|
101
|
+
*/
|
|
102
|
+
export function startCollectorSession(spawnImpl = spawn, stubPath = collectorStubPath) {
|
|
103
|
+
spawnCollector(["hook-session-start"], spawnImpl, stubPath)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Per-turn: `daemon ensure` and nothing else.
|
|
108
|
+
*
|
|
109
|
+
* `session.idle` fires once per TURN, and the session-start entrypoint above
|
|
110
|
+
* runs a whole bootstrap — a service-registration probe (a subprocess on Linux
|
|
111
|
+
* and Windows), a config load, the nudges. That is a session-start's worth of
|
|
112
|
+
* work to pay for every turn. All this handler actually owes the collector is
|
|
113
|
+
* "is a daemon up?", which is one lock-file read when the answer is yes.
|
|
114
|
+
*/
|
|
115
|
+
export function ensureCollectorDaemon(spawnImpl = spawn, stubPath = collectorStubPath) {
|
|
116
|
+
spawnCollector(["daemon", "ensure"], spawnImpl, stubPath)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// OpenCode plugin entry. The `config` hook receives OpenCode's config singleton;
|
|
120
|
+
// mutations here are visible when skills and MCP servers are resolved later.
|
|
121
|
+
// The `client` param is OpenCode's plugin client, used by the session.created
|
|
122
|
+
// hook to surface a version nudge (see formatVersionNudge above). `spawnImpl`
|
|
123
|
+
// and `stubPath` exist for test injection only — production callers never
|
|
124
|
+
// pass them, so they default to the real child_process.spawn and the shared
|
|
125
|
+
// collector stub path.
|
|
126
|
+
export const gloria = async ({ client, spawnImpl = spawn, stubPath = collectorStubPath } = {}) => ({
|
|
127
|
+
config: async (config) => {
|
|
128
|
+
applyGloriaConfig(config)
|
|
129
|
+
},
|
|
130
|
+
"session.created": async () => {
|
|
131
|
+
startCollectorSession(spawnImpl, stubPath)
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(LATEST_VERSION_URL)
|
|
134
|
+
if (!res.ok) return
|
|
135
|
+
const { latest } = await res.json()
|
|
136
|
+
const message = formatVersionNudge(INSTALLED_VERSION, latest)
|
|
137
|
+
if (message) await client?.app?.log({ service: "gloria", level: "warn", message })
|
|
138
|
+
} catch {
|
|
139
|
+
// Network hiccups must never block session start.
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
// Closest OpenCode analog to Claude Code's per-turn Stop hook: fires once
|
|
143
|
+
// the agent loop finishes a turn and is waiting on the user again. Cheap on
|
|
144
|
+
// purpose — see ensureCollectorDaemon.
|
|
145
|
+
"session.idle": async () => {
|
|
146
|
+
ensureCollectorDaemon(spawnImpl, stubPath)
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
export default gloria
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./gloria.js"
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandgarden/gloria",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.3.88",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "gloria.dev OpenCode plugin — dependency-discovery skills + the gloria.dev MCP server.",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"gloria.js",
|
|
10
|
+
"skills",
|
|
11
|
+
"collector"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://gloria.dev",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
}
|
|
18
|
+
}
|