#!/usr/bin/env bash # gstack setup — build browser binary + register skills with Claude Code / Codex set -e umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs) # Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a # pipe in the forked child before exec, with no reader on the other end. On # macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any # body >=512B blocks write() forever — ./setup --help would hang with no # output. Compat level 50 restores the tempfile path. This script is # bash-3.2-clean, so the compat level costs it nothing. Not exported: the # guard is per-script, and it survives `bash setup` call sites that bypass # the shebang. BASH_COMPAT=50 usage() { cat <<'EOF' gstack setup — install gstack skills + build browse binary Usage: ./setup [options] Options: --host Install for one host. Default: claude. Hosts by tier: full: claude experimental: codex, kiro, factory, opencode, cursor, copilot, agy instruction-only: slate, openclaw, hermes, gbrain auto: every installed agent that setup detects An explicit --host never changes another host's install. --global Allow this checkout to replace an existing global install (or register Codex globally from a project-vendored source). --prefix Install skills with the gstack- prefix (e.g. /gstack-review). --no-prefix Install skills with short names (e.g. /review). Default. --team Switch to team mode (per-repo gstack with auto-update). --no-team Force solo install even if a team-mode repo is detected. --status Print one row per gstack install, a Codex row and the path to gstack-doctor (read-only), then exit. Exit 1 when an install's SKILL.md section links are broken. --no-browser Skip the Chromium bootstrap and the emoji font (sets GSTACK_SKIP_PLAYWRIGHT=1 and GSTACK_SKIP_FONTS=1 internally; the env vars alone do the same). bin/gstack-browser-ensure installs Chromium later, on the first browser skill run; bin/gstack-capy-install passes --no-browser unless it is given --with-browser. --refresh-registered Refresh every install registered for this checkout (what /gstack-upgrade runs), then print one row per install. -q, --quiet Suppress progress output. -h, --help Show this help and exit. Model selection: --model Codex model profile override. Otherwise reads Codex config. --claude-model Claude skill overlay for a Claude model ID (for example claude-opus-4-8). Saved as claude_overlay_model and used by every later setup, upgrade and gbrain-refresh. Without it the generic claude overlay is used; --claude-model claude returns to it. Examples: ./setup # solo install for Claude Code ./setup --host codex # install for OpenAI Codex CLI only ./setup --host auto # install for every detected agent ./setup --host codex --model gpt-5.6-sol ./setup --claude-model claude-opus-4-8 # Opus 4.8 overlay for Claude skills ./setup --status # what is installed, where, at which version ./setup --team # team mode for a shared repo ./setup --no-prefix # use short slash-command names Docs: https://github.com/garrytan/gstack#other-ai-agents docs/ADDING_A_HOST.md (install ownership rules, host tiers) EOF } # Short-circuit on -h/--help before any environment checks so users can # discover flags even without bun installed. for _arg in "$@"; do case "$_arg" in -h|--help) usage; exit 0 ;; esac done # --status is read-only: no bun, no build, no writes (docs/ADDING_A_HOST.md). # _status_install_checks — per registered install (C3): is the router a real # file (Codex skips a symlinked SKILL.md), and does every section pointer in an # installed SKILL.md resolve? A pointer is "Read `/sections/.md`", # optionally "relative to the installed `` SKILL.md directory". A bare # `sections/...` pointer counts in a carved skill (sections/ installed, or a # **STOP.** line); prefixed ones ($GSTACK_ROOT, ~, absolute, ../) everywhere. _status_install_checks() { local reg="$GSTACK_STATE_ROOT/installs.tsv" host scope project dest root src rest router md dir stop tok names n path checked broken first fix _move any_broken=0 [ -f "$reg" ] || return 0 echo "Install checks:" while IFS=' ' read -r host scope project dest root src rest; do case "$host" in ''|'#'*) continue ;; esac fix="Fix: cd $src && ./setup --host $host" _move="$(gstack_install_move_hint "$src" "$host" "$root")" && fix="Fix: the checkout lives in this host's runtime root, so move it first: $_move" router="$root/SKILL.md" if [ -L "$router" ]; then router="router is a symlink, which Codex skips. $fix" elif [ -f "$router" ]; then router="router is a real file" else router="router missing at $router. $fix"; fi checked=0 broken=0 first="" for md in "$dest"/*/SKILL.md; do [ -f "$md" ] || continue dir="${md%/SKILL.md}" while IFS='|' read -r stop tok names; do [ -n "$tok" ] || continue path="" case "$tok" in sections/*) if [ -n "$names" ]; then for n in $names; do path="$dest/$n/$tok"; [ -r "$path" ] && break; done elif [ -d "$dir/sections" ] || [ "$stop" = 1 ]; then path="$dir/$tok" else continue; fi ;; '$GSTACK_ROOT/'*) path="$root/${tok#\$GSTACK_ROOT/}" ;; '~/'*) path="$HOME/${tok#\~/}" ;; /*) path="$tok" ;; ../*) path="$dir/$tok" ;; *) continue ;; esac checked=$((checked + 1)) [ -r "$path" ] && continue broken=$((broken + 1)) [ -n "$first" ] || first="${md#"$dest"/} names $tok" done <&2 exit "$_status_rc" ;; esac done # Informational hosts do not install anything. Resolve them before tool, # platform, and installation-path probes (especially costly in Git Bash). # ─── Parse flags ────────────────────────────────────────────── HOST="claude" QUIET=0 LOCAL_INSTALL=0 GLOBAL_INSTALL=0 SKILL_PREFIX=1 SKILL_PREFIX_FLAG=0 TEAM_MODE=0 NO_TEAM_MODE=0 PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit TIMELINE_STOP_HOOK_MODE="" # "" = resolve from env/config; "yes"/"no" = explicit (#2677) MODEL_OVERRIDE="" MODEL_OVERRIDE_SET=0 CLAUDE_MODEL_ARG="" CLAUDE_MODEL_SET=0 REFRESH_REGISTERED=0 HOST_FLAG_SET=0 # Reject unknown options before anything is written, with the closest flag. _setup_unknown_option() { local bad="$1" next="${2:-}" best="" best_d=99 flag d value="" for flag in --host --model --claude-model --global --local --prefix --no-prefix --team --no-team \ --plan-tune-hooks --no-plan-tune-hooks --timeline-stop-hook --no-timeline-stop-hook \ --refresh-registered --status --no-browser --quiet --help; do d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN { n = length(a); m = length(b) for (i = 0; i <= n; i++) d[i, 0] = i for (j = 0; j <= m; j++) d[0, j] = j for (i = 1; i <= n; i++) for (j = 1; j <= m; j++) { c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1 v = d[i-1, j] + 1; if (d[i, j-1] + 1 < v) v = d[i, j-1] + 1; if (d[i-1, j-1] + c < v) v = d[i-1, j-1] + c d[i, j] = v } print d[n, m] }')" if [ "$d" -lt "$best_d" ]; then best_d="$d"; best="$flag"; fi done echo "Unknown option: $bad — nothing was installed or changed." >&2 case "$best" in --host|--model|--claude-model) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac if [ "$best_d" -le 3 ]; then echo "Did you mean: ./setup $best$value" >&2 fi echo "Run ./setup --help for every option." >&2 } while [ $# -gt 0 ]; do case "$1" in --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, cursor, copilot, agy, slate, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; HOST_FLAG_SET=1; shift 2 ;; --host=*) HOST="${1#--host=}"; HOST_FLAG_SET=1; shift ;; --model) [ -z "$2" ] && echo "Missing value for --model" >&2 && exit 1; MODEL_OVERRIDE="$2"; MODEL_OVERRIDE_SET=1; shift 2 ;; --model=*) MODEL_OVERRIDE="${1#--model=}"; MODEL_OVERRIDE_SET=1; shift ;; --claude-model) [ -z "$2" ] && echo "Missing value for --claude-model (a Claude model ID such as claude-opus-4-8, or claude)" >&2 && exit 1; CLAUDE_MODEL_ARG="$2"; CLAUDE_MODEL_SET=1; shift 2 ;; --claude-model=*) CLAUDE_MODEL_ARG="${1#--claude-model=}"; [ -z "$CLAUDE_MODEL_ARG" ] && echo "Missing value for --claude-model (a Claude model ID such as claude-opus-4-8, or claude)" >&2 && exit 1; CLAUDE_MODEL_SET=1; shift ;; --local) LOCAL_INSTALL=1; shift ;; --global) GLOBAL_INSTALL=1; shift ;; --prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;; --no-prefix) SKILL_PREFIX=0; SKILL_PREFIX_FLAG=1; shift ;; --team) TEAM_MODE=1; shift ;; --no-team) NO_TEAM_MODE=1; shift ;; --plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="yes"; shift ;; --no-plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="no"; shift ;; --plan-tune-hooks=*) PLAN_TUNE_HOOKS_MODE="${1#--plan-tune-hooks=}"; shift ;; --timeline-stop-hook) TIMELINE_STOP_HOOK_MODE="yes"; shift ;; --no-timeline-stop-hook) TIMELINE_STOP_HOOK_MODE="no"; shift ;; --timeline-stop-hook=*) TIMELINE_STOP_HOOK_MODE="${1#--timeline-stop-hook=}"; shift ;; --refresh-registered) REFRESH_REGISTERED=1; shift ;; --no-browser) export GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_SKIP_FONTS=1; shift ;; -q|--quiet) QUIET=1; shift ;; *) _setup_unknown_option "$@"; exit 2 ;; esac done # Shared by the instruction-tier explainer arms (openclaw, hermes). The digest # path is anchored to the script's own directory — $(pwd) would print a # nonexistent path when setup is invoked from anywhere else. print_instruction_tier() { local source_dir source_dir="$(cd "$(dirname "$0")" && pwd -P)" echo "" echo "Instruction-only tier (no install): copy the 2KB rules digest into a" echo "location your agent reads (e.g. append to your project's AGENTS.md)." echo "It carries gstack's ethos, reuse ladder, and voice rules:" echo "" echo " $source_dir/agents-digest/gstack-AGENTS.md" echo "" echo "Re-copy it after upgrading gstack — the digest's first line shows its version." echo "" } [ "$HOST" = "antigravity" ] && HOST="agy" # Antigravity CLI is named after its binary case "$HOST" in claude|codex|kiro|factory|opencode|cursor|copilot|agy|auto) ;; slate) echo "" echo "Slate is not yet a first-class install target (docs/designs/SLATE_HOST.md —" echo "blocked on the host-config refactor). Slate discovers skills from" echo ".claude/skills as a compatibility fallback, so a Slate user is served by" echo "the Claude install today:" echo "" echo " ./setup --host claude" echo "" exit 0 ;; openclaw) echo "" echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code" echo "sessions natively via ACP. gstack provides methodology artifacts, not a" echo "full skill installation." echo "" echo "To integrate gstack with OpenClaw:" echo " 1. Tell your OpenClaw agent: 'install gstack for openclaw'" echo " 2. Or generate artifacts: bun run gen:skill-docs --host openclaw" echo " (in an install, add 'openclaw' to .gstack-installed-hosts first, or ./setup prunes them)" echo " 3. See docs/OPENCLAW.md for the full architecture" print_instruction_tier exit 0 ;; hermes) echo "" echo "Hermes is an instruction-only gstack host: setup installs nothing for" echo "Hermes and changed nothing on this machine." echo "" echo "To use gstack with Hermes:" echo " 1. Copy the rules digest below into a file Hermes reads, or" echo " 2. Render Hermes-flavoured skills yourself (not certified; #2826):" echo " bun run gen:skill-docs --host hermes" echo " then point a Hermes profile's skills.external_dirs at .hermes/skills." echo " Add 'hermes' to .gstack-installed-hosts in this checkout first: ./setup" echo " prunes renders of hosts that file does not name (#1694)." print_instruction_tier exit 0 ;; gbrain) echo "" echo "GBrain is a mod for gstack — it makes coding skills brain-aware." echo "GBrain generates brain-enhanced skill variants that search your brain" echo "for context before starting and save results after finishing." echo "" echo "To generate brain-aware skills:" echo " bun run gen:skill-docs --host gbrain" echo " (in an install, add 'gbrain' to .gstack-installed-hosts first, or ./setup prunes them)" echo "" echo "GBrain setup and brain skills ship from the GBrain repo." echo "" exit 0 ;; *) echo "Unknown --host value: $HOST — nothing was installed or changed. Expected one of: claude (default), codex, kiro, factory, opencode, cursor, copilot, agy (alias antigravity), slate, openclaw, hermes, gbrain, or auto. Tiers: ./setup --help" >&2; exit 1 ;; esac # Bun versions come from one sourced file (floor and tested; E1). . "$(dirname "$0")/bin/gstack-bun-version.sh" 2>/dev/null || { echo "$0: bin/gstack-bun-version.sh is missing; nothing was installed or changed. Fix: reinstall gstack (git clone https://github.com/garrytan/gstack)" >&2; exit 1; } if ! command -v bun >/dev/null 2>&1; then echo "Error: bun is required but not installed." >&2 echo "Install with checksum verification:" >&2 echo " BUN_VERSION=\"$GSTACK_BUN_TESTED\"" >&2 echo ' tmpfile=$(mktemp)' >&2 echo ' curl -fsSL "https://bun.sh/install" -o "$tmpfile"' >&2 echo ' echo "Verify checksum before running: sha256sum $tmpfile # or: shasum -a 256 $tmpfile"' >&2 echo ' bash "$tmpfile" "bun-v$BUN_VERSION" && rm "$tmpfile" # the script pins by argument, not by env' >&2 exit 1 fi _BUN_FOUND="$(bun --version 2>/dev/null | head -1)" case "$(gstack_bun_status "$_BUN_FOUND")" in too-old) echo "gstack needs Bun $GSTACK_BUN_FLOOR or newer ($GSTACK_BUN_TESTED recommended); found $_BUN_FOUND at $(command -v bun). Nothing was installed or changed." >&2 echo "Why: older Bun ignores --no-compile-autoload-dotenv, so gstack's compiled tools could read a project's .env." >&2 echo "Fix: bun upgrade, then re-run ./setup (docs/troubleshooting.md#bun-too-old)" >&2 exit 1 ;; untested) echo "warning: gstack is tested on Bun $GSTACK_BUN_TESTED (CI pin); found $_BUN_FOUND. Upgrade with: bun upgrade (docs/troubleshooting.md#bun-too-old)" >&2 ;; malformed) echo "warning: could not read the Bun version (bun --version printed '$(printf '%s' "$_BUN_FOUND" | head -c 60)'); continuing. gstack is tested on Bun $GSTACK_BUN_TESTED (docs/troubleshooting.md#bun-too-old)" >&2 ;; esac INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)" # One state root for everything setup writes (bin/gstack-state-root.sh, docs/state-root.md). . "$SOURCE_GSTACK_DIR/bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SOURCE_GSTACK_DIR/bin/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" . "$SOURCE_GSTACK_DIR/bin/gstack-path-spelling.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-path-spelling.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } . "$SOURCE_GSTACK_DIR/bin/gstack-claude-root.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-claude-root.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } . "$SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh is missing. fix: reinstall from a fresh clone (docs/ADDING_A_HOST.md)" >&2; exit 1; } . "$SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } . "$SOURCE_GSTACK_DIR/bin/gstack-host-renders.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-host-renders.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } SETUP_VERSION="$(cat "$SOURCE_GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)" # _runtime_root_refused HOST ROOT — setup never replaces or removes a host # runtime root that is, or contains, the checkout it runs from (#3084: the # Cursor arm's swap deleted a checkout at ~/.cursor/skills/gstack). Prints the # refusal with the commands that move the checkout out, records it for the # install summary, and returns 0; returns 1 when ROOT is safe to replace (a # symlinked ROOT is: only the link goes). _SETUP_REFUSED="" _runtime_root_refused() { local host="$1" root="$2" move move="$(gstack_install_move_hint "$SOURCE_GSTACK_DIR" "$host" "$root")" || return 1 { echo "gstack setup: refusing to replace $root for $host: this runtime directory contains the source checkout setup runs from ($SOURCE_GSTACK_DIR), so replacing it would delete the checkout. Nothing was changed." echo " Fix: move the checkout out of $host's skills directory (a symlinked ~/.claude/skills/gstack that points at it becomes the checkout), then run setup from its new place:" echo " $move" echo " https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-runtime-root-contains-checkout" } >&2 _SETUP_REFUSED="refused: $root is or contains this checkout, so setup did not replace it; nothing was changed. Move it first: $move" return 0 } # --claude-model is validated before anything is written, and persisted once # Claude is known to be selected (below, and in the --refresh-registered parent). if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then if ! _CLAUDE_MODEL_RESOLVED="$(cd "$SOURCE_GSTACK_DIR" && bun --no-env-file --config=/dev/null run scripts/models.ts claude-overlay "$CLAUDE_MODEL_ARG" 2>&1)"; then printf 'Error: --claude-model %s\n' "$_CLAUDE_MODEL_RESOLVED" | sed '2,$s/^/ /' >&2 echo " Nothing was installed or changed." >&2 exit 1 fi fi # _persist_claude_model — save the validated --claude-model value as given. _persist_claude_model() { local overlay generic [ "$CLAUDE_MODEL_SET" -eq 1 ] || return 0 if ! "$SOURCE_GSTACK_DIR/bin/gstack-config" set claude_overlay_model "$CLAUDE_MODEL_ARG" >/dev/null; then echo "Error: could not save claude_overlay_model. Nothing was installed or changed." >&2 exit 1 fi overlay="$(printf '%s' "$_CLAUDE_MODEL_RESOLVED" | tail -1 | cut -f1)" generic="$(printf '%s' "$_CLAUDE_MODEL_RESOLVED" | tail -1 | cut -f2)" if [ "$generic" = 1 ]; then log "Claude overlay: $CLAUDE_MODEL_ARG has no overlay of its own, so it uses the generic claude overlay (saved as claude_overlay_model)." else log "Claude overlay: $CLAUDE_MODEL_ARG uses the $overlay overlay (saved as claude_overlay_model; ./setup --claude-model claude returns to the generic overlay)." fi } # ─── Quiet mode helper ──────────────────────────────────────── log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; } # _repair_crlf_checkout — #3110: a checkout made before an `eol=lf` rule # existed (Git for Windows sets core.autocrlf=true) keeps its CRLF copies, # because git never rewrites a stat-clean file; `add --renormalize`, # `checkout --` and `checkout-index -f` all leave one alone. One # `git ls-files --eol` pass finds tracked files whose rule says LF but whose # working copy has CRLF. Locally edited ones are named and left alone; the # rest are removed and checked out again in one batch. Runs under the install # registry lock, the one lock every setup takes, so two setups never race on # the same files. _repair_crlf_checkout() { local dir="$SOURCE_GSTACK_DIR" line info p e rc=0 local stale=() edited=() repair=() [ -e "$dir/.git" ] || return 0 command -v git >/dev/null 2>&1 || return 0 while IFS= read -r -d '' line; do info="${line%% *}" case "$info" in *eol=lf*) ;; *) continue ;; esac case "$info" in *w/crlf*|*w/mixed*) stale+=("${line#* }") ;; esac done < <(git -C "$dir" ls-files --eol -z 2>/dev/null) [ "${#stale[@]}" -gt 0 ] || return 0 if ! _gstack_registry_lock; then echo " warning: skipped the CRLF repair of ${#stale[@]} file(s) in $dir: another setup holds the install registry lock." >&2 echo " Fix: re-run ./setup once the other setup finishes (cd $dir && ./setup). See docs/troubleshooting.md#setup-crlf-repair" >&2 return 0 fi while IFS= read -r -d '' line; do edited+=("${line:3}") done < <(git -C "$dir" --literal-pathspecs status --porcelain=v1 -z --untracked-files=no --no-renames -- "${stale[@]}" 2>/dev/null) for p in "${stale[@]}"; do for e in ${edited[@]+"${edited[@]}"}; do [ "$e" = "$p" ] && continue 2; done repair+=("$p") done if [ "${#repair[@]}" -gt 0 ]; then for p in "${repair[@]}"; do rm -f "$dir/$p" || rc=1; done [ "$rc" -ne 0 ] || printf '%s\0' "${repair[@]}" \ | git -C "$dir" --literal-pathspecs checkout --pathspec-from-file=- --pathspec-file-nul >/dev/null 2>&1 || rc=1 if [ "$rc" -eq 0 ]; then log " Rewrote ${#repair[@]} file(s) checked out with CRLF line endings as LF (#3110)." else echo " warning: the CRLF repair could not rewrite every file in $dir (rm or git checkout failed)." >&2 echo " Fix: cd $dir && git checkout -- . && ./setup See docs/troubleshooting.md#setup-crlf-repair" >&2 fi fi if [ "${#edited[@]}" -gt 0 ]; then echo " warning: left ${#edited[@]} locally edited file(s) with CRLF line endings that gstack pins to LF; a Linux bash cannot run them:" >&2 printf ' %s\n' "${edited[@]}" >&2 echo " Why: setup rewrites only unmodified files, so your edits are kept." >&2 echo " Fix (keeps your edits, rewrites them as LF): cd $dir && git stash && git stash pop See docs/troubleshooting.md#setup-crlf-repair" >&2 fi _gstack_registry_unlock } [ -n "${GSTACK_SETUP_REFRESH_CHILD:-}" ] || _repair_crlf_checkout # _setup_ready_line — the final `gstack ready (, ...)` line from summary # rows on stdin (host...result in field 7): every host installed, # updated or unchanged, in table order; nothing when no host row exists. _setup_ready_line() { local hosts hosts=$(awk -F'\t' '$1 != "state" && $7 ~ /^(installed|updated|unchanged)$/ && !seen[$1]++ { printf "%s%s", (n++ ? ", " : ""), $1 }') [ -z "$hosts" ] || log "gstack ready ($hosts)" } # ─── --refresh-registered: refresh exactly the registered installs (#1925) ─── # What /gstack-upgrade and the team-mode auto-update run. Each install this # checkout registered is refreshed by running setup for that one host, in its # own process; installs owned by another checkout, and project installs of # another project, are listed with the command that refreshes them there and # are never repointed. Pre-registry installs that resolve to this checkout are # discovered and registered; with nothing registered or discovered, the # selected host (default claude) is installed as before. if [ "$REFRESH_REGISTERED" -eq 1 ] && [ -z "${GSTACK_SETUP_REFRESH_CHILD:-}" ]; then gstack_install_registry_reconcile || true _RF_HERE="$(pwd -P)" _RF_TARGETS="" _RF_SKIPPED="" _rf_add() { case " $_RF_TARGETS" in *" $1 $2 $3 "*) return 0 ;; esac _RF_TARGETS="$_RF_TARGETS$1 $2 $3 " } while IFS=' ' read -r _rf_host _rf_scope _rf_project _rf_dest _rf_root _rf_src _rf_ver _rf_rest; do [ -n "$_rf_host" ] || continue if [ "$_rf_src" != "$SOURCE_GSTACK_DIR" ]; then _RF_SKIPPED="$_RF_SKIPPED$_rf_host $_rf_scope $_rf_src $_rf_dest $_rf_ver $(cat "$_rf_src/VERSION" 2>/dev/null || echo -) skipped owned by another checkout; refresh it there: cd $_rf_src && ./setup --refresh-registered " continue fi if [ "$_rf_scope" = project ]; then case "$_RF_HERE/" in "$(cd "$_rf_project" 2>/dev/null && pwd -P)/"*) ;; *) _RF_SKIPPED="$_RF_SKIPPED$_rf_host $_rf_scope $_rf_src $_rf_dest $_rf_ver $SETUP_VERSION skipped project install of $_rf_project; refresh it from inside that project: cd $_rf_project && $_rf_root/setup --refresh-registered " continue ;; esac fi _rf_add "$_rf_host" "$_rf_scope" "$_rf_root" done </dev/null || echo -) skipped pre-registry install owned by another checkout; choose: refresh it there (cd $_rf_src && ./setup --host $_rf_host) or move it to this checkout (./setup --host $_rf_host --global) " fi done <&2 exit 1 ;; esac fi _RF_SUMMARY="$(mktemp "${TMPDIR:-/tmp}/gstack-refresh.XXXXXX")" printf '%s' "$_RF_SKIPPED" > "$_RF_SUMMARY" _RF_FAILED=0 log "Refreshing registered gstack installs from source: $SOURCE_GSTACK_DIR (v$SETUP_VERSION)" while IFS=' ' read -r _rf_host _rf_scope _rf_root; do [ -n "$_rf_host" ] || continue _rf_setup="$SOURCE_GSTACK_DIR/setup" for _rf_try in "$_rf_root" "$(dirname "$(dirname "$(dirname "$_rf_root")")")/.claude/skills/gstack"; do if [ -f "$_rf_try/setup" ] && [ "$(cd "$_rf_try" && pwd -P)" = "$SOURCE_GSTACK_DIR" ]; then _rf_setup="$_rf_try/setup"; break fi done _rf_args=(--host "$_rf_host") if [ "$_rf_host" = codex ] && [ "$_rf_scope" = global ]; then _rf_args+=(--global); fi if [ "$QUIET" -eq 1 ]; then _rf_args+=(-q); fi log "" log "── $_rf_host ($_rf_scope) ← $_rf_setup" _rf_before="$(wc -l < "$_RF_SUMMARY" | tr -d ' ')" if ! GSTACK_SETUP_REFRESH_CHILD=1 GSTACK_SETUP_SUMMARY_FILE="$_RF_SUMMARY" bash "$_rf_setup" "${_rf_args[@]}" > "$_RF_SUMMARY" fi fi done </dev/null || true)")" if [ "$_rf_disabled" != " " ]; then echo " disabled skills (not registered on any host):$_rf_disabled" fi _setup_ready_line < "$_RF_SUMMARY" rm -f "$_RF_SUMMARY" exit "$_RF_FAILED" fi INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")" BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse" CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills" CODEX_GSTACK="$CODEX_SKILLS/gstack" FACTORY_SKILLS="$HOME/.factory/skills" FACTORY_GSTACK="$FACTORY_SKILLS/gstack" OPENCODE_SKILLS="$HOME/.config/opencode/skills" OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack" CURSOR_SKILLS="$HOME/.cursor/skills" CURSOR_GSTACK="$CURSOR_SKILLS/gstack" KIRO_SKILLS="$HOME/.kiro/skills" COPILOT_SKILLS="$HOME/.copilot/skills" COPILOT_GSTACK="$COPILOT_SKILLS/gstack" AGY_SKILLS="$HOME/.gemini/antigravity-cli/skills" AGY_GSTACK="$AGY_SKILLS/gstack" SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")" SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")" CODEX_REPO_LOCAL=0 if [ "$(basename "$INSTALL_GSTACK_DIR")" = "gstack" ] && [ "$SKILLS_BASENAME" = "skills" ] \ && { [ "$SKILLS_PARENT_BASENAME" = ".agents" ] || [ "$SKILLS_PARENT_BASENAME" = ".claude" ]; } \ && [ "$INSTALL_SKILLS_DIR" != "$HOME/.agents/skills" ] \ && [ "$INSTALL_SKILLS_DIR" != "$HOME/.claude/skills" ] \ && [ "$INSTALL_SKILLS_DIR" != "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills" ]; then if [ "$GLOBAL_INSTALL" -eq 1 ]; then echo "Global Codex registration requested from project source: $INSTALL_GSTACK_DIR (resolved: $SOURCE_GSTACK_DIR)" >&2 else CODEX_REPO_LOCAL=1 if [ "$SKILLS_PARENT_BASENAME" = ".agents" ]; then CODEX_SKILLS="$INSTALL_SKILLS_DIR" CODEX_GSTACK="$INSTALL_GSTACK_DIR" else CODEX_SKILLS="$(dirname "$(dirname "$INSTALL_SKILLS_DIR")")/.agents/skills" CODEX_GSTACK="$CODEX_SKILLS/gstack" fi fi fi if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then _resolve_codex_namespace() { local namespace="$1" label="$2" parent="$1" suffix="" physical while [ ! -d "$parent" ]; do if [ -L "$parent" ] || [ -e "$parent" ]; then echo "Refusing: project-local Codex $label contains an unresolvable directory: $parent. Use a separate project-local skills directory; setup will not relink it automatically." >&2 return 1 fi suffix="/$(basename "$parent")$suffix" parent="$(dirname "$parent")" done if ! physical="$(cd "$parent" && pwd -P)"; then echo "Refusing: project-local Codex $label contains an unresolvable directory: $namespace. Use a separate project-local skills directory; setup will not relink it automatically." >&2 return 1 fi printf '%s%s\n' "$physical" "$suffix" } _codex_project="$(cd "$(dirname "$(dirname "$INSTALL_SKILLS_DIR")")" && pwd -P)" _codex_parent="$(_resolve_codex_namespace "$CODEX_SKILLS" destination)" || exit 1 case "$_codex_parent" in "$_codex_project"|"$_codex_project"/*) ;; *) echo "Refusing: project-local Codex destination resolves outside the project: $CODEX_SKILLS -> $_codex_parent. Use a separate project-local skills directory; setup will not relink it automatically." >&2; exit 1 ;; esac _codex_payload="$_codex_parent" while :; do if [ "$_codex_payload" = "$SOURCE_GSTACK_DIR" ] \ || { [ -f "$_codex_payload/setup" ] && [ -f "$_codex_payload/VERSION" ] && [ -f "$_codex_payload/bin/gstack-relink" ]; }; then echo "Refusing: project-local Codex destination overlaps the source tree at $_codex_payload: $CODEX_SKILLS -> $_codex_parent. Use a separate project-local skills directory; setup will not relink it automatically." >&2 exit 1 fi [ "$_codex_payload" = / ] && break _codex_payload="$(dirname "$_codex_payload")" done _codex_generation="$(_resolve_codex_namespace "$SOURCE_GSTACK_DIR/.agents/skills" 'generation namespace')" || exit 1 case "$_codex_generation" in "$SOURCE_GSTACK_DIR"/*) ;; *) echo "Refusing: project-local Codex generation namespace resolves outside the source tree: $SOURCE_GSTACK_DIR/.agents/skills -> $_codex_generation. Use a separate project-local skills directory; setup will not relink it automatically." >&2; exit 1 ;; esac if [ -d "$CODEX_GSTACK" ] && [ "$(cd "$CODEX_GSTACK" && pwd -P)" != "$SOURCE_GSTACK_DIR" ] \ && { [ -e "$CODEX_GSTACK/.git" ] \ || { [ -f "$CODEX_GSTACK/setup" ] && [ -f "$CODEX_GSTACK/VERSION" ] && [ -f "$CODEX_GSTACK/bin/gstack-relink" ]; }; }; then echo "Refusing to replace existing source checkout at $CODEX_GSTACK. Run setup from that checkout or choose a separate project-local install location." >&2 exit 1 fi fi # A Codex runtime root that IS the checkout is a direct install, migrated # below (migrate_direct_codex_install); one that contains it is refused. if { [ "$CODEX_REPO_LOCAL" -eq 1 ] || [ "$HOST" = codex ] || { [ "$HOST" = auto ] && command -v codex >/dev/null 2>&1; }; } \ && [ -d "$CODEX_GSTACK" ] && ! gstack_path_same "$CODEX_GSTACK" "$SOURCE_GSTACK_DIR" \ && _runtime_root_refused codex "$CODEX_GSTACK"; then exit 1 fi IS_WINDOWS=0 case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;; esac # Windows: binaries are compiled with .exe suffix if [ "$IS_WINDOWS" -eq 1 ]; then BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse.exe" fi # ─── Symlink-or-copy helper ─────────────────────────────────── # On macOS/Linux: create a symlink (existing behavior). # On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently # creates a frozen file copy that doesn't refresh after `git pull`. We use # explicit `cp -R` / `cp -f` so the user gets a real copy and the staleness # is reportable (re-run ./setup after pull). Auto-detects file vs dir. # # INVARIANT: every symlink in this script MUST route through this helper. # A raw ln call here will be caught by test/setup-windows-fallback.test.ts # (the static-invariant assertion D7). # # Windows copies are staged: the new copy is built beside the destination and # swapped in only when complete, so a failed or interrupted refresh keeps the # last working copy. Interruption recovery: a destination that is missing # while "$dst.gstack-old.*" exists (killed between the two renames) is # restored from it on the next run; stale "$dst.gstack-new.*" partial copies # are discarded. _link_or_copy() { local src="$1" local dst="$2" local new old leftover if [ "$IS_WINDOWS" -eq 1 ]; then for leftover in "$dst".gstack-old.*; do [ -e "$leftover" ] || continue if [ ! -e "$dst" ]; then mv "$leftover" "$dst"; else rm -rf "$leftover"; fi done for leftover in "$dst".gstack-new.*; do [ -e "$leftover" ] && rm -rf "$leftover" done # Unix `ln -snf` accepts a name-only or relative-path source even when the # target doesn't resolve from CWD (e.g. the connect-chrome alias points at # the sibling-relative "gstack/open-gstack-browser"). On Windows the # equivalent semantics don't exist — we'd need a real source on disk to # copy. Skip the alias quietly rather than aborting setup under `set -e`. if [ ! -e "$src" ]; then return 0 fi new="$dst.gstack-new.$$" old="$dst.gstack-old.$$" if [ -d "$src" ]; then cp -R "$src" "$new" || { rm -rf "$new"; return 1; } else cp -f "$src" "$new" || { rm -f "$new"; return 1; } fi if [ -e "$dst" ] || [ -L "$dst" ]; then mv "$dst" "$old" || { rm -rf "$new"; return 1; } fi if ! mv "$new" "$dst"; then [ -e "$old" ] && mv "$old" "$dst" rm -rf "$new" return 1 fi rm -rf "$old" else ln -snf "$src" "$dst" fi } # _link_runtime_dists SOURCE ROOT — the compiled design and make-pdf binaries # behind $GSTACK_DESIGN and $GSTACK_MAKE_PDF on every env-var host (#2891), # and freeze/bin (with the careful/bin helper it sources), whose state and # check scripts /freeze, /guard, /unfreeze and /investigate run as # "$GSTACK_ROOT/freeze/bin/...". Subdirectories only, never the whole skill # directory: make-pdf/, freeze/ and careful/ ship a Claude SKILL.md a host # scanning its skills root would discover as a duplicate skill. # Also the files the skills read on demand as "$GSTACK_ROOT/" (#1077 # residue): the preamble's jargon list, question registry and AskUserQuestion # docs, the review checklists and specialists, /plan-devex-review's Hall of # Fame, the test-value bar, design-html's vendored pretext.js and VERSION. # Pinned by test/runtime-root-assets.test.ts. A destination that already exists # as a real entry is left alone: in the repo-local sidecar review/ is a link to # the source, so its files already resolve and must never be linked over. _link_runtime_dists() { local d f # Record the source checkout (#3026): on Windows the root's browse/dist is a # copy with no node_modules beside it, and the browse CLI runs the checkout's # Node server bundle from here instead. /gstack-upgrade reads it too. if [ "$IS_WINDOWS" -eq 1 ] && command -v cygpath >/dev/null 2>&1; then cygpath -m "$1" > "$2/.source-path" else printf '%s\n' "$1" > "$2/.source-path" fi for d in design/dist make-pdf/dist freeze/bin careful/bin review/specialists design-html/vendor; do [ -d "$1/$d" ] || continue if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$2/$d" ] || [ ! -e "$2/$d" ]; then mkdir -p "$2/${d%/*}" _link_or_copy "$1/$d" "$2/$d" fi done for f in VERSION scripts/jargon-list.json scripts/question-registry.ts \ docs/askuserquestion-split.md docs/askuserquestion-cjk.md docs/test-value-bar.md \ review/checklist.md review/design-checklist.md review/greptile-triage.md review/TODOS-format.md \ plan-devex-review/dx-hall-of-fame.md; do [ -f "$1/$f" ] || continue if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$2/$f" ] || [ ! -e "$2/$f" ]; then case "$f" in */*) mkdir -p "$2/${f%/*}" ;; esac _link_or_copy "$1/$f" "$2/$f" fi done } # _copy_runtime_skill_refs RENDER_SKILLS ROOT — skills that read another # skill's instructions as "$GSTACK_ROOT//SKILL.md" get that host's # rendered copy there: plan-eng-review's inline office-hours step (#2449) and # the plan-design-review sections. Real-file copies, like every SKILL.md in a # runtime root, and only the SKILL.md, never the skill's source directory. _copy_runtime_skill_refs() { local s for s in office-hours plan-design-review; do [ -f "$1/gstack-$s/SKILL.md" ] || continue mkdir -p "$2/$s" _copy_skill_md "$1/gstack-$s/SKILL.md" "$2/$s/SKILL.md" done } # ─── Real-file SKILL.md copies in host runtime roots (C3, #333) ────────────── # Codex skips a symlinked SKILL.md file, so the router and the nested SKILL.md # files in every host runtime root are real copies, rewritten on every run. # setup records the hash of each copy it writes in $GSTACK_STATE_ROOT/ # skill-copies.tsv (hashpath); a copy whose bytes no longer match was # edited by hand and is saved to a uniquely named backup under the state root # before the tree is replaced, so the backup outlives the old tree. _SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv" _copy_skill_md() { rm -f "$2" && cp "$1" "$2" } _skill_copy_hash() { git hash-object "$1" 2>/dev/null } # _skill_copy_unmodified FILE — FILE is a copy setup wrote and nobody edited. _skill_copy_unmodified() { local h [ -f "$1" ] && [ ! -L "$1" ] && [ -f "$_SKILL_COPIES_FILE" ] || return 1 h="$(_skill_copy_hash "$1")" || return 1 grep -qxF "$h $1" "$_SKILL_COPIES_FILE" } # _preserve_skill_copy_edits ROOT — back up every recorded copy under ROOT # whose bytes changed. Non-zero when a backup failed: the caller must then # keep the old tree (a failed backup is never a license to overwrite). _preserve_skill_copy_edits() { local root="$1" hash path dir="" [ -f "$_SKILL_COPIES_FILE" ] || return 0 while IFS=' ' read -r hash path; do case "$path" in "$root"/*) ;; *) continue ;; esac { [ -f "$path" ] && [ ! -L "$path" ]; } || continue [ "$(_skill_copy_hash "$path")" = "$hash" ] && continue if [ -z "$dir" ]; then mkdir -p "$GSTACK_STATE_ROOT/backups/skill-copies" || return 1 dir="$(mktemp -d "$GSTACK_STATE_ROOT/backups/skill-copies/$(date +%Y%m%dT%H%M%S)-XXXXXX")" || return 1 fi mkdir -p "$dir/$(dirname "${path#/}")" && cp -p "$path" "$dir/${path#/}" || return 1 echo " saved your edited $path to $dir/${path#/} (setup rewrites gstack's SKILL.md copies on every run)" done < "$_SKILL_COPIES_FILE" } # _record_skill_copies ROOT — replace ROOT's rows with the real SKILL.md files # now under it (find does not descend into linked directories). Sets # _COPIES_REFRESHED and _COPIES_REMOVED (paths relative to ROOT). _record_skill_copies() { local root="$1" f tmp old="" new _COPIES_REFRESHED="" _COPIES_REMOVED="" if [ -f "$_SKILL_COPIES_FILE" ]; then old="$(awk -F'\t' -v r="$root/" 'index($2, r) == 1 { print substr($2, length(r) + 1) }' "$_SKILL_COPIES_FILE" | sort)" fi new="$(cd "$root" 2>/dev/null && find . -name SKILL.md -type f | sed 's|^\./||' | sort)" mkdir -p "$GSTACK_STATE_ROOT" || return 1 tmp="$_SKILL_COPIES_FILE.tmp.$$" { [ -f "$_SKILL_COPIES_FILE" ] && awk -F'\t' -v r="$root/" 'index($2, r) != 1' "$_SKILL_COPIES_FILE" for f in $new; do printf '%s\t%s\n' "$(_skill_copy_hash "$root/$f")" "$root/$f"; done } > "$tmp" && mv -f "$tmp" "$_SKILL_COPIES_FILE" || { rm -f "$tmp"; return 1; } _COPIES_REFRESHED="$(printf '%s\n' "$new" | tr '\n' ' ' | sed 's/ *$//')" _COPIES_REMOVED="$(printf '%s\n' "$old" | grep -vxF -f <(printf '%s\n' "$new") | tr '\n' ' ' | sed 's/ *$//')" } # ─── Ownership gate for skill entries (#2119) ───────────────────────────────── # setup and gstack-relink must never delete or link over a skill they do not # own. This is the single rule both use (relink carries the same logic — keep # them in sync until the shared helper filed in TODOS.md lands). Proof has two # strengths: STRONG (a symlink resolving into gstack, or the .gstack-owned # marker) means we created the entry and may delete or refresh it whole; WEAK # (byte-identity with our source, or the two-line generated banner on a real # file) covers only that SKILL.md — never the directory — and a differing # weakly-proven file is moved to $GSTACK_STATE_ROOT/backups/skills// # before we install over it. An entry is OURS # when: it is a symlink resolving into the gstack payload / render dir (or any # path with a `gstack` segment, the convention cleanup and gstack-uninstall # already use, so entries from a sibling worktree still count), a real dir # whose SKILL.md is such a symlink, or a real-file copy proven by the # .gstack-owned marker, byte-identity with the source, or gen-skill-docs' # generated header. Anything else is FOREIGN: skipped, reported, listed in # the final summary. _FOREIGN_SKIPPED_ENTRIES=() _gstack_link_target_abs() { # readlink of a relative link is relative to the link's directory; anchor it # there and canonicalize the directory part (`..`, symlinked components). 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="${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 } _gstack_target_is_ours() { # $1 = absolute target path, $2 = gstack payload dir local t="$1" g="$2" g_real render render_real g_real="$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")" render="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}" render_real="$(cd "$render" 2>/dev/null && pwd -P || printf '%s' "$render")" case "$t" in "$g"/*|"$g_real"/*|"$render"/*|"$render_real"/*|gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;; esac # A checkout named without a `gstack` segment (git worktree add # ../gstack-, a ZIP unpacked as gstack-main): 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 and a setup script does not). local root="${t%/*/SKILL.md}" if [ "$root" != "$t" ] && [ -f "$root/VERSION" ] && [ -f "$root/setup" ] && [ -f "$root/bin/gstack-relink" ]; then return 0; fi return 1 } _claude_entry_is_ours() { # $1 = existing entry (dir or symlink), $2 = the gstack source SKILL.md it # would be linked to, $3 = gstack payload dir local entry="$1" src_md="$2" g="$3" render_md _claude_entry_owned_strongly "$entry" "$g" && 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 # No SKILL.md at all: an UNCLAIMED directory (a weak cleanup left the user's # other files behind, or it was never a skill). Adding our SKILL.md # overwrites nothing, so installing into it is allowed; the cleanup arms # require a SKILL.md and so never touch it. if [ -d "$entry" ] && [ ! -e "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ]; then return 0; fi if [ -d "$entry" ] && [ -f "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ]; then [ -n "$src_md" ] && [ -f "$src_md" ] && cmp -s "$entry/SKILL.md" "$src_md" && return 0 # A gbrain install serves the RENDERED file (link_claude_skill_dirs prefers # it), so an exact copy of that render is ours too. if [ -n "$src_md" ]; then render_md="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md" [ -f "$render_md" ] && cmp -s "$entry/SKILL.md" "$render_md" && return 0 fi _gstack_generated_header "$entry/SKILL.md" && return 0 fi return 1 } # _claude_entry_owned_strongly ENTRY GSTACK_DIR — we created it: a symlink into # gstack, or a real dir with the .gstack-owned marker or a SKILL.md symlink # into gstack. Only strong proof authorizes deleting a directory whole. _claude_entry_owned_strongly() { local entry="$1" g="$2" dest if [ -L "$entry" ]; then dest="$(_gstack_link_target_abs "$entry")" || return 1 _gstack_target_is_ours "$dest" "$g"; return $? fi [ -d "$entry" ] || return 1 [ -f "$entry/.gstack-owned" ] && return 0 if [ -L "$entry/SKILL.md" ]; then dest="$(_gstack_link_target_abs "$entry/SKILL.md")" || return 1 _gstack_target_is_ours "$dest" "$g"; return $? fi return 1 } # Weakly-proven real files we would otherwise overwrite are moved here (mv, so # the path is free for the link); the final summary prints one line. _SKILL_BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)" _BACKED_UP_SKILL_MDS=() _backup_skill_md() { # Returns non-zero when the file could NOT be moved: the caller must then # leave the entry untouched (a failed backup is never a license to overwrite). local file="$1" name="$2" mkdir -p "$_SKILL_BACKUP_ROOT/$name" 2>/dev/null || return 1 mv -f "$file" "$_SKILL_BACKUP_ROOT/$name/SKILL.md" 2>/dev/null || return 1 _BACKED_UP_SKILL_MDS+=("$name") return 0 } # _cleanup_weak_dir DIR — remove only what weak proof covers: the SKILL.md and # our marker. User files in the directory stay, and so does the directory # when it is not empty afterwards. # _cleanup_weak_dir DIR GSTACK_DIR [SRC_SKILL_MD NAME] — remove only what weak # proof covers. A real SKILL.md that differs from our source (raw, or with its # name: line rewritten to NAME, which is how alias and prefixed copies differ) # is a customized file: it is moved to the backup root, never deleted, and if # the backup fails it stays. Our runtime-asset links go; the user's files stay. _cleanup_weak_dir() { local d="$1" g="$2" src="${3:-}" name="${4:-${1##*/}}" e dest if [ -f "$d/SKILL.md" ] && [ ! -L "$d/SKILL.md" ] && [ -n "$src" ] && [ -f "$src" ] \ && ! cmp -s "$d/SKILL.md" "$src" \ && ! sed "1,/^---\$/ s/^name:[[:space:]].*/name: $name/" "$src" | cmp -s - "$d/SKILL.md"; then if ! _backup_skill_md "$d/SKILL.md" "$name"; then echo " kept $name/SKILL.md: could not back up the customized file — left untouched" >&2 return 0 fi else rm -f "$d/SKILL.md" fi rm -f "$d/.gstack-owned" for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do [ -L "$e" ] || continue dest="$(_gstack_link_target_abs "$e")" || continue if _gstack_target_is_ours "$dest" "$g"; then rm -f "$e"; fi done rmdir "$d" 2>/dev/null || echo " cleaned ${d##*/}/SKILL.md (other files in that directory were left in place)" } # _gstack_dir_only_links DIR GSTACK_DIR — true when deleting DIR whole loses # nothing of the user's: every entry is a symlink resolving into gstack, or # our marker. A user's own link (notes.md -> ~/notes) makes the dir mixed. _gstack_dir_only_links() { local d="$1" g="$2" e dest for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do { [ -e "$e" ] || [ -L "$e" ]; } || continue [ "${e##*/}" = ".gstack-owned" ] && continue [ -L "$e" ] || return 1 dest="$(_gstack_link_target_abs "$e")" || return 1 _gstack_target_is_ours "$dest" "$g" || return 1 done return 0 } # _cleanup_linked_dir DIR GSTACK_DIR — a real dir whose SKILL.md is a symlink # into gstack. Whole-directory removal needs the marker (we created it) or a # directory holding nothing but our links; otherwise only our files go. _cleanup_linked_dir() { if [ -f "$1/.gstack-owned" ] || _gstack_dir_only_links "$1" "$2"; then rm -rf "$1"; else _cleanup_weak_dir "$1" "$2"; fi } # _gstack_generated_header FILE — a pre-marker legacy COPY (Windows, before # .gstack-owned existed) is recognized by gen-skill-docs' full two-line banner # near the top, not by a one-line substring another generator could plausibly # emit. Still forgeable by a gstack fork that renders the same banner — that # residual is accepted and filed; the marker is the load-bearing signal. _gstack_generated_header() { # Bytes, not lines: a long frontmatter pushes the banner past line 40 in # four real skills (investigate: line 57), and a line-count check left them # "foreign" on every pre-marker Windows install. local f="$1" head40 head40="$(head -c 8192 "$f" 2>/dev/null)" || return 1 case "$head40" in *''*) return 0 ;; esac return 1 } _write_owned_marker() { # Windows copy installs have no symlink to readlink; the marker proves # provenance. Records the owning payload's real path for forensics. local dir="$1" g="$2" printf '%s\n' "$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")" > "$dir/.gstack-owned" 2>/dev/null || true } # ─── Ownership gates for the Windows refresh bypass (#2444 → #2142) ───────── # On Windows a refresh means rm -rf + re-copy (_link_or_copy). The host # skills dirs are SHARED namespaces (~/.codex/skills, ~/.factory/skills, # ~/.cursor/skills, ...), so a gstack* glob name can collide with a user's # OWN real directory (e.g. ~/.cursor/skills/gstack-notes) — deleting it on # every ./setup re-run is silent data loss. Mirror of bin/gstack-uninstall's # provenance gate (#2563): an existing REAL skill dir may only be replaced # when its SKILL.md carries the generated banner. Missing targets and # symlinks always pass (replacing a link never destroys content); non-dir # targets pass (file targets live inside gstack-owned roots). _owned_for_windows_refresh() { local dst="$1" if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then return 0; fi if [ -L "$dst" ]; then return 0; fi if [ ! -d "$dst" ]; then return 0; fi grep -q '' mkdir -p "$cmd_dir" for d in "$skills_dir"/gstack-*/; do [ -f "$d/SKILL.md" ] || continue name="$(basename "$d")" file="$cmd_dir/$name.md" if [ -e "$file" ] && ! grep -qF "$marker" "$file" 2>/dev/null; then echo " kept $file: an OpenCode command you own already uses this name" >&2 continue fi case "${_DISABLED_SKILLS:- }" in *" ${name#gstack-} "*) rm -f "$file"; continue ;; esac desc="$(awk '/^description:/ { f = 1; next } f && /^[^ ]/ { exit } f && NF { sub(/^ +/, ""); print; exit }' "$d/SKILL.md" | sed 's/\\/\\\\/g; s/"/\\"/g')" # The skill tool knows a skill by its frontmatter name (gstack-review's is # `review`, which OpenCode's builtin /review shadows as a command, #2651). skill="$(awk '/^---$/ { fm++; next } fm == 1 && $1 == "name:" { print $2; exit }' "$d/SKILL.md")" printf -- '---\ndescription: "%s"\nagent: build\nsubtask: false\n---\n%s\nLoad the `%s` skill with the skill tool and follow its instructions.\n\n$ARGUMENTS\n' \ "${desc:-gstack $name}" "$marker" "${skill:-$name}" > "$file.tmp.$$" && mv -f "$file.tmp.$$" "$file" done for file in "$cmd_dir"/gstack-*.md; do [ -f "$file" ] && grep -qF "$marker" "$file" 2>/dev/null || continue [ -f "$skills_dir/$(basename "$file" .md)/SKILL.md" ] || rm -f "$file" done } # ─── Helper: create a minimal ~/.copilot/skills/gstack runtime root ───────── # Copilot scans ~/.copilot/skills recursively. Same shape as the Cursor root: # root SKILL.md from the generated tree + runtime assets only, plus # .source-path so /gstack-upgrade can find the source checkout (upgrade-path # idea from @andrey-esipov, PR #2323). Built through _activate_runtime_root. create_copilot_runtime_root() { local gstack_dir="$1" local copilot_gstack="$2" local copilot_dir="$gstack_dir/.copilot/skills" local asset f mkdir -p "$copilot_gstack" "$copilot_gstack/browse" "$copilot_gstack/gstack-upgrade" "$copilot_gstack/review" printf '%s\n' "$gstack_dir" > "$copilot_gstack/.source-path" if [ -f "$copilot_dir/gstack/SKILL.md" ]; then _copy_skill_md "$copilot_dir/gstack/SKILL.md" "$copilot_gstack/SKILL.md" fi for asset in bin lib browse/dist browse/bin; do if [ -d "$gstack_dir/$asset" ]; then _link_or_copy "$gstack_dir/$asset" "$copilot_gstack/$asset" fi done _link_runtime_dists "$gstack_dir" "$copilot_gstack" if [ -f "$copilot_dir/gstack-upgrade/SKILL.md" ]; then _copy_skill_md "$copilot_dir/gstack-upgrade/SKILL.md" "$copilot_gstack/gstack-upgrade/SKILL.md" fi _copy_runtime_skill_refs "$copilot_dir" "$copilot_gstack" if [ -f "$gstack_dir/ETHOS.md" ]; then _link_or_copy "$gstack_dir/ETHOS.md" "$copilot_gstack/ETHOS.md" fi if [ -f "$gstack_dir/supabase/config.sh" ]; then mkdir -p "$copilot_gstack/supabase" _link_or_copy "$gstack_dir/supabase/config.sh" "$copilot_gstack/supabase/config.sh" fi } # Copilot invokes skills by frontmatter name, and /review is a built-in # Copilot command, so names get the gstack- prefix to match their dirs. link_copilot_skill_dirs() { local gstack_dir="$1" local skills_dir="$2" local copilot_dir="$gstack_dir/.copilot/skills" local linked=() if [ ! -d "$copilot_dir" ]; then echo " Generating .copilot/ skill docs..." ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host copilot ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi if [ ! -d "$copilot_dir" ]; then echo " warning: .copilot/skills/ generation failed — run 'bun run gen:skill-docs --host copilot' manually" >&2 return 1 fi "$gstack_dir/bin/gstack-patch-names" "$copilot_dir" 1 for skill_dir in "$copilot_dir"/gstack*/; do if [ -f "$skill_dir/SKILL.md" ]; then skill_name="$(basename "$skill_dir")" [ "$skill_name" = "gstack" ] && continue case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac target="$skills_dir/$skill_name" if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then if _owned_for_windows_refresh "$target"; then _link_or_copy "$skill_dir" "$target" linked+=("$skill_name") else echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2 fi fi fi done if [ ${#linked[@]} -gt 0 ]; then echo " linked skills: ${linked[*]}" fi } # ─── Helper: ~/.gemini/antigravity-cli/skills/gstack runtime root ──────────── # Same shape as the Copilot root: root SKILL.md + runtime assets, with the # .source-path _link_runtime_dists writes for /gstack-upgrade. Antigravity CLI # and Gemini CLI share ~/.gemini; setup writes only gstack entries under # ~/.gemini/antigravity-cli/skills, never Gemini CLI's ~/.gemini/skills. # Built through _activate_runtime_root. create_agy_runtime_root() { local gstack_dir="$1" local agy_gstack="$2" local agy_dir="$gstack_dir/.agy/skills" local asset f mkdir -p "$agy_gstack" "$agy_gstack/browse" "$agy_gstack/gstack-upgrade" "$agy_gstack/review" if [ -f "$agy_dir/gstack/SKILL.md" ]; then _copy_skill_md "$agy_dir/gstack/SKILL.md" "$agy_gstack/SKILL.md" fi for asset in bin lib browse/dist browse/bin; do if [ -d "$gstack_dir/$asset" ]; then _link_or_copy "$gstack_dir/$asset" "$agy_gstack/$asset" fi done _link_runtime_dists "$gstack_dir" "$agy_gstack" if [ -f "$agy_dir/gstack-upgrade/SKILL.md" ]; then _copy_skill_md "$agy_dir/gstack-upgrade/SKILL.md" "$agy_gstack/gstack-upgrade/SKILL.md" fi _copy_runtime_skill_refs "$agy_dir" "$agy_gstack" if [ -f "$gstack_dir/ETHOS.md" ]; then _link_or_copy "$gstack_dir/ETHOS.md" "$agy_gstack/ETHOS.md" fi if [ -f "$gstack_dir/supabase/config.sh" ]; then mkdir -p "$agy_gstack/supabase" _link_or_copy "$gstack_dir/supabase/config.sh" "$agy_gstack/supabase/config.sh" fi } # Frontmatter names already equal the gstack- directories # (nameMatchesDirectory), so no name patching. Fails when nothing was linked. link_agy_skill_dirs() { local gstack_dir="$1" local skills_dir="$2" local agy_dir="$gstack_dir/.agy/skills" local linked=() if [ ! -d "$agy_dir" ]; then echo " Generating .agy/ skill docs..." ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host agy ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi if [ ! -d "$agy_dir" ]; then echo " warning: .agy/skills/ generation failed — run 'bun run gen:skill-docs --host agy' manually" >&2 return 1 fi for skill_dir in "$agy_dir"/gstack*/; do if [ -f "$skill_dir/SKILL.md" ]; then skill_name="$(basename "$skill_dir")" [ "$skill_name" = "gstack" ] && continue case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac target="$skills_dir/$skill_name" if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then if _owned_for_windows_refresh "$target"; then _link_or_copy "$skill_dir" "$target" linked+=("$skill_name") else echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2 fi fi fi done if [ ${#linked[@]} -gt 0 ]; then echo " linked skills: ${linked[*]}" fi if ! ls "$skills_dir"/gstack-*/SKILL.md >/dev/null 2>&1; then echo " error: no gstack skills are installed in $skills_dir" >&2 return 1 fi } # 4. Install for Claude (default) # _claude_scope SKILLS_DIR — "global" for the user's Claude skills dir, else # "project ". _claude_scope() { case "$1" in "$HOME/.claude/skills"|"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills") echo "global -" ;; *) echo "project $(dirname "$(dirname "$1")")" ;; esac } _claude_prefix_setting() { [ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false; } if [ "$INSTALL_CLAUDE" -eq 1 ]; then if [ "$SKILLS_BASENAME" = "skills" ]; then read -r _CL_SCOPE _CL_PROJECT </dev/null && pwd -P || echo "") if [ -n "$_EXISTING_REAL" ] && [ "$_EXISTING_REAL" != "$SOURCE_GSTACK_DIR" ]; then _SKIP_CLAUDE_REGISTER=1 fi fi # Ownership rule 1 (docs/ADDING_A_HOST.md): a global install that links to # a different, still-present gstack checkout is never silently repointed. _CLAUDE_REPOINT_REFUSED=0 if [ -L "$CLAUDE_GSTACK_LINK" ] && [ "$GLOBAL_INSTALL" -eq 0 ]; then _EXISTING_REAL=$(cd "$CLAUDE_GSTACK_LINK" 2>/dev/null && pwd -P || echo "") if [ -n "$_EXISTING_REAL" ] && [ "$_EXISTING_REAL" != "$SOURCE_GSTACK_DIR" ] \ && [ -f "$_EXISTING_REAL/setup" ] && [ -f "$_EXISTING_REAL/VERSION" ]; then _CLAUDE_REPOINT_REFUSED=1 fi fi if [ "$_SKIP_CLAUDE_REGISTER" -eq 1 ]; then log "" log " $CLAUDE_GSTACK_LINK already exists as a separate global install." log " Skipping Claude skill registration to avoid polluting it with" log " per-worktree symlinks. (Binaries still built locally for dev.)" log "" log " Global install: $CLAUDE_GSTACK_LINK" log " This worktree: $SOURCE_GSTACK_DIR" log "" log " To register this worktree as the active gstack, remove the global" log " install first: rm -rf $CLAUDE_GSTACK_LINK" log "" log "gstack built (claude registration skipped)." log " browse: $BROWSE_BIN" _browser_hint _setup_row claude global "$CLAUDE_SKILLS_DIR" - "$SETUP_VERSION" skipped "left alone: $CLAUDE_GSTACK_LINK is a separate global install (rule: setup never replaces a global install it does not own)" elif [ "$_CLAUDE_REPOINT_REFUSED" -eq 1 ]; then echo "" >&2 echo "Left alone: $CLAUDE_GSTACK_LINK, the global Claude install, which links to another gstack checkout:" >&2 echo " $_EXISTING_REAL (v$(cat "$_EXISTING_REAL/VERSION" 2>/dev/null || echo unknown))" >&2 echo " Rule: setup never silently replaces a global install from a different checkout." >&2 echo " To make this checkout ($SOURCE_GSTACK_DIR) the global install: ./setup --global" >&2 echo " To refresh the existing one instead: cd $_EXISTING_REAL && ./setup" >&2 echo " Docs: docs/ADDING_A_HOST.md#install-ownership-rules" >&2 log "gstack built (claude registration skipped)." _setup_row claude global "$CLAUDE_SKILLS_DIR" - "$SETUP_VERSION" skipped "left alone: links to $_EXISTING_REAL; to replace it run ./setup --global from this checkout" else _setup_arm_begin claude global "$CLAUDE_SKILLS_DIR" mkdir -p "$CLAUDE_SKILLS_DIR" _link_or_copy "$SOURCE_GSTACK_DIR" "$CLAUDE_GSTACK_LINK" log " symlinked $CLAUDE_GSTACK_LINK -> $SOURCE_GSTACK_DIR" INSTALL_SKILLS_DIR="$CLAUDE_SKILLS_DIR" INSTALL_GSTACK_DIR="$CLAUDE_GSTACK_LINK" # Clean up stale symlinks from the opposite prefix mode if [ "$SKILL_PREFIX" -eq 1 ]; then cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" else cleanup_prefixed_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" fi "$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX" link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" _CLAUDE_SKILLS_LINKED=1 GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink" if [ -x "$GSTACK_RELINK" ]; then _run_relink_quiet fi # Rewritten copy, not a symlink: a symlinked alias re-serves the # canonical name: open-gstack-browser, so one of the two silently # shadows the other (#2201) — and duplicate names can drop the whole # skill set (#2511). _OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome" _OGB_ALIAS_NAME="connect-chrome" if [ "$SKILL_PREFIX" -eq 1 ]; then _OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome" _OGB_ALIAS_NAME="gstack-connect-chrome" fi _install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME" _setup_arm_publish claude global - "$INSTALL_SKILLS_DIR" "$INSTALL_GSTACK_DIR" "$(_claude_prefix_setting)" log "gstack ready (claude)." log " browse: $BROWSE_BIN" _browser_hint fi fi fi # 5. Install for Codex if [ "$INSTALL_CODEX" -eq 1 ]; then if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then _CX_SCOPE=project; _CX_PROJECT="$_codex_project"; else _CX_SCOPE=global; _CX_PROJECT=-; fi _setup_arm_begin codex "$_CX_SCOPE" "$CODEX_SKILLS" if [ "$INSTALL_CLAUDE" -eq 0 ]; then case "$INSTALL_SKILLS_DIR" in "$HOME/.claude/skills"|"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills") echo " note: this checkout lives in Claude Code's skills directory ($INSTALL_GSTACK_DIR), so Claude Code" >&2 echo " still discovers it as a skill. For a Codex-only install, move it: mv $INSTALL_GSTACK_DIR ~/gstack && cd ~/gstack && ./setup --host codex" >&2 ;; esac fi mkdir -p "$CODEX_SKILLS" CODEX_RUNTIME_IN_PLACE=0 if [ "$CODEX_REPO_LOCAL" -eq 1 ] && [ -d "$CODEX_GSTACK" ]; then _codex_runtime="$(cd "$CODEX_GSTACK" && pwd -P)" _codex_render="$(cd "$SOURCE_GSTACK_DIR/.agents/skills/gstack" 2>/dev/null && pwd -P || true)" if [ "$_codex_runtime" = "$SOURCE_GSTACK_DIR" ] || [ "$_codex_runtime" = "$_codex_render" ]; then CODEX_RUNTIME_IN_PLACE=1 fi fi # CODEX_HOME (a global root other than ~/.codex/skills/gstack): serve a # per-install render whose runtime root names this install. Repo-local # installs resolve their root at run time and keep the committed render. _CODEX_RENDER_ROOT="" if [ "$CODEX_REPO_LOCAL" -eq 0 ] && _render_install codex "$CODEX_GSTACK" && [ -n "$_INSTALL_RENDER" ]; then _CODEX_RENDER_ROOT="$_INSTALL_RENDER" fi if [ "$CODEX_RUNTIME_IN_PLACE" -eq 0 ]; then if [ "$CODEX_REPO_LOCAL" -eq 1 ] && _sidecar_root_user_owned "$CODEX_GSTACK"; then echo " left in place (existing dir not gstack-managed — no generated banner): $CODEX_GSTACK" >&2 else _activate_runtime_root create_codex_runtime_root "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK" fi fi # Install generated Codex-format skills (not Claude source dirs) _prune_stale_generated "$SOURCE_GSTACK_DIR" "${_CODEX_RENDER_ROOT:-$SOURCE_GSTACK_DIR}/.agents/skills" "$CODEX_SKILLS" link_codex_skill_dirs "${_CODEX_RENDER_ROOT:-$SOURCE_GSTACK_DIR}" "$CODEX_SKILLS" _SETUP_ARM="" log "gstack ready (codex)." log " browse: $BROWSE_BIN" _browser_hint log " codex skills: $CODEX_SKILLS" log " model profile: $CODEX_GENERATION_MODEL ($CODEX_GENERATION_MODEL_SOURCE)" log " model changes: rerun ./setup --host codex" if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then log " note: --model applies to this run only. To persist across upgrades," log " set model = \"$MODEL_OVERRIDE\" in \${CODEX_HOME:-~/.codex}/config.toml." fi fi # 6. Install for Kiro CLI from its own host render if [ "$INSTALL_KIRO" -eq 1 ]; then KIRO_DIR="$SOURCE_GSTACK_DIR/.kiro/skills" KIRO_GSTACK="$KIRO_SKILLS/gstack" # Host identity controls outside-review routing as well as skill availability. # Never borrow .agents or temporarily replace a live Codex model profile. ( cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host kiro ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) mkdir -p "$KIRO_SKILLS" if _sidecar_root_user_owned "$KIRO_GSTACK"; then echo " left in place (existing Kiro runtime root is not gstack-managed): $KIRO_GSTACK" >&2 _setup_row kiro global "$KIRO_SKILLS" - "$SETUP_VERSION" skipped "left alone: $KIRO_GSTACK is not gstack-managed (no generated banner)" else _setup_arm_begin kiro global "$KIRO_SKILLS" [ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK" mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review" _link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin" _link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib" _link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist" _link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin" _link_runtime_dists "$SOURCE_GSTACK_DIR" "$KIRO_GSTACK" if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then _link_or_copy "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md" fi if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then mkdir -p "$KIRO_GSTACK/supabase" _link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$KIRO_GSTACK/supabase/config.sh" fi if ! _preserve_skill_copy_edits "$KIRO_GSTACK"; then echo " error: could not back up an edited SKILL.md copy under $KIRO_GSTACK; Kiro install stopped before overwriting it" >&2 exit 1 fi if [ -f "$KIRO_DIR/gstack-upgrade/SKILL.md" ]; then _copy_skill_md "$KIRO_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md" fi if [ -f "$KIRO_DIR/gstack/SKILL.md" ]; then _copy_skill_md "$KIRO_DIR/gstack/SKILL.md" "$KIRO_GSTACK/SKILL.md" fi _copy_runtime_skill_refs "$KIRO_DIR" "$KIRO_GSTACK" for skill_dir in "$KIRO_DIR"/gstack*/; do [ -f "$skill_dir/SKILL.md" ] || continue skill_name="$(basename "$skill_dir")" [ "$skill_name" = "gstack" ] && continue target_dir="$KIRO_SKILLS/$skill_name" case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$target_dir"; continue ;; esac if { [ -e "$target_dir" ] || [ -L "$target_dir" ]; } && ! _claude_entry_is_ours "$target_dir" "$skill_dir/SKILL.md" "$SOURCE_GSTACK_DIR"; then echo " skipped $skill_name: existing entry is not gstack-managed — left untouched" >&2 continue fi # Existing real copy installs retain unrelated files alongside SKILL.md. if [ -L "$target_dir" ]; then rm -f "$target_dir"; fi mkdir -p "$target_dir" if [ -f "$target_dir/SKILL.md" ] && [ ! -L "$target_dir/SKILL.md" ] \ && ! _claude_entry_owned_strongly "$target_dir" "$SOURCE_GSTACK_DIR" \ && ! _skill_copy_unmodified "$target_dir/SKILL.md" \ && ! cmp -s "$target_dir/SKILL.md" "$skill_dir/SKILL.md"; then if ! _backup_skill_md "$target_dir/SKILL.md" "$skill_name"; then echo " skipped $skill_name: could not back up its customized SKILL.md — left untouched" >&2 continue fi fi _copy_skill_md "$skill_dir/SKILL.md" "$target_dir/SKILL.md" _record_skill_copies "$target_dir" || true # Native sections already contain Kiro paths/provider markers. Refresh # generated files individually so user assets next to them survive. if [ -d "$skill_dir/sections" ]; then if [ -L "$target_dir/sections" ]; then section_target="$(_gstack_link_target_abs "$target_dir/sections")" if ! _gstack_target_is_ours "$section_target" "$SOURCE_GSTACK_DIR"; then echo " kept $skill_name/sections: directory link is not gstack-managed" >&2 continue fi rm -f "$target_dir/sections" fi mkdir -p "$target_dir/sections" for section_file in "$skill_dir/sections"/*; do [ -f "$section_file" ] || continue section_dest="$target_dir/sections/$(basename "$section_file")" if [ -L "$section_dest" ]; then section_target="$(_gstack_link_target_abs "$section_dest")" _gstack_target_is_ours "$section_target" "$SOURCE_GSTACK_DIR" || continue elif [ -e "$section_dest" ] && ! _gstack_generated_header "$section_dest"; then echo " kept $skill_name/sections/$(basename "$section_file"): existing file is not gstack-managed" >&2 continue fi _link_or_copy "$section_file" "$section_dest" done fi if [ "$skill_name" = "gstack-qa" ] && [ -f "$skill_dir/templates/functional-report-template.md" ]; then if [ -L "$target_dir/templates" ]; then template_target="$(_gstack_link_target_abs "$target_dir/templates")" if ! _gstack_target_is_ours "$template_target" "$SOURCE_GSTACK_DIR"; then echo " kept $skill_name/templates: directory link is not gstack-managed" >&2 continue fi rm -f "$target_dir/templates" elif [ -e "$target_dir/templates" ] && [ ! -d "$target_dir/templates" ]; then echo " kept $skill_name/templates: existing entry is not a directory" >&2 continue fi mkdir -p "$target_dir/templates" template_dest="$target_dir/templates/functional-report-template.md" if [ -L "$template_dest" ]; then template_target="$(_gstack_link_target_abs "$template_dest")" if ! _gstack_target_is_ours "$template_target" "$SOURCE_GSTACK_DIR"; then echo " kept $skill_name/templates/functional-report-template.md: file link is not gstack-managed" >&2 continue fi elif [ -e "$template_dest" ] && ! _gstack_generated_header "$template_dest"; then echo " kept $skill_name/templates/functional-report-template.md: existing file is not gstack-managed" >&2 continue fi _link_or_copy "$skill_dir/templates/functional-report-template.md" "$template_dest" fi done _prune_stale_generated "$SOURCE_GSTACK_DIR" "$KIRO_DIR" "$KIRO_SKILLS" _record_skill_copies "$KIRO_GSTACK" || echo " warning: could not record SKILL.md copy hashes in $_SKILL_COPIES_FILE" >&2 [ -n "$_COPIES_REFRESHED" ] && log " refreshed SKILL.md copies in $KIRO_GSTACK: $_COPIES_REFRESHED" _setup_arm_publish kiro global - "$KIRO_SKILLS" "$KIRO_GSTACK" - echo "gstack ready (kiro)." echo " browse: $BROWSE_BIN" _browser_hint echo " kiro skills: $KIRO_SKILLS" fi fi # 6b. Install for Factory Droid if [ "$INSTALL_FACTORY" -eq 1 ]; then _setup_arm_begin factory global "$FACTORY_SKILLS" mkdir -p "$FACTORY_SKILLS" _activate_runtime_root create_factory_runtime_root "$SOURCE_GSTACK_DIR" "$FACTORY_GSTACK" _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.factory/skills" "$FACTORY_SKILLS" link_factory_skill_dirs "$SOURCE_GSTACK_DIR" "$FACTORY_SKILLS" _setup_arm_publish factory global - "$FACTORY_SKILLS" "$FACTORY_GSTACK" - echo "gstack ready (factory)." echo " browse: $BROWSE_BIN" _browser_hint echo " factory skills: $FACTORY_SKILLS" fi # 6c. Install for OpenCode if [ "$INSTALL_OPENCODE" -eq 1 ]; then _setup_arm_begin opencode global "$OPENCODE_SKILLS" mkdir -p "$OPENCODE_SKILLS" _activate_runtime_root create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK" _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.opencode/skills" "$OPENCODE_SKILLS" link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS" write_opencode_commands "$SOURCE_GSTACK_DIR" "$HOME/.config/opencode/commands" _setup_arm_publish opencode global - "$OPENCODE_SKILLS" "$OPENCODE_GSTACK" - echo "gstack ready (opencode)." echo " browse: $BROWSE_BIN" _browser_hint echo " opencode skills: $OPENCODE_SKILLS" fi # 6d. Install for Cursor if [ "$INSTALL_CURSOR" -eq 1 ]; then _setup_arm_begin cursor global "$CURSOR_SKILLS" mkdir -p "$CURSOR_SKILLS" _activate_runtime_root create_cursor_runtime_root "$SOURCE_GSTACK_DIR" "$CURSOR_GSTACK" # Link before sidecar. Sidecar mkdir -p creates .cursor/skills/gstack, which # would make link_cursor_skill_dirs' "[ ! -d generated ]" gen fallback a no-op. _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.cursor/skills" "$CURSOR_SKILLS" link_cursor_skill_dirs "$SOURCE_GSTACK_DIR" "$CURSOR_SKILLS" # 1.31 left unprefixed / folders here whose SKILL.md links into the # checkout, so Cursor loaded every skill twice (#3133). Cursor installs only # gstack-* names, so the Claude flat-name cleanup applies as is: it removes # only entries proven to link into gstack (#2119). After linking, so a run # that fails before the new links exist keeps the old folders. cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$CURSOR_SKILLS" create_cursor_sidecar "$SOURCE_GSTACK_DIR" _setup_arm_publish cursor global - "$CURSOR_SKILLS" "$CURSOR_GSTACK" - echo "gstack ready (cursor)." echo " browse: $BROWSE_BIN" _browser_hint echo " cursor skills: $CURSOR_SKILLS" fi # 6e. Install for GitHub Copilot CLI (the Copilot app reads the same skills) if [ "$INSTALL_COPILOT" -eq 1 ]; then if _sidecar_root_user_owned "$COPILOT_GSTACK"; then echo " left in place (existing dir not gstack-managed — no generated banner): $COPILOT_GSTACK" >&2 _setup_row copilot global "$COPILOT_SKILLS" - "$SETUP_VERSION" skipped "left alone: $COPILOT_GSTACK is not gstack-managed (no generated banner)" else _setup_arm_begin copilot global "$COPILOT_SKILLS" mkdir -p "$COPILOT_SKILLS" _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.copilot/skills" "$COPILOT_SKILLS" link_copilot_skill_dirs "$SOURCE_GSTACK_DIR" "$COPILOT_SKILLS" _activate_runtime_root create_copilot_runtime_root "$SOURCE_GSTACK_DIR" "$COPILOT_GSTACK" _setup_arm_publish copilot global - "$COPILOT_SKILLS" "$COPILOT_GSTACK" - echo "gstack ready (copilot)." echo " browse: $BROWSE_BIN" _browser_hint echo " copilot skills: $COPILOT_SKILLS (invoke as /gstack-)" fi fi # 6f. Install for Antigravity CLI (agy) if [ "$INSTALL_AGY" -eq 1 ]; then if _sidecar_root_user_owned "$AGY_GSTACK"; then echo " left in place (existing dir not gstack-managed — no generated banner): $AGY_GSTACK" >&2 _setup_row agy global "$AGY_SKILLS" - "$SETUP_VERSION" skipped "left alone: $AGY_GSTACK is not gstack-managed (no generated banner)" else _setup_arm_begin agy global "$AGY_SKILLS" mkdir -p "$AGY_SKILLS" _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.agy/skills" "$AGY_SKILLS" link_agy_skill_dirs "$SOURCE_GSTACK_DIR" "$AGY_SKILLS" _activate_runtime_root create_agy_runtime_root "$SOURCE_GSTACK_DIR" "$AGY_GSTACK" _setup_arm_publish agy global - "$AGY_SKILLS" "$AGY_GSTACK" - echo "gstack ready (agy)." echo " browse: $BROWSE_BIN" _browser_hint echo " agy skills: $AGY_SKILLS (invoke as /gstack-)" fi fi # 7. Create .agents/ sidecar symlinks for the real Codex skill target. # The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack, # so the runtime assets must live there for both global and repo-local installs. if [ "$INSTALL_CODEX" -eq 1 ]; then _setup_arm_begin codex "$_CX_SCOPE" "$CODEX_SKILLS" create_agents_sidecar "$SOURCE_GSTACK_DIR" _setup_arm_publish codex "$_CX_SCOPE" "$_CX_PROJECT" "$CODEX_SKILLS" "$CODEX_GSTACK" - "${_CODEX_RENDER_ROOT:-committed}" fi # 7b. Prune the skill renders of hosts this checkout does not serve (#1694), # on every run, so each install converges on its own recorded hosts even when # several installs share one state root. Generated files go to the state # root's backups; anything not proven generated stays in place. if ! gstack_prune_host_renders "$SOURCE_GSTACK_DIR"; then echo " warning: some generated host renders could not be backed up, so they were left in place; the next ./setup retries" >&2 fi # 8. Run pending version migrations # Migrations handle state fixes that ./setup alone can't cover (stale config, # orphaned files, directory structure changes). Each migration is idempotent. # They are STATE-ROOT migrations: their completion marker lives in the state # root they repaired ($GSTACK_STATE_ROOT/.last-setup-version), not in a global # marker, and it only advances past a migration that succeeded. A failed # migration is reported as a failed row and is retried by the next ./setup. # Install/host migrations are tracked per install by the registry row version. MIGRATIONS_DIR="$SOURCE_GSTACK_DIR/gstack-upgrade/migrations" CURRENT_VERSION="$SETUP_VERSION" MIGRATION_MARKER="$GSTACK_STATE_ROOT/.last-setup-version" _LEGACY_MARKER="$HOME/.gstack/.last-setup-version" if [ -f "$MIGRATION_MARKER" ]; then LAST_SETUP_VERSION="$(cat "$MIGRATION_MARKER" 2>/dev/null || echo 0.0.0.0)" elif [ -f "$_LEGACY_MARKER" ]; then LAST_SETUP_VERSION="$(cat "$_LEGACY_MARKER" 2>/dev/null || echo 0.0.0.0)" else LAST_SETUP_VERSION="" fi _MIGRATED_TO="$LAST_SETUP_VERSION" _MIGRATION_FAILED="" if [ -d "$MIGRATIONS_DIR" ] && [ "$CURRENT_VERSION" != "unknown" ] && [ -n "$LAST_SETUP_VERSION" ] && [ "$LAST_SETUP_VERSION" != "$CURRENT_VERSION" ]; then while IFS= read -r migration; do [ -n "$migration" ] || continue m_ver="$(basename "$migration" .sh | sed 's/^v//')" # Run if migration is newer than last setup version AND not newer than current version if [ "$(printf '%s\n%s' "$LAST_SETUP_VERSION" "$m_ver" | sort -V | head -1)" = "$LAST_SETUP_VERSION" ] && [ "$LAST_SETUP_VERSION" != "$m_ver" ] \ && [ "$(printf '%s\n%s' "$m_ver" "$CURRENT_VERSION" | sort -V | tail -1)" = "$CURRENT_VERSION" ]; then echo " running migration $m_ver..." # GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just # ~/.gstack state) default to ~/.claude/skills/gstack when unset — # a repo-local ./setup would silently no-op them against the wrong # tree without this. if CODEX_HOME="$(dirname "$CODEX_SKILLS")" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" GSTACK_STATE_ROOT="$GSTACK_STATE_ROOT" bash "$migration"; then _MIGRATED_TO="$m_ver" else _MIGRATION_FAILED="$m_ver" break fi fi done </dev/null | sort -V) EOF fi mkdir -p "$GSTACK_STATE_ROOT" if [ -n "$_MIGRATION_FAILED" ]; then [ -n "$_MIGRATED_TO" ] && printf '%s\n' "$_MIGRATED_TO" > "$MIGRATION_MARKER" echo " warning: migration $_MIGRATION_FAILED failed; the state root stays marked at ${_MIGRATED_TO:-its previous version}, so the next ./setup retries it" >&2 _SETUP_ROWS="$_SETUP_ROWS""state global $SOURCE_GSTACK_DIR $GSTACK_STATE_ROOT ${_MIGRATED_TO:--} $CURRENT_VERSION failed migration $_MIGRATION_FAILED failed; installs are active, the migration is retried by: cd $SOURCE_GSTACK_DIR && ./setup " elif [ "$CURRENT_VERSION" != "unknown" ]; then echo "$CURRENT_VERSION" > "$MIGRATION_MARKER" fi # 9. First-time welcome + legacy cleanup if [ ! -f "$HOME/.gstack/.welcome-seen" ]; then log "" log " gstack is ready. First move:" log " New idea / empty repo? /office-hours or /spec" log " Existing code? /qa to see it work, or /investigate" log " (Run /gstack-upgrade anytime to stay current)" log "" # Best-effort onboarding telemetry (respects telemetry!=off; never blocks setup). if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_welcome --outcome shown --no-sweep >/dev/null 2>&1 || true fi touch "$HOME/.gstack/.welcome-seen" fi rm -f /tmp/gstack-latest-version # 10. Team mode: register/unregister SessionStart hook SETTINGS_HOOK="$SOURCE_GSTACK_DIR/bin/gstack-settings-hook" # Claude Code hooks belong to the Claude install. A setup that did not select # Claude (--host codex, kiro, …) never heals, registers or removes them (#2347). if [ "$INSTALL_CLAUDE" -ne 1 ]; then SETTINGS_HOOK="" fi # ─── Canonical hook paths + self-heal (phantom-hooks fix) ───────────────────── # Hook commands written to GLOBAL settings.json must survive deletion of the # tree setup ran from: SOURCE_GSTACK_DIR is `pwd -P` of the running tree, which # for Conductor workspaces / manual worktrees / temp clones is EPHEMERAL — # baking it produced dead hooks erroring on every AskUserQuestion until v1.67. # Hook registration is therefore CANONICAL-ONLY: the stable install path below, # or no registration at all. By this point setup has already installed/linked # the canonical tree, so a missing canonical hook means "don't register", never # "fall back to the running tree". The canonical path is symlink-preserving, so # re-pointing ~/.claude/skills/gstack at a new clone heals every hook with zero # settings writes. Repo-local --local installs don't register global Claude # hooks (by design). # # WARNING for future code AND migrations (the v1.58.0.0.sh defect class): # NEVER register ${SCRIPT_DIR}/$SOURCE_GSTACK_DIR-relative hook paths. # bin/gstack-claude-root.sh owns the choice: ${CLAUDE_CONFIG_DIR:-$HOME/.claude} # by default, the $HOME install when the config dir has none (the installer # writes to $HOME) or links back to it (a per-session CLAUDE_CONFIG_DIR, #3180). CANONICAL_GSTACK_ROOT="$(gstack_canonical_claude_root)" # Echo the canonical path for a hook (repo-relative arg); fails when the hook # is not executable at the canonical install — callers must skip + log. # A hook the parse gate below refused is never registered. _HOOK_REFUSED="" _hook_refused() { case " $_HOOK_REFUSED " in *" $1 "*) return 0 ;; esac return 1 } _hook_command_path() { _hook_refused "$1" && return 1 if [ -x "$CANONICAL_GSTACK_ROOT/$1" ]; then printf '%s\n' "$CANONICAL_GSTACK_ROOT/$1" return 0 fi return 1 } # Hook parse gate: Claude Code runs these shims through /bin/sh, and a shell # file that does not parse exits 2, which for a PreToolUse hook blocks the tool # call in every session on the machine. bin/gstack-hook-check parses every hook # path this file resolves for registration, and the TypeScript each one runs. # The canonical root is the checkout Claude Code runs hooks from, so its files # are already live: there is no staged swap to abort here. Setup registers the # hooks that parse, refuses the ones that do not (the resolver skips them), and # exits non-zero at the end with the file and line. The auto-updater runs the # same check before the checkout moves. if [ -d "$CANONICAL_GSTACK_ROOT" ]; then _HOOK_CHECK_RC=0 _HOOK_CHECK_OUT=$(GSTACK_HOOK_CHECK_BUN="$BUN_CMD" "$SOURCE_GSTACK_DIR/bin/gstack-hook-check" \ --setup "$SOURCE_GSTACK_DIR/setup" "$CANONICAL_GSTACK_ROOT") || _HOOK_CHECK_RC=$? _HOOK_REFUSED=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail \([^ ]*\) .*/\1/p' | awk '!seen[$0]++' | tr '\n' ' ') _HOOK_PASSED=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^ok //p' | tr '\n' ' ') _HOOK_REFUSED_AT=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail [^ ]* \([^ ]*\): .*/\1/p' | head -1) if [ -n "$_HOOK_REFUSED" ]; then { echo "gstack setup: refusing to register hooks that do not parse (Claude Code would block tool calls with them):" printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail [^ ]* / /p' echo " Hooks that parse register as usual: ${_HOOK_PASSED:-none}" echo " Skipped: $_HOOK_REFUSED" echo " This is a gstack bug; report $_HOOK_REFUSED_AT at https://github.com/garrytan/gstack/issues" echo " Claude Code runs hooks straight from $CANONICAL_GSTACK_ROOT, so a skipped hook that an earlier setup registered, or that a skill file runs, keeps running the broken file until it is fixed." echo " Fix: repair the file (git -C \"$CANONICAL_GSTACK_ROOT\" status shows half-applied edits or merge conflicts), then re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse" } >&2 elif [ "$_HOOK_CHECK_RC" -ne 0 ]; then echo "gstack setup: warning: the hook parse check could not run (exit $_HOOK_CHECK_RC); hooks are registered unchecked." >&2 fi fi # Heal-first: prune dead gstack hook entries and re-point survivors at the # stable install BEFORE any tag-presence guard below (a dead entry carrying the # tag otherwise blocks re-registration forever — the missing-Stop-hook failure # mode). Runs on EVERY setup, including --no-team, so upgrades self-heal # without migrations. One log line only when something actually changed; stderr # passes through uncaptured (zero silent failures). if [ -x "$SETTINGS_HOOK" ]; then if [ -d "$CANONICAL_GSTACK_ROOT" ]; then _HEAL_OUT=$("$SETTINGS_HOOK" prune-stale --repoint "$CANONICAL_GSTACK_ROOT" || true) else _HEAL_OUT=$("$SETTINGS_HOOK" prune-stale || true) fi _HEAL_REMOVED=$(printf '%s' "$_HEAL_OUT" | sed -n 's/^OK: removed \([0-9]*\).*/\1/p') _HEAL_REPOINTED=$(printf '%s' "$_HEAL_OUT" | sed -n 's/.*repointed \([0-9]*\).*/\1/p') if [ "${_HEAL_REMOVED:-0}" -gt 0 ] 2>/dev/null || [ "${_HEAL_REPOINTED:-0}" -gt 0 ] 2>/dev/null; then log " healed hook registrations: removed ${_HEAL_REMOVED:-0}, repointed ${_HEAL_REPOINTED:-0} (backup: settings.json.bak.; note: later registrations in this run move the rollback pointer — restore the heal's own .bak file directly if needed)" fi # Explicit opt-out + live plan-tune hooks is a contradiction worth surfacing: # the heal honors the opt-out (dead plan-tune entries pruned, never # re-pointed) but live hooks stay until the user removes them. if "$GSTACK_CONFIG" has plan_tune_hooks 2>/dev/null; then _PT_CFG_VAL=$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true) case "$(printf '%s' "$_PT_CFG_VAL" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in n|no|false|skip|off|0) if "$SETTINGS_HOOK" list-sources 2>/dev/null | grep -q "plan-tune-cathedral"; then log " note: plan_tune_hooks is 'no' in config but live plan-tune hooks exist — remove with ./setup --no-team or $SETTINGS_HOOK remove-source --source plan-tune-cathedral" fi ;; esac fi fi # On Windows (Git Bash / MSYS2 / Cygwin), extensionless scripts can't be # launched directly by the OS — the file-association dialog appears instead. # Prefix with 'bash' so Claude Code's hook runner invokes Git Bash explicitly. # Paths with whitespace are quoted so the hook command survives shell parsing. SESSION_UPDATE_CMD="$(_hook_command_path bin/gstack-session-update || true)" HOOK_CMD="" if [ -n "$SESSION_UPDATE_CMD" ]; then # No caller-side quoting: add-event is the single quoting authority — it # normalizes every registered command through the same gsQuoteCmd round-trip # the healer uses, so metachar/space paths cannot drift per call site. if [ "$IS_WINDOWS" -eq 1 ]; then HOOK_CMD="bash $SESSION_UPDATE_CMD" else HOOK_CMD="$SESSION_UPDATE_CMD" fi fi if [ "$TEAM_MODE" -eq 1 ]; then "$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true "$GSTACK_CONFIG" set team_mode true 2>/dev/null || true # Register SessionStart hook in Claude Code settings (schema-aware: the # legacy `add` action's substring dedupe bypasses the KNOWN_HOOKS identity # system; add-event re-points stale paths in place instead of appending). # stderr stays attached (zero silent settings mutations — a fail-closed # parse error or lock give-up must reach the user). if [ -x "$SETTINGS_HOOK" ] && [ -n "$HOOK_CMD" ]; then "$SETTINGS_HOOK" add-event --event SessionStart --command "$HOOK_CMD" --source gstack-session-update >/dev/null || true elif [ -z "$HOOK_CMD" ]; then if _hook_refused bin/gstack-session-update; then log " SessionStart hook not registered: bin/gstack-session-update does not parse (see above)" else log " SessionStart hook not registered: bin/gstack-session-update missing at $CANONICAL_GSTACK_ROOT (no stable install)" fi fi log "" if [ -n "$HOOK_CMD" ]; then log "Team mode enabled: gstack will auto-update at the start of each Claude Code session." log " Hook: $HOOK_CMD" else log "Team mode enabled (auto-update hook pending a stable install — re-run ./setup after installing globally)." fi log " To disable: ./setup --no-team" log "" log "Bootstrap your repo:" log " cd && $SOURCE_GSTACK_DIR/bin/gstack-team-init required" fi if [ "$NO_TEAM_MODE" -eq 1 ]; then "$GSTACK_CONFIG" set auto_upgrade false 2>/dev/null || true "$GSTACK_CONFIG" set team_mode false 2>/dev/null || true # Remove SessionStart hook from Claude Code settings if [ -x "$SETTINGS_HOOK" ]; then "$SETTINGS_HOOK" remove "$HOOK_CMD" 2>/dev/null || true fi log "Team mode disabled: auto-update hook removed." fi # ─── GBrain detection + conditional SKILL.md render ───────────────────── # # Detect whether gbrain is installed and persist the result to # ~/.gstack/gbrain-detection.json so gen-skill-docs can decide whether to # render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks. If detected, # render the Claude-host :user variant (un-suppressed brain-aware blocks) # into an UNTRACKED out-dir — ${GSTACK_HOME}/render/claude — and repoint the # installed skills at it (#2569). The old in-place render wrote into TRACKED # files of the install checkout, so a global-git install stayed permanently # dirty and every upgrade stashed 16 files of generated dirt. # # If gbrain is not detected, the canonical no-gbrain SKILL.md files stay # as-is (zero token overhead) and any stale render dir is removed so it # can't shadow canonical files on the next relink. # # A pinned Claude overlay (claude_overlay_model, set by ./setup # --claude-model) is rendered into the same dir whatever detection says; a # missing or failed detector counts as "no brain blocks" # (bin/gstack-render-claude.sh). # # Users who install gbrain after running ./setup should re-run setup OR # call `gstack-config gbrain-refresh`. DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect" GBRAIN_STATE_DIR="$GSTACK_STATE_ROOT" DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json" _GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}" # PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't # clobber each other's in-flight detection write. DETECTION_TMP="$DETECTION_FILE.$$.tmp" # ok | absent | unknown (detector missing or failed). _GBRAIN_RENDER_STATE=unknown mkdir -p "$GBRAIN_STATE_DIR" if [ -x "$DETECT_BIN" ]; then if "$DETECT_BIN" > "$DETECTION_TMP" 2>/dev/null; then mv "$DETECTION_TMP" "$DETECTION_FILE" # Single source of truth for "is gbrain usable" — `--is-ok` runs live # detection (exit 0 iff ok), so setup, bin/dev-setup, and gstack-config # all gate on the same check instead of re-grepping the JSON. if [ -n "$_CLAUDE_INSTALL_RENDER" ] && [ "$INSTALL_CLAUDE" -eq 1 ]; then # This install serves its own per-install render (:user, so gbrain # detection decides the brain-aware blocks); refresh it with the # detection just written. _GBRAIN_RENDER_STATE=detected if _render_claude_install "$INSTALL_GSTACK_DIR" && [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null fi elif "$DETECT_BIN" --is-ok 2>/dev/null; then _GBRAIN_RENDER_STATE=ok if [ -n "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then # Dev/source tree (set by bin/dev-setup): detection is persisted # above; the dev workspace renders the :user variant into its own # untracked dir (.claude/gstack-rendered), and other projects get # blocks via `gstack-config gbrain-refresh`. log "gbrain detected — GSTACK_SKIP_GBRAIN_REGEN set: leaving tracked SKILL.md canonical (dev/source tree)." elif [ "$INSTALL_CLAUDE" -ne 1 ]; then log "gbrain detected — the Claude render is left as is: Claude is not selected for this setup (rule: an explicit --host never changes another host)." else log "gbrain detected — rendering brain-aware Claude SKILL.md into $_GSTACK_RENDER_DIR (~250 token overhead per planning skill; source checkout stays clean)..." # Render into a tmp dir and swap it in only on SUCCESS. Installed # skills SYMLINK into the render dir (relink prefers it), so wiping # it before the render meant one transient failure left every # brain-aware SKILL.md link dangling — the whole skill set vanished # from Claude Code until a successful re-render. _GSTACK_RENDER_TMP="$_GSTACK_RENDER_DIR.tmp.$$" rm -rf "$_GSTACK_RENDER_TMP" if ( cd "$SOURCE_GSTACK_DIR" # No pipe before the || guard: `cmd | tail -3` reports TAIL's exit # status, so a generator crash read as success (same masking the # main gen:skill-docs site had). Capture, show the tail, propagate. _GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_TMP" --link-root "$_GSTACK_RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} 2>&1) _GEN_USER_RC=$? printf '%s\n' "$_GEN_USER_OUT" | tail -3 exit "$_GEN_USER_RC" ); then gstack_render_lock "$_GSTACK_RENDER_DIR" _swap_in_render "$_GSTACK_RENDER_DIR" "$_GSTACK_RENDER_TMP" gstack_render_unlock "$_GSTACK_RENDER_DIR" gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" ok rendered else rm -rf "$_GSTACK_RENDER_TMP" log " warning: gen:skill-docs:user failed — previous render (if any) left in place, links stay valid. Run 'bun run gen:skill-docs:user --host claude --out-dir $_GSTACK_RENDER_DIR' manually if you want fresh brain-aware blocks" gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" ok failed fi # Repoint the installed skills at the fresh render — the installer # prefers rendered files when present (#2569). if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null fi fi else _GBRAIN_RENDER_STATE=absent log "gbrain not detected — brain-aware blocks suppressed in planning-skill SKILL.md files (zero token overhead)." log " To enable: install gbrain via /setup-gbrain, then re-run ./setup or 'gstack-config gbrain-refresh'." # A render from a previous gbrain install would shadow canonical files # on the next link/relink — drop it and restore canonical links. A # pinned overlay re-renders it without brain blocks instead (below). if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] && [ "$INSTALL_CLAUDE" -eq 1 ] && [ "$_GSTACK_OVERLAY" = claude ] && [ -z "$_PLAN_MODE_ARG" ]; then rm -rf "$_GSTACK_RENDER_DIR" gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" absent removed if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null fi fi fi else rm -f "$DETECTION_TMP" log " warning: gstack-gbrain-detect failed — brain-aware blocks will stay suppressed" fi fi # Default install without brain blocks: render a pinned overlay, or (detector # missing or failed) drop a previous pinned render after a switch back to claude. if [ "$INSTALL_CLAUDE" -eq 1 ] && [ -z "$_CLAUDE_INSTALL_RENDER" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] \ && { [ "$_GBRAIN_RENDER_STATE" = absent ] || [ "$_GBRAIN_RENDER_STATE" = unknown ]; }; then gstack_claude_render_action "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE" case "$_GSTACK_RENDER_ACTION" in plain) if gstack_claude_render_plain "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR"; then gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE" rendered else gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE" failed fi ;; remove) if [ "$_GBRAIN_RENDER_STATE" = unknown ]; then rm -rf "$_GSTACK_RENDER_DIR" gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" unknown removed else _GSTACK_RENDER_ACTION=keep fi ;; esac if [ "$_GSTACK_RENDER_ACTION" != keep ] && [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null fi fi # Hook path resolution is CANONICAL-ONLY via the resolver defined near # CANONICAL_GSTACK_ROOT above (_hook_command_path): a hook command registered into # ~/.claude/settings.json must survive deletion of the directory setup ran # from, and no heuristic can enumerate every ephemeral tree (manual worktrees, # temp clones, CI checkouts) — so there is deliberately NO fallback to # $SOURCE_GSTACK_DIR here. A missing canonical hook means "skip registration # with a log line", never "bake the running tree's path". # 11. Plan-tune cathedral hook install (T8). # # Registers PostToolUse (deterministic AUQ capture) + PreToolUse (preference # enforcement) hooks in ~/.claude/settings.json so /plan-tune actually does # something at runtime instead of being agent-convention. Explicit consent UX # per D4 + Codex: never mutate settings.json silently. # # Idempotent via _gstack_source tag = 'plan-tune-cathedral'. If both hooks # already registered under that tag, the install skips the consent prompt and # only refreshes the registered command paths in place (ensure-event is a # no-op when they already match). PLAN_TUNE_LOG_HOOK="$(_hook_command_path hosts/claude/hooks/question-log-hook || true)" PLAN_TUNE_PREF_HOOK="$(_hook_command_path hosts/claude/hooks/question-preference-hook || true)" AUQ_ERROR_FALLBACK_HOOK="$(_hook_command_path hosts/claude/hooks/auq-error-fallback-hook || true)" # Windows: extensionless bash shims need the explicit 'bash ' prefix (same # rationale as HOOK_CMD above — the OS file-association dialog otherwise). # KNOWN_HOOKS identity round-trips the prefix, so healing preserves it. if [ "$IS_WINDOWS" -eq 1 ]; then [ -n "$PLAN_TUNE_LOG_HOOK" ] && PLAN_TUNE_LOG_HOOK="bash $PLAN_TUNE_LOG_HOOK" [ -n "$PLAN_TUNE_PREF_HOOK" ] && PLAN_TUNE_PREF_HOOK="bash $PLAN_TUNE_PREF_HOOK" [ -n "$AUQ_ERROR_FALLBACK_HOOK" ] && AUQ_ERROR_FALLBACK_HOOK="bash $AUQ_ERROR_FALLBACK_HOOK" fi PLAN_TUNE_INSTALL_MARKER="$HOME/.gstack/.plan-tune-hooks-prompted" # Canonical-only: an ephemeral tree with no stable install gets a visible skip, # never a baked worktree path. if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] \ && { { [ -z "$PLAN_TUNE_LOG_HOOK" ] && ! _hook_refused hosts/claude/hooks/question-log-hook; } \ || { [ -z "$PLAN_TUNE_PREF_HOOK" ] && ! _hook_refused hosts/claude/hooks/question-preference-hook; }; }; then log " AskUserQuestion hooks not registered: hooks missing at $CANONICAL_GSTACK_ROOT (no stable install)" fi # A hook refused by the parse gate leaves its variable empty; the others in # this block still register. if [ "$NO_TEAM_MODE" -ne 1 ] \ && [ -x "$SETTINGS_HOOK" ] \ && { [ -n "$PLAN_TUNE_LOG_HOOK" ] || _hook_refused hosts/claude/hooks/question-log-hook; } \ && { [ -n "$PLAN_TUNE_PREF_HOOK" ] || _hook_refused hosts/claude/hooks/question-preference-hook; } \ && [ -n "$PLAN_TUNE_LOG_HOOK$PLAN_TUNE_PREF_HOOK$AUQ_ERROR_FALLBACK_HOOK" ]; then # Already installed? Require BOTH the plan-tune source AND the AUQ-error-fallback # source — so an existing install that predates the fallback hook re-runs the # install (which is idempotent for the plan-tune hooks) and picks up the new one. ALREADY_INSTALLED=0 _HOOK_SOURCES=$("$SETTINGS_HOOK" list-sources 2>/dev/null || true) if printf '%s' "$_HOOK_SOURCES" | grep -q "plan-tune-cathedral" \ && printf '%s' "$_HOOK_SOURCES" | grep -q "auq-error-fallback"; then ALREADY_INSTALLED=1 fi # Resolve the desired action without ever blocking. # Priority: CLI flag (--plan-tune-hooks / --no-plan-tune-hooks) # > env (GSTACK_PLAN_TUNE_HOOKS=yes|no) # > saved config (plan_tune_hooks) # > smart default ("prompt" → timed prompt on a real TTY, else skip). # This guarantees scripted/workspace setups (conductor, CI) are never # interactive: pass --no-plan-tune-hooks (or --plan-tune-hooks) and the # block runs to completion with no `read`. # PT_EXPLICIT provenance: an EXPLICIT decision (CLI flag, env var, or a key # literally present in the config file) must never be overridden by the # Conductor auto-opt-in below. `gstack-config get` returns the default # "prompt" for absent keys, so provenance uses `gstack-config has` (which # resolves GSTACK_STATE_ROOT/GSTACK_HOME/GSTACK_STATE_DIR the same way get # does — never grep a hardcoded ~/.gstack/config.yaml). PT_EXPLICIT=0 if [ -n "$PLAN_TUNE_HOOKS_MODE" ]; then PT_DECISION="$PLAN_TUNE_HOOKS_MODE" PT_EXPLICIT=1 elif [ -n "${GSTACK_PLAN_TUNE_HOOKS:-}" ]; then PT_DECISION="${GSTACK_PLAN_TUNE_HOOKS}" PT_EXPLICIT=1 else PT_DECISION="$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)" if "$GSTACK_CONFIG" has plan_tune_hooks 2>/dev/null; then PT_EXPLICIT=1 fi fi # Normalize: strip whitespace + lowercase so "YES", "Yes", " yes" from a flag # or env var all resolve correctly (an unrecognized opt-in must NOT silently # downgrade to skip). Unknown values fall through to "prompt". PT_DECISION=$(printf '%s' "$PT_DECISION" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') case "$PT_DECISION" in y|yes|true|install|on|1) PT_DECISION="yes" ;; n|no|false|skip|off|0) PT_DECISION="no" ;; *) PT_DECISION="prompt" ;; esac # Conductor (Q3, #2207): the PreToolUse preference hook breaks Conductor's # native AskUserQuestion round-trip even when it only defers, so setup never # installs it in a Conductor workspace unless the user explicitly opted in, # and removes one an earlier setup added. The other plan-tune hooks are # unaffected. _PT_SKIP_PREF=0 if { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; } \ && ! { [ "$PT_EXPLICIT" -eq 1 ] && [ "$PT_DECISION" = "yes" ]; }; then _PT_SKIP_PREF=1 if [ -n "$("$SETTINGS_HOOK" list-items --event PreToolUse --owned-by plan-tune-cathedral 2>/dev/null)" ]; then if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral --event PreToolUse >/dev/null 2>&1; then log " removed the AskUserQuestion preference hook: it breaks Conductor's native AskUserQuestion (#2207). Keep it anyway: gstack-config set plan_tune_hooks yes, then ./setup" else log " warning: could not remove the AskUserQuestion preference hook, which breaks Conductor's native AskUserQuestion. Fix: $SETTINGS_HOOK remove-source --source plan-tune-cathedral --event PreToolUse" fi fi fi _install_plan_tune_hooks() { # ensure-event (not add-event): registers when missing, RE-POINTS a stale # command path in place when the registration differs, and is a true no-op # (no write, no backup churn) when it already matches. # Returns non-zero if ANY registration was skipped (lock contention or a # fail-closed settings error) so callers log honestly instead of claiming # success for a mutation that never happened. local _pt_install_rc=0 if [ -n "$PLAN_TUNE_LOG_HOOK" ]; then "$SETTINGS_HOOK" ensure-event \ --event PostToolUse \ --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \ --command "$PLAN_TUNE_LOG_HOOK" \ --source plan-tune-cathedral \ --timeout 5 || _pt_install_rc=1 fi if [ "$_PT_SKIP_PREF" -eq 0 ] && [ -n "$PLAN_TUNE_PREF_HOOK" ]; then "$SETTINGS_HOOK" ensure-event \ --event PreToolUse \ --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \ --command "$PLAN_TUNE_PREF_HOOK" \ --source plan-tune-cathedral \ --timeout 5 || _pt_install_rc=1 fi # AskUserQuestion-failure prose-fallback reliability hook (OV3:B). Fires only when # an AskUserQuestion call returns an error/missing result; inert on success and # inert if the platform doesn't invoke PostToolUse on tool errors. MUST use its # OWN source tag: gstack-settings-hook dedupes by (event, matcher, source) and # REPLACES the entry's hooks, so sharing 'plan-tune-cathedral' would overwrite the # question-log capture hook (same event+matcher). A distinct source = a second # PostToolUse entry; both run in parallel. if [ -n "$AUQ_ERROR_FALLBACK_HOOK" ]; then "$SETTINGS_HOOK" ensure-event \ --event PostToolUse \ --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \ --command "$AUQ_ERROR_FALLBACK_HOOK" \ --source auq-error-fallback \ --timeout 5 || _pt_install_rc=1 fi return $_pt_install_rc } if [ "$ALREADY_INSTALLED" -eq 1 ]; then # Consent already recorded — no prompt. But a registration from an earlier # setup may carry a stale absolute path (a since-deleted dev worktree); # ensure-event re-points it in place and no-ops when everything matches. # Non-fatal to setup, but never silent: the hardened settings-hook refuses # to rewrite a corrupt settings.json (exit 1), and swallowing that refusal # left users with stale hooks and no signal. if ! _PT_ENSURE_ERR=$(_install_plan_tune_hooks 2>&1 >/dev/null); then log " warning: settings hook update failed: $(printf '%s\n' "$_PT_ENSURE_ERR" | head -1) — run $SETTINGS_HOOK manually" fi log "" log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect." elif [ "$PT_DECISION" = "yes" ]; then # Explicit opt-in (flag / env / config). Non-interactive. if _install_plan_tune_hooks; then log "" log "Plan-tune hooks installed. Run /plan-tune anytime to inspect." else log "" log " warning: some AskUserQuestion hooks were NOT registered (settings lock contention or a settings error above) — re-run ./setup to complete." fi touch "$PLAN_TUNE_INSTALL_MARKER" elif [ "$PT_DECISION" = "no" ]; then # Explicit opt-out (flag / env / config). Non-interactive. log "" log "Plan-tune cathedral hooks not installed (opted out)." log "Install later with: ./setup --plan-tune-hooks (or /update-config)." touch "$PLAN_TUNE_INSTALL_MARKER" elif [ -f "$PLAN_TUNE_INSTALL_MARKER" ]; then # Previously declined. Don't re-ask. User can re-enable via /update-config. : elif [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then # Real interactive terminal with no recorded preference: ask, with explicit # consent + diff preview. The read is time-bounded and defaults to "skip" so # it can never hang an automated/forwarded TTY (the conductor failure mode). _PT_PROMPT_TIMEOUT=10 # single source of truth for the read + the countdown text log "" log "──────────────────────────────────────────────────────────" log "Plan-tune cathedral: install Claude Code hooks?" log "──────────────────────────────────────────────────────────" log "" log "These hooks make /plan-tune settings actually bind at runtime:" log " • PostToolUse hook captures every AskUserQuestion fire (no agent" log " compliance required). Today it's agent-convention and the log" log " is empty in dogfood." log " • PreToolUse hook enforces 'never-ask' preferences via Claude Code's" log " permissionDecision protocol. Today preferences are agent-honored" log " convention; this makes them binding." log "" log "Diff preview (PostToolUse capture hook):" "$SETTINGS_HOOK" diff-event \ --event PostToolUse \ --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \ --command "$PLAN_TUNE_LOG_HOOK" \ --source plan-tune-cathedral \ --timeout 5 2>/dev/null || true log "" log "Backup: settings.json.bak. written before any mutation." log "Rollback: $SETTINGS_HOOK rollback" log "" printf "Install both hooks now? [y/N] (default: N, auto-skips in %ss): " "$_PT_PROMPT_TIMEOUT" read -t "$_PT_PROMPT_TIMEOUT" -r PLAN_TUNE_INSTALL_REPLY /dev/null || PLAN_TUNE_INSTALL_REPLY="" case "$PLAN_TUNE_INSTALL_REPLY" in y|Y) if _install_plan_tune_hooks; then log "" log "Plan-tune hooks installed. Run /plan-tune anytime to inspect." else log "" log " warning: some AskUserQuestion hooks were NOT registered (settings lock contention or a settings error above) — re-run ./setup to complete." fi touch "$PLAN_TUNE_INSTALL_MARKER" ;; n|N) log "" log "Skipped. Re-run ./setup --plan-tune-hooks or use /update-config to install later." touch "$PLAN_TUNE_INSTALL_MARKER" ;; *) # Empty / timed out — treat as "ask me again" (don't persist a decline). log "" log "No response — skipped for now. Re-run ./setup --plan-tune-hooks to install." ;; esac else # Non-interactive (CI, scripted/workspace setup, quiet). Never prompt. log "" log "Plan-tune cathedral hooks not installed (non-interactive setup)." log "Install with: ./setup --plan-tune-hooks" log " (or set GSTACK_PLAN_TUNE_HOOKS=yes, or run the commands below)" log " $SETTINGS_HOOK add-event --event PostToolUse \\" log " --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \\" log " --command $PLAN_TUNE_LOG_HOOK --source plan-tune-cathedral --timeout 5" log " $SETTINGS_HOOK add-event --event PreToolUse \\" log " --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \\" log " --command $PLAN_TUNE_PREF_HOOK --source plan-tune-cathedral --timeout 5" fi fi # ─── Timeline Stop hook (#2553) ────────────────────────────────────────────── # The preamble writes event:"started" to the project timeline at every skill # start; the completion write lives in end-of-workflow prose and is # unenforceable — interrupted sessions leaked started > completed forever. # Register a Stop-event hook that closes dangling entries. FAIL-OPEN contract # (F5): the hook always exits 0 and repairs best-effort — it can never block # a session. Removed by --no-team and gstack-uninstall. # # The command path is canonical-only (see _hook_command_path): a dev-worktree # setup used to bake its own absolute dir into settings.json, so deleting the # worktree left a dead hook erroring on every session stop — and the old # presence-only dedup (list-sources | grep) never re-pointed it on a re-run. # ensure-event registers when missing, replaces a stale path in place (one # atomic write — never zero or two registrations), and no-ops when the # registration already matches. TIMELINE_STOP_HOOK="$(_hook_command_path hosts/claude/hooks/timeline-stop-hook || true)" if [ "$IS_WINDOWS" -eq 1 ] && [ -n "$TIMELINE_STOP_HOOK" ]; then TIMELINE_STOP_HOOK="bash $TIMELINE_STOP_HOOK" fi # #2677: PERSISTENT gate, mirroring the plan_tune_hooks pattern. --no-team is # (and stays) a one-shot teardown — every later bare ./setup, including the # ones /gstack-upgrade runs, re-registered the hook with no way to say # "never". Resolution: flag > env (GSTACK_TIMELINE_STOP_HOOK) > saved config # (timeline_stop_hook) > default yes. An explicit FLAG persists to config so # the decision survives upgrades; env stays session-scoped. Do NOT initialize # NO_TEAM_MODE from config — that would silently change --no-team semantics. if [ -n "$TIMELINE_STOP_HOOK_MODE" ]; then TL_DECISION="$TIMELINE_STOP_HOOK_MODE"; TL_SOURCE="flag" elif [ -n "${GSTACK_TIMELINE_STOP_HOOK:-}" ]; then TL_DECISION="${GSTACK_TIMELINE_STOP_HOOK}"; TL_SOURCE="env GSTACK_TIMELINE_STOP_HOOK" else TL_DECISION="$("$GSTACK_CONFIG" get timeline_stop_hook 2>/dev/null || true)" TL_SOURCE="config timeline_stop_hook" fi TL_DECISION=$(printf '%s' "$TL_DECISION" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') TL_UNRECOGNIZED=0 case "$TL_DECISION" in n|no|false|skip|off|0) TL_DECISION="no" ;; y|yes|true|on|1|"") TL_DECISION="yes" ;; *) # A typo'd value (--timeline-stop-hook=noo) must not silently become a # PERSISTED "yes" — warn, apply the default for this run only. log " WARNING: unrecognized timeline-stop-hook value '$TL_DECISION' (from $TL_SOURCE) — using default 'yes' for this run, not persisting" TL_DECISION="yes"; TL_UNRECOGNIZED=1 ;; esac if [ -n "$TIMELINE_STOP_HOOK_MODE" ] && [ "$TL_UNRECOGNIZED" -eq 0 ]; then "$GSTACK_CONFIG" set timeline_stop_hook "$TL_DECISION" >/dev/null 2>&1 || true fi if [ "$TL_DECISION" = "no" ] && [ -x "$SETTINGS_HOOK" ]; then # Reconciliation arm: an explicit "no" with a live registration removes it — # the opt-out works even when the hook was registered by an older setup. "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop >/dev/null 2>&1 || true log " timeline Stop hook disabled (via $TL_SOURCE) — removed its registration if one existed" fi if [ "$NO_TEAM_MODE" -ne 1 ] && [ "$TL_DECISION" != "no" ] && [ -x "$SETTINGS_HOOK" ] && [ -n "$TIMELINE_STOP_HOOK" ]; then if _TL_ENSURE_OUT=$("$SETTINGS_HOOK" ensure-event \ --event Stop \ --command "$TIMELINE_STOP_HOOK" \ --source gstack-timeline-stop \ --timeout 5 2>&1); then case "$_TL_ENSURE_OUT" in *unchanged*) : # already registered with the canonical command — quiet no-op ;; *re-pointed*) log " re-pointed Stop hook to $TIMELINE_STOP_HOOK (previous registration held a stale path)" ;; *) log " registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)" ;; esac else # Non-fatal to setup, but never silent: the hardened settings-hook refuses # to mutate a corrupt settings.json (exit 3) or under a held lock (exit 5), # and swallowing that refusal left the Stop hook unregistered with no signal. log " warning: settings hook update failed: $(printf '%s\n' "$_TL_ENSURE_OUT" | head -1) — run $SETTINGS_HOOK manually" fi fi # Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern). # Tag-only remove-source misses untagged entries (Claude Code strips # _gstack_source), so the identity sweep (prune-stale --all) finishes the job. # stderr stays attached on every call: a lock give-up or fail-closed parse # error during TEARDOWN must be visible — "the next setup retries" does not # apply when the user is turning the hooks off. if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral >/dev/null || true "$SETTINGS_HOOK" remove-source --source auq-error-fallback >/dev/null || true "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop >/dev/null || true # verify-gate and gstack-memorable are user-registered opt-ins unrelated to # team mode -- turning team mode off must not delete them (uninstall still # sweeps both, correctly, because there the binaries themselves are removed). GSTACK_SWEEP_EXCLUDE_SOURCES="verify-gate,gstack-memorable" "$SETTINGS_HOOK" prune-stale --all >/dev/null || true fi # ─── Redact pre-push guard consent (#1946) ─────────────────────────────────── # The credential pre-push hook is per-REPO state — setup runs in the gstack # checkout, the wrong repo to install it into, so setup NEVER installs the # hook itself. /ship installs it silently in any repo where # redact_prepush_hook=true. What setup owns is CONSENT: on a real interactive # terminal it asks ONCE whether pushes should be scanned, recording the # answer to the existing redact_prepush_hook key (default stays false — a # timeout or non-interactive run changes nothing and keeps the hint-only # posture). An explicit answer is persisted and never re-asked; an explicit # "false" is a recorded decline (adversarial review finding 11). # `gstack-config get` defaults absent keys to "false", which is # indistinguishable from a decline — test key presence in the config file # of the resolved state root (the twin beside gstack-config). . "$(dirname "$GSTACK_CONFIG")/gstack-state-root.sh" 2>/dev/null && gstack_state_root_select _GSTACK_CFG_FILE="${_gstack_sr_root:-$GSTACK_STATE_ROOT}/config.yaml" if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then if [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then _REDACT_PROMPT_TIMEOUT=10 log "" log "Credential push guard: gstack can block pushes containing credentials" log "(a per-repo git pre-push hook; /ship installs it automatically in every" log "repo you ship from — nothing is installed right now)." printf "Enable the pre-push credential guard? [y/N] (default: N, auto-skips in %ss): " "$_REDACT_PROMPT_TIMEOUT" read -t "$_REDACT_PROMPT_TIMEOUT" -r _REDACT_REPLY /dev/null || _REDACT_REPLY="" case "$_REDACT_REPLY" in y|Y) "$GSTACK_CONFIG" set redact_prepush_hook true 2>/dev/null || true log "Enabled. /ship will install the guard in each repo at first push." ;; n|N) "$GSTACK_CONFIG" set redact_prepush_hook false 2>/dev/null || true log "Declined — recorded. Re-enable anytime: gstack-config set redact_prepush_hook true" ;; *) # Timed out / empty: don't persist a decline — hint and ask next time. log "" log "Skipped for now. Enable anytime: gstack-config set redact_prepush_hook true" ;; esac else log "" log "Tip: gstack can block pushes containing credentials (per-repo git hook)." log " Enable once: gstack-config set redact_prepush_hook true — /ship" log " installs the hook automatically in every repo you ship from." fi fi _setup_print_summary # ─── CSO native-helper summary ──────────────────────────────────────────────── # A missing prerequisite comes from this run's probe. A build or publish # failure comes from bin/.gstack-cso-build-result, which scripts/build-cso.sh # writes and gstack-doctor also reads. _cso_field() { sed -n "s/^$1=//p" "$SOURCE_GSTACK_DIR/bin/.gstack-cso-build-result" 2>/dev/null | head -1; } if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then case "$CSO_FAIL_REASON" in bun-compile-flags) _CSO_PREREQ="upgrade Bun to a release supporting all four --no-compile-autoload-* build flags" ;; c-compiler) _CSO_PREREQ="install a C compiler (cc, Clang, or GCC)" ;; static-c-toolchain) _CSO_PREREQ="install a C toolchain capable of static linking" ;; macos-codesign|macos-native-toolchain) _CSO_PREREQ="install the macOS compiler and codesign command-line tools" ;; windows-shell-toolchain) _CSO_PREREQ="run setup from Git Bash with PowerShell (pwsh or Windows PowerShell) available" ;; windows-msvc-toolchain) _CSO_PREREQ="install Visual Studio 2022 Build Tools with Desktop development with C++" ;; windows-msvc-compile) _CSO_PREREQ="fix the MSVC compile error its toolchain probe reported (${CSO_PROBE_DETAIL:-see scripts/build-cso-windows.ps1 -CheckOnly}); Visual Studio is already installed" ;; windows-toolchain-probe) _CSO_PREREQ="fix the PowerShell toolchain probe failure (${CSO_PROBE_DETAIL:-no output}); run scripts/build-cso-windows.ps1 -CheckOnly from pwsh to see it" ;; windows-git) _CSO_PREREQ="install Git for Windows and run setup from its Git Bash" ;; unsupported-platform) _CSO_PREREQ="use a supported macOS, Linux, or Windows host" ;; skipped-by-request) _CSO_PREREQ="re-run without GSTACK_SETUP_SKIP_CSO_BUILD=1" ;; *) _CSO_PREREQ="install the native CSO build prerequisites" ;; esac log "" log "CSO unavailable: its native helper was not built ($CSO_FAIL_REASON)." log " Failed stage: probe. /cso will report not assessed and the install prerequisite; it will not use repository tooling." log " Everything else is installed and works. To enable /cso, $_CSO_PREREQ, then re-run ./setup." elif [ "$(_cso_field result)" = "failed" ]; then _CSO_STAGE="$(_cso_field stage)" _CSO_INSTALLED="$(_cso_field installed)" _CSO_WHAT="failed ($(_cso_field reason))" [ "$(_cso_field reason)" != "interrupted" ] || _CSO_WHAT="was interrupted" log "" if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" ] && [ -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-core$_EXE" ] && [ -n "$_CSO_INSTALLED" ]; then log "CSO $_CSO_STAGE step $_CSO_WHAT for $(_cso_field revision); previous CSO kept: $_CSO_INSTALLED." log " /cso keeps using that helper until a rebuild succeeds." else log "CSO unavailable: the native helper's $_CSO_STAGE step $_CSO_WHAT, so /cso will report not assessed." fi log " Diagnostic: $(_cso_field diagnostic)" log " Everything else is installed and works. Retry: cd $SOURCE_GSTACK_DIR && bun run build:cso && ./setup" log " Details: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#cso-build-or-publish-failed" fi # ─── Windows launch probe (#2595, #2124) ────────────────────────────────────── # Smart App Control blocks unsigned executables at launch, which `[ -x ]` cannot # see and Git Bash reports as "Permission denied". Probe each compiled binary. _windows_launch_summary() { local rel detail shell wsl . "$SOURCE_GSTACK_DIR/bin/gstack-launch-probe.sh" || return 0 gstack_launch_probe_all "$SOURCE_GSTACK_DIR" ".exe" if [ -n "$_glp_blocked" ]; then case "$(uname -s)" in MINGW*|MSYS*) shell="Git Bash" ;; CYGWIN*) shell="Cygwin" ;; *) shell="$(uname -s)" ;; esac if command -v wsl.exe >/dev/null 2>&1; then wsl="WSL is installed"; else wsl="WSL is not installed (wsl --install)"; fi log "" log "Windows blocked compiled gstack binaries at launch (Smart App Control or another application-control policy, #2595):" while IFS=' ' read -r rel detail; do [ -n "$rel" ] || continue log " $rel.exe: ${detail:-no output} -> $(gstack_launch_probe_skills "$rel")" done < App & browser control." log " To undo: once Windows allows the binaries, re-run ./setup and this message goes away." log " Details: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#windows-smart-app-control" fi if [ -n "$_glp_broken" ]; then log "" log "Compiled gstack binaries launched but failed (a build problem, not Smart App Control):" while IFS=' ' read -r rel detail; do [ -n "$rel" ] || continue log " $rel.exe: $detail -> $(gstack_launch_probe_skills "$rel")" done </dev/null 2>&1; then # Aside is the primary driver; the bundled browser is its fallback. Say so, # instead of telling an Aside user their browser skills are gone. log " Aside is installed, so $_PW_ASIDE_SKILLS keep running there; only their bundled fallback is missing." log " /pair-agent needs the bundled browser itself." else log " Skills that need it: $_PW_BROWSER_SKILLS." fi log " Everything else is installed and works. Fix the cause and re-run ./setup." case "$_PW_FAIL_REASON" in *chromium-install-timeout*) log " Slow link? Raise the bound: GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800 ./setup" ;; esac case "$_PW_FAIL_REASON" in *post-install-launch*) log " Ubuntu 24.04+ (AppArmor user namespaces): GSTACK_CHROMIUM_NO_SANDBOX=1 ./setup (#2157)" ;; esac # Reason code only — never a path, hostname, or the installer's output. The # event is a one-shot with no session of its own, so --no-sweep keeps it # from finalizing other live sessions' in-flight .pending markers as # outcome:unknown. Telemetry-gated inside gstack-telemetry-log. if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_playwright --outcome "$_PW_FAIL_REASON" --no-sweep >/dev/null 2>&1 || true fi fi if [ ${#_FOREIGN_SKIPPED_ENTRIES[@]} -gt 0 ]; then log "" log "Not registered (a skill you own already uses the name; left untouched): ${_FOREIGN_SKIPPED_ENTRIES[*]}" log " Rename or move yours, or switch modes (./setup --prefix / --no-prefix) so the names no longer collide." fi if [ ${#_BACKED_UP_SKILL_MDS[@]} -gt 0 ]; then log "" log "Moved ${#_BACKED_UP_SKILL_MDS[@]} customized SKILL.md file(s) to $_SKILL_BACKUP_ROOT before installing gstack's: ${_BACKED_UP_SKILL_MDS[*]}" log " Those were gstack-generated files you had edited; restore anything you meant to keep under a different skill name." fi if [ -n "${_HOOK_REFUSED:-}" ]; then echo "" >&2 echo "gstack setup: everything else is installed, but these hooks were not registered because they do not parse: $_HOOK_REFUSED(first: $_HOOK_REFUSED_AT). This is a gstack bug; report $_HOOK_REFUSED_AT. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse" >&2 exit 1 fi # The final line names every host this run installed, from the same rows as # the summary table; instruction-only hosts write no row and print none. printf '%s' "$_SETUP_ROWS" | _setup_ready_line