Files
gstack/bin/gstack-relink
T
Garry TanandClaude Fable 5.1 c241216637 v1.80.0.0 fix: setup survives a failed Chromium install, hooks share one state root, gstack never clobbers a skill it did not create (#2802)
* fix(freeze): hook reads the same state root /freeze writes — fails closed under GSTACK_HOME (#1459, #1509)

check-freeze.sh resolved its state dir as ${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}
while every writer (/freeze, /guard, /unfreeze, /investigate) resolves through
bin/gstack-paths, GSTACK_HOME first. With GSTACK_HOME set, /freeze wrote
freeze-dir.txt under GSTACK_HOME, the hook read $HOME/.gstack, found no file,
and allowed everything — a deny-tier boundary failing open.

One resolver now: gstack_hook_state_root() in careful/bin/hook-extract.sh
(already sourced by both check-freeze.sh and check-careful.sh) implements the
exact gstack-paths chain, including the CLAUDE_PLUGIN_ROOT guard that keeps a
CLAUDE_PLUGIN_DATA leaked from another plugin from redirecting our state.
check-freeze.sh and gstack_hook_log_fire both call it; nothing spawns
gstack-paths from a hook.

Tests: the GSTACK_HOME deny regression, GSTACK_HOME-over-CLAUDE_PLUGIN_DATA
precedence, plugin-root guard both ways, and a byte-parity check against
bin/gstack-paths across six env combinations. Existing freeze tests now pass
CLAUDE_PLUGIN_ROOT like a real plugin install would.

Idea from PR #1509 (@NikhileshNanduri); implemented natively against the shared
resolver rather than a second fallback chain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(relink): never delete or link over a skill gstack does not own (#2119)

gstack-relink runs on every ./setup. Its cleanup did `rm -rf` on any same-name
entry whose SKILL.md was a symlink, with no readlink check, and its link step
did `mkdir -p` then `ln -snf` onto any existing SKILL.md — on Linux that
replaces a user's real file with a symlink into gstack (macOS refused by
accident). setup's Windows mode-flip cleanup deleted any real dir whose name
matched a gstack skill. A personal `qa` skill, or a fork installed under
another path, was destroyed by the installer of a tool it never asked for.

Ownership is now proven, never assumed. An entry is ours when it is a symlink
resolving into INSTALL_DIR or RENDER_DIR, a real dir whose SKILL.md is such a
symlink, or a real dir carrying the .gstack-owned marker setup now writes for
Windows copy installs (legacy copies count when byte-identical to the source
or carrying gen-skill-docs' AUTO-GENERATED header). Anything else — including
an entry whose readlink fails — is foreign: left untouched, reported on
stderr, and listed in relink's summary line. The same rule replaces setup's
Windows name-match deletion; setup:1040 and gstack-uninstall:204 already
gated on readlink, so this closes the last unguarded deleter of the class.

Tests: foreign real dir in flat mode, foreign flat entry on a prefix flip,
foreign directory symlink, RENDER_DIR-targeted entry (ours), marker-carrying
copy (ours), marker-less copy (foreign); the Windows cleanup test now proves
provenance three ways and keeps the user's own same-name skill.

Idea and two regression cases from PR #2119 (@smblight); implemented on the
destination entry, not only the symlink target.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(setup): Chromium bootstrap is best-effort and bounded — skills always register (#1900, #1901, #1902, #913, #2233)

setup runs under `set -e`, and the Chromium bootstrap in section 2 sat ahead
of skill registration in section 4 with a bare `bunx playwright install
chromium`, an unbounded download, and an explicit `exit 1` after the
post-install launch probe. On an offline, proxied, or AppArmor-restricted box
the user ended with ZERO skills registered and a re-run that died at the same
line; a wedged download hung setup indefinitely.

Every browser failure now records a reason code in _PW_FAIL_REASON and setup
continues: skipped (GSTACK_SKIP_PLAYWRIGHT=1, #913), chromium-install,
chromium-install-timeout (the download is bounded by the existing
_wait_with_deadline helper, default 600s, env GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT,
process tree killed via _kill_tree), chromium-install-locked (another setup
holds the lock: this one registers skills and re-probes next time instead of
exiting), windows-no-node, windows-node-modules, post-install-launch (with the
GSTACK_CHROMIUM_NO_SANDBOX=1 hint for Ubuntu 24.04's userns policy, #2157).
The daemon font refresh is skipped when Chromium is unavailable. The final
summary names the skills that need the browser (/qa, /qa-only,
/design-review, /browse, make-pdf, /pair-agent) and the fix for the recorded
reason, and logs the reason code (never a path) through gstack-telemetry-log
when telemetry is on.

Tests: static invariants over the anchor-sliced block (no exit, every reason
code, deadline helper, trap chaining, guarded refresh, summary contents) plus
an integration harness that executes the real block with a stubbed probe and
installer: install failure, hang killed at the deadline with the tree kill
recorded, non-numeric knob fallback, live lock (continues, installer not run,
lock preserved), stale lock reclaimed, post-install probe failure, and the
skip flag.

Credit @DavidMiserak (PR #1900) for the best-effort shape; re-implemented on
the current block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(designs): preserve the time-attack fork-port residual evaluation

The read-only evaluation of what remains portable from time-attack/gstack
(583 raw candidates, 415 canonical, 287 with a residual, 48 adversarially
refuted, 14 standing) lived only on a throwaway VM. This records the report,
the lite residual index, the absorbed/superseded ledger, the refuter
verdicts, and SHAS.md with the fork tip, upstream HEAD, merge-base, and a
sha256 per file, so every scheduled fix in this wave series traces to its
evidence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: file the fork-port residual deferrals and document the Chromium bootstrap knobs

TODOS.md gains the seven items the CEO and eng reviews of the fork-port
residual plan deliberately deferred (shared ownership helper, config-key
reader tripwire, "pre-existing" vocabulary, opt-in reply_language, .auth.json
writer removal, the fork-derived-change rule for CONTRIBUTING, hook slug
parity audit), each with rationale, and updates the two residual bullets for
PR #2232 and PR #2233 with their dispositions. README's Troubleshooting
section explains the best-effort Chromium bootstrap and its three knobs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(relink): canonicalize link targets before the ownership check

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>

* fix(telemetry): one-shot setup events never sweep other sessions' pending markers

gstack-telemetry-log finalizes every .pending-<session> marker that is not
the caller's own as outcome:unknown and deletes it. setup's onboarding
events (_setup_welcome, _setup_playwright) have no session of their own, so
a Chromium bootstrap failure during a live skill session recorded a false
unknown for that session and removed its marker.

New --no-sweep flag skips the stale-marker pass; both setup call sites use
it (the synthetic --session-id did not prevent the sweep). Surfaced by the
Codex adversarial pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(hooks): partial upgrades fail closed for freeze and fall back for careful

A hook script and its sourced helper can be copied at different times. With
an older careful/bin/hook-extract.sh that lacks gstack_hook_state_root:

- check-freeze.sh now emits a deny ("fail closed, re-run ./setup or
  /unfreeze") instead of dying under set -e with no decision JSON.
- check-careful.sh falls back to ${GSTACK_HOME:-$HOME/.gstack} so project
  rules under the plain chain still load and a decision is always emitted
  (a warn hook must never break on a stale helper).

gstack_hook_state_root prints its root without a trailing newline and both
callers capture it with a printf-x sentinel, so a GSTACK_HOME ending in a
newline round-trips byte-for-byte with the writer's %q form.
gstack_hook_log_fire stays on ${GSTACK_HOME:-$HOME/.gstack}/analytics, the
same two-step chain every other analytics writer and reader uses, so the
usage log remains one file under a plugin install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(setup): never link over, copy over, or reap a skill gstack does not own (#2119)

The relink gate alone left three destructive sites open:

- link_claude_skill_dirs runs BEFORE relink on every ./setup and used
  `ln -snf` (Linux replaces a user's real SKILL.md with a symlink into
  gstack) or, on Windows, rm -rf + cp followed by a marker that made the
  user's directory "ours" on the next flip. It and _install_alias_skill_md
  now consult _claude_entry_is_ours first and skip loudly.
- cleanup_prefixed_claude_symlinks kept a bare name-match deletion and a
  `*gstack*` substring match. Symlink arms use anchored `gstack/` segment
  patterns; the Windows real-file arm proves provenance (marker,
  byte-identity with our source, or the full two-line gen-skill-docs banner
  within the first 40 lines, never a one-line substring another generator
  could emit). cleanup_old_claude_symlinks uses the same banner rule.
- gstack-relink's fast path judged absolute targets before canonicalizing,
  so `/x/gstack/../foreign/SKILL.md` counted as ours; dot-segment targets
  now canonicalize first. Its banner rule matches setup's.

The `.gstack-owned` marker records the owning payload's realpath. Entries
skipped by setup or relink are listed in the final setup summary.

Chromium bootstrap refinements from the pre-landing review: an INT/TERM
trap kills the installer's process tree; the Windows npm chain no longer
masks an install failure; GSTACK_SKIP_PLAYWRIGHT=1 is reported as a choice
rather than a failure and sends no telemetry; the timeout knob is
normalized (0, 000, non-numeric, or more than nine digits fall back to the
600s default instead of killing on the first poll or never killing).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: README Chromium note outside the CLAUDE.md fence; report banner stripped; deferrals name the four gate sites

- README: the Chromium troubleshooting paragraph sat inside the CLAUDE.md
  snippet code fence, so copy-paste put it into users' CLAUDE.md. Moved to
  the troubleshooting list.
- docs/designs/fork-port-residual-2026-09/REPORT.md: the scratch-run
  preamble banner is gone; SHAS.md re-hashed.
- TODOS: the ownership-gate deferral names the four sites and the
  marker-path idea for the fork-with-banner residual.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(todos): the bootstrap block coverage gap is pinned except the quarantine helper

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(setup,relink): ownership proof has two strengths; weak proof never deletes a directory or discards a differing file

The first #2119 gate treated a byte-identical or banner-bearing real-file
SKILL.md as full ownership, so a prefix flip could rm -rf a user's directory
(their own qa skill started from a gstack SKILL.md, plus my-templates/) and
the link pass could replace their customized file with a symlink. Two
strengths now:

- STRONG: the .gstack-owned marker (we created the directory), or a
  directory holding nothing but symlinks and the marker (deleting it loses
  no data). Only strong proof removes a directory whole.
- WEAK: byte-identity with our source or the two-line gen-skill-docs banner
  on a real file. Weak proof covers that SKILL.md and our runtime-asset
  links only; a differing file is moved to
  ${GSTACK_HOME:-~/.gstack}/backups/skills/<ts>/<skill>/ before we link
  over it, and setup/relink print one summary line naming what moved.

The marker is written on every platform now (path-independent proof for
Windows copies and for checkouts whose path carries no gstack segment), but
only for a directory gstack creates: a directory we merely link into
(unclaimed, or a legacy install) never becomes deletable whole. A directory
with no SKILL.md at all is unclaimed: the link pass may add our file, the
cleanup pass has nothing to remove.

Also from the review passes: the banner check reads 8192 bytes, not 40
lines (investigate, office-hours, plan-ceo-review and design-consultation
carry the banner past line 40 and were left "foreign" on pre-marker
Windows installs); a link into a checkout named without a gstack segment
(git worktree add ../gstack-<branch>) is ours when that tree carries
setup + VERSION + bin/; relink's fast path is gone so both files
canonicalize before judging; relink's root alias (_gstack-command) is
gated and stamped like every other entry; relink reports the bare entry
name with setup's wording and setup dedupes when forwarding
(_run_relink_quiet); the summary names the browser skills as examples.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(setup): Chromium-install lock reclaim is atomic and pid-validated; abandoned locks expire; the tree kill walks /proc without pgrep

- A pid file holding "", "-1" or "0" counted as a live holder (kill -0 -1
  signals every process and succeeds), locking Chromium out for good. A pid
  must be a positive integer; anything else is stale.
- Two setups judging the same lock stale raced on rm -rf + mkdir and the
  loser deleted the winner's fresh lock. The stale dir is renamed first
  (atomic), so exactly one reclaims.
- A lock dir with no pid file (killed between mkdir and echo) was never
  reclaimed; it now expires once older than the install bound.
- _kill_tree needed pgrep; debian-slim and git-bash ship none, so the bound
  killed only the wrapper subshell and the installer kept running. Without
  pgrep the children are found by walking /proc/*/stat.
- The timeout knob is normalized in one place with one comment; the trap's
  exit 130 is the only exit the block may contain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(freeze): an unexpected non-zero death denies via an EXIT backstop instead of exiting with no decision

set -e plus a failing pipeline (a tool on PATH exiting non-zero, a deleted
cwd) ended the deny-tier hook with no JSON, which Claude Code treats as
non-blocking: the edit outside the boundary proceeded. The EXIT trap now
prints a deny for any non-zero exit that happens before a decision was
written; every deliberate output sets _FREEZE_DECIDED first so a late
failure never prints a second object.

Tests also pin careful's state-root precedence (GSTACK_HOME over
CLAUDE_PLUGIN_DATA, plugin data when CLAUDE_PLUGIN_ROOT names gstack) and
the specific "out of date" deny for a helper without gstack_hook_state_root.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(telemetry): guard the stale-marker sweep with an if, not a break inside the loop

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(todos): the ownership gate lives in six sites, and the cleanup arms inline their own chain

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: the two remaining linker harnesses extract the ownership helpers; the marker is the one allowed dotfile

setup-claude-skill-assets and user-render-out-dir-install slice
link_claude_skill_dirs out of setup without the helpers it now calls, so
the extracted function died with "command not found" (or, inside an if,
degraded into "foreign, skipped"). Both harnesses now carry the full helper
set and the globals. The hidden-files census allows .gstack-owned, which
the linker writes for directories it creates rather than copying from the
skill source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(setup,relink): weak proof never costs the user a file — assets, flips, failed backups, foreign dir links, alias markers

Third review cycle on the ownership model, every item reproduced against a
fixture before the fix:

- Runtime assets (sections/, templates/, checklist.md, ...) were refreshed
  with rm -rf regardless of who owned the directory, so an unclaimed or
  weakly-owned directory lost the user's same-named real files. Real assets
  are now replaced only in a directory gstack created or strongly owns
  (marker, or SKILL.md symlink into gstack), plus the legacy Windows
  real-copy shape; elsewhere they are kept and reported. Symlinks are never
  content and are always refreshed.
- The prefix-flip cleanup deleted a customized banner-bearing SKILL.md that
  the link pass would have backed up. Both cleanups now compare the file
  against the source (raw, or with its name: line rewritten to the entry
  name, which is how alias and prefixed copies legitimately differ) and
  move a differing file to the backup root.
- A failed backup (unwritable root) returned success and the caller linked
  over the file anyway. It now fails, and the entry is left untouched and
  reported.
- A foreign DIRECTORY symlink whose target had no SKILL.md fell through to
  the "unclaimed directory" rule and was replaced by a real directory. A
  symlink that does not resolve into gstack is foreign, full stop.
- The alias installers stamped .gstack-owned into pre-existing directories;
  they now follow the same created-or-already-marked rule.
- A directory counts as "only links" only when every link resolves into
  gstack: a user's own symlink makes it mixed, so their link survives.
- The gstack-tree heuristic requires bin/gstack-relink, not just a VERSION
  file, a setup script and a bin/ directory.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(setup): lock reclaim hands a fresh lock back; a live holder past the bound is stale; /proc walk strips through the last paren

- Reclaim renamed the lock by path after judging it stale, so a second setup
  that had already reclaimed and re-created it lost its fresh lock and two
  installers ran. After the rename the moved directory's pid is re-read: a
  new live holder, or a fresh lock whose pid is not written yet, is moved
  straight back.
- A pid file whose process is alive but whose lock is older than the install
  bound is stale too (the holder is past its own deadline, or the pid was
  recycled to an unrelated long-lived process); it was locked forever.
- The /proc fallback stripped the comm field to the FIRST ") ", so a comm
  containing ") " hid a child from the kill. proc(5) says the last paren.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(freeze): mark the decision written after the helper prints, not before

If gstack_hook_decision ever failed between the flag and its output the
backstop would have stayed silent; setting the flag after the print keeps
the deny backstop armed until a decision is actually on stdout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: bump version and changelog (v1.80.0.0)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: update project documentation for v1.80.0.0

README troubleshooting + manual uninstall cover the skill ownership gate
(.gstack-owned marker, ~/.gstack/backups/skills/<ts>/, foreign same-name
skills left untouched). CLAUDE.md and CONTRIBUTING carry the ownership and
best-effort Chromium bootstrap invariants for people editing setup and
gstack-relink. PROJECT_STRUCTURE gains careful/, freeze/, guard/, unfreeze/,
gstack-upgrade/, gstack-relink, and the setup/relink/hook test files.
TESTING_INTERNALS documents the anchor-sliced setup harness convention.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(setup): the final summary reports customized SKILL.md files moved to the backup root

The linker moved a weakly-proven, customized SKILL.md aside before linking
over it but never said so; only relink printed a "Moved N" line, and by the
time relink runs the file is already a symlink. The summary now names each
moved file and where it went, next to the foreign-entry report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: run assembled setup harness scripts from a temp file, not `bash -c` argv (Windows MSYS2 8 KB truncation)

windows-free-tests (run 33907177851) failed in
test/setup-alias-name-uniqueness.test.ts with
  bash: -c: line 178: unexpected EOF while looking for matching `'
The harness slices functions out of `setup` and passed the joined script as
one `bash -c` argv element. The ownership gate grew that script from 6.7 KB
to 15.7 KB, and on Windows bash is an MSYS2 program: when its parent is a
non-MSYS process (bun), msys-2.0.dll's build_argv() runs any argument
containing `?*["'(){}` through globify()/glob(), which copies the pattern
into a fixed `Char patbuf[8192]` and silently stops after 8192 - MB_CUR_MAX
(8186 chars under C.UTF-8); GLOB_NOCHECK then returns the truncated text as
the argument. Character 8186 lands inside the single-quoted sed token on
line 178. Rebuilding the exact script with CI path shapes and cutting it at
8186-8190 characters reproduces the identical message locally; cmd.exe's
8191-UTF-16 cap and CreateProcess's 32767 do not fit the evidence.

Fix: test/helpers/bash-script.ts writes the script to a temp file and runs
`bash <path>` — a short glob-free argument that never enters globify. Every
setup harness that assembled a script for `bash -c` (11 files, 22 sites)
uses it; timeouts and env are preserved verbatim, spawn/timeout errors are
appended to stderr, temp cleanup is best-effort. `spawnSync('bash',
[<Windows absolute path>])` already passes on windows-latest in setup-help,
uninstall-windows-copies and the migration tests. The Windows-curated list
is byte-identical before and after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(test-free-shards): the rerun-refresh harness spawns bash <tempfile> via test/helpers/bash-script.ts, not bash -c

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:45:28 -07:00

381 lines
18 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 (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_HOME:-~/.gstack}/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_HOME:-$HOME/.gstack}/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
}
_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 (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" "$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"
# 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.
"$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 [ ${#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