From 02b62f787e1aec26c6723de4fb5affe1a4225f9d Mon Sep 17 00:00:00 2001 From: bastien Date: Mon, 28 Sep 2026 11:48:45 +0200 Subject: [PATCH] fix(gstack): one helper-link tree for every hardcoded path, honest doctor stats gstack skills hardcode ~/.claude/skills/gstack/ 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. --- doctor.sh | 32 +++---- install-plugins.sh | 35 +++++--- lib/doctor-skills.sh | 62 +++++++++++++ lib/gstack-links.sh | 150 ++++++++++++++++++++++++++++++++ lib/tests/doctor-skills.test.sh | 91 +++++++++++++++++++ lib/tests/gstack-links.test.sh | 101 +++++++++++++++++++++ link.sh | 46 +++------- update-all.sh | 14 ++- 8 files changed, 463 insertions(+), 68 deletions(-) create mode 100644 lib/doctor-skills.sh create mode 100644 lib/gstack-links.sh create mode 100755 lib/tests/doctor-skills.test.sh create mode 100755 lib/tests/gstack-links.test.sh diff --git a/doctor.sh b/doctor.sh index 122bf71..e0b430a 100644 --- a/doctor.sh +++ b/doctor.sh @@ -24,6 +24,8 @@ source "$REPO/lib/detect-plugins.sh" source "$REPO/lib/gstack-playwright.sh" # shellcheck source=lib/doctor-vendored.sh disable=SC1091 source "$REPO/lib/doctor-vendored.sh" +# shellcheck source=lib/doctor-skills.sh disable=SC1091 +source "$REPO/lib/doctor-skills.sh" echo "" echo "═══ claude-config doctor (v${VERSION}) ═══" @@ -403,23 +405,23 @@ echo "── Token budget estimate ──" CLAUDE_MD_CHARS=$(wc -c < "$REPO/CLAUDE.global.md" 2>/dev/null || echo 0) CLAUDE_MD_TOKENS=$((CLAUDE_MD_CHARS / 4)) -# Skill descriptions only (frontmatter description field — loaded passively at startup) -SKILL_DESC_CHARS=0 -for f in "$HOME/.claude/skills/"*/SKILL.md; do - [ -f "$f" ] || continue - desc=$(grep "^description:" "$f" 2>/dev/null | head -1 | sed 's/^description: *//' ) - SKILL_DESC_CHARS=$((SKILL_DESC_CHARS + ${#desc})) -done +# Skill descriptions across the whole live catalog — every SKILL.md +# reachable through ~/.claude/skills/*/SKILL.md, symlinks included +# (lib/doctor-skills.sh; catches a `|`/`>` block-scalar description that +# the old `grep '^description:' | head -1` counted as 0 chars, and a +# symlinked skill dir that the old `find -maxdepth 2` without `-L` missed). +read -r SKILL_COUNT SKILL_DESC_CHARS \ + < <(skill_catalog_stats "$HOME/.claude/skills") SKILL_DESC_TOKENS=$((SKILL_DESC_CHARS / 4)) -SKILL_COUNT=$(find "$HOME/.claude/skills/" -maxdepth 2 -name "SKILL.md" 2>/dev/null | wc -l | tr -d ' ') -# Plugin passive cost estimates (tokens) +# Plugin passive cost estimates (tokens) — session-start injections and +# hook prompts that never show up as a skill description above. gstack, +# context7 (find-docs) and graphifyy dropped 2026-09-28 (skill-catalog +# prune): their skills sit under ~/.claude/skills and are already counted +# by the stats above — a separate constant here double-counted them. PLUGIN_TOKENS=0 -if detect_superpowers 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 800)); fi -if detect_gstack 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 2750)); fi -if detect_uiux_pro_max 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 400)); fi -if detect_context7 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 200)); fi -if detect_graphifyy 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 300)); fi +if detect_superpowers 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 1500)); fi +if detect_uiux_pro_max 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 670)); fi TOTAL_TOKENS=$((CLAUDE_MD_TOKENS + SKILL_DESC_TOKENS + PLUGIN_TOKENS)) CONTEXT_WINDOW=200000 # Claude Code default context window (conservative; 1M is opt-in) @@ -430,7 +432,7 @@ echo " CLAUDE.global.md: ~${CLAUDE_MD_TOKENS}t" echo " Skill descriptions: ~${SKILL_DESC_TOKENS}t (${SKILL_COUNT} skills)" echo " Plugin passive cost: ~${PLUGIN_TOKENS}t (active plugins)" echo " ─────────────────────────────────────────" -info " Total: ~${TOTAL_TOKENS}t (measured ~11.4k post-audit, LRN-088)" +info " Total: ~${TOTAL_TOKENS}t (re-measure after a catalog change; LRN-088)" info " Context window: ${CONTEXT_WINDOW}t (default; 1M opt-in)" info " Usage: ~${PCT}% of context" echo "" diff --git a/install-plugins.sh b/install-plugins.sh index 4867d29..e9dfc67 100644 --- a/install-plugins.sh +++ b/install-plugins.sh @@ -30,6 +30,8 @@ fi source "$REPO/lib/detect-plugins.sh" # shellcheck source=lib/gstack-playwright.sh disable=SC1091 source "$REPO/lib/gstack-playwright.sh" +# shellcheck source=lib/gstack-links.sh disable=SC1091 +source "$REPO/lib/gstack-links.sh" # ── Guard hand-curated config against installer drift ──────── # graphify's installer (Step 7) rewrites CLAUDE.md + .claude/settings.json @@ -372,19 +374,17 @@ if [ -d "$GSTACK_DIR" ]; then warn "GStack NOT ready — ./setup did not complete (see warnings above)" fi - # GStack shared infrastructure: bin/ (CLI tools) and browse/dist/ (compiled binary). - # Per-skill SKILL.md symlinks don't expose these, but multiple skills hardcode - # ~/.claude/skills/gstack/bin/ and gstack/browse/dist/. + # GStack shared helper tree: every asset the skills hardcode under + # ~/.claude/skills/gstack/ (bin/, browse/dist/, ETHOS.md, …) that a + # per-skill SKILL.md symlink never exposes — see lib/gstack-links.sh. + # Run AFTER ./setup so the lib's own stale-symlink guard removes any + # `skills/gstack -> skills-external/gstack` link setup may have planted. GSTACK_DST="$HOME/.claude/skills/gstack" - if [ -d "$GSTACK_DIR/bin" ]; then - mkdir -p "$GSTACK_DST" - [ -L "$GSTACK_DST/bin" ] || ln -sf "$GSTACK_DIR/bin" "$GSTACK_DST/bin" - ok "gstack/bin/ symlink OK" - fi - if [ -d "$GSTACK_DIR/browse/dist" ]; then - mkdir -p "$GSTACK_DST/browse" - [ -L "$GSTACK_DST/browse/dist" ] || ln -sf "$GSTACK_DIR/browse/dist" "$GSTACK_DST/browse/dist" - ok "gstack/browse/dist/ symlink OK" + _n_gstack_links=$(link_gstack_helpers "$GSTACK_DIR" "$GSTACK_DST") + if [ "$_n_gstack_links" -gt 0 ]; then + ok "gstack helper tree linked ($_n_gstack_links new)" + else + ok "gstack helper tree up to date" fi else warn "GStack submodule directory not found after init — check .gitmodules" @@ -546,6 +546,15 @@ install_plugin "ui-ux-pro-max" "ui-ux-pro-max-skill" echo "" +# frontend-design@claude-plugins-official — NEVER installed: byte-identical +# to the skills-external copy Step 8b syncs from the example-skills cache; +# uninstalled 2026-09-28 (skill-catalog prune). + +# brightdata-plugin@synced — account-synced from claude.ai, kept `false` in +# settings.json: every skill needs a Bright Data account and its +# bright-data-mcp skill orders WebFetch/WebSearch replaced "no exceptions" +# (would hijack /seo /geo /harden). + # Caveman plugin removed (cleanup/caveman-always-on, v3.5.0): on a # subscription plan its ~75% output-token compression has no cost benefit, # and the plugin's always-on SessionStart/UserPromptSubmit hooks added @@ -1196,7 +1205,7 @@ echo "║ Install Summary ║" echo "╚══════════════════════════════════════════════════════════╝" echo "" echo " ALWAYS ON (installed at user scope):" -echo " ✅ security-guidance — PreToolUse security hook (0 tokens) [claude-code-plugins]" +echo " ✅ security-guidance — regex hints on Edit/Write + out-of-band LLM reviews on commit/push (Stop review off via ENABLE_STOP_REVIEW=0; quota, not context) [claude-code-plugins]" echo " ✅ rtk — token compression hook (0 tokens)" echo " ✅ superpowers — brainstorm/plan/implement/debug workflow" echo "" diff --git a/lib/doctor-skills.sh b/lib/doctor-skills.sh new file mode 100644 index 0000000..51ecb71 --- /dev/null +++ b/lib/doctor-skills.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# ============================================================ +# lib/doctor-skills.sh — doctor.sh's skill-catalog stats +# +# `skill_catalog_stats ` counts every skill reachable +# through /*/SKILL.md (symlinks included — Python's +# glob.glob follows them, verified: a symlinked skill dir matches the +# pattern the same as a real one) and sums their description length, +# reusing lib/skill-routing-census.py's extract_description() (handles +# a plain scalar AND a `|`/`>` YAML block scalar) instead of doctor.sh's +# old `grep '^description:' | head -1` (0 chars on every block-scalar +# description) and its `find -maxdepth 2` skill count (missed +# symlinked skill dirs without `-L`). +# +# Prints " " on stdout and ALWAYS exits 0 — an +# absent naturally globs to nothing (0 0, no error); a +# python failure also prints "0 0" so doctor.sh (which runs under +# `set -euo pipefail`) never aborts on this check, PLUS one warn line on +# stderr so a real failure still shows instead of reading as a healthy +# empty catalog. The warn goes to stderr explicitly (not just via the +# caller's own warn() convention) because the caller reads this +# function's stdout with `read -r … < <(skill_catalog_stats …)` — any +# extra stdout line would corrupt that capture. +# +# No `set -euo pipefail` here (mirrors lib/vendor-skills.sh): a sourced +# lib must not change the caller's shell options. +# ============================================================ + +DOCTOR_SKILLS_LIB_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! declare -F warn >/dev/null 2>&1; then + YELLOW='\033[1;33m'; NC='\033[0m' + warn() { echo -e "${YELLOW}⚠${NC} $1"; } +fi + +# skill_catalog_stats — see file header. +skill_catalog_stats() { + local dir="$1" census="$DOCTOR_SKILLS_LIB_DIR/skill-routing-census.py" + local out rc + out=$(python3 - "$dir" "$census" <<'PY' +import glob, importlib.util, sys + +skills_dir, census_path = sys.argv[1], sys.argv[2] +spec = importlib.util.spec_from_file_location( + "skill_routing_census", census_path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +paths = glob.glob(skills_dir + "/*/SKILL.md") +chars = sum(len(module.extract_description(p) or "") for p in paths) +print(len(paths), chars) +PY + ) + rc=$? + if [ "$rc" -eq 0 ]; then + echo "$out" + return 0 + fi + warn "skill_catalog_stats: python failed (rc=$rc) — showing 0 0" >&2 + echo "0 0" + return 0 +} diff --git a/lib/gstack-links.sh b/lib/gstack-links.sh new file mode 100644 index 0000000..b3b0c6c --- /dev/null +++ b/lib/gstack-links.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# ============================================================ +# lib/gstack-links.sh — shared gstack helper-tree linker +# +# gstack skills hardcode `~/.claude/skills/gstack/` 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 , 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 — must never +# expose a SKILL.md at ANY depth, or skill discovery lists gstack/ +# 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 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 — removes a stale symlink +# (gstack ./setup plants `skills/gstack -> skills-external/gstack` when +# the dir is absent), refuses ever writing INTO (dst resolving +# inside src), then ensures 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 — 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 — 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 — 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 — mirrors every gstack shared asset +# under (the skills-external/gstack submodule) into +# (normally ~/.claude/skills/gstack), idempotent (ln -sfn), never +# exposing a SKILL.md at any depth under . 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 resolves inside — 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" +} diff --git a/lib/tests/doctor-skills.test.sh b/lib/tests/doctor-skills.test.sh new file mode 100755 index 0000000..c6baeca --- /dev/null +++ b/lib/tests/doctor-skills.test.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# lib/tests/doctor-skills.test.sh — lib/doctor-skills.sh's +# skill_catalog_stats(): an inline scalar description, a `|` block +# scalar, a `>-` folded block, a SKILL.md with no description, a +# symlinked skill dir (counted, matching Python glob.glob's symlink +# behavior), and an absent skills dir ("0 0", rc 0 — doctor.sh runs +# under `set -euo pipefail` and must never abort on this check). +set -u +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LIB="$ROOT/lib/doctor-skills.sh" +pass=0; fail=0 +check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); + printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; } + +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +SKILLS="$WORK/skills" +REAL="$WORK/real-skill" +mkdir -p "$SKILLS/inline" "$SKILLS/pipe-block" "$SKILLS/fold-block" \ + "$SKILLS/no-desc" "$REAL" + +cat > "$SKILLS/inline/SKILL.md" <<'EOF' +--- +name: inline +description: "Inline description, sixteen." +--- +body +EOF + +cat > "$SKILLS/pipe-block/SKILL.md" <<'EOF' +--- +name: pipe-block +description: | + Block scalar description + spanning two lines. +--- +body +EOF + +cat > "$SKILLS/fold-block/SKILL.md" <<'EOF' +--- +name: fold-block +description: >- + Folded block description + on two lines too. +--- +body +EOF + +cat > "$SKILLS/no-desc/SKILL.md" <<'EOF' +--- +name: no-desc +--- +body +EOF + +cat > "$REAL/SKILL.md" <<'EOF' +--- +name: symlinked +description: "Symlinked skill description." +--- +body +EOF +ln -s "$REAL" "$SKILLS/symlinked" + +# ── hand-computed expectations (mirrors extract_description()'s scalar +# and block-scalar handling) ── +INLINE_DESC="Inline description, sixteen." +PIPE_DESC="Block scalar description spanning two lines." +FOLD_DESC="Folded block description on two lines too." +SYM_DESC="Symlinked skill description." +EXP_COUNT=5 +EXP_CHARS=$((${#INLINE_DESC} + ${#PIPE_DESC} + ${#FOLD_DESC} + ${#SYM_DESC})) + +# shellcheck source=../doctor-skills.sh disable=SC1091 +source "$LIB" + +out="$(skill_catalog_stats "$SKILLS")" +rc=$? +got_count="${out%% *}" +got_chars="${out##* }" +check T1-rc "$rc" 0 +check T1-count "$got_count" "$EXP_COUNT" +check T1-chars "$got_chars" "$EXP_CHARS" + +# ── T2: absent dir — "0 0", rc 0 ── +out2="$(skill_catalog_stats "$WORK/does-not-exist")" +rc2=$? +check T2-rc "$rc2" 0 +check T2-zeroes "$out2" "0 0" + +printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ] diff --git a/lib/tests/gstack-links.test.sh b/lib/tests/gstack-links.test.sh new file mode 100755 index 0000000..803496a --- /dev/null +++ b/lib/tests/gstack-links.test.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# lib/tests/gstack-links.test.sh — lib/gstack-links.sh's +# link_gstack_helpers(): whole-class mirroring of a gstack skill dir +# (SKILL.md excluded), whole-dir symlink for a clean non-skill dir/file, +# skip-whole for a non-skill dir hiding a nested SKILL.md +# (browser-skills/, openclaw/-style), skip-by-name for `.git*` and +# `node_modules`, idempotent re-run (echoes 0, nothing changes), a stale +# dst-is-symlink-to-src planted by gstack ./setup (removed, dst becomes +# a real dir, nothing written into src), and a dst path that would +# resolve inside src (refused, rc 1, nothing created). Covers contract +# criterion 3 (whole class) and the r4 confirmation-pass fixtures. +set -u +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LIB="$ROOT/lib/gstack-links.sh" +pass=0; fail=0 +check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); + printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; } + +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +SRC="$WORK/src" +DST="$WORK/dst" + +# ── fixture src: one skill dir (browse), one nested skill-dir tree +# (review, with a sub-directory of its own), plain shared assets +# (bin/, ETHOS.md), the top-level gstack SKILL.md itself, vcs metadata, +# and the two non-skill-dir-hiding-a-nested-SKILL.md cases (other/deep, +# node_modules/pkg) ── +mkdir -p "$SRC/bin" "$SRC/browse/dist" "$SRC/review/specialists" \ + "$SRC/.git" "$SRC/other/deep" "$SRC/node_modules/pkg" +echo x > "$SRC/bin/x" +echo ethos > "$SRC/ETHOS.md" +echo skill > "$SRC/SKILL.md" +echo browse-skill > "$SRC/browse/SKILL.md" +echo browse-bin > "$SRC/browse/dist/browse" +echo review-skill > "$SRC/review/SKILL.md" +echo checklist > "$SRC/review/checklist.md" +echo spec-a > "$SRC/review/specialists/a.md" +echo head > "$SRC/.git/HEAD" +echo nested > "$SRC/other/deep/SKILL.md" +echo pkg-skill > "$SRC/node_modules/pkg/SKILL.md" + +# shellcheck source=../gstack-links.sh disable=SC1091 +source "$LIB" + +# ── T1: first run mirrors the whole class, exposes no SKILL.md ── +n1=$(link_gstack_helpers "$SRC" "$DST" 2>/dev/null) +check T1-bin-resolves \ + "$([ -f "$DST/bin/x" ] && cat "$DST/bin/x" || echo missing)" x +check T1-ethos-resolves \ + "$([ -f "$DST/ETHOS.md" ] && cat "$DST/ETHOS.md" || echo missing)" ethos +check T1-browse-dist-resolves \ + "$([ -f "$DST/browse/dist/browse" ] && cat "$DST/browse/dist/browse" \ + || echo missing)" browse-bin +check T1-review-checklist-resolves \ + "$([ -f "$DST/review/checklist.md" ] && cat "$DST/review/checklist.md" \ + || echo missing)" checklist +check T1-review-specialists-resolves \ + "$([ -f "$DST/review/specialists/a.md" ] \ + && cat "$DST/review/specialists/a.md" || echo missing)" spec-a +check T1-no-skillmd-anywhere \ + "$(find -L "$DST" -name SKILL.md 2>/dev/null | wc -l | tr -d ' ')" 0 +check T1-no-dotgit "$([ -e "$DST/.git" ] && echo present || echo absent)" \ + absent +check T1-no-other "$([ -e "$DST/other" ] && echo present || echo absent)" \ + absent +check T1-no-node-modules \ + "$([ -e "$DST/node_modules" ] && echo present || echo absent)" absent +check T1-count-positive "$([ "$n1" -gt 0 ] && echo yes || echo no)" yes + +# ── T2: idempotent re-run — echoes 0, tree unchanged ── +n2=$(link_gstack_helpers "$SRC" "$DST" 2>/dev/null) +check T2-echoes-zero "$n2" 0 +check T2-bin-still-resolves \ + "$([ -f "$DST/bin/x" ] && cat "$DST/bin/x" || echo missing)" x + +# ── T3: dst is a symlink to src (gstack ./setup's stale-link case) — +# removed, dst becomes a real dir, nothing written into src ── +DST3="$WORK/dst-symlinked" +ln -s "$SRC" "$DST3" +n3=$(link_gstack_helpers "$SRC" "$DST3" 2>/dev/null) +check T3-dst-is-real-dir "$([ -d "$DST3" ] && [ ! -L "$DST3" ] \ + && echo yes || echo no)" yes +check T3-bin-resolves \ + "$([ -f "$DST3/bin/x" ] && cat "$DST3/bin/x" || echo missing)" x +check T3-nothing-written-in-src \ + "$(find "$SRC" -type l 2>/dev/null | wc -l | tr -d ' ')" 0 +check T3-count-positive "$([ "$n3" -gt 0 ] && echo yes || echo no)" yes + +# ── T4: dst path resolves inside src — refused, rc 1, nothing created ── +DST4="$SRC/helpers" +out4=$(link_gstack_helpers "$SRC" "$DST4" 2>&1 >/dev/null) +rc4=$? +n4=$(link_gstack_helpers "$SRC" "$DST4" 2>/dev/null) +check T4-rc "$rc4" 1 +check T4-echoes-zero "$n4" 0 +check T4-nothing-created "$([ -e "$DST4" ] && echo present || echo absent)" \ + absent +check T4-warns "$(printf '%s' "$out4" | grep -qi 'refusing' \ + && echo yes || echo no)" yes + +printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ] diff --git a/link.sh b/link.sh index 470240b..3da8d76 100644 --- a/link.sh +++ b/link.sh @@ -55,41 +55,23 @@ for item in hooks githooks agents skills lib templates rules; do done # GStack is exposed via per-skill symlinks under skills/ (browse, -# canary, autoplan, design-review, …) created by gstack's own -# `./setup`. A global `skills/gstack -> skills-external/gstack/` -# symlink duplicated the top-level gstack SKILL.md alongside those -# individual skills, producing two entries with the same description -# ("Fast headless browser for QA testing…"). Remove any stale global -# link — only per-skill entries remain. -if [ -L "$REPO/skills/gstack" ] || [ -L "$CLAUDE/skills/gstack" ]; then - rm -f "$REPO/skills/gstack" "$CLAUDE/skills/gstack" - CHANGED=$((CHANGED + 1)) -fi -if [ ! -d "$REPO/skills-external/gstack" ]; then +# canary, autoplan, design-review, …) created by gstack's own `./setup`, +# PLUS a shared helper tree at skills/gstack/ mirroring every asset the +# skills hardcode (bin/, browse/dist/, ETHOS.md, …) that a per-skill +# symlink never exposes — see lib/gstack-links.sh. The helper tree +# never contains a SKILL.md at any depth, so it never duplicates a +# per-skill entry the way a flat `skills/gstack -> skills-external/gstack` +# link used to (removed by the lib's own stale-symlink guard). +# shellcheck source=lib/gstack-links.sh disable=SC1091 +source "$REPO/lib/gstack-links.sh" +if [ -d "$REPO/skills-external/gstack" ]; then + n=$(link_gstack_helpers "$REPO/skills-external/gstack" \ + "$CLAUDE/skills/gstack") + CHANGED=$((CHANGED + n)) +else echo "⚠️ GStack submodule not found — run: git submodule update --init" fi -# GStack shared infrastructure: bin/ (CLI tools, config, analytics) and -# browse/dist/ (compiled browse binary). Per-skill SKILL.md symlinks don't -# expose these, but multiple skills hardcode ~/.claude/skills/gstack/bin/ -# and ~/.claude/skills/gstack/browse/dist/. Create targeted symlinks. -GSTACK_SRC="$REPO/skills-external/gstack" -GSTACK_DST="$CLAUDE/skills/gstack" -if [ -d "$GSTACK_SRC/bin" ]; then - mkdir -p "$GSTACK_DST" - if [ ! -L "$GSTACK_DST/bin" ]; then - ln -sf "$GSTACK_SRC/bin" "$GSTACK_DST/bin" - CHANGED=$((CHANGED + 1)) - fi -fi -if [ -d "$GSTACK_SRC/browse/dist" ]; then - mkdir -p "$GSTACK_DST/browse" - if [ ! -L "$GSTACK_DST/browse/dist" ]; then - ln -sf "$GSTACK_SRC/browse/dist" "$GSTACK_DST/browse/dist" - CHANGED=$((CHANGED + 1)) - fi -fi - # impeccable is NOT here: its installer writes the skill straight into # skills/ (and its agents into agents/) at --scope=global, so there is no # skills-external/ copy to symlink. See install-plugins.sh Step 8d. diff --git a/update-all.sh b/update-all.sh index 03854ac..5929c9b 100644 --- a/update-all.sh +++ b/update-all.sh @@ -19,6 +19,8 @@ VERSION=$(cat "$REPO/version.txt" 2>/dev/null || echo "unknown") source "$REPO/lib/detect-plugins.sh" # shellcheck source=lib/gstack-playwright.sh disable=SC1091 source "$REPO/lib/gstack-playwright.sh" +# shellcheck source=lib/gstack-links.sh disable=SC1091 +source "$REPO/lib/gstack-links.sh" echo "" echo "═══ claude-config update (v${VERSION}) ═══" @@ -103,16 +105,12 @@ if [[ "$_gstack_confirm" =~ ^[Yy]$ ]]; then warn "GStack submodule update failed — run: git submodule update --init" fi - # Refresh gstack shared infrastructure symlinks (bin/ + browse/dist/) + # Refresh the gstack shared helper tree (bin/, browse/dist/, ETHOS.md, + # …) — see lib/gstack-links.sh. GSTACK_DIR="$REPO/skills-external/gstack" GSTACK_DST="$HOME/.claude/skills/gstack" - if [ -d "$GSTACK_DIR/bin" ]; then - mkdir -p "$GSTACK_DST" - ln -sf "$GSTACK_DIR/bin" "$GSTACK_DST/bin" - fi - if [ -d "$GSTACK_DIR/browse/dist" ]; then - mkdir -p "$GSTACK_DST/browse" - ln -sf "$GSTACK_DIR/browse/dist" "$GSTACK_DST/browse/dist" + if [ -d "$GSTACK_DIR" ]; then + link_gstack_helpers "$GSTACK_DIR" "$GSTACK_DST" >/dev/null fi # Restore prior enabled/disabled state