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.
This commit is contained in:
@@ -24,6 +24,8 @@ source "$REPO/lib/detect-plugins.sh"
|
|||||||
source "$REPO/lib/gstack-playwright.sh"
|
source "$REPO/lib/gstack-playwright.sh"
|
||||||
# shellcheck source=lib/doctor-vendored.sh disable=SC1091
|
# shellcheck source=lib/doctor-vendored.sh disable=SC1091
|
||||||
source "$REPO/lib/doctor-vendored.sh"
|
source "$REPO/lib/doctor-vendored.sh"
|
||||||
|
# shellcheck source=lib/doctor-skills.sh disable=SC1091
|
||||||
|
source "$REPO/lib/doctor-skills.sh"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "═══ claude-config doctor (v${VERSION}) ═══"
|
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_CHARS=$(wc -c < "$REPO/CLAUDE.global.md" 2>/dev/null || echo 0)
|
||||||
CLAUDE_MD_TOKENS=$((CLAUDE_MD_CHARS / 4))
|
CLAUDE_MD_TOKENS=$((CLAUDE_MD_CHARS / 4))
|
||||||
|
|
||||||
# Skill descriptions only (frontmatter description field — loaded passively at startup)
|
# Skill descriptions across the whole live catalog — every SKILL.md
|
||||||
SKILL_DESC_CHARS=0
|
# reachable through ~/.claude/skills/*/SKILL.md, symlinks included
|
||||||
for f in "$HOME/.claude/skills/"*/SKILL.md; do
|
# (lib/doctor-skills.sh; catches a `|`/`>` block-scalar description that
|
||||||
[ -f "$f" ] || continue
|
# the old `grep '^description:' | head -1` counted as 0 chars, and a
|
||||||
desc=$(grep "^description:" "$f" 2>/dev/null | head -1 | sed 's/^description: *//' )
|
# symlinked skill dir that the old `find -maxdepth 2` without `-L` missed).
|
||||||
SKILL_DESC_CHARS=$((SKILL_DESC_CHARS + ${#desc}))
|
read -r SKILL_COUNT SKILL_DESC_CHARS \
|
||||||
done
|
< <(skill_catalog_stats "$HOME/.claude/skills")
|
||||||
SKILL_DESC_TOKENS=$((SKILL_DESC_CHARS / 4))
|
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
|
PLUGIN_TOKENS=0
|
||||||
if detect_superpowers 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 800)); fi
|
if detect_superpowers 2>/dev/null; then PLUGIN_TOKENS=$((PLUGIN_TOKENS + 1500)); 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 + 670)); 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
|
|
||||||
|
|
||||||
TOTAL_TOKENS=$((CLAUDE_MD_TOKENS + SKILL_DESC_TOKENS + PLUGIN_TOKENS))
|
TOTAL_TOKENS=$((CLAUDE_MD_TOKENS + SKILL_DESC_TOKENS + PLUGIN_TOKENS))
|
||||||
CONTEXT_WINDOW=200000 # Claude Code default context window (conservative; 1M is opt-in)
|
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 " Skill descriptions: ~${SKILL_DESC_TOKENS}t (${SKILL_COUNT} skills)"
|
||||||
echo " Plugin passive cost: ~${PLUGIN_TOKENS}t (active plugins)"
|
echo " Plugin passive cost: ~${PLUGIN_TOKENS}t (active plugins)"
|
||||||
echo " ─────────────────────────────────────────"
|
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 " Context window: ${CONTEXT_WINDOW}t (default; 1M opt-in)"
|
||||||
info " Usage: ~${PCT}% of context"
|
info " Usage: ~${PCT}% of context"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+22
-13
@@ -30,6 +30,8 @@ fi
|
|||||||
source "$REPO/lib/detect-plugins.sh"
|
source "$REPO/lib/detect-plugins.sh"
|
||||||
# shellcheck source=lib/gstack-playwright.sh disable=SC1091
|
# shellcheck source=lib/gstack-playwright.sh disable=SC1091
|
||||||
source "$REPO/lib/gstack-playwright.sh"
|
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 ────────
|
# ── Guard hand-curated config against installer drift ────────
|
||||||
# graphify's installer (Step 7) rewrites CLAUDE.md + .claude/settings.json
|
# 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)"
|
warn "GStack NOT ready — ./setup did not complete (see warnings above)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# GStack shared infrastructure: bin/ (CLI tools) and browse/dist/ (compiled binary).
|
# GStack shared helper tree: every asset the skills hardcode under
|
||||||
# Per-skill SKILL.md symlinks don't expose these, but multiple skills hardcode
|
# ~/.claude/skills/gstack/ (bin/, browse/dist/, ETHOS.md, …) that a
|
||||||
# ~/.claude/skills/gstack/bin/ and gstack/browse/dist/.
|
# 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"
|
GSTACK_DST="$HOME/.claude/skills/gstack"
|
||||||
if [ -d "$GSTACK_DIR/bin" ]; then
|
_n_gstack_links=$(link_gstack_helpers "$GSTACK_DIR" "$GSTACK_DST")
|
||||||
mkdir -p "$GSTACK_DST"
|
if [ "$_n_gstack_links" -gt 0 ]; then
|
||||||
[ -L "$GSTACK_DST/bin" ] || ln -sf "$GSTACK_DIR/bin" "$GSTACK_DST/bin"
|
ok "gstack helper tree linked ($_n_gstack_links new)"
|
||||||
ok "gstack/bin/ symlink OK"
|
else
|
||||||
fi
|
ok "gstack helper tree up to date"
|
||||||
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"
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "GStack submodule directory not found after init — check .gitmodules"
|
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 ""
|
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
|
# Caveman plugin removed (cleanup/caveman-always-on, v3.5.0): on a
|
||||||
# subscription plan its ~75% output-token compression has no cost benefit,
|
# subscription plan its ~75% output-token compression has no cost benefit,
|
||||||
# and the plugin's always-on SessionStart/UserPromptSubmit hooks added
|
# and the plugin's always-on SessionStart/UserPromptSubmit hooks added
|
||||||
@@ -1196,7 +1205,7 @@ echo "║ Install Summary ║"
|
|||||||
echo "╚══════════════════════════════════════════════════════════╝"
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
echo ""
|
echo ""
|
||||||
echo " ALWAYS ON (installed at user scope):"
|
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 " ✅ rtk — token compression hook (0 tokens)"
|
||||||
echo " ✅ superpowers — brainstorm/plan/implement/debug workflow"
|
echo " ✅ superpowers — brainstorm/plan/implement/debug workflow"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================
|
||||||
|
# lib/doctor-skills.sh — doctor.sh's skill-catalog stats
|
||||||
|
#
|
||||||
|
# `skill_catalog_stats <skills_dir>` counts every skill reachable
|
||||||
|
# through <skills_dir>/*/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 "<count> <desc_chars>" on stdout and ALWAYS exits 0 — an
|
||||||
|
# absent <skills_dir> 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 <skills_dir> — 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/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"
|
||||||
|
}
|
||||||
Executable
+91
@@ -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 ]
|
||||||
Executable
+101
@@ -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 ]
|
||||||
@@ -55,41 +55,23 @@ for item in hooks githooks agents skills lib templates rules; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# GStack is exposed via per-skill symlinks under skills/ (browse,
|
# GStack is exposed via per-skill symlinks under skills/ (browse,
|
||||||
# canary, autoplan, design-review, …) created by gstack's own
|
# canary, autoplan, design-review, …) created by gstack's own `./setup`,
|
||||||
# `./setup`. A global `skills/gstack -> skills-external/gstack/`
|
# PLUS a shared helper tree at skills/gstack/ mirroring every asset the
|
||||||
# symlink duplicated the top-level gstack SKILL.md alongside those
|
# skills hardcode (bin/, browse/dist/, ETHOS.md, …) that a per-skill
|
||||||
# individual skills, producing two entries with the same description
|
# symlink never exposes — see lib/gstack-links.sh. The helper tree
|
||||||
# ("Fast headless browser for QA testing…"). Remove any stale global
|
# never contains a SKILL.md at any depth, so it never duplicates a
|
||||||
# link — only per-skill entries remain.
|
# per-skill entry the way a flat `skills/gstack -> skills-external/gstack`
|
||||||
if [ -L "$REPO/skills/gstack" ] || [ -L "$CLAUDE/skills/gstack" ]; then
|
# link used to (removed by the lib's own stale-symlink guard).
|
||||||
rm -f "$REPO/skills/gstack" "$CLAUDE/skills/gstack"
|
# shellcheck source=lib/gstack-links.sh disable=SC1091
|
||||||
CHANGED=$((CHANGED + 1))
|
source "$REPO/lib/gstack-links.sh"
|
||||||
fi
|
if [ -d "$REPO/skills-external/gstack" ]; then
|
||||||
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"
|
echo "⚠️ GStack submodule not found — run: git submodule update --init"
|
||||||
fi
|
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
|
# 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/ (and its agents into agents/) at --scope=global, so there is no
|
||||||
# skills-external/ copy to symlink. See install-plugins.sh Step 8d.
|
# skills-external/ copy to symlink. See install-plugins.sh Step 8d.
|
||||||
|
|||||||
+6
-8
@@ -19,6 +19,8 @@ VERSION=$(cat "$REPO/version.txt" 2>/dev/null || echo "unknown")
|
|||||||
source "$REPO/lib/detect-plugins.sh"
|
source "$REPO/lib/detect-plugins.sh"
|
||||||
# shellcheck source=lib/gstack-playwright.sh disable=SC1091
|
# shellcheck source=lib/gstack-playwright.sh disable=SC1091
|
||||||
source "$REPO/lib/gstack-playwright.sh"
|
source "$REPO/lib/gstack-playwright.sh"
|
||||||
|
# shellcheck source=lib/gstack-links.sh disable=SC1091
|
||||||
|
source "$REPO/lib/gstack-links.sh"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "═══ claude-config update (v${VERSION}) ═══"
|
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"
|
warn "GStack submodule update failed — run: git submodule update --init"
|
||||||
fi
|
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_DIR="$REPO/skills-external/gstack"
|
||||||
GSTACK_DST="$HOME/.claude/skills/gstack"
|
GSTACK_DST="$HOME/.claude/skills/gstack"
|
||||||
if [ -d "$GSTACK_DIR/bin" ]; then
|
if [ -d "$GSTACK_DIR" ]; then
|
||||||
mkdir -p "$GSTACK_DST"
|
link_gstack_helpers "$GSTACK_DIR" "$GSTACK_DST" >/dev/null
|
||||||
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"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Restore prior enabled/disabled state
|
# Restore prior enabled/disabled state
|
||||||
|
|||||||
Reference in New Issue
Block a user