mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(windows): shell bins pass NUL as Bun's empty config when MSYS path conversion is off (#3415) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): detect Git Bash/MSYS2/Cygwin from OSTYPE, as gstack-doctor does, so uname stubs in setup fixtures stay untouched Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (#3415) - Choose NUL only when cygpath is also on PATH, so an OSTYPE=msys inherited by a POSIX bash keeps /dev/null instead of a cwd-relative NUL file. - gstack-developer-profile --log-session reports a Bun failure instead of skipping it as invalid input, like gstack-timeline-log. - learnings-injection tests check that their anchored bun -e block exists. - CHANGELOG names the callers this fix does not cover; curation reason fixed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes, cycle 2 (#3415) - timeline-log and developer-profile validators print their verdict; any non-zero Bun exit is a reported failure (a Bun crash on Windows exits 3). - timeline-log keeps the original event when ts stamping fails. - The definition ratchet scopes a local GSTACK_BUN_CONFIG to its function. - The shell-bins fixture clears its preload marker per test. - CHANGELOG states the callers that discard the report and the Bun 1.3.x case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG notes Bun 1.3.10 evidence and remaining literal /dev/null callers (#3415) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
506 lines
24 KiB
Bash
Executable File
506 lines
24 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_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
|
|
# GSTACK_INSTALL_DIR — override gstack install directory
|
|
# GSTACK_SKILLS_DIR — override target skills directory
|
|
set -euo pipefail
|
|
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
GSTACK_CONFIG="${SCRIPT_DIR}/gstack-config"
|
|
|
|
. "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
|
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
|
|
|
|
# Install root: an explicit override, else the install this script belongs to
|
|
# (it sits in a skills directory), else the Claude installs the registry
|
|
# recorded for this checkout. Never the global install first: relink from a
|
|
# vendored copy touches only that copy (docs/ADDING_A_HOST.md, ownership rules).
|
|
INSTALL_DIR="${GSTACK_INSTALL_DIR:-}"
|
|
if [ -z "$INSTALL_DIR" ]; then
|
|
_SELF_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
_SELF_REAL="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
if [ -f "$_SELF_ROOT/setup" ] && [ "$(basename "$(dirname "$_SELF_ROOT")")" = skills ]; then
|
|
INSTALL_DIR="$_SELF_ROOT"
|
|
else
|
|
_REG_TARGETS=""
|
|
if [ -f "$SCRIPT_DIR/gstack-install-registry.sh" ]; then
|
|
. "$SCRIPT_DIR/gstack-install-registry.sh"
|
|
_REG_TARGETS="$(gstack_install_registry_rows | awk -F '\t' -v s="$_SELF_REAL" '$1 == "claude" && $6 == s { print $5 "\t" $4 }')"
|
|
fi
|
|
_REG_COUNT="$(printf '%s' "$_REG_TARGETS" | grep -c . || true)"
|
|
if [ "$_REG_COUNT" -gt 1 ]; then
|
|
_RELINK_FAILED=0
|
|
while IFS=' ' read -r _reg_root _reg_dest; do
|
|
echo "Relinking $_reg_dest (install root $_reg_root):"
|
|
GSTACK_INSTALL_DIR="$_reg_root" GSTACK_SKILLS_DIR="$_reg_dest" "$0" "$@" || { echo " relink failed for $_reg_dest" >&2; _RELINK_FAILED=1; }
|
|
done <<EOF
|
|
$_REG_TARGETS
|
|
EOF
|
|
exit "$_RELINK_FAILED"
|
|
elif [ "$_REG_COUNT" -eq 1 ]; then
|
|
IFS=' ' read -r INSTALL_DIR _reg_dest <<EOF
|
|
$_REG_TARGETS
|
|
EOF
|
|
GSTACK_SKILLS_DIR="${GSTACK_SKILLS_DIR:-$_reg_dest}"
|
|
elif [ "$(cd "$HOME/.claude/skills/gstack" 2>/dev/null && pwd -P)" = "$_SELF_REAL" ]; then
|
|
INSTALL_DIR="$HOME/.claude/skills/gstack"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$INSTALL_DIR" ] || [ ! -d "$INSTALL_DIR" ]; then
|
|
echo "Error: no Claude install of this gstack checkout ($SCRIPT_DIR/..) was found." >&2
|
|
echo "Run its setup to install it: cd \"$(cd "$SCRIPT_DIR/.." && pwd)\" && ./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")
|
|
# disabled_skills (#1206): no entry for a disabled skill; its files stay.
|
|
DISABLED=" "
|
|
if [ -f "$SCRIPT_DIR/gstack-install-registry.sh" ]; then
|
|
. "$SCRIPT_DIR/gstack-install-registry.sh"
|
|
DISABLED="$(gstack_disabled_normalize "$("$GSTACK_CONFIG" get disabled_skills 2>/dev/null || true)")"
|
|
fi
|
|
DISABLED_COUNT=0
|
|
|
|
# #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:-}"
|
|
# An install outside the default root serves the per-install render setup
|
|
# recorded for it in the registry (render contract, #1882).
|
|
if [ -z "$RENDER_DIR" ] && command -v gstack_install_render_for >/dev/null 2>&1; then
|
|
RENDER_DIR="$(gstack_install_render_for claude "$SKILLS_DIR")"
|
|
fi
|
|
RENDER_DIR="${RENDER_DIR:-$GSTACK_STATE_ROOT/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 (link_claude_skill_dirs, cleanup_old_claude_symlinks,
|
|
# cleanup_prefixed_claude_symlinks) applies the same rule; keep the two files
|
|
# in sync until the shared helper filed in TODOS.md lands. gstack-uninstall
|
|
# has its own stricter inventory+banner gate.
|
|
#
|
|
# Proof comes in two strengths. STRONG (a symlink resolving into gstack, or the
|
|
# .gstack-owned marker) means we created the entry: it may be deleted whole or
|
|
# refreshed in place. WEAK (byte-identity with our source, or gen-skill-docs'
|
|
# two-line banner on a real file) proves only that the SKILL.md came from us:
|
|
# it authorizes touching that one file, never deleting the directory, and a
|
|
# differing file is moved to $GSTACK_STATE_ROOT/backups/skills/<ts>/
|
|
# before we link over it (a user who started their own skill from a gstack
|
|
# SKILL.md looks exactly like a pre-marker legacy copy).
|
|
#
|
|
# An entry is OURS when:
|
|
# - it is a symlink resolving into $INSTALL_DIR or $RENDER_DIR (as written or
|
|
# as realpath), or into any path with a `gstack` segment — the convention
|
|
# setup and gstack-uninstall use, so a sibling worktree's entries and a
|
|
# moved checkout's dangling links still count as ours, or
|
|
# - it is a real dir whose SKILL.md is such a symlink, or
|
|
# - it is a real dir with a real-file SKILL.md proven by the .gstack-owned
|
|
# marker (Windows copy installs), byte-identity with our source, or
|
|
# gen-skill-docs' generated header (legacy copies made before the marker).
|
|
# Anything else — a foreign symlink, a real dir with a hand-written SKILL.md,
|
|
# 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.
|
|
local root
|
|
case "$1" in
|
|
"$INSTALL_DIR"/*|"$RENDER_DIR"/*|"$_INSTALL_REAL"/*|"$_RENDER_REAL"/*) return 0 ;;
|
|
gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;;
|
|
esac
|
|
# A checkout named without a `gstack` segment (git worktree add
|
|
# ../gstack-<branch>): the target's skill root is a gstack tree if it
|
|
# carries setup + VERSION + bin/gstack-relink (a hand-written skill repo with
|
|
# a VERSION file does not). Same rule as setup's _gstack_target_is_ours.
|
|
root="${1%/*/SKILL.md}"
|
|
if [ "$root" != "$1" ] && [ -f "$root/VERSION" ] && [ -f "$root/setup" ] && [ -f "$root/bin/gstack-relink" ]; then return 0; fi
|
|
return 1
|
|
}
|
|
|
|
# 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="${link%/*}/$dest" ;;
|
|
esac
|
|
d="${dest%/*}"; b="${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_owned_strongly ENTRY — we created this entry: a symlink resolving
|
|
# into gstack, or a real dir carrying the .gstack-owned marker or a SKILL.md
|
|
# symlink into gstack.
|
|
_entry_owned_strongly() {
|
|
local entry="$1" dest
|
|
if [ -L "$entry" ]; then
|
|
dest="$(_link_target_abs "$entry")" || return 1
|
|
_target_is_ours "$dest"
|
|
return $?
|
|
fi
|
|
[ -d "$entry" ] || return 1
|
|
[ -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
|
|
}
|
|
|
|
# _entry_is_ours ENTRY SKILL — strong proof, or WEAK proof on a real-file
|
|
# SKILL.md (SKILL names the gstack skill this entry would serve, so the copy
|
|
# can be compared against our own source).
|
|
_entry_is_ours() {
|
|
local entry="$1" skill="${2:-}" src
|
|
_entry_owned_strongly "$entry" && return 0
|
|
# A symlink that did not resolve into gstack is someone else's; never follow
|
|
# it into the "unclaimed directory" rule below.
|
|
[ -L "$entry" ] && return 1
|
|
if [ -d "$entry" ] && [ ! -L "$entry/SKILL.md" ]; then
|
|
# No SKILL.md at all: an UNCLAIMED directory (a weak cleanup left the
|
|
# user's other files behind, or the dir was never a skill). Adding our
|
|
# SKILL.md overwrites nothing, so the link pass may proceed; the cleanup
|
|
# pass has nothing of ours to remove (see _cleanup_skill_entry).
|
|
[ -e "$entry/SKILL.md" ] || return 0
|
|
if [ -f "$entry/SKILL.md" ]; then
|
|
for src in "$RENDER_DIR/$skill/SKILL.md" "$INSTALL_DIR/$skill/SKILL.md"; do
|
|
[ -n "$skill" ] && [ -f "$src" ] && cmp -s "$entry/SKILL.md" "$src" && return 0
|
|
done
|
|
# Pre-marker legacy copy: gen-skill-docs' full two-line banner near the
|
|
# top (same rule as setup's _gstack_generated_header), not a one-line
|
|
# substring another generator could emit. A gstack fork rendering the
|
|
# same banner is the accepted, filed residual.
|
|
case "$(head -c 8192 "$entry/SKILL.md" 2>/dev/null)" in
|
|
*'<!-- AUTO-GENERATED from '*'<!-- Regenerate: bun run gen:skill-docs -->'*) return 0 ;;
|
|
esac
|
|
fi
|
|
return 1
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
FOREIGN_SKIPPED=()
|
|
_report_foreign() {
|
|
# Same wording and bare name as setup's own line, so setup can dedupe when it
|
|
# forwards relink's output.
|
|
echo " skipped ${1##*/}: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2
|
|
FOREIGN_SKIPPED+=("${1##*/}")
|
|
}
|
|
|
|
# Weakly-proven real files we would otherwise overwrite go here, one summary
|
|
# line at the end. mv, not cp: the link that follows needs the path free.
|
|
BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)"
|
|
BACKED_UP=()
|
|
_backup_skill_md() {
|
|
# Non-zero when the file could NOT be moved: the caller leaves the entry alone.
|
|
local file="$1" name="$2"
|
|
mkdir -p "$BACKUP_ROOT/$name" 2>/dev/null || return 1
|
|
mv -f "$file" "$BACKUP_ROOT/$name/SKILL.md" 2>/dev/null || return 1
|
|
BACKED_UP+=("$name")
|
|
return 0
|
|
}
|
|
|
|
# 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" skill="${2:-}" e dest src
|
|
[ -e "$entry" ] || [ -L "$entry" ] || return 0
|
|
# Unclaimed dir (no SKILL.md, no marker): nothing of ours to clean.
|
|
if [ -d "$entry" ] && [ ! -L "$entry" ] && [ ! -e "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ] && [ ! -f "$entry/.gstack-owned" ]; then
|
|
return 0
|
|
fi
|
|
if ! _entry_is_ours "$entry" "$skill"; then
|
|
_report_foreign "$entry"
|
|
return 0
|
|
fi
|
|
if [ -L "$entry" ]; then
|
|
rm -f "$entry"
|
|
elif [ -d "$entry" ]; then
|
|
# Whole-directory removal needs proof that nothing of the user's is inside:
|
|
# the marker (we created the dir) or a directory holding nothing but links.
|
|
if [ -f "$entry/.gstack-owned" ] || { [ -L "$entry/SKILL.md" ] && _dir_only_links "$entry"; }; then
|
|
rm -rf "$entry"
|
|
else
|
|
# Otherwise only what is ours goes: the SKILL.md, the marker, and our
|
|
# runtime-asset links. The user's files stay, and so does the directory
|
|
# if it is not empty afterwards. A real SKILL.md that differs from our
|
|
# source (raw, or with its name: rewritten to the entry name) is a
|
|
# customized file: moved to the backup root, never deleted.
|
|
if [ -f "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ] && [ -n "$skill" ]; then
|
|
src="$INSTALL_DIR/$skill/SKILL.md"; [ -f "$RENDER_DIR/$skill/SKILL.md" ] && src="$RENDER_DIR/$skill/SKILL.md"
|
|
if [ -f "$src" ] && ! cmp -s "$entry/SKILL.md" "$src" \
|
|
&& ! sed "1,/^---\$/ s/^name:[[:space:]].*/name: ${entry##*/}/" "$src" | cmp -s - "$entry/SKILL.md"; then
|
|
if ! _backup_skill_md "$entry/SKILL.md" "${entry##*/}"; then
|
|
echo " kept ${entry##*/}/SKILL.md: could not back up the customized file — left untouched" >&2
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
rm -f "$entry/SKILL.md" "$entry/.gstack-owned"
|
|
for e in "$entry"/* "$entry"/.[!.]* "$entry"/..?*; do
|
|
[ -L "$e" ] || continue
|
|
dest="$(_link_target_abs "$e")" || continue
|
|
if _target_is_ours "$dest"; then rm -f "$e"; fi
|
|
done
|
|
rmdir "$entry" 2>/dev/null || echo " cleaned ${entry##*/}/SKILL.md (other files in that directory were left in place)"
|
|
fi
|
|
fi
|
|
}
|
|
# _dir_only_links DIR — deleting DIR whole loses no real data: every entry is
|
|
# a symlink or our marker.
|
|
_dir_only_links() {
|
|
# Every entry must be a symlink resolving into gstack, or our marker: a
|
|
# user's own link (notes.md -> ~/notes) makes the directory mixed.
|
|
local d="$1" e dest
|
|
for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do
|
|
{ [ -e "$e" ] || [ -L "$e" ]; } || continue
|
|
[ "${e##*/}" = ".gstack-owned" ] && continue
|
|
[ -L "$e" ] || return 1
|
|
dest="$(_link_target_abs "$e")" || return 1
|
|
_target_is_ours "$dest" || return 1
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# _serve_rendered_assets SKILL TARGET — F5 (#2706): when the gbrain render has
|
|
# a counterpart of a runtime asset setup linked from the checkout (sections/,
|
|
# ...), serve the rendered file instead, per file (render dirs carry only the
|
|
# generated *.md). An asset whose render counterpart is gone goes back to the
|
|
# checkout. Only entries that are our links, or directories of nothing but our
|
|
# links, are touched; anything else is the user's and stays.
|
|
_serve_rendered_assets() {
|
|
local skill="$1" target="$2" asset name t child
|
|
for asset in "$INSTALL_DIR/$skill"/*; do
|
|
[ -e "$asset" ] || continue
|
|
name="${asset##*/}"
|
|
case "$name" in SKILL.md|node_modules|dist|test|*.tmpl) continue ;; esac
|
|
t="$target/$name"
|
|
if [ -L "$t" ]; then
|
|
_target_is_ours "$(_link_target_abs "$t")" || continue
|
|
elif [ -d "$t" ]; then
|
|
_dir_only_links "$t" || continue
|
|
else
|
|
continue
|
|
fi
|
|
if [ -d "$asset" ] && [ -d "$RENDER_DIR/$skill/$name" ]; then
|
|
rm -rf "$t"
|
|
mkdir -p "$t"
|
|
for child in "$asset"/*; do
|
|
[ -e "$child" ] || continue
|
|
if [ -f "$RENDER_DIR/$skill/$name/${child##*/}" ]; then
|
|
ln -snf "$RENDER_DIR/$skill/$name/${child##*/}" "$t/${child##*/}"
|
|
else
|
|
ln -snf "$child" "$t/${child##*/}"
|
|
fi
|
|
done
|
|
elif [ -f "$asset" ] && [ -f "$RENDER_DIR/$skill/$name" ]; then
|
|
rm -rf "$t"
|
|
ln -snf "$RENDER_DIR/$skill/$name" "$t"
|
|
elif [ -d "$t" ] && [ ! -L "$t" ] || [ "$(_link_target_abs "$t")" != "$asset" ]; then
|
|
rm -rf "$t"
|
|
ln -snf "$asset" "$t"
|
|
fi
|
|
done
|
|
}
|
|
|
|
_link_root_skill_alias() {
|
|
local target="$SKILLS_DIR/_gstack-command"
|
|
|
|
[ -f "$INSTALL_DIR/SKILL.md" ] || return 0
|
|
# Same ownership gate as every other entry (#2119): a user's own
|
|
# `_gstack-command` skill is reported and left alone, never overwritten.
|
|
if { [ -e "$target" ] || [ -L "$target" ]; } && ! _entry_is_ours "$target" ""; then
|
|
_report_foreign "$target"
|
|
return 0
|
|
fi
|
|
local pre=0
|
|
if [ -e "$target" ] || [ -L "$target" ]; then pre=1; fi
|
|
[ -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"
|
|
# The rewritten copy is a real file on every platform: the marker, not the
|
|
# banner, is what proves it ours on the next run — written only for a
|
|
# directory we created (or already marked), never one we merely wrote into.
|
|
if [ "$pre" -eq 0 ] || [ -f "$target/.gstack-owned" ]; then
|
|
printf '%s\n' "$_INSTALL_REAL" > "$target/.gstack-owned" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
_link_root_skill_alias
|
|
|
|
# Discover skills: every top-level directory with a SKILL.md
|
|
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")
|
|
# A directory is a skill iff it carries a SKILL.md (#2263): a name list
|
|
# dropped the real browse skill. Support dirs have no SKILL.md.
|
|
[ -f "$skill_dir/SKILL.md" ] || continue
|
|
case "$DISABLED" in
|
|
*" ${skill#gstack-} "*)
|
|
_cleanup_skill_entry "$SKILLS_DIR/$skill" "$skill"
|
|
case "$skill" in gstack-*) ;; *) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" "$skill" ;; esac
|
|
DISABLED_COUNT=$((DISABLED_COUNT + 1))
|
|
continue ;;
|
|
esac
|
|
|
|
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" "$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" "$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" "$skill"; then
|
|
_report_foreign "$target"
|
|
continue
|
|
fi
|
|
# Remember whether WE are creating this directory: only then may the marker
|
|
# below make it deletable whole. A directory we merely link into (unclaimed,
|
|
# or a legacy install) never gets one — legacy all-links dirs are removed by
|
|
# the only-links rule instead.
|
|
_pre_exists=0
|
|
if [ -e "$target" ] || [ -L "$target" ]; then _pre_exists=1; 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"
|
|
# A real-file SKILL.md we can only WEAKLY prove ours and whose content
|
|
# differs from what we are about to serve is moved aside, not overwritten.
|
|
if [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ] && ! _entry_owned_strongly "$target" \
|
|
&& ! cmp -s "$target/SKILL.md" "$skill_md_src"; then
|
|
if ! _backup_skill_md "$target/SKILL.md" "$link_name"; then
|
|
echo " skipped $link_name: could not back up its customized SKILL.md — left untouched" >&2
|
|
FOREIGN_SKIPPED+=("$link_name")
|
|
continue
|
|
fi
|
|
fi
|
|
ln -snf "$skill_md_src" "$target/SKILL.md"
|
|
_serve_rendered_assets "$skill" "$target"
|
|
# Provenance marker on every platform (path-independent proof; on Windows
|
|
# without Developer Mode `ln -snf` degrades to a copy and this is the only
|
|
# proof), but only for a directory we created or already owned.
|
|
if [ "$_pre_exists" -eq 0 ] || [ -f "$target/.gstack-owned" ]; then
|
|
printf '%s\n' "$_INSTALL_REAL" > "$target/.gstack-owned" 2>/dev/null || true
|
|
fi
|
|
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.
|
|
# C8 (#3018): Claude reads the router straight from the install root, so with
|
|
# disabled_skills set it is re-rendered in place without their routing rules
|
|
# (in place, like gstack-patch-names). Clearing the list restores the committed
|
|
# bytes. Only a generated router is rewritten, and only from a checkout that
|
|
# carries the generator; a failed render keeps the current router.
|
|
# plan_mode_preamble=off (#3168) renders it without the plan-mode preamble the
|
|
# same way, and turning it back on restores the committed bytes.
|
|
_ROUTER="$INSTALL_DIR/SKILL.md"
|
|
_GEN_SRC="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
_PLAN_MODE_ARG=""
|
|
[ "$("$GSTACK_CONFIG" get plan_mode_preamble 2>/dev/null || true)" != off ] || _PLAN_MODE_ARG="--plan-mode=off"
|
|
if { [ "$DISABLED" != " " ] || grep -q '^Disabled on this install' "$_ROUTER" 2>/dev/null \
|
|
|| [ -n "$_PLAN_MODE_ARG" ] \
|
|
|| { grep -q 'PLAN MODE EXCEPTION' "$_ROUTER" 2>/dev/null && ! grep -q '^## Plan Mode Safe Operations' "$_ROUTER" 2>/dev/null; }; } \
|
|
&& grep -q '<!-- AUTO-GENERATED from' "$_ROUTER" 2>/dev/null && [ -f "$_GEN_SRC/scripts/gen-skill-docs.ts" ]; then
|
|
_ROUTER_TMP="$(mktemp -d "${TMPDIR:-/tmp}/gstack-router.XXXXXX")" || _ROUTER_TMP=""
|
|
_DISABLED_CSV="$(printf '%s' "$DISABLED" | xargs | tr ' ' ',')"
|
|
if [ -n "$_ROUTER_TMP" ] && command -v bun >/dev/null 2>&1 \
|
|
&& (cd "$_GEN_SRC" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run scripts/gen-skill-docs.ts --host claude --out-dir "$_ROUTER_TMP" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1) \
|
|
&& [ -s "$_ROUTER_TMP/SKILL.md" ] && cp "$_ROUTER_TMP/SKILL.md" "$_ROUTER.tmp.$$" && mv "$_ROUTER.tmp.$$" "$_ROUTER"; then
|
|
_link_root_skill_alias
|
|
[ -n "$_DISABLED_CSV" ] && echo "Router: disabled skills left out of $_ROUTER"
|
|
else
|
|
rm -f "$_ROUTER.tmp.$$"
|
|
echo "gstack-relink: could not re-render the router for disabled_skills or plan_mode_preamble (bun missing or generation failed); $_ROUTER is unchanged. Fix: install Bun, then run $0" >&2
|
|
fi
|
|
[ -z "$_ROUTER_TMP" ] || rm -rf "$_ROUTER_TMP"
|
|
fi
|
|
|
|
"$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 [ "$DISABLED_COUNT" -gt 0 ]; then
|
|
echo "Disabled (gstack-config disabled_skills, not registered):$DISABLED"
|
|
fi
|
|
if [ ${#BACKED_UP[@]} -gt 0 ]; then
|
|
echo "Moved ${#BACKED_UP[@]} pre-existing SKILL.md file(s) to $BACKUP_ROOT before linking gstack's: ${BACKED_UP[*]}"
|
|
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
|