#!/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) 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. --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 --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: 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="$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 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 '