Files
claude/lib/gstack-links.sh
bastien 02b62f787e fix(gstack): one helper-link tree for every hardcoded path, honest doctor stats
gstack skills hardcode ~/.claude/skills/gstack/<path> for 83 shared assets
(bin, scripts/jargon-list.json, ETHOS.md, */sections, review/specialists,
make-pdf/dist, lib/diagram-render/dist, freeze/bin...) but only bin and
browse/dist were linked: make-pdf and diagram failed on every run, cso and
plan-*-review could not read their sections, the freeze hook exited 127.
lib/gstack-links.sh links every top-level entry except SKILL.md, skips
non-skill dirs holding a nested SKILL.md (browser-skills, openclaw,
node_modules), removes the global symlink gstack ./setup plants and refuses
a destination inside the submodule. link.sh, install-plugins.sh and
update-all.sh all call it (three hand-copied blocks gone).

doctor.sh counted 34 skills (find without -L) and zero chars for block
scalar descriptions; lib/doctor-skills.sh reuses the census parser and
counts through the symlinks. Plugin constants re-based on measured values;
install-plugins.sh notes why frontend-design@claude-plugins-official and
brightdata-plugin@synced stay off and describes security-guidance truthfully.
2026-09-28 11:48:45 +02:00

151 lines
6.0 KiB
Bash

