#!/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 a specific host (claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, auto). Default: claude. --model Codex model profile override. Otherwise reads Codex config. --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. -q, --quiet Suppress progress output. -h, --help Show this help and exit. Examples: ./setup # solo install for Claude Code ./setup --host codex # install for OpenAI Codex CLI ./setup --host codex --model gpt-5.6-sol ./setup --team # team mode for a shared repo ./setup --no-prefix # use short slash-command names Docs: https://github.com/garrytan/gstack 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 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="1.3.10"' >&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 ' BUN_VERSION="$BUN_VERSION" bash "$tmpfile" && rm "$tmpfile"' >&2 exit 1 fi INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)" 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" 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). _link_or_copy() { local src="$1" local dst="$2" if [ "$IS_WINDOWS" -eq 1 ]; then rm -rf "$dst" # 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 if [ -d "$src" ]; then cp -R "$src" "$dst" else cp -f "$src" "$dst" fi else ln -snf "$src" "$dst" fi } # ─── 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_HOME:-~/.gstack}/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_HOME:-$HOME/.gstack}/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/. local root="${t%/*/SKILL.md}" if [ "$root" != "$t" ] && [ -f "$root/VERSION" ] && [ -f "$root/setup" ] && [ -d "$root/bin" ]; 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 # 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_HOME:-$HOME/.gstack}/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_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)" _BACKED_UP_SKILL_MDS=() _backup_skill_md() { local file="$1" name="$2" mkdir -p "$_SKILL_BACKUP_ROOT/$name" 2>/dev/null || return 0 if mv -f "$file" "$_SKILL_BACKUP_ROOT/$name/SKILL.md" 2>/dev/null; then _BACKED_UP_SKILL_MDS+=("$name"); fi 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() { local d="$1" g="$2" e dest rm -f "$d/SKILL.md" "$d/.gstack-owned" # Our runtime-asset links (sections/, templates, checklist.md, ...) go too; # anything the user put there stays. 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 — true when deleting DIR whole loses no real data: # every entry is a symlink (ours or not — a link is not content) or our marker. _gstack_dir_only_links() { local d="$1" e for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do { [ -e "$e" ] || [ -L "$e" ]; } || continue [ -L "$e" ] && continue [ "${e##*/}" = ".gstack-owned" ] && continue 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 links; otherwise only our files go. _cleanup_linked_dir() { if [ -f "$1/.gstack-owned" ] || _gstack_dir_only_links "$1"; 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 '