#!/usr/bin/env bash # gstack-hook-check — parse-check every hook Claude Code runs from a gstack tree. # # Usage: # gstack-hook-check [--setup ] --list print the checked hooks # gstack-hook-check [--setup ] check them # # Claude Code runs gstack's hook shims through /bin/sh; a shim that does not # parse exits 2, which for a PreToolUse hook blocks the tool call in every # session. setup runs this before it registers hooks, and the auto-updater # (bin/gstack-session-update) runs it on the incoming revision before the # live checkout moves. # # The hook list comes from the registration code: every # `_hook_command_path ` argument in /setup (or the setup named by # --setup, when another tree's setup registers 's hooks), plus every # gstack path on a `command:` line in a skill's frontmatter (/*/SKILL.md: # /autoplan, /careful, /freeze, /guard, /investigate, /plan-ceo-review). Claude # Code runs those while the skill is active, and a broken PreToolUse one blocks # the tool call. test/setup-hook-syntax-gate.test.ts keeps the lists equal. # # Each shim is parsed by the interpreter its shebang names (bash -n, sh -n), # and so is every gstack shell file it sources, transitively. Each TypeScript # entry the shim runs (a `.ts` beside it) is bundled with its local # imports into a temp directory, packages external, so a broken or missing # local import fails; a missing entry fails. Bundles run from , so the # caller's tsconfig.json and bunfig.toml are never read. The shim, its sourced # helpers, each entry and every local module the entry bundles are scanned for # merge conflict marker lines, which still parse inside a heredoc, a string, a # comment or a template literal. # # Output, one line per hook: # ok # missing (no shim: setup skips registering it) # fail :: # Exit: 0 when nothing failed, 1 when a hook failed, 2 on usage, 3 when bun # (needed when a hook runs TypeScript) is not found. Set GSTACK_HOOK_CHECK_BUN to # the bun to run (setup passes its own). set -u 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 LIST_ONLY=0 SETUP_FILE="" while [ $# -gt 1 ]; do case "$1" in --list) LIST_ONLY=1; shift ;; --setup) SETUP_FILE="$2"; shift 2 ;; *) break ;; esac done ROOT="${1:-}" if [ -z "$ROOT" ] || [ ! -d "$ROOT" ]; then echo "usage: gstack-hook-check [--setup ] [--list] " >&2 exit 2 fi case "$ROOT" in /*|[A-Za-z]:[\\/]*) ;; *) ROOT="$PWD/$ROOT" ;; esac # Git Bash hands native Bun its drive paths as C:/x, and spells the native temp # directory /tmp: one spelling (/c/x) on both sides of every built.tsv lookup # (#3088, #3100). The checker is self-contained on purpose (it runs on an # extracted archive and must not fail for a missing helper); the same mapping # is gstack_path_shell in bin/gstack-path-spelling.sh, and # test/path-spelling.test.ts keeps the two equal. case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*) _native=$(cygpath -am "$ROOT" 2>/dev/null) || _native="" case "$_native" in [A-Za-z]:/*) ROOT="/$(printf '%s' "${_native%%:*}" | tr '[:upper:]' '[:lower:]')${_native#?:}" ;; *) case "$ROOT" in [A-Za-z]:[\\/]*) command -v cygpath >/dev/null 2>&1 && ROOT=$(cygpath -u "$ROOT") ;; esac ;; esac ;; CYGWIN*) case "$ROOT" in [A-Za-z]:[\\/]*) ROOT=$(cygpath -u "$ROOT") ;; esac ;; esac [ "$ROOT" = / ] || ROOT=${ROOT%/} SETUP_FILE="${SETUP_FILE:-$ROOT/setup}" # Frontmatter is the block between a SKILL.md's first two `---` lines. hook_list() { sed -n 's/.*\$(_hook_command_path \([A-Za-z0-9._/-]*\).*/\1/p' "$SETUP_FILE" 2>/dev/null # One awk over every skill (a process start is slow on Windows): only lines # inside each file's leading frontmatter block count. set -- "$ROOT"/*/SKILL.md [ -f "$1" ] || return 0 awk 'FNR == 1 { fm = /^---/; next } { sub(/\r$/, "") } fm && /^---/ { fm = 0 } fm && /^[[:space:]]*command:/' "$@" \ | grep -oE 'skills/gstack/[A-Za-z0-9._/-]+' | sed 's|^skills/gstack/||' } HOOKS=$(hook_list | awk '!seen[$0]++') if [ "$LIST_ONLY" -eq 1 ]; then printf '%s\n' "$HOOKS" exit 0 fi BUN="${GSTACK_HOOK_CHECK_BUN:-bun}" TMP_OUT=$(mktemp -d "${TMPDIR:-/tmp}/gstack-hook-check.XXXXXX") || { echo "gstack-hook-check: cannot create a temp directory" >&2; exit 1; } trap 'rm -rf "$TMP_OUT"' EXIT # first_location — ":" from bun's "at f:l:c" # or bash's "f: line N:" (falls back to ). first_location() { _loc=$(printf '%s\n' "$2" | sed -n 's/^[[:space:]]*at \(.*\):\([0-9][0-9]*\):[0-9][0-9]*$/\1:\2/p' | head -1) [ -n "$_loc" ] || _loc=$(printf '%s\n' "$2" | sed -n 's/^\(.*\): line \([0-9][0-9]*\):.*/\1:\2/p' | head -1) printf '%s\n' "${_loc:-$1}" } first_message() { _msg=$(printf '%s\n' "$1" | sed -n 's/^error: //p' | head -1) [ -n "$_msg" ] || _msg=$(printf '%s\n' "$1" | sed -n 's/^.*: line [0-9][0-9]*: //p' | head -1) [ -n "$_msg" ] || _msg=$(printf '%s\n' "$1" | grep -v '^[[:space:]]*$' | head -1) printf '%s\n' "$_msg" } # Lists below are newline-separated, so a root with spaces stays one word. NL=' ' IFS=$NL FAILED=0 fail() { echo "fail $1 $2: $3"; FAILED=1; } # sourced_by — the gstack files sources: each `*.sh` path on a # `.`/`source` line, in a variable assignment, or in a `# shellcheck source=` # directive. A path resolves from , then beside , then beside # without its first segment (a `$DIR/` prefix); only files inside # count. sourced_by() { _sdir=${1%/*} grep -E '^[[:space:]]*#[[:space:]]*shellcheck[[:space:]].*source=|(^|[;&|({[:space:]])(\.|source)[[:space:]]|^[[:space:]]*(local |export |readonly )?[A-Za-z_][A-Za-z0-9_]*=' "$1" \ | grep -oE '[A-Za-z0-9._/-]*\.sh([^A-Za-z0-9_]|$)' | sed -E 's/\.sh.*$/.sh/' | awk '!seen[$0]++' | while IFS= read -r _tok; do for _cand in "$ROOT/$_tok" "$_sdir/$_tok" "$_sdir/${_tok#*/}"; do [ -f "$_cand" ] || continue _cand="$(cd "${_cand%/*}" && pwd)/${_cand##*/}" case "$_cand" in "$ROOT"/*) [ "$_cand" != "$1" ] && echo "$_cand"; break ;; esac done done } # helpers_of — sourced_by, followed transitively. helpers_of() { _seen="$NL$1$NL" _queue=$1 while [ -n "$_queue" ]; do _next="" for _f in $_queue; do for _c in $(sourced_by "$_f"); do case "$_seen" in *"$NL$_c$NL"*) continue ;; esac _seen="$_seen$_c$NL" echo "$_c" _next="$_next$_c$NL" done done _queue=$_next done } # ts_entries — the TypeScript files a shim runs, beside it. ts_entries() { grep -v '^[[:space:]]*#' "$1" | grep -oE '[A-Za-z0-9._-]+\.ts([^A-Za-z0-9_]|$)' | sed -E 's/\.ts.*$/.ts/' | awk '!seen[$0]++' } # One bun process bundles every hook's TypeScript entries (a bun start costs # seconds on Windows). Each line is "\tok\t" per bundled input, # or "\tfail\t\t". # TS_OF[i] keeps hook i's entries for the check loop below, so each shim is # scanned once (four process starts, ~80 ms on Windows). ENTRIES="" i=0 for hook in $HOOKS; do shim="$ROOT/$hook" TS_OF[i]="" [ -f "$shim" ] && TS_OF[i]=$(ts_entries "$shim") i=$((i + 1)) [ -f "$shim" ] || continue for entry in ${TS_OF[i - 1]}; do [ -f "${shim%/*}/$entry" ] && ENTRIES="$ENTRIES${shim%/*}/$entry$NL" done done BUILT="$TMP_OUT/built.tsv" : > "$BUILT" if [ -n "$ENTRIES" ] && ! command -v "$BUN" >/dev/null 2>&1; then echo "gstack-hook-check: bun not found, so TypeScript hook entries cannot be checked" >&2 exit 3 fi if [ -n "$ENTRIES" ]; then # Bun reports native spellings; shellPath maps them back to the keys looked # up below (lib/path-spelling.ts shellPath; test/path-spelling.test.ts keeps # the two equal). # shellcheck disable=SC2086 # newline-split list (IFS is set to a newline below) (cd "$ROOT" && IFS=$NL && "$BUN" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const shellPath = (value) => process.platform === "win32" ? String(value).replace(/\\/g, "/").replace(/^([A-Za-z]):(?=\/|$)/, (_, drive) => `/${drive.toLowerCase()}`) : String(value); for (const entry of process.argv.slice(1)) { const key = shellPath(entry); const r = await Bun.build({ entrypoints: [entry], target: "bun", packages: "external", metafile: true, throw: false }).catch(e => ({ success: false, logs: [e] })); if (r.success) for (const input of Object.keys(r.metafile?.inputs ?? {})) console.log(`${key}\tok\t${shellPath(input)}`); else { const log = (r.logs ?? []).find(l => l?.level === "error") ?? r.logs?.[0] ?? {}; const at = log.position ? `${shellPath(log.position.file)}:${log.position.line}` : key; console.log(`${key}\tfail\t${at}\t${String(log.message ?? log).split("\n")[0]}`); } }' $ENTRIES) > "$BUILT" 2>/dev/null || true fi i=0 for hook in $HOOKS; do shim="$ROOT/$hook" i=$((i + 1)) if [ ! -f "$shim" ]; then echo "missing $hook"; continue; fi hook_ok=1 shebang="" IFS= read -r shebang < "$shim" case "$shebang" in '#!'*bash*) interp=bash ;; '#!'*/sh|'#!'*/sh' '*|'#!'*' sh'|'#!'*' sh '*) interp=sh ;; *) interp="" ;; esac if [ -z "$interp" ]; then fail "$hook" "$shim:1" "unsupported shebang '$shebang' (expected bash or sh)" continue fi if ! err=$("$interp" -n "$shim" 2>&1); then fail "$hook" "$(first_location "$shim" "$err")" "$(first_message "$err")" hook_ok=0 fi scan="$shim" for helper in $(helpers_of "$shim"); do if ! err=$("$interp" -n "$helper" 2>&1); then fail "$hook" "$(first_location "$helper" "$err")" "$(first_message "$err")" hook_ok=0 fi scan="$scan$NL$helper" done dir=${shim%/*} for entry in ${TS_OF[i - 1]}; do if [ ! -f "$dir/$entry" ]; then fail "$hook" "$dir/$entry:1" "missing TypeScript entry (the hook runs it)" hook_ok=0 continue fi scan="$scan$NL$dir/$entry" result=$(awk -F '\t' -v e="$dir/$entry" '$1 == e' "$BUILT") if [ -z "$result" ]; then fail "$hook" "$dir/$entry:1" "bun could not bundle the TypeScript entry" hook_ok=0 continue fi failed=$(printf '%s\n' "$result" | awk -F '\t' '$2 == "fail" { print $3 "\t" $4; exit }') if [ -n "$failed" ]; then fail "$hook" "${failed%% *}" "${failed#* }" hook_ok=0 continue fi for rel in $(printf '%s\n' "$result" | awk -F '\t' '$2 == "ok" { print $3 }'); do case "$rel" in /*|../*) ;; *) scan="$scan$NL$ROOT/$rel" ;; esac done done # One grep for every scanned file: -m1 stops at each file's first marker, # and -H prefixes "::" (the line is the last colon field before # the marker text, so paths containing colons still split right). # shellcheck disable=SC2046 # newline-split list (IFS is a newline) files="" for f in $scan; do case "$NL$files" in *"$NL$f$NL"*) ;; *) files="$files$f$NL" ;; esac done for found in $(grep -HnE -m1 '^(<<<<<<<|=======|>>>>>>>)( |$)' $files 2>/dev/null); do marker_file=${found%%:[0-9]*:[<=>]*} marker_line=${found#"$marker_file":} marker_line=${marker_line%%:*} fail "$hook" "$marker_file:$marker_line" "unresolved merge conflict marker" hook_ok=0 done [ "$hook_ok" -eq 1 ] && echo "ok $hook" done exit "$FAILED"