mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
Pre-landing review finding: the ownership gate compared readlink output textually against INSTALL_DIR and RENDER_DIR, so two shapes of gstack's OWN entries read as foreign and were left behind on a mode flip — a legacy relative link (`gstack/qa/SKILL.md`, resolved against $PWD instead of the link's directory) and an entry linked against the real path of a symlinked install dir (~/.claude/skills/gstack -> checkout). Both now resolve: relative targets anchor at the link's directory, the directory part is canonicalized with pwd -P (the basename stays verbatim so a dangling managed target is not misread), and both spellings of each root are accepted. Two regression tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
225 lines
9.2 KiB
Bash
Executable File
225 lines
9.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-relink — re-create skill symlinks based on skill_prefix config
|
|
#
|
|
# Usage:
|
|
# gstack-relink
|
|
#
|
|
# Env overrides (for testing):
|
|
# GSTACK_STATE_DIR — override ~/.gstack state directory
|
|
# GSTACK_INSTALL_DIR — override gstack install directory
|
|
# GSTACK_SKILLS_DIR — override target skills directory
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
GSTACK_CONFIG="${SCRIPT_DIR}/gstack-config"
|
|
|
|
# Detect install dir
|
|
INSTALL_DIR="${GSTACK_INSTALL_DIR:-}"
|
|
if [ -z "$INSTALL_DIR" ]; then
|
|
if [ -d "$HOME/.claude/skills/gstack" ]; then
|
|
INSTALL_DIR="$HOME/.claude/skills/gstack"
|
|
elif [ -d "${SCRIPT_DIR}/.." ] && [ -f "${SCRIPT_DIR}/../setup" ]; then
|
|
INSTALL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$INSTALL_DIR" ] || [ ! -d "$INSTALL_DIR" ]; then
|
|
echo "Error: gstack install directory not found." >&2
|
|
echo "Run: cd ~/.claude/skills/gstack && ./setup" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Detect target skills dir
|
|
SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}"
|
|
[ -d "$SKILLS_DIR" ] || mkdir -p "$SKILLS_DIR"
|
|
|
|
# Read prefix setting
|
|
PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
|
|
|
|
# #2569: rendered :user variants (brain-aware blocks) live in an UNTRACKED
|
|
# out-dir instead of the tracked install checkout. When a render exists for a
|
|
# skill, relink serves it — otherwise a config change would silently flip
|
|
# every skill back to the canonical (blockless) source.
|
|
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
|
|
|
|
# ─── Ownership gate ───────────────────────────────────────────────────────────
|
|
# relink runs on every ./setup and used to `rm -rf` any same-name entry with a
|
|
# symlinked SKILL.md and `ln -snf` over any existing SKILL.md — so a user's own
|
|
# skill that happened to share a name (a personal `qa`, a fork under another
|
|
# path) was deleted or had its SKILL.md replaced by a symlink into gstack
|
|
# (#2119; Linux replaces a real file with `ln -snf`, macOS refuses by accident).
|
|
# setup:1040 and gstack-uninstall:204 already gate on readlink; this is the
|
|
# same rule for the one remaining unguarded deleter.
|
|
#
|
|
# An entry is OURS when:
|
|
# - it is a symlink resolving into $INSTALL_DIR or $RENDER_DIR, or
|
|
# - it is a real dir whose SKILL.md is a symlink resolving into either, or
|
|
# - it is a real dir carrying the .gstack-owned marker setup writes for
|
|
# Windows copy installs (no symlinks there to read).
|
|
# Anything else — a foreign symlink, a real dir with a real SKILL.md and no
|
|
# marker, or an entry whose readlink fails — is FOREIGN: never deleted, never
|
|
# linked over, reported on stderr.
|
|
# The install/render roots as written AND as resolved: a standalone relink
|
|
# detects INSTALL_DIR as ~/.claude/skills/gstack, which may itself be a symlink
|
|
# to a checkout, while setup linked entries against the checkout's real path.
|
|
# Both spellings are ours. (Roots stay quoted inside the case patterns, so a
|
|
# glob character or space in a path is matched literally.)
|
|
_INSTALL_REAL="$(cd "$INSTALL_DIR" 2>/dev/null && pwd -P || printf '%s' "$INSTALL_DIR")"
|
|
_RENDER_REAL="$(cd "$RENDER_DIR" 2>/dev/null && pwd -P || printf '%s' "$RENDER_DIR")"
|
|
_target_is_ours() {
|
|
# $1 = an ABSOLUTE path a symlink resolves to; ours when it lives under one
|
|
# of our roots. "$ROOT"/* requires the separator, so /home/u/gstack2/x never
|
|
# matches a /home/u/gstack root.
|
|
case "$1" in
|
|
"$INSTALL_DIR"/*|"$RENDER_DIR"/*|"$_INSTALL_REAL"/*|"$_RENDER_REAL"/*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# readlink of a RELATIVE symlink (older installs wrote `gstack/qa/SKILL.md`)
|
|
# is relative to the link's own directory, not to $PWD. Anchor it there, then
|
|
# canonicalize the DIRECTORY part (pwd -P) so `..` segments and symlinked
|
|
# path components (a `gstack` alias dir, a symlinked install) compare against
|
|
# the real roots. The basename is kept verbatim: canonicalizing it would follow
|
|
# the final link and turn every dangling target into "not ours".
|
|
_link_target_abs() {
|
|
local link="$1" dest d b d_real
|
|
dest="$(readlink "$link" 2>/dev/null || true)"
|
|
[ -n "$dest" ] || return 1
|
|
case "$dest" in
|
|
/*) ;;
|
|
*) dest="$(dirname "$link")/$dest" ;;
|
|
esac
|
|
d="$(dirname "$dest")"; b="$(basename "$dest")"
|
|
if d_real="$(cd "$d" 2>/dev/null && pwd -P)"; then
|
|
printf '%s\n' "$d_real/$b"
|
|
else
|
|
printf '%s\n' "$dest"
|
|
fi
|
|
}
|
|
|
|
_entry_is_ours() {
|
|
local entry="$1" dest
|
|
if [ -L "$entry" ]; then
|
|
dest="$(_link_target_abs "$entry")" || return 1
|
|
_target_is_ours "$dest"
|
|
return $?
|
|
fi
|
|
if [ -d "$entry" ]; then
|
|
[ -f "$entry/.gstack-owned" ] && return 0
|
|
if [ -L "$entry/SKILL.md" ]; then
|
|
dest="$(_link_target_abs "$entry/SKILL.md")" || return 1
|
|
_target_is_ours "$dest"
|
|
return $?
|
|
fi
|
|
return 1
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
FOREIGN_SKIPPED=()
|
|
_report_foreign() {
|
|
echo " skipped $1: not a gstack-managed entry (foreign skill with the same name) — left untouched" >&2
|
|
FOREIGN_SKIPPED+=("$1")
|
|
}
|
|
|
|
# Helper: remove an OLD skill entry from the opposite prefix mode. Only entries
|
|
# we can prove are ours are removed; anything else is reported and kept.
|
|
_cleanup_skill_entry() {
|
|
local entry="$1"
|
|
[ -e "$entry" ] || [ -L "$entry" ] || return 0
|
|
if ! _entry_is_ours "$entry"; then
|
|
_report_foreign "$entry"
|
|
return 0
|
|
fi
|
|
if [ -L "$entry" ]; then
|
|
rm -f "$entry"
|
|
elif [ -d "$entry" ]; then
|
|
rm -rf "$entry"
|
|
fi
|
|
}
|
|
|
|
_link_root_skill_alias() {
|
|
local target="$SKILLS_DIR/_gstack-command"
|
|
|
|
[ -f "$INSTALL_DIR/SKILL.md" ] || return 0
|
|
[ -L "$target" ] && rm -f "$target"
|
|
mkdir -p "$target"
|
|
# Copy-then-rewrite, never a symlink (#2511): a symlinked alias re-serves
|
|
# the canonical `name: gstack`, Claude Code sees a duplicate skill name,
|
|
# and drops the ENTIRE personal-skills set. sed reads the source and writes
|
|
# a fresh copy — remove any prior symlink first so the redirect can never
|
|
# write through it into the generated source.
|
|
rm -f "$target/SKILL.md"
|
|
sed "1,/^---\$/ s/^name:[[:space:]].*/name: _gstack-command/" "$INSTALL_DIR/SKILL.md" > "$target/SKILL.md"
|
|
}
|
|
|
|
_link_root_skill_alias
|
|
|
|
# Discover skills (directories with SKILL.md, excluding meta dirs)
|
|
SKILL_COUNT=0
|
|
for skill_dir in "$INSTALL_DIR"/*/; do
|
|
[ -d "$skill_dir" ] || continue
|
|
# Skip symlinked skill dirs (connect-chrome → open-gstack-browser): linking
|
|
# one under the symlink's basename would duplicate the canonical frontmatter
|
|
# name and collide in Claude Code's skill registry (#2201). setup owns the
|
|
# rewritten-copy alias for those.
|
|
[ -L "${skill_dir%/}" ] && continue
|
|
skill=$(basename "$skill_dir")
|
|
# Skip non-skill directories
|
|
case "$skill" in bin|browse|design|docs|extension|lib|node_modules|scripts|test|.git|.github) continue ;; esac
|
|
[ -f "$skill_dir/SKILL.md" ] || continue
|
|
|
|
if [ "$PREFIX" = "true" ]; then
|
|
# Don't double-prefix directories already named gstack-*
|
|
case "$skill" in
|
|
gstack-*) link_name="$skill" ;;
|
|
*) link_name="gstack-$skill" ;;
|
|
esac
|
|
# Remove old flat entry if it exists (and isn't the same as the new link)
|
|
[ "$link_name" != "$skill" ] && _cleanup_skill_entry "$SKILLS_DIR/$skill"
|
|
else
|
|
link_name="$skill"
|
|
# Don't remove gstack-* dirs that are their real name (e.g., gstack-upgrade)
|
|
case "$skill" in
|
|
gstack-*) ;; # Already the real name, no old prefixed link to clean
|
|
*) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" ;;
|
|
esac
|
|
fi
|
|
target="$SKILLS_DIR/$link_name"
|
|
# A destination that already exists and is NOT ours is a foreign skill that
|
|
# shares our name. Never `ln -snf` over its SKILL.md (on Linux that replaces
|
|
# a real file with a symlink into gstack) and never mkdir into it — skip
|
|
# loudly and leave registration of that one name to the user.
|
|
if { [ -e "$target" ] || [ -L "$target" ]; } && ! _entry_is_ours "$target"; then
|
|
_report_foreign "$target"
|
|
continue
|
|
fi
|
|
# Upgrade old directory symlinks to real directories
|
|
[ -L "$target" ] && rm -f "$target"
|
|
# Create real directory with symlinked SKILL.md (absolute path)
|
|
mkdir -p "$target"
|
|
skill_md_src="$INSTALL_DIR/$skill/SKILL.md"
|
|
[ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md"
|
|
ln -snf "$skill_md_src" "$target/SKILL.md"
|
|
SKILL_COUNT=$((SKILL_COUNT + 1))
|
|
done
|
|
|
|
# Patch SKILL.md name: fields to match prefix setting. When a gbrain render
|
|
# is active the loop above links SKILL.md from RENDER_DIR — the file the host
|
|
# actually serves — so patch THAT tree too or skill_prefix=true is a no-op
|
|
# for every brain-aware skill (#2738). gstack-patch-names takes an arbitrary
|
|
# root, skips already-prefixed names (idempotent), and the render dir is
|
|
# user-owned and untracked, so patching it never dirties a checkout.
|
|
"$INSTALL_DIR/bin/gstack-patch-names" "$INSTALL_DIR" "$PREFIX"
|
|
[ -d "$RENDER_DIR" ] && "$INSTALL_DIR/bin/gstack-patch-names" "$RENDER_DIR" "$PREFIX"
|
|
|
|
if [ "$PREFIX" = "true" ]; then
|
|
echo "Relinked $SKILL_COUNT skills as gstack-*"
|
|
else
|
|
echo "Relinked $SKILL_COUNT skills as flat names"
|
|
fi
|
|
if [ ${#FOREIGN_SKIPPED[@]} -gt 0 ]; then
|
|
echo "Skipped ${#FOREIGN_SKIPPED[@]} foreign entr$( [ ${#FOREIGN_SKIPPED[@]} -eq 1 ] && echo y || echo ies) (not gstack-managed, left untouched): ${FOREIGN_SKIPPED[*]}"
|
|
fi
|