#!/usr/bin/env bash
# ============================================================
# lib/gstack-links.sh — shared gstack helper-tree linker
#
# gstack skills hardcode `~/.claude/skills/gstack/<path>` for shared
# assets (bin/, browse/dist/, design/dist/, lib/diagram-render/dist/,
# ETHOS.md, scripts/jargon-list.json, freeze/bin/, */sections/*.md,
# review/checklist.md + specialists/, …) that per-skill SKILL.md
# symlinks never expose (BDR-030 links gstack skills individually).
# `link_gstack_helpers()` walks the submodule ONCE and mirrors every one
# of those assets under <dst>, so make-pdf, diagram, the freeze hook,
# cso/plan-*-review sections etc. actually resolve — before this, only
# bin/ and browse/dist/ were hand-linked and everything else returned
# *_NOT_AVAILABLE or exited 127 (LRN-096 class).
#
# A skill dir (one holding its own SKILL.md, e.g. review/, careful/) is
# mirrored child-by-child with SKILL.md excluded — <dst> must never
# expose a SKILL.md at ANY depth, or skill discovery lists gstack/<name>
# as a duplicate entry alongside the individually-linked skill. A
# non-skill dir that HOLDS a nested SKILL.md somewhere below it
# (browser-skills/, openclaw/ — vendored/generated content, not a gstack
# asset) is skipped whole: mirroring it would expose that nested
# SKILL.md through <dst> too. `.git*` and `node_modules` are skipped by
# name (vcs metadata / vendored deps, never worth walking).
#
# Sourced by link.sh, install-plugins.sh (Step 2) and update-all.sh — the
# same block used to be hand-duplicated in all three (three divergent
# copies, one of them `ln -sf` without `-n` — nests src/bin/bin on a
# re-run — criterion 18 forbids the duplication now).
#
# No `set -e` (mirrors lib/vendor-skills.sh): a sourced lib must not
# change the caller's shell options.
# ============================================================
# Fallback color helpers when sourced standalone (hermetic test suite) —
# every diagnostic call below is explicitly redirected to stderr (>&2) so
# `n=$(link_gstack_helpers …)` captures ONLY the final link count,
# whichever ok/warn/info implementation (caller's or this fallback) runs.
if ! declare -F ok >/dev/null 2>&1; then
GREEN='\033[0;32m'; NC='\033[0m'
ok() { echo -e "${GREEN}✓${NC} $1"; }
fi
if ! declare -F warn >/dev/null 2>&1; then
YELLOW='\033[1;33m'; NC='\033[0m'
warn() { echo -e "${YELLOW}⚠${NC} $1"; }
fi
if ! declare -F info >/dev/null 2>&1; then
BLUE='\033[0;34m'; NC='\033[0m'
info() { echo -e "${BLUE}→${NC} $1"; }
fi
# _gstack_links_guard_dst <src> <dst> — removes a stale <dst> symlink
# (gstack ./setup plants `skills/gstack -> skills-external/gstack` when
# the dir is absent), refuses ever writing INTO <src> (dst resolving
# inside src), then ensures <dst> exists as a real dir. rc 1 on the
# write-into-src guard; nothing is created in that case.
_gstack_links_guard_dst() {
local src="$1" dst="$2" real_src real_dst
if [ -L "$dst" ]; then
info "removing stale gstack symlink: $dst" >&2
rm -f "$dst"
fi
real_src="$(realpath "$src")"
real_dst="$(realpath -m "$dst")"
case "$real_dst" in
"$real_src"/*|"$real_src")
warn "refusing to write into the gstack submodule: $dst" >&2
return 1
;;
esac
mkdir -p "$dst"
}
# _gstack_links_skip_entry <name> — true iff a top-level entry is never
# mirrored by name alone (vcs metadata, vendored deps, the top-level
# SKILL.md itself).
_gstack_links_skip_entry() {
case "$1" in
.git*|node_modules|SKILL.md) return 0 ;;
*) return 1 ;;
esac
}
# _gstack_links_skill_dir <src_entry> <dst_dir> — mirrors a gstack skill
# dir (one that holds its own SKILL.md) child-by-child, SKILL.md
# excluded. Echoes the count of links freshly created (idempotent on a
# re-run: an already-correct symlink is not recounted).
_gstack_links_skill_dir() {
local entry="$1" dst_dir="$2" child base n=0
mkdir -p "$dst_dir"
for child in "$entry"/*; do
[ -e "$child" ] || continue
base="$(basename "$child")"
[ "$base" = "SKILL.md" ] && continue
if [ ! -L "$dst_dir/$base" ] \
|| [ "$(readlink "$dst_dir/$base")" != "$child" ]; then
n=$((n + 1))
fi
ln -sfn "$child" "$dst_dir/$base"
done
echo "$n"
}
# _gstack_links_top_entry <src_entry> <dst_entry> — links ONE top-level
# src entry into dst: mirror child-by-child if it is a skill dir, skip
# whole if it is a non-skill dir hiding a nested SKILL.md, else a single
# whole-entry symlink (file or clean non-skill dir). Echoes the count of
# links freshly created.
_gstack_links_top_entry() {
local src_entry="$1" dst_entry="$2" n=0
if [ -d "$src_entry" ] && [ -f "$src_entry/SKILL.md" ]; then
n=$(_gstack_links_skill_dir "$src_entry" "$dst_entry")
elif [ -d "$src_entry" ] \
&& [ -n "$(find -L "$src_entry" -name SKILL.md -print -quit)" ]; then
info "skipped $(basename "$src_entry") (nested SKILL.md, not a \
gstack asset)" >&2
else
if [ ! -L "$dst_entry" ] \
|| [ "$(readlink "$dst_entry")" != "$src_entry" ]; then
n=1
fi
ln -sfn "$src_entry" "$dst_entry"
fi
echo "$n"
}
# link_gstack_helpers <src> <dst> — mirrors every gstack shared asset
# under <src> (the skills-external/gstack submodule) into <dst>
# (normally ~/.claude/skills/gstack), idempotent (ln -sfn), never
# exposing a SKILL.md at any depth under <dst>. Echoes the total link
# count freshly created THIS run on stdout (add it to the caller's
# CHANGED counter); prints one ok/warn summary on stderr. rc 1 (echoing
# 0) iff <dst> resolves inside <src> — nothing is created in that case.
link_gstack_helpers() {
local src="$1" dst="$2" entry base total=0 n
_gstack_links_guard_dst "$src" "$dst" || { echo 0; return 1; }
for entry in "$src"/*; do
[ -e "$entry" ] || continue
base="$(basename "$entry")"
_gstack_links_skip_entry "$base" && continue
n=$(_gstack_links_top_entry "$entry" "$dst/$base")
total=$((total + n))
done
if [ "$total" -gt 0 ]; then
ok "gstack helper tree: $total link(s) created under $dst" >&2
else
ok "gstack helper tree up to date ($dst)" >&2
fi
echo "$total"
